feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { render } from '@testing-library/react'
import { Popover } from '../popover'
vi.mock('../popover/hooks/use-tree', () => {
return {
useTreeRefresh() {},
useTreeSearch() {},
}
});
vi.mock('@coze-arch/bot-semi', async () => {
const { forwardRef } = await vi.importActual('react') as any;
return {
Popover({ content }) {
return <div>{content}</div>
},
Tree: forwardRef((_, ref) => {
return <div ref={ref}></div>
})
}
})
vi.mock('@coze-editor/editor', () => {
return {
mixLanguages() {},
astDecorator: {
whole: {
of() {}
},
fromCursor: {
of() {}
},
},
};
});
vi.mock('@coze-editor/editor/react', () => {
return {
Renderer() {},
CursorMirror() {
return null;
},
SelectionSide: {
Head: 'head',
Anchor: 'anchor',
},
useEditor() {
return {
disableKeybindings() {},
$on() {},
$off() {},
replaceTextByRange() {},
$view: {
state: {
selection: {
main: {
from: 0,
to: 0,
anchor: 0,
head: 0
}
}
}
}
};
},
};
});
vi.mock('@coze-editor/editor/preset-expression', () => {
return {
default: []
};
});
vi.mock('@/expression-editor', () => ({}));
describe('popover', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('Should render props.className correctly', () => {
const { container } = render(<Popover variableTree={[]} className='foo' />)
const elements = container.querySelectorAll('.foo')
expect(elements.length).toBe(1)
})
})

View File

@@ -0,0 +1,189 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect } from 'react';
import { render } from '@testing-library/react';
import { Renderer } from '../renderer';
const { SDKRenderer } = vi.hoisted(() => ({ SDKRenderer: vi.fn() }));
vi.mock('@coze-editor/editor', () => {
return {
mixLanguages() {},
astDecorator: {
whole: {
of() {},
},
fromCursor: {
of() {},
},
},
};
});
vi.mock('@coze-editor/editor/react', () => {
return {
Renderer: SDKRenderer,
};
});
vi.mock('@coze-editor/editor/preset-expression', () => {
return {
default: [],
};
});
vi.mock('@/expression-editor', () => ({}));
describe('renderer', () => {
beforeEach(() => {
SDKRenderer.mockImplementation(({ defaultValue, didMount }) => {
useEffect(() => {
didMount({
getValue() {},
setValue() {},
updateWholeDecorations() {},
});
}, []);
return null;
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('Should props.className work correctly', () => {
render(<Renderer variableTree={[]} className="foo" />);
// @ts-expect-error -- mock
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
options: {
contentAttributes: {
class: 'foo flow-canvas-not-draggable',
},
},
});
});
it('Should props.dataTestID work correctly', () => {
render(<Renderer variableTree={[]} dataTestID="foo" />);
// @ts-expect-error -- mock
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
options: {
contentAttributes: {
'data-testid': 'foo',
},
},
});
});
it('Should props.placeholder work correctly', () => {
render(<Renderer variableTree={[]} placeholder="foo" />);
// @ts-expect-error -- mock
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
options: {
placeholder: 'foo',
},
});
});
it('Should props.value work correctly', () => {
let value = '';
const getValue = () => value;
const setValue = vi.fn();
SDKRenderer.mockImplementation(({ defaultValue, didMount }) => {
useEffect(() => {
value = defaultValue;
didMount({
getValue,
setValue,
updateWholeDecorations() {},
});
}, []);
return null;
});
const { rerender } = render(<Renderer variableTree={[]} value="value" />);
// @ts-expect-error -- mock
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
defaultValue: 'value',
});
rerender(<Renderer variableTree={[]} value="value2" />);
expect(setValue).toHaveBeenCalledTimes(1);
expect(setValue).toHaveBeenLastCalledWith('value2');
});
it('Should props.onChange work correctly', () => {
let change: ((e: { value: string }) => void) | null = null;
SDKRenderer.mockImplementation(({ onChange, didMount }) => {
change = onChange;
useEffect(() => {
didMount({
getValue() {},
setValue() {},
updateWholeDecorations() {},
});
}, []);
});
const onChange = vi.fn();
render(<Renderer variableTree={[]} onChange={onChange} />);
change!({
value: 'foo',
});
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenLastCalledWith('foo');
change!({
value: 'bar',
});
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenLastCalledWith('bar');
});
it('Should props.readonly work correctly', () => {
const { rerender } = render(<Renderer variableTree={[]} readonly={true} />);
expect(SDKRenderer).toHaveBeenCalledTimes(1);
// @ts-expect-error -- mock
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
options: {
readOnly: true,
},
});
rerender(<Renderer variableTree={[]} readonly={false} />);
expect(SDKRenderer).toHaveBeenCalledTimes(2);
// @ts-expect-error -- mock
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
options: {
readOnly: false,
},
});
});
});

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EditorProvider } from '@coze-editor/editor/react';
import { Renderer } from './renderer';
import { Popover } from './popover';
// eslint-disable-next-line @typescript-eslint/naming-convention
const Expression = {
Renderer,
Popover,
EditorProvider,
};
export { Expression };

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { useInterpolationContent } from './use-interpolation-content';
export { useEmptyContent } from './use-empty-content';
export { usePrunedVariableTree } from './use-pruned-variable-tree';
export { useFilteredVariableTree } from './use-filtered-variable-tree';
export { useFocused } from './use-focused';
export { useTreeRefresh, useTreeSearch } from './use-tree';
export { useKeyboard } from './use-keyboard';
export { useSelection } from './use-selection';
export { useOptionsOperations } from './use-options-operations';
export { useSelectedValue } from './use-selected-value';

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
interface InterpolationContent {
from: number;
to: number;
text: string;
offset: number;
textBefore: string;
}
export type { InterpolationContent };

View File

@@ -0,0 +1,79 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo } from 'react';
import { I18n } from '@coze-arch/i18n';
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
import {
ExpressionEditorTreeHelper,
type ExpressionEditorTreeNode,
} from '@/expression-editor';
import { type InterpolationContent } from './types';
function isEmpty(value: unknown) {
return !value || !Array.isArray(value) || value.length === 0;
}
function useEmptyContent(
fullVariableTree: ExpressionEditorTreeNode[] | undefined,
variableTree: ExpressionEditorTreeNode[] | undefined,
interpolationContent: InterpolationContent | undefined,
) {
return useMemo(() => {
if (!interpolationContent) {
return;
}
if (isEmpty(fullVariableTree)) {
if (interpolationContent.textBefore === '') {
return I18n.t('workflow_variable_refer_no_input');
}
return;
}
if (isEmpty(variableTree)) {
if (interpolationContent.text === '') {
return I18n.t('workflow_variable_refer_no_input');
}
const segments = ExpressionEditorParserBuiltin.toSegments(
interpolationContent.textBefore,
);
if (!segments) {
return;
}
const matchTreeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
tree: fullVariableTree ?? [],
segments,
});
const isMatchedButEmpty = matchTreeBranch && matchTreeBranch.length !== 0;
if (isMatchedButEmpty) {
return I18n.t('workflow_variable_refer_no_sub_variable');
}
return;
}
return;
}, [fullVariableTree, variableTree, interpolationContent]);
}
export { useEmptyContent };

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo } from 'react';
import { type ExpressionEditorTreeNode } from '@/expression-editor';
import { getSearchValue } from '../../shared';
import { type InterpolationContent } from './types';
function useFilteredVariableTree(
interpolationContent: InterpolationContent | undefined,
prunedVariableTree: ExpressionEditorTreeNode[],
) {
return useMemo(() => {
if (!prunedVariableTree) {
return [];
}
if (!interpolationContent) {
return prunedVariableTree;
}
const searchValue = getSearchValue(interpolationContent.textBefore);
return prunedVariableTree.filter(variable =>
variable.label.startsWith(searchValue),
);
}, [interpolationContent, prunedVariableTree]);
}
export { useFilteredVariableTree };

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect } from 'react';
function useFocused(editor) {
const [focused, setFocused] = useState(false);
useEffect(() => {
if (!editor) {
return;
}
function handleFocus() {
setFocused(true);
}
function handleBlur() {
setFocused(false);
}
editor.$on('focus', handleFocus);
editor.$on('blur', handleBlur);
return () => {
editor.$off('focus', handleFocus);
editor.$off('blur', handleBlur);
};
}, [editor]);
return focused;
}
export { useFocused };

View File

@@ -0,0 +1,67 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo } from 'react';
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
import { type EditorView } from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import { type InterpolationContent } from './types';
function getInterpolationContentAtPos(view: EditorView, pos: number) {
const tree = syntaxTree(view.state);
const cursor = tree.cursorAt(pos);
do {
if (
cursor.node.type.name === 'Interpolation' &&
cursor.node.firstChild &&
cursor.node.lastChild &&
pos >= cursor.node.firstChild.to &&
pos <= cursor.node.lastChild.from
) {
const text = view.state.sliceDoc(
cursor.node.firstChild.to,
cursor.node.lastChild.from,
);
const offset = pos - cursor.node.firstChild.to;
return {
from: cursor.node.firstChild.to,
to: cursor.node.lastChild.from,
text,
offset,
textBefore: text.slice(0, offset),
};
}
} while (cursor.parent());
}
function useInterpolationContent(
editor: ExpressionEditorAPI | undefined,
pos: number | undefined,
): InterpolationContent | undefined {
return useMemo(() => {
if (!editor || typeof pos === 'undefined') {
return;
}
const view = editor.$view;
return getInterpolationContentAtPos(view, pos);
}, [editor, pos]);
}
export { useInterpolationContent };

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect } from 'react';
import { useLatest } from '../../shared';
type Keymap = Record<string, (e: KeyboardEvent) => void>;
function useKeyboard(enable: boolean, keymap: Keymap) {
const keymapRef = useLatest(keymap);
useEffect(() => {
if (!enable) {
return;
}
function handleKeydown(e) {
const callback = keymapRef.current[e.key];
if (typeof callback === 'function') {
callback(e);
}
}
document.addEventListener('keydown', handleKeydown, false);
return () => {
document.removeEventListener('keydown', handleKeydown, false);
};
}, [enable]);
}
export { useKeyboard };

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo } from 'react';
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
import { applyNode, getOptionInfoFromDOM, selectNodeByIndex } from '../shared';
import { useLatest } from '../../shared';
import { type InterpolationContent } from './types';
// eslint-disable-next-line max-params
function useOptionsOperations(
editor: ExpressionEditorAPI | undefined,
interpolationContent: InterpolationContent | undefined,
treeContainerRef,
treeRef,
) {
const editorRef = useLatest(editor);
const interpolationContentRef = useLatest(interpolationContent);
return useMemo(() => {
function prev() {
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
if (!optionsInfo) {
return;
}
const { elements, selectedIndex } = optionsInfo;
if (elements.length === 1) {
return;
}
const newIndex =
selectedIndex - 1 < 0 ? elements.length - 1 : selectedIndex - 1;
selectNodeByIndex(elements, newIndex);
}
function next() {
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
if (!optionsInfo) {
return;
}
const { elements, selectedIndex } = optionsInfo;
const newIndex =
selectedIndex + 1 >= elements.length ? 0 : selectedIndex + 1;
selectNodeByIndex(elements, newIndex);
}
function apply() {
if (!interpolationContentRef.current) {
return;
}
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
if (!optionsInfo) {
return;
}
const { selectedElement } = optionsInfo;
const selectedDataKey = selectedElement?.getAttribute('data-key');
if (!selectedDataKey) {
return;
}
const variableTreeNode =
treeRef.current?.state?.keyEntities?.[selectedDataKey]?.data;
if (!variableTreeNode) {
return;
}
applyNode(
editorRef.current,
variableTreeNode,
interpolationContentRef.current,
);
}
return {
prev,
next,
apply,
};
}, []);
}
export { useOptionsOperations };

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo } from 'react';
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
import {
ExpressionEditorTreeHelper,
type ExpressionEditorTreeNode,
} from '@/expression-editor';
import { type InterpolationContent } from './types';
function usePrunedVariableTree(
editor: ExpressionEditorAPI | undefined,
variableTree: ExpressionEditorTreeNode[],
interpolationContent: InterpolationContent | undefined,
): ExpressionEditorTreeNode[] {
return useMemo(() => {
if (!editor || !interpolationContent) {
return [];
}
const segments = ExpressionEditorParserBuiltin.toSegments(
interpolationContent.textBefore,
);
if (!segments) {
return [];
}
const prunedVariableTree = ExpressionEditorTreeHelper.pruning({
tree: variableTree,
segments,
});
return prunedVariableTree;
}, [editor, variableTree, interpolationContent]);
}
export { usePrunedVariableTree };

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo } from 'react';
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
import {
ExpressionEditorTreeHelper,
type ExpressionEditorTreeNode,
} from '@/expression-editor';
function useSelectedValue(
interpolationText: string | undefined,
variableTree: ExpressionEditorTreeNode[],
) {
return useMemo(() => {
if (!interpolationText) {
return;
}
const segments =
ExpressionEditorParserBuiltin.toSegments(interpolationText);
if (!segments) {
return;
}
const treeBrach = ExpressionEditorTreeHelper.matchTreeBranch({
tree: variableTree,
segments,
});
if (!treeBrach) {
return;
}
return treeBrach[treeBrach.length - 1];
}, [interpolationText, variableTree]);
}
export { useSelectedValue };

View File

@@ -0,0 +1,95 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect } from 'react';
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
import { type ViewUpdate } from '@codemirror/view';
import { isSkipSelectionChangeUserEvent } from '../shared';
import { useLatest } from '../../shared';
interface Selection {
anchor: number;
head: number;
from: number;
to: number;
}
function isSameSelection(a?: Selection, b?: Selection) {
if (!a && !b) {
return true;
}
return (
a &&
b &&
a.anchor === b.anchor &&
a.head === b.head &&
a.from === b.from &&
a.to === b.to
);
}
function useSelection(editor: ExpressionEditorAPI | undefined) {
const [selection, setSelection] = useState<Selection | undefined>();
const selectionRef = useLatest(selection);
useEffect(() => {
if (!editor) {
return;
}
const view = editor.$view;
function updateSelection(update?: ViewUpdate) {
// 忽略 replaceTextByRange 导致的 selection change效果不触发 selection 变更,进而不显示推荐面板)
if (update?.transactions.some(tr => isSkipSelectionChangeUserEvent(tr))) {
setSelection(undefined);
return;
}
const { from, to, anchor, head } = view.state.selection.main;
const newSelection = { from, to, anchor, head };
if (isSameSelection(newSelection, selectionRef.current)) {
return;
}
setSelection({ from, to, anchor, head });
}
function handleSelectionChange(e: { update: ViewUpdate }) {
updateSelection(e.update);
}
function handleFocus() {
updateSelection();
}
editor.$on('selectionChange', handleSelectionChange);
editor.$on('focus', handleFocus);
return () => {
editor.$off('selectionChange', handleSelectionChange);
editor.$off('focus', handleFocus);
};
}, [editor]);
return selection;
}
export { useSelection };

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect, type MutableRefObject } from 'react';
import { type Tree } from '@coze-arch/bot-semi';
import { type ExpressionEditorTreeNode } from '@/expression-editor';
import { generateUniqueId, getSearchValue, useLatest } from '../../shared';
import { type InterpolationContent } from './types';
// 在数据更新后,强制 Tree 组件重新渲染
function useTreeRefresh(filteredVariableTree: ExpressionEditorTreeNode[]) {
const [treeRefreshKey, setTreeRefreshKey] = useState('');
useEffect(() => {
setTreeRefreshKey(generateUniqueId());
}, [filteredVariableTree]);
return treeRefreshKey;
}
// Tree 组件重新渲染后进行搜索
// eslint-disable-next-line max-params
function useTreeSearch(
treeRefreshKey: string,
treeRef: MutableRefObject<Tree | null>,
interpolationContent: InterpolationContent | undefined,
callback: () => void,
) {
const interpolationContentRef = useLatest(interpolationContent);
useEffect(() => {
if (treeRef.current && interpolationContentRef.current) {
const searchValue = getSearchValue(
interpolationContentRef.current.textBefore,
);
treeRef.current.search(searchValue);
callback();
}
}, [treeRefreshKey, interpolationContent]);
}
export { useTreeRefresh, useTreeSearch };

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Popover } from './popover';

View File

@@ -0,0 +1,146 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable max-nesting-depth */
.expression-editor-suggestion {
z-index: 1000;
overflow: auto;
width: 272px;
max-height: 236px;
background: var(--light-usage-bg-color-bg-3, #FFF);
border: 0.5px solid rgba(153, 182, 255, 12%);
border-radius: 8px;
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 25%);
}
.expression-editor-suggestion-empty {
z-index: 1000;
background: #FFF;
border: 0.5px solid rgba(153, 182, 255, 12%);
border-radius: 8px;
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 25%);
p {
margin: 4px 6px;
font-size: 12px;
font-weight: 600;
font-style: normal;
line-height: 16px;
color: var(--light-usage-text-color-text-2, rgba(29, 28, 35, 60%));
}
}
.expression-editor-suggestion-tree {
:global {
.semi-tree-search-wrapper {
display: none;
}
.semi-tree-option-list {
width: fit-content;
min-width: 100%;
padding: 4px;
padding-left: 0;
li {
height: 32px;
}
.semi-tree-option {
pointer-events: none;
background-color: transparent;
}
.semi-tree-option-label {
pointer-events: auto;
height: 24px;
margin-right: 4px;
padding: 0 4px;
border-radius: 4px;
&:hover {
background: var(--light-usage-fill-color-fill-1, rgb(46 46 56 / 8%));
}
&:active {
background: var(--light-usage-fill-color-fill-2, rgb(46 46 56 / 12%));
}
.semi-tree-option-label-text {
display: inline-block;
width: fit-content;
white-space: nowrap;
& span {
display: inline-block;
width: fit-content;
white-space: nowrap;
}
.semi-tree-option-highlight {
color: var(--light-usage-warning-color-warning, #FF9600)
}
}
}
.semi-tree-option-selected {
font-weight: 600;
color: var(--light-usage-primary-color-primary, #4D53E8);
}
.semi-tree-option-disabled {
.semi-tree-option-label {
cursor: not-allowed;
background: transparent;
}
.semi-icon+.semi-tree-option-label {
color: var(--light-usage-text-color-text-0, #1D1C23);
}
}
}
.semi-tree-option-empty-icon {
width: 16px;
}
.semi-tree-option-expand-icon {
pointer-events: auto;
width: 16px;
height: 16px;
margin-right: 0;
padding: 4px;
border-radius: 4px;
&:hover {
background: var(--light-usage-fill-color-fill-1, rgb(46 46 56 / 8%));
}
&:active {
background: var(--light-usage-fill-color-fill-2, rgb(46 46 56 / 12%));
}
svg {
width: 16px;
height: 16px;
}
}
}
}
.expression-editor-suggestion-keyboard-selected {
:global {
.semi-tree-option-label {
background: var(--light-usage-fill-color-fill-1, rgba(46, 46, 56, 8%)) !important;
}
}
}

View File

@@ -0,0 +1,257 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, {
type ReactNode,
forwardRef,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import classNames from 'classnames';
import {
CursorMirror,
useEditor,
SelectionSide,
} from '@coze-editor/editor/react';
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
import { type TreeNodeData } from '@coze-arch/bot-semi/Tree';
import { type PopoverProps } from '@coze-arch/bot-semi/Popover';
import { Popover as SemiPopover, Tree } from '@coze-arch/bot-semi';
import { type ExpressionEditorTreeNode } from '@/expression-editor';
import { generateUniqueId, useDeepEqualMemo, useLatest } from '../shared';
import { applyNode, getOptionInfoFromDOM, selectNodeByIndex } from './shared';
import {
useEmptyContent,
useFilteredVariableTree,
useFocused,
useInterpolationContent,
usePrunedVariableTree,
useSelection,
useTreeRefresh,
useTreeSearch,
useKeyboard,
useOptionsOperations,
useSelectedValue,
} from './hooks';
import styles from './popover.module.less';
interface Props {
getPopupContainer?: PopoverProps['getPopupContainer'];
variableTree: ExpressionEditorTreeNode[];
className?: string;
onVisibilityChange?: (visible: boolean) => void;
}
// eslint-disable-next-line @coze-arch/max-line-per-function
function Popover({
getPopupContainer,
variableTree: vTree,
className,
onVisibilityChange,
}: Props) {
const variableTree = useDeepEqualMemo(vTree);
const treeRef = useRef<Tree | null>(null);
const treeContainerRef = useRef<HTMLDivElement | null>(null);
const onVisibilityChangeRef = useLatest(onVisibilityChange);
const [posKey, setPosKey] = useState('');
const editor = useEditor<ExpressionEditorAPI | undefined>();
const editorRef = useLatest(editor);
const selection = useSelection(editor);
const focused = useFocused(editor);
const interpolationContent = useInterpolationContent(
editor,
selection?.anchor,
);
const prunedVariableTree = usePrunedVariableTree(
editor,
variableTree,
interpolationContent,
);
const filteredVariableTree = useFilteredVariableTree(
interpolationContent,
prunedVariableTree,
);
const emptyContent = useEmptyContent(
variableTree,
prunedVariableTree,
interpolationContent,
);
const treeRefreshKey = useTreeRefresh(filteredVariableTree);
useTreeSearch(treeRefreshKey, treeRef, interpolationContent, () => {
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
if (!optionsInfo) {
return;
}
const { elements } = optionsInfo;
selectNodeByIndex(elements, 0);
});
// selected 仅用于 Tree 组件对应项展示蓝色选中效果,无其他用途
const selected = useSelectedValue(interpolationContent?.text, variableTree);
// 基于用户选中项,替换所在 {{}} 中的内容
const handleSelect = useCallback(
// eslint-disable-next-line @typescript-eslint/naming-convention
(_, __, node: TreeNodeData) => {
if (!editor || !interpolationContent) {
return;
}
applyNode(editor, node as ExpressionEditorTreeNode, interpolationContent);
},
[editor, interpolationContent],
);
const internalVisible =
focused &&
((Boolean(interpolationContent) && filteredVariableTree.length > 0) ||
Boolean(emptyContent));
const [allowVisible, setAllowVisible] = useState(false);
// 选区变化时,清除锁定效果
useEffect(() => {
setAllowVisible(true);
}, [selection]);
const visible = internalVisible && allowVisible;
const { prev, next, apply } = useOptionsOperations(
editor,
interpolationContent,
treeContainerRef,
treeRef,
);
// 上下键切换推荐项,回车填入
useKeyboard(visible, {
ArrowUp: prev,
ArrowDown: next,
Enter: apply,
});
// ESC 关闭
useKeyboard(visible, {
// eslint-disable-next-line @typescript-eslint/naming-convention
Escape() {
setAllowVisible(false);
},
});
// 推荐面板出现时,禁用 ArrowUp/ArrowDown/Enter 的默认行为(行为改为上下键切换推荐项 & 回车插入)
useEffect(() => {
if (visible === true) {
editorRef.current?.disableKeybindings(['ArrowUp', 'ArrowDown', 'Enter']);
} else {
editorRef.current?.disableKeybindings([]);
}
}, [visible]);
useEffect(() => {
if (typeof onVisibilityChangeRef.current === 'function') {
onVisibilityChangeRef.current(visible);
}
}, [visible]);
return (
<SemiPopover
trigger="custom"
visible={visible}
keepDOM={true}
rePosKey={posKey}
getPopupContainer={getPopupContainer}
content={
<div
onMouseDown={e => e.preventDefault()}
style={{ display: visible ? 'block' : 'none' }}
// The data-attribute is used for other components to ignore some click outside event
data-expression-popover
>
<EmptyContent visible={!!emptyContent} content={emptyContent} />
<TreeContainer
ref={treeContainerRef}
visible={!emptyContent}
className={className}
>
<Tree
// key={treeRefreshKey}
className={styles['expression-editor-suggestion-tree']}
showFilteredOnly
filterTreeNode
onChangeWithObject
ref={treeRef}
treeData={prunedVariableTree}
searchRender={false}
value={selected}
emptyContent={null}
onSelect={handleSelect}
/>
</TreeContainer>
</div>
}
>
<CursorMirror
side={SelectionSide.Anchor}
onChange={() => setPosKey(generateUniqueId())}
/>
</SemiPopover>
);
}
interface EmptyContentProps {
visible: boolean;
content?: string;
}
function EmptyContent({ visible, content }: EmptyContentProps) {
return (
<div
className={styles['expression-editor-suggestion-empty']}
style={{ display: visible ? 'block' : 'none' }}
>
<p>{content}</p>
</div>
);
}
interface TreeContainerProps {
visible: boolean;
className?: string;
children?: ReactNode;
}
const TreeContainer = forwardRef<HTMLDivElement, TreeContainerProps>(
function TreeContainer({ visible, className, children }, ref) {
return (
<div
className={classNames(
className,
styles['expression-editor-suggestion'],
)}
style={{ display: visible ? 'block' : 'none' }}
ref={ref}
>
{children}
</div>
);
},
);
export { Popover };

View File

@@ -0,0 +1,147 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
import {
ExpressionEditorTreeHelper,
type ExpressionEditorTreeNode,
} from '@/expression-editor';
import styles from './popover.module.less';
const SKIP_SELECTION_CHANGE_USER_EVENT = 'api.skip-selection-change';
const SELECTED_OPTION_CLASSNAME =
styles['expression-editor-suggestion-keyboard-selected'];
// modified from:
// file: packages/workflow/components/src/expression-editor/components/suggestion/hooks.ts
// method: computeUIOptions
interface OptionsInfo {
elements: Element[];
selectedIndex: number;
selectedElement?: Element;
}
const getOptionInfoFromDOM = (
root: Element | null,
): OptionsInfo | undefined => {
if (!root) {
return;
}
// 获取所有的选项元素
const foundNodes = root.querySelectorAll(
'.semi-tree-option-list .semi-tree-option',
);
if (foundNodes.length === 0) {
return;
}
const optionElements = [...foundNodes];
// 找到当前高亮的选项
const selectedIndex = optionElements.findIndex(element =>
element.classList.contains(SELECTED_OPTION_CLASSNAME),
);
return {
elements: optionElements,
selectedIndex,
selectedElement: optionElements[selectedIndex],
};
};
function selectNodeByIndex(elements: Element[], index: number) {
const newSelectedElement = elements[index];
if (!newSelectedElement) {
return;
}
// remove old selected class
elements.forEach(element => {
if (element.classList.contains(SELECTED_OPTION_CLASSNAME)) {
element.classList.remove(SELECTED_OPTION_CLASSNAME);
}
});
newSelectedElement.classList.add(SELECTED_OPTION_CLASSNAME);
newSelectedElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
interface ApplyNodeOptions {
from: number;
to: number;
textBefore: string;
}
function isLeafNode(node: ExpressionEditorTreeNode) {
return !node.variable?.children || node.variable.children.length === 0;
}
function applyNode(
editor: ExpressionEditorAPI | undefined,
node: ExpressionEditorTreeNode,
options: ApplyNodeOptions,
) {
if (!editor) {
return;
}
const { from, to, textBefore } = options;
const text = getInsertTextFromNode(node, textBefore);
if (!text) {
return;
}
editor.replaceTextByRange({
from,
to,
text,
cursorOffset: isLeafNode(node) ? '}}'.length : 0,
userEvent: SKIP_SELECTION_CHANGE_USER_EVENT,
});
}
function isSkipSelectionChangeUserEvent(tr) {
return tr.isUserEvent(SKIP_SELECTION_CHANGE_USER_EVENT);
}
function getInsertTextFromNode(
node: ExpressionEditorTreeNode,
textBefore: string,
) {
const segments = ExpressionEditorParserBuiltin.toSegments(textBefore) ?? [];
return ExpressionEditorTreeHelper.concatFullPath({
node,
segments,
});
}
export {
getOptionInfoFromDOM,
applyNode,
selectNodeByIndex,
isSkipSelectionChangeUserEvent,
};

View File

@@ -0,0 +1,148 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type MutableRefObject, useMemo } from 'react';
import { type EditorAPI } from '@coze-editor/editor/preset-expression';
import { mixLanguages, astDecorator } from '@coze-editor/editor';
import { EditorView } from '@codemirror/view';
import { type ExpressionEditorTreeNode } from '@/expression-editor/type';
import { validateExpression } from './validate';
function useInputRules(apiRef: MutableRefObject<EditorAPI | null>) {
return useMemo(
() => [
{
type: 'character' as const,
triggerCharacter: '{',
handler({ from, to }) {
apiRef.current?.replaceTextByRange({
from,
to,
text: '{{}}',
cursorOffset: -2,
});
return true;
},
},
],
[],
);
}
type Extension =
| {
extension: Extension;
}
| readonly Extension[];
function useExtensions(
variableTreeRef: MutableRefObject<ExpressionEditorTreeNode[] | undefined>,
): Extension[] {
return useMemo(
() => [
mixLanguages({}),
EditorView.baseTheme({
'& .cm-line': {
padding: 0,
},
'& .cm-placeholder': {
color: 'inherit',
opacity: 0.333,
},
'& .cm-content': {
wordBreak: 'break-all',
},
}),
[
astDecorator.whole.of((cursor, state) => {
if (!variableTreeRef.current) {
return;
}
if (
cursor.node.type.name === 'Interpolation' &&
// 由于 parser 存在容错能力
// 可能出现缺少右花括号也被正常解析为 Interpolation 的情况
// 如:{{variable
cursor.node.firstChild?.type.name === '{{' &&
cursor.node.lastChild?.type.name === '}}'
) {
const source = state.sliceDoc(
cursor.node.firstChild.to,
cursor.node.lastChild.from,
);
const isValid = validateExpression(source, variableTreeRef.current);
if (isValid) {
return {
type: 'className',
className: 'cm-decoration-interpolation-valid',
};
}
return {
type: 'className',
className: 'cm-decoration-interpolation-invalid',
};
}
}),
EditorView.baseTheme({
'& .cm-decoration-interpolation-valid': {
color: '#6675D9',
caretColor: '#6675D9',
},
// '& .cm-decoration-interpolation-invalid': {
// },
}),
],
[
astDecorator.fromCursor.of((cursor, state) => {
const { anchor } = state.selection.main;
const pos = anchor;
if (
cursor.node.type.name === 'Interpolation' &&
cursor.node.firstChild &&
cursor.node.lastChild &&
pos >= cursor.node.firstChild.to &&
pos <= cursor.node.lastChild.from
) {
return {
type: 'background',
className: 'cm-decoration-interpolation-active',
from: cursor.node.firstChild.from,
to: cursor.node.lastChild.to,
};
}
}),
EditorView.baseTheme({
'& .cm-decoration-interpolation-active': {
borderRadius: '2px',
backgroundColor:
'var(--light-usage-fill-color-fill-1, rgba(46, 46, 56, 0.08))',
},
}),
],
],
[],
);
}
export { useInputRules, useExtensions };

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Renderer } from './renderer';

View File

@@ -0,0 +1,127 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Renderer as SDKRenderer } from '@coze-editor/editor/react';
import expression, {
type EditorAPI as ExpressionEditorAPI,
} from '@coze-editor/editor/preset-expression';
import { type ExpressionEditorTreeNode } from '@/expression-editor';
import { useDeepEqualMemo, useLatest } from '../shared';
import { useInputRules, useExtensions } from './hooks';
interface RendererProps {
value?: string;
className?: string;
readonly?: boolean;
placeholder?: string;
dataTestID?: string;
variableTree: ExpressionEditorTreeNode[];
onChange?: (value: string) => void;
}
function Renderer({
value,
variableTree,
className,
readonly,
placeholder,
dataTestID,
onChange,
}: RendererProps) {
const apiRef = useRef<ExpressionEditorAPI | null>(null);
const variableTreeRef = useLatest<ExpressionEditorTreeNode[] | undefined>(
variableTree,
);
const changedVariableTree = useDeepEqualMemo(variableTree);
const inputRules = useInputRules(apiRef);
const extensions = useExtensions(variableTreeRef);
const contentAttributes = useMemo(
() => ({
class: `${className ?? ''} flow-canvas-not-draggable`,
'data-testid': dataTestID ?? '',
'data-flow-editor-selectable': 'false',
}),
[className, dataTestID],
);
const handleChange = useCallback(
(e: { value: string }) => {
if (typeof onChange === 'function') {
onChange(e.value);
}
},
[onChange],
);
// Note: changedVariableTree 这里只用来进行性能优化
// useVariableTree 的触发时机仍然存在问题,缩放画布也会频繁触发 variableTree 的变更
useEffect(() => {
const editor = apiRef.current;
if (!editor) {
return;
}
editor.updateWholeDecorations();
}, [changedVariableTree]);
function handleFocus() {
const editor = apiRef.current;
if (!editor) {
return;
}
editor.updateWholeDecorations();
}
// 值受控
useEffect(() => {
const editor = apiRef.current;
if (!editor) {
return;
}
if (typeof value === 'string' && value !== editor.getValue()) {
editor.setValue(value);
}
}, [value]);
return (
<SDKRenderer
plugins={expression}
defaultValue={value ?? ''}
options={{
fontSize: 14,
inputRules,
readOnly: readonly,
placeholder,
contentAttributes,
}}
onFocus={handleFocus}
onChange={handleChange}
extensions={extensions}
didMount={api => (apiRef.current = api)}
/>
);
}
export { Renderer };

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
import {
ExpressionEditorSegmentType,
ExpressionEditorTreeHelper,
type ExpressionEditorTreeNode,
} from '@/expression-editor';
function validateExpression(source: string, tree: ExpressionEditorTreeNode[]) {
const segments = ExpressionEditorParserBuiltin.toSegments(source);
if (!segments) {
return false;
}
if (
segments[segments.length - 1].type === ExpressionEditorSegmentType.EndEmpty
) {
return false;
}
// 2. segments mix variable tree, match tree branch
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
tree,
segments,
});
if (!treeBranch) {
return false;
}
// 3. if full segments path could match one tree branch, the pattern is valid
return true;
}
export { validateExpression };

View File

@@ -0,0 +1,99 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type MutableRefObject,
useRef,
useState,
useLayoutEffect,
} from 'react';
import { dequal } from 'dequal';
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
import { ExpressionEditorSegmentType } from '@/expression-editor';
function useLatest<T>(value: T): MutableRefObject<T> {
const ref = useRef(value);
ref.current = value;
return ref;
}
// 解除 parent 导致的循环依赖(否则无法深比较)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function cloneWithout(target: any, keys: string[]) {
// target 为 undefined 时会抛错
try {
return JSON.parse(
JSON.stringify(target, function (key, value) {
if (keys.includes(key)) {
return null;
}
return value;
}),
);
} catch (e) {
return target;
}
}
function useDeepEqualMemo<T>(value: T): T {
const [state, setState] = useState<T>(value);
const lastValueRef = useRef<T>(value);
useLayoutEffect(() => {
if (
!dequal(
cloneWithout(value, ['parent']),
cloneWithout(lastValueRef.current, ['parent']),
)
) {
setState(value);
lastValueRef.current = value;
}
}, [value]);
return state;
}
function generateUniqueId(): string {
return Math.floor(Math.random() * 2e6).toString(36);
}
function getSearchValue(textBefore: string) {
const segments = ExpressionEditorParserBuiltin.toSegments(textBefore);
if (!segments) {
return '';
}
const lastSegment =
segments[segments.length - 1].type ===
ExpressionEditorSegmentType.ArrayIndex
? segments[segments.length - 2] // 数组索引属于上一层级,需要去除防止影响到搜索值
: segments[segments.length - 1];
if (
!lastSegment ||
lastSegment.type !== ExpressionEditorSegmentType.ObjectKey
) {
return '';
}
return lastSegment.objectKey;
}
export { useLatest, useDeepEqualMemo, getSearchValue, generateUniqueId };

View File

@@ -0,0 +1,23 @@
.expression-editor-counter {
position: absolute;
right: 0;
bottom: 0;
color: var(--semi-color-text-2);
p {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 24px;
padding: 3px 12px 5px;
font-size: 12px;
line-height: 16px;
text-align: right;
}
&-error {
color: var(--semi-color-danger);
}
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo, type FC } from 'react';
import classNames from 'classnames';
import { type ExpressionEditorModel } from '../../model';
import styles from './index.module.less';
interface ExpressionEditorCounterProps {
className?: string;
model: ExpressionEditorModel;
maxLength?: number;
disabled?: boolean;
isError?: boolean;
}
/**
* 长度计数器
*/
export const ExpressionEditorCounter: FC<
ExpressionEditorCounterProps
> = props => {
const { className, model, maxLength, disabled, isError } = props;
const { visible, count, max } = useMemo(() => {
if (typeof model.value.length !== 'number') {
return {
visible: false,
};
}
if (typeof maxLength !== 'number') {
return {
visible: false,
};
}
return {
visible: true,
count: model.value.length,
max: maxLength,
};
}, [model.value.length, maxLength]);
if (disabled || !visible) {
return <></>;
}
return (
<div
className={classNames(styles['expression-editor-counter'], className, {
[styles['expression-editor-counter-error']]: isError,
})}
>
<p>
{count} / {max}
</p>
</div>
);
};

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ExpressionEditorCounter } from './counter';
export { ExpressionEditorLeaf } from './leaf';
export { ExpressionEditorRender } from './render';
export { ExpressionEditorSuggestion } from './suggestion';

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { type CSSProperties } from 'react';
import type { RenderLeafProps } from 'slate-react';
import { ExpressionEditorSignal } from '../../constant';
const LeafStyle: Partial<Record<ExpressionEditorSignal, CSSProperties>> = {
[ExpressionEditorSignal.Valid]: {
color: '#6675D9',
},
[ExpressionEditorSignal.Invalid]: {
color: 'inherit',
},
[ExpressionEditorSignal.SelectedValid]: {
color: '#6675D9',
borderRadius: 2,
backgroundColor:
'var(--light-usage-fill-color-fill-1, rgba(46, 46, 56, 0.08))',
},
[ExpressionEditorSignal.SelectedInvalid]: {
color: 'inherit',
borderRadius: 2,
backgroundColor:
'var(--light-usage-fill-color-fill-1, rgba(46, 46, 56, 0.08))',
},
};
export const ExpressionEditorLeaf = (props: RenderLeafProps) => {
const { type } = props.leaf as {
type?: ExpressionEditorSignal;
};
return (
<span style={type && LeafStyle[type]} {...props.attributes}>
{props.children}
</span>
);
};

View File

@@ -0,0 +1,11 @@
/* stylelint-disable declaration-no-important */
/* stylelint-disable property-no-vendor-prefix */
.slate-editable {
// WARNING: 别删,有人在全局加了 user-select: none 删了会在低版本 safari 浏览器上出现无法输入文本
user-select: text !important;
-webkit-user-select: text !important;
-moz-user-select: text !important;
-ms-user-select: text !important;
-o-user-select: text !important;
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { type CompositionEventHandler } from 'react';
import { Slate, Editable } from 'slate-react';
import classNames from 'classnames';
import { ExpressionEditorLeaf } from '../leaf';
import { type ExpressionEditorLine } from '../../type';
import { type ExpressionEditorModel } from '../../model';
import styles from './index.module.less';
interface ExpressionEditorRenderProps {
model: ExpressionEditorModel;
className?: string;
placeholder?: string;
readonly?: boolean;
onFocus?: () => void;
onBlur?: () => void;
dataTestID?: string;
}
/**
* 应当只包含编辑器逻辑,业务无关
*/
export const ExpressionEditorRender: React.FC<
ExpressionEditorRenderProps
> = props => {
const {
model,
className,
placeholder,
onFocus,
onBlur,
readonly = false,
dataTestID,
} = props;
return (
<div className={className}>
<Slate
editor={model.editor}
initialValue={model.lines}
onChange={value => {
// eslint-disable-next-line @typescript-eslint/require-await -- 防止阻塞 slate 渲染
const asyncOnChange = async () => {
const lines = value as ExpressionEditorLine[];
model.change(lines);
model.select(lines);
};
asyncOnChange();
}}
>
<Editable
data-testid={dataTestID}
className={classNames(
styles.slateEditable,
'flow-canvas-not-draggable',
)}
data-flow-editor-selectable="false"
readOnly={readonly}
onFocus={onFocus}
onBlur={onBlur}
placeholder={placeholder}
renderLeaf={ExpressionEditorLeaf}
decorate={model.decorate}
onKeyDown={e => model.keydown(e)}
onCompositionStart={e =>
model.compositionStart(
e as unknown as CompositionEventHandler<HTMLDivElement>,
)
}
/>
</Slate>
</div>
);
};

View File

@@ -0,0 +1,693 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/no-namespace */
/* eslint-disable @typescript-eslint/no-magic-numbers */
/* eslint-disable max-lines */
import { useCallback, useEffect } from 'react';
import { ReactEditor } from 'slate-react';
import { Range, type Selection, Transforms } from 'slate';
import { I18n } from '@coze-arch/i18n';
import type {
ExpressionEditorEventParams,
ExpressionEditorParseData,
ExpressionEditorTreeNode,
} from '../../type';
import { ExpressionEditorTreeHelper } from '../../tree-helper';
import { ExpressionEditorParser } from '../../parser';
import type { ExpressionEditorModel } from '../../model';
import {
ExpressionEditorEvent,
ExpressionEditorSegmentType,
ExpressionEditorToken,
} from '../../constant';
import {
type SuggestionReducer,
SuggestionActionType,
type SuggestionState,
} from './type';
import styles from './index.module.less';
/** 内置函数 */
namespace SuggestionViewUtils {
/** 编辑器选中事件处理 */
export const editorSelectHandler = (params: {
reducer: SuggestionReducer;
payload: ExpressionEditorEventParams<ExpressionEditorEvent.Select>;
}) => {
const { reducer, payload } = params;
const [state, dispatch] = reducer;
// 设置解析数据
const parseData = ExpressionEditorParser.parse({
lineContent: payload.content,
lineOffset: payload.offset,
});
if (!parseData) {
dispatch({
type: SuggestionActionType.ClearParseDataAndEditorPath,
});
dispatch({
type: SuggestionActionType.SetVisible,
payload: false,
});
dispatch({
type: SuggestionActionType.SetRect,
payload: undefined,
});
return;
}
dispatch({
type: SuggestionActionType.SetParseDataAndEditorPath,
payload: {
parseData,
editorPath: payload.path,
},
});
// 重置UI组件内部状态
const shouldRefresh = parseData.content.reachable === '';
if (shouldRefresh) {
dispatch({
type: SuggestionActionType.Refresh,
});
}
// 设置选中值
const selected = SuggestionViewUtils.computeSelected({
model: state.model,
parseData,
});
dispatch({
type: SuggestionActionType.SetSelected,
payload: selected,
});
// 设置可见变量树
const variableTree = SuggestionViewUtils.computeVariableTree({
model: state.model,
parseData,
});
dispatch({
type: SuggestionActionType.SetVariableTree,
payload: variableTree,
});
// 设置匹配树枝
const matchTreeBranch: ExpressionEditorTreeNode[] | undefined =
ExpressionEditorTreeHelper.matchTreeBranch({
tree: state.model.variableTree,
segments: parseData.segments.reachable,
});
dispatch({
type: SuggestionActionType.SetMatchTreeBranch,
payload: matchTreeBranch,
});
// 设置空内容
const emptyContent = SuggestionViewUtils.computeEmptyContent({
parseData,
fullVariableTree: state.model.variableTree,
variableTree,
matchTreeBranch,
});
dispatch({
type: SuggestionActionType.SetEmptyContent,
payload: emptyContent,
});
// 设置UI相对坐标
const rect = SuggestionViewUtils.computeRect(state);
dispatch({
type: SuggestionActionType.SetRect,
payload: rect,
});
if (!rect) {
dispatch({
type: SuggestionActionType.ClearParseDataAndEditorPath,
});
return;
}
// FIXME: 设置搜索值很hack的逻辑后面建议重构不用semi组件自己写一个
if (!state.ref.tree.current) {
// 不设为可见获取不到ref
dispatch({
type: SuggestionActionType.SetVisible,
payload: true,
});
}
dispatch({
type: SuggestionActionType.SearchEffectStart,
});
};
export const getFinalScale = (state: SuggestionState): number => {
if (state.entities.playgroundConfig) {
return state.entities.playgroundConfig.finalScale;
}
return 1;
};
/** 计算可见时相对父容器坐标 */
export const computeRect = (
state: SuggestionState,
):
| {
top: number;
left: number;
}
| undefined => {
const borderTopOffset = 5;
const containerRect = state.ref.container.current?.getBoundingClientRect();
if (!state.model.editor.selection || !containerRect) {
return;
}
try {
const rect = ReactEditor.toDOMRange(
state.model.editor,
state.model.editor.selection,
).getBoundingClientRect();
return {
top:
(rect.top - containerRect.top) / getFinalScale(state) +
borderTopOffset,
left: (rect.left - containerRect.left) / getFinalScale(state),
};
} catch (e) {
// slate DOM 计算报错可忽略
return;
}
};
/** 计算当前选中变量 */
export const computeSelected = (params: {
model: ExpressionEditorModel;
parseData: ExpressionEditorParseData;
}): ExpressionEditorTreeNode | undefined => {
const { model, parseData } = params;
if (!parseData?.segments.inline) {
return;
}
const treeBrach = ExpressionEditorTreeHelper.matchTreeBranch({
tree: model.variableTree,
segments: parseData.segments.inline,
});
if (!treeBrach) {
return;
}
return treeBrach[treeBrach.length - 1];
};
/** 计算当前搜索值 */
export const computeSearch = (
parseData: ExpressionEditorParseData,
): string => {
if (!parseData) {
return '';
}
const segments = parseData.segments.reachable;
const lastSegment =
segments[segments.length - 1].type ===
ExpressionEditorSegmentType.ArrayIndex
? segments[segments.length - 2] // 数组索引属于上一层级,需要去除防止影响到搜索值
: segments[segments.length - 1];
if (
!lastSegment ||
lastSegment.type !== ExpressionEditorSegmentType.ObjectKey
) {
return '';
}
return lastSegment.objectKey;
};
/** 计算裁剪层级的变量树 */
export const computeVariableTree = (params: {
model: ExpressionEditorModel;
parseData: ExpressionEditorParseData;
}): ExpressionEditorTreeNode[] => {
const { model, parseData } = params;
if (!parseData?.segments.reachable) {
return [];
}
const prunedVariableTree = ExpressionEditorTreeHelper.pruning({
tree: model.variableTree,
segments: parseData.segments.reachable,
});
return prunedVariableTree;
};
export const computeEmptyContent = (params: {
parseData: ExpressionEditorParseData;
fullVariableTree: ExpressionEditorTreeNode[];
variableTree: ExpressionEditorTreeNode[];
matchTreeBranch: ExpressionEditorTreeNode[] | undefined;
}): string | undefined => {
const { parseData, fullVariableTree, variableTree, matchTreeBranch } =
params;
if (
!fullVariableTree ||
!Array.isArray(fullVariableTree) ||
fullVariableTree.length === 0
) {
if (parseData.content.reachable === '') {
return I18n.t('workflow_variable_refer_no_input');
}
return;
}
if (
!variableTree ||
!Array.isArray(variableTree) ||
variableTree.length === 0
) {
if (parseData.content.inline === '') {
return I18n.t('workflow_variable_refer_no_input');
}
if (matchTreeBranch && matchTreeBranch.length !== 0) {
return I18n.t('workflow_variable_refer_no_sub_variable');
}
return;
}
return;
};
export const keyboardSelectedClassName = () =>
styles['expression-editor-suggestion-keyboard-selected'];
/** 将选中项设为高亮 */
export const setUIOptionSelected = (uiOption: Element): void => {
if (
!uiOption?.classList?.add ||
!uiOption?.classList?.contains ||
uiOption.classList.contains('semi-tree-option-empty')
) {
return;
}
uiOption.classList.add(SuggestionViewUtils.keyboardSelectedClassName());
};
/** 获取所有选项UI元素 */
export const computeUIOptions = (
state: SuggestionState,
):
| {
optionList: Element[];
selectedIndex: number;
selectedOption?: Element;
}
| undefined => {
// 获取所有的选项元素
const optionListDom =
state.ref.suggestion.current?.children?.[0]?.children?.[1]?.children;
if (!optionListDom) {
return;
}
const optionList = Array.from(optionListDom);
// 找到当前高亮的选项
const selectedIndex = optionList.findIndex(element =>
element.classList.contains(keyboardSelectedClassName()),
);
return {
optionList,
selectedIndex,
selectedOption: optionList[selectedIndex],
};
};
/** 禁止变更 visible 防止 ui 抖动 */
export const preventVisibleJitter = (
reducer: SuggestionReducer,
time = 150,
) => {
const [state, dispatch] = reducer;
if (!state.allowVisibleChange) {
return;
}
dispatch({
type: SuggestionActionType.SetAllowVisibleChange,
payload: false,
});
setTimeout(() => {
dispatch({
type: SuggestionActionType.SetAllowVisibleChange,
payload: true,
});
}, time);
};
/** 清空键盘UI选项 */
export const clearSelectedUIOption = (state: SuggestionState) => {
const uiOptions = SuggestionViewUtils.computeUIOptions(state);
if (uiOptions?.selectedOption) {
// 清空键盘选中状态
uiOptions.selectedOption.classList.remove(
SuggestionViewUtils.keyboardSelectedClassName(),
);
}
};
/** 默认键盘UI选项为第一项 */
export const selectFirstUIOption = (state: SuggestionState) => {
const uiOptions = SuggestionViewUtils.computeUIOptions(state);
if (!uiOptions?.optionList) {
return;
}
clearSelectedUIOption(state);
if (!uiOptions?.optionList?.[0]?.classList?.add) {
return;
}
// 默认首项高亮
SuggestionViewUtils.setUIOptionSelected(uiOptions.optionList[0]);
};
}
/** 选中节点 */
export const useSelectNode = (reducer: SuggestionReducer) => {
const [state] = reducer;
return useCallback(
(node: ExpressionEditorTreeNode) => {
const fullPath: string = ExpressionEditorTreeHelper.concatFullPath({
node,
segments: state.parseData?.segments.reachable ?? [],
});
if (!state.parseData || !state.editorPath) {
return;
}
const selection: Selection = {
anchor: {
path: state.editorPath,
offset: state.parseData.offset.lastStart - 1,
},
focus: {
path: state.editorPath,
offset: state.parseData.offset.firstEnd + 2,
},
};
const insertText = `${ExpressionEditorToken.FullStart}${fullPath}${ExpressionEditorToken.FullEnd}`;
// 替换文本
Transforms.insertText(state.model.editor, insertText, {
at: selection,
});
},
[state],
);
};
/** 挂载监听器 */
export const useListeners = (reducer: SuggestionReducer) => {
const [state, dispatch] = reducer;
useEffect(() => {
// 挂载监听: 鼠标点击事件
const mouseHandler = (e: MouseEvent) => {
if (!state.visible || !state.ref.suggestion.current) {
return;
}
if (state.ref.suggestion.current?.contains(e.target as Node)) {
return;
}
dispatch({
type: SuggestionActionType.SetVisible,
payload: false,
});
dispatch({
type: SuggestionActionType.SetRect,
payload: undefined,
});
};
window.addEventListener('mousedown', mouseHandler);
const mouseDisposer = () => {
window.removeEventListener('mousedown', mouseHandler);
};
return () => {
// 销毁时卸载监听器防止内存泄露
mouseDisposer();
};
}, [state]);
useEffect(() => {
// 挂载监听: 编辑器选择事件
const editorSelectDisposer = state.model.on<ExpressionEditorEvent.Select>(
ExpressionEditorEvent.Select,
payload =>
SuggestionViewUtils.editorSelectHandler({
reducer,
payload,
}),
);
return () => {
// 销毁时卸载监听器防止内存泄露
editorSelectDisposer();
};
}, []);
useEffect(() => {
// 挂载监听: 编辑器拼音输入事件
const compositionStartDisposer =
state.model.on<ExpressionEditorEvent.CompositionStart>(
ExpressionEditorEvent.CompositionStart,
payload =>
dispatch({
type: SuggestionActionType.SetVisible,
payload: false,
}),
);
return () => {
// 销毁时卸载监听器防止内存泄露
compositionStartDisposer();
};
}, []);
useEffect(() => {
// 初始化前首次渲染激活DOM
if (state.initialized) {
return;
}
dispatch({
type: SuggestionActionType.SetVisible,
payload: false,
});
}, []);
useEffect(() => {
// 初始化前监听到DOM激活后隐藏
if (state.initialized || state.visible) {
return;
}
dispatch({
type: SuggestionActionType.SetHiddenDOM,
payload: false,
});
dispatch({
type: SuggestionActionType.SetInitialized,
});
}, [state]);
};
/** 键盘上下回车键选中节点 */
export const useKeyboardSelect = (
reducer: SuggestionReducer,
selectNode: (node: ExpressionEditorTreeNode) => void,
) => {
const [state, dispatch] = reducer;
// 键盘上下
useEffect(() => {
const keyboardArrowHandler = event => {
if (
!state.visible ||
!state.ref.suggestion.current ||
!['ArrowDown', 'ArrowUp'].includes(event.key)
) {
return;
}
const uiOptions = SuggestionViewUtils.computeUIOptions(state);
if (!uiOptions) {
return;
}
const { optionList, selectedIndex } = uiOptions;
if (optionList.length === 1) {
// 仅有一项可选项的时候不做处理
return;
}
event.preventDefault();
let newIndex = selectedIndex;
if (event.key === 'ArrowDown') {
// 如果当前没有高亮的选项或者是最后一个选项,则高亮第一个选项
newIndex =
selectedIndex === -1 || selectedIndex === optionList.length - 1
? 0
: selectedIndex + 1;
} else if (event.key === 'ArrowUp') {
// 如果当前没有高亮的选项或者是第一个选项,则高亮最后一个选项
newIndex =
selectedIndex <= 0 ? optionList.length - 1 : selectedIndex - 1;
}
const selectedOption = optionList[newIndex];
// 更新高亮选项
if (selectedIndex !== -1) {
optionList[selectedIndex].classList.remove(
SuggestionViewUtils.keyboardSelectedClassName(),
);
}
SuggestionViewUtils.setUIOptionSelected(selectedOption);
// 将新选中的选项滚动到视图中
selectedOption.scrollIntoView({
behavior: 'smooth', // 平滑滚动
block: 'nearest', // 最接近的视图边界,可能是顶部或底部
});
};
document.addEventListener('keydown', keyboardArrowHandler);
return () => {
document.removeEventListener('keydown', keyboardArrowHandler);
};
}, [state]);
// 键盘回车
useEffect(() => {
const keyboardEnterHandler = event => {
if (
!state.visible ||
!state.ref.suggestion.current ||
event.key !== 'Enter'
) {
return;
}
const uiOptions = SuggestionViewUtils.computeUIOptions(state);
if (!uiOptions?.selectedOption) {
return;
}
const { selectedOption } = uiOptions;
const selectedDataKey = selectedOption.getAttribute('data-key');
if (!selectedDataKey) {
return;
}
const variableTreeNode =
state.ref.tree.current?.state?.keyEntities?.[selectedDataKey]?.data;
if (!variableTreeNode) {
return;
}
event.preventDefault();
dispatch({
type: SuggestionActionType.SetVisible,
payload: false,
});
selectNode(variableTreeNode as ExpressionEditorTreeNode);
if (
!variableTreeNode.variable?.children ||
variableTreeNode.variable?.children.length === 0
) {
// 叶子节点
return;
}
// 非叶子节点,光标向前移动两格
const { selection } = state.model.editor;
if (selection && Range.isCollapsed(selection)) {
// 向前移动两个字符的光标
Transforms.move(state.model.editor, { distance: 2, reverse: true });
}
SuggestionViewUtils.preventVisibleJitter(reducer);
};
document.addEventListener('keydown', keyboardEnterHandler);
return () => {
document.removeEventListener('keydown', keyboardEnterHandler);
};
}, [state]);
// 键盘 ESC 取消弹窗
useEffect(() => {
const keyboardESCHandler = event => {
if (
!state.visible ||
!state.ref.suggestion.current ||
event.key !== 'Escape'
) {
return;
}
event.preventDefault();
dispatch({
type: SuggestionActionType.SetVisible,
payload: false,
});
};
document.addEventListener('keydown', keyboardESCHandler);
return () => {
document.removeEventListener('keydown', keyboardESCHandler);
};
}, [state]);
// 默认选中首项
useEffect(() => {
SuggestionViewUtils.selectFirstUIOption(state);
}, [state]);
};
/** 等待semi组件数据更新后的副作用 */
export const useRenderEffect = (reducer: SuggestionReducer) => {
const [state, dispatch] = reducer;
// 组件树状数据更新后设置搜索值
useEffect(() => {
if (!state.renderEffect.search || !state.parseData) {
return;
}
dispatch({
type: SuggestionActionType.SearchEffectEnd,
});
const searchValue = SuggestionViewUtils.computeSearch(state.parseData);
state.ref.tree.current?.search(searchValue);
if (!searchValue && state.matchTreeBranch) {
dispatch({
type: SuggestionActionType.SetVisible,
payload: true,
});
return;
}
dispatch({
type: SuggestionActionType.FilteredEffectStart,
});
}, [state]);
// 搜索过滤后是否为空
useEffect(() => {
if (!state.renderEffect.filtered) {
return;
}
dispatch({
type: SuggestionActionType.FilteredEffectEnd,
});
const filteredKeys = Array.from(
state.ref.tree.current?.state.filteredKeys || [],
);
if (!state.emptyContent && filteredKeys.length === 0) {
dispatch({
type: SuggestionActionType.SetVisible,
payload: false,
});
return;
}
dispatch({
type: SuggestionActionType.SetVisible,
payload: true,
});
}, [state]);
};

View File

@@ -0,0 +1,145 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable declaration-no-important */
/* stylelint-disable max-nesting-depth */
.expression-editor-suggestion-pin {
position: absolute;
width: 0;
height: 1.5rem;
transform: translateY(-0.5rem);
}
.expression-editor-suggestion {
max-height: 236px;
width: 272px;
z-index: 1000;
background: var(--light-usage-bg-color-bg-3, #FFF);
border-radius: 8px;
border: 0.5px solid rgba(153, 182, 255, 12%);
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 25%);
overflow: auto;
}
.expression-editor-suggestion-empty {
z-index: 1000;
border-radius: 8px;
border: 0.5px solid rgba(153, 182, 255, 12%);
background: #FFF;
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 25%);
p {
margin: 4px 6px;
color: var(--light-usage-text-color-text-2, rgba(29, 28, 35, 60%));
font-size: 12px;
font-style: normal;
font-weight: 600;
line-height: 16px;
}
}
.expression-editor-suggestion-tree {
:global {
.semi-tree-search-wrapper {
display: none;
}
.semi-tree-option-list {
width: fit-content;
min-width: 100%;
padding: 4px;
padding-left: 0;
li {
height: 32px;
}
.semi-tree-option {
background-color: transparent;
pointer-events: none;
}
.semi-tree-option-label {
height: 24px;
padding: 0 4px;
margin-right: 4px;
border-radius: 4px;
pointer-events: auto;
&:hover {
background: var(--light-usage-fill-color-fill-1, rgb(46 46 56 / 8%));
}
&:active {
background: var(--light-usage-fill-color-fill-2, rgb(46 46 56 / 12%));
}
.semi-tree-option-label-text {
width: fit-content;
display: inline-block;
white-space: nowrap;
& span {
width: fit-content;
display: inline-block;
white-space: nowrap;
}
.semi-tree-option-highlight {
color: var(--light-usage-warning-color-warning, #FF9600)
}
}
}
.semi-tree-option-selected {
color: var(--light-usage-primary-color-primary, #4D53E8);
font-weight: 600;
}
.semi-tree-option-disabled {
.semi-tree-option-label {
cursor: not-allowed;
background: transparent;
}
.semi-icon+.semi-tree-option-label {
color: var(--light-usage-text-color-text-0, #1D1C23);
}
}
}
.semi-tree-option-empty-icon {
width: 16px;
}
.semi-tree-option-expand-icon {
width: 16px;
height: 16px;
padding: 4px;
margin-right: 0;
border-radius: 4px;
pointer-events: auto;
&:hover {
background: var(--light-usage-fill-color-fill-1, rgb(46 46 56 / 8%));
}
&:active {
background: var(--light-usage-fill-color-fill-2, rgb(46 46 56 / 12%));
}
svg {
width: 16px;
height: 16px;
}
}
}
}
.expression-editor-suggestion-keyboard-selected {
:global {
.semi-tree-option-label {
background: var(--light-usage-fill-color-fill-1, rgba(46, 46, 56, 8%)) !important;
}
}
}

View File

@@ -0,0 +1,150 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC, useRef, type RefObject } from 'react';
import classNames from 'classnames';
import { type PopoverProps } from '@coze-arch/bot-semi/Popover';
import { Popover, Tree } from '@coze-arch/bot-semi';
import type { SelectorBoxConfigEntity } from '@flowgram-adapter/free-layout-editor';
import type { PlaygroundConfigEntity } from '@flowgram-adapter/free-layout-editor';
import { type ExpressionEditorTreeNode } from '../../type';
import { type ExpressionEditorModel } from '../../model';
import { useSuggestionReducer } from './state';
import {
useListeners,
useSelectNode,
useKeyboardSelect,
useRenderEffect,
} from './hooks';
import styles from './index.module.less';
interface ExpressionEditorSuggestionProps {
className?: string;
model: ExpressionEditorModel;
containerRef: RefObject<HTMLDivElement>;
getPopupContainer?: PopoverProps['getPopupContainer'];
playgroundConfig?: PlaygroundConfigEntity;
selectorBoxConfig?: SelectorBoxConfigEntity;
disabled?: boolean;
}
/**
* 自动提示
*/
export const ExpressionEditorSuggestion: FC<
ExpressionEditorSuggestionProps
> = props => {
const {
model,
containerRef,
className,
playgroundConfig,
selectorBoxConfig,
disabled = false,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getPopupContainer = () => containerRef.current!,
} = props;
const suggestionRef = useRef<HTMLDivElement>(null);
const treeRef = useRef<Tree>(null);
const suggestionReducer = useSuggestionReducer({
model,
entities: {
playgroundConfig,
selectorBoxConfig,
},
ref: {
container: containerRef,
suggestion: suggestionRef,
tree: treeRef,
},
});
const [state] = suggestionReducer;
const selectNode = useSelectNode(suggestionReducer);
useRenderEffect(suggestionReducer);
useListeners(suggestionReducer);
useKeyboardSelect(suggestionReducer, selectNode);
if (disabled) {
return <></>;
}
return (
<Popover
trigger="custom"
visible={state.visible}
keepDOM={true}
getPopupContainer={getPopupContainer}
content={
<>
<div
className={styles['expression-editor-suggestion-empty']}
style={{
display:
!state.visible || !state.emptyContent ? 'none' : 'inherit',
}}
>
<p>{state.emptyContent}</p>
</div>
<div
className={classNames(
className,
styles['expression-editor-suggestion'],
)}
ref={suggestionRef}
style={{
display:
!state.visible || state.emptyContent || state.hiddenDOM
? 'none'
: 'inherit',
}}
onClick={e => {
e.stopPropagation();
e.preventDefault();
}}
>
<Tree
key={state.key}
className={styles['expression-editor-suggestion-tree']}
showFilteredOnly
filterTreeNode
onChangeWithObject
ref={treeRef}
treeData={state.variableTree}
searchRender={false}
value={state.selected}
emptyContent={<></>}
onSelect={(key, selected, node) => {
selectNode(node as ExpressionEditorTreeNode);
}}
/>
</div>
</>
}
>
<div
className={styles['expression-editor-suggestion-pin']}
style={{
top: state.rect?.top,
left: state.rect?.left,
}}
/>
</Popover>
);
};

View File

@@ -0,0 +1,199 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable complexity */
import { useReducer } from 'react';
import { type ExpressionEditorTreeNode } from '../../type';
import {
type SuggestionState,
type SuggestionAction,
SuggestionActionType,
type SuggestionActionPayload,
type SuggestionReducer,
} from './type';
const updateState = (
state: SuggestionState,
snapshot: Partial<SuggestionState>,
): SuggestionState => ({
...state,
version: state.version + 1,
...snapshot,
});
export const suggestionReducer = (
state: SuggestionState,
action: SuggestionAction,
) => {
if (action.type === SuggestionActionType.SetInitialized) {
return updateState(state, {
initialized: true,
});
}
if (action.type === SuggestionActionType.Refresh) {
return updateState(state, {
key: state.key + 1,
});
}
if (action.type === SuggestionActionType.SetParseDataAndEditorPath) {
const { parseData, editorPath } =
(action.payload as SuggestionActionPayload<SuggestionActionType.SetParseDataAndEditorPath>) ||
{};
return updateState(state, {
parseData,
editorPath,
});
}
if (action.type === SuggestionActionType.ClearParseDataAndEditorPath) {
return updateState(state, {
parseData: undefined,
editorPath: undefined,
});
}
if (action.type === SuggestionActionType.SetVariableTree) {
const variableTree = action.payload as ExpressionEditorTreeNode[];
return updateState(state, {
variableTree,
});
}
if (action.type === SuggestionActionType.SetAllowVisibleChange) {
const allowVisibleChange =
action.payload as SuggestionActionPayload<SuggestionActionType.SetAllowVisibleChange>;
return updateState(state, {
allowVisibleChange,
});
}
if (
action.type === SuggestionActionType.SetVisible &&
state.allowVisibleChange
) {
const visible =
action.payload as SuggestionActionPayload<SuggestionActionType.SetVisible>;
if (state.entities.selectorBoxConfig) {
if (visible) {
state.entities.selectorBoxConfig.disabled = true; // 防止鼠标拖选不触发点击
}
if (!visible) {
state.entities.selectorBoxConfig.disabled = false;
}
}
return updateState(state, {
visible,
});
}
if (action.type === SuggestionActionType.SetHiddenDOM) {
const hiddenDOM =
action.payload as SuggestionActionPayload<SuggestionActionType.SetHiddenDOM>;
return updateState(state, {
hiddenDOM,
});
}
if (action.type === SuggestionActionType.SetRect) {
const rect = action.payload as {
top: number;
left: number;
};
return updateState(state, {
rect,
});
}
if (action.type === SuggestionActionType.SetSelected) {
const selected = action.payload as ExpressionEditorTreeNode;
return updateState(state, {
selected,
});
}
if (action.type === SuggestionActionType.SetEmptyContent) {
const emptyContent = action.payload as string;
return updateState(state, {
emptyContent,
});
}
if (action.type === SuggestionActionType.SetMatchTreeBranch) {
const matchTreeBranch = action.payload as
| ExpressionEditorTreeNode[]
| undefined;
return updateState(state, {
matchTreeBranch,
});
}
if (action.type === SuggestionActionType.SearchEffectStart) {
return updateState(state, {
renderEffect: {
...state.renderEffect,
search: true,
},
});
}
if (action.type === SuggestionActionType.SearchEffectEnd) {
return updateState(state, {
renderEffect: {
...state.renderEffect,
search: false,
},
});
}
if (action.type === SuggestionActionType.FilteredEffectStart) {
return updateState(state, {
renderEffect: {
...state.renderEffect,
filtered: true,
},
});
}
if (action.type === SuggestionActionType.FilteredEffectEnd) {
return updateState(state, {
renderEffect: {
...state.renderEffect,
filtered: false,
},
});
}
return state;
};
/** 获取状态 */
export const useSuggestionReducer = (
initialState: Omit<
SuggestionState,
| 'initialized'
| 'version'
| 'key'
| 'visible'
| 'allowVisibleChange'
| 'hiddenDOM'
| 'variableTree'
| 'renderEffect'
>,
): SuggestionReducer => {
const [state, dispatch]: SuggestionReducer = useReducer(suggestionReducer, {
...initialState,
initialized: false, // 初始化
version: 0, // 更新状态计数
key: 0, // 用于触发 react 重新渲染组件
variableTree: [], // 用于展示的组件树
visible: true, // 默认显示让ref能访问到DOM
hiddenDOM: true, // 默认隐藏让用户看不到UI
allowVisibleChange: true, // 允许visible变更
renderEffect: {
// 渲染副作用
search: false,
filtered: false,
},
});
return [state, dispatch];
};

View File

@@ -0,0 +1,115 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dispatch, RefObject } from 'react';
import type { Tree } from '@coze-arch/bot-semi';
import type { SelectorBoxConfigEntity } from '@flowgram-adapter/free-layout-editor';
import type { PlaygroundConfigEntity } from '@flowgram-adapter/free-layout-editor';
import type {
ExpressionEditorParseData,
ExpressionEditorTreeNode,
} from '../../type';
import type { ExpressionEditorModel } from '../../model';
export interface SuggestionState {
initialized: boolean;
version: number;
model: ExpressionEditorModel;
entities: {
playgroundConfig?: PlaygroundConfigEntity;
selectorBoxConfig?: SelectorBoxConfigEntity;
};
ref: {
container: RefObject<HTMLDivElement>;
suggestion: RefObject<HTMLDivElement>;
tree: RefObject<Tree>;
};
key: number;
variableTree: ExpressionEditorTreeNode[];
visible: boolean;
allowVisibleChange: boolean;
hiddenDOM: boolean;
renderEffect: {
search: boolean;
filtered: boolean;
};
rect?: {
top: number;
left: number;
};
selected?: ExpressionEditorTreeNode;
parseData?: ExpressionEditorParseData;
editorPath?: number[];
emptyContent?: string;
matchTreeBranch?: ExpressionEditorTreeNode[];
}
export enum SuggestionActionType {
SetInitialized = 'set_initialized',
Refresh = 'refresh',
SetParseDataAndEditorPath = 'set_parse_data_and_editor_path',
ClearParseDataAndEditorPath = 'clear_parse_data_and_editor_path',
SetVariableTree = 'set_variable_tree',
SetVisible = 'set_visible',
SetAllowVisibleChange = 'set_allow_visible_change',
SetHiddenDOM = 'set_hidden_dom',
SetRect = 'set_rect',
SetSelected = 'set_selected',
SetEmptyContent = 'set_empty_content',
SetMatchTreeBranch = 'set_match_tree_branch',
SearchEffectStart = 'search_effect_start',
SearchEffectEnd = 'search_effect_end',
FilteredEffectStart = 'filtered_effect_start',
FilteredEffectEnd = 'filtered_effect_end',
}
export type SuggestionActionPayload<T extends SuggestionActionType> = {
[SuggestionActionType.SetInitialized]?: undefined;
[SuggestionActionType.Refresh]?: undefined;
[SuggestionActionType.SetParseDataAndEditorPath]?: {
parseData: ExpressionEditorParseData;
editorPath: number[];
};
[SuggestionActionType.ClearParseDataAndEditorPath]?: undefined;
[SuggestionActionType.SetVariableTree]: ExpressionEditorTreeNode[];
[SuggestionActionType.SetVisible]: boolean;
[SuggestionActionType.SetAllowVisibleChange]: boolean;
[SuggestionActionType.SetHiddenDOM]: boolean;
[SuggestionActionType.SetRect]: {
top: number;
left: number;
};
[SuggestionActionType.SetSelected]?: ExpressionEditorTreeNode;
[SuggestionActionType.SetEmptyContent]?: string;
[SuggestionActionType.SetMatchTreeBranch]:
| ExpressionEditorTreeNode[]
| undefined;
[SuggestionActionType.SearchEffectStart]?: undefined;
[SuggestionActionType.SearchEffectEnd]?: undefined;
[SuggestionActionType.FilteredEffectStart]?: undefined;
[SuggestionActionType.FilteredEffectEnd]?: undefined;
}[T];
export interface SuggestionAction<
T extends SuggestionActionType = SuggestionActionType,
> {
type: SuggestionActionType;
payload?: SuggestionActionPayload<T>;
}
export type SuggestionReducer = [SuggestionState, Dispatch<SuggestionAction>];

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export enum ExpressionEditorEvent {
Change = 'change',
Select = 'select',
Dispose = 'dispose',
CompositionStart = 'compositionStart',
}
export enum ExpressionEditorToken {
Start = '{',
End = '}',
FullStart = '{{',
FullEnd = '}}',
Separator = '.',
ArrayStart = '[',
ArrayEnd = ']',
}
export enum ExpressionEditorSegmentType {
ObjectKey = 'object_key',
ArrayIndex = 'array_index',
EndEmpty = 'end_empty',
}
export enum ExpressionEditorSignal {
Line = 'paragraph',
Valid = 'valid',
Invalid = 'invalid',
SelectedValid = 'selectedValid',
SelectedInvalid = 'selectedInvalid',
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {
ExpressionEditorEvent,
ExpressionEditorToken,
ExpressionEditorSegmentType,
ExpressionEditorSignal,
} from './constant';
export {
ExpressionEditorEventParams,
ExpressionEditorEventDisposer,
ExpressionEditorSegment,
ExpressionEditorVariable,
ExpressionEditorTreeNode,
ExpressionEditorParseData,
ExpressionEditorLine,
ExpressionEditorValidateData,
ExpressionEditorRange,
} from './type';
export type { SelectorBoxConfigEntity } from '@flowgram-adapter/free-layout-editor';
export type { PlaygroundConfigEntity } from '@flowgram-adapter/free-layout-editor';
export {
ExpressionEditorLeaf,
ExpressionEditorSuggestion,
ExpressionEditorRender,
ExpressionEditorCounter,
} from './components';
export { ExpressionEditorModel } from './model';
export { ExpressionEditorParser } from './parser';
export { ExpressionEditorTreeHelper } from './tree-helper';
export { ExpressionEditorValidator } from './validator';
export { useSuggestionReducer } from './components/suggestion/state';
export {
useListeners,
useSelectNode,
useKeyboardSelect,
useRenderEffect,
} from './components/suggestion/hooks';

View File

@@ -0,0 +1,327 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { CompositionEventHandler, KeyboardEventHandler } from 'react';
import { type ReactEditor, withReact } from 'slate-react';
import { withHistory } from 'slate-history';
import {
Text,
type NodeEntry,
Transforms,
createEditor,
Range,
type BaseEditor,
Editor,
} from 'slate';
import EventEmitter from 'eventemitter3';
import { ExpressionEditorValidator } from '../validator';
import type {
ExpressionEditorEventParams,
ExpressionEditorRange,
ExpressionEditorEventDisposer,
ExpressionEditorLine,
ExpressionEditorTreeNode,
ExpressionEditorValidateData,
} from '../type';
import { ExpressionEditorParser } from '../parser';
import {
ExpressionEditorEvent,
ExpressionEditorToken,
ExpressionEditorSignal,
} from '../constant';
export class ExpressionEditorModel {
public readonly editor: BaseEditor & ReactEditor;
protected innerValue: string;
protected innerFocus: boolean;
protected innerLines: ExpressionEditorLine[];
protected innerVariableTree: ExpressionEditorTreeNode[];
protected emitter: EventEmitter;
constructor(initialValue: string) {
this.emitter = new EventEmitter();
this.editor = withReact(withHistory(createEditor()));
this.innerValue = initialValue;
this.innerLines = ExpressionEditorParser.deserialize(initialValue);
}
/** 设置变量树 */
public setVariableTree(variableTree: ExpressionEditorTreeNode[]): void {
this.innerVariableTree = variableTree;
}
/** 获取变量树 */
public get variableTree(): ExpressionEditorTreeNode[] {
return this.innerVariableTree;
}
/** 获取行数据 */
public get lines(): ExpressionEditorLine[] {
return this.innerLines;
}
/** 获取序列化值 */
public get value(): string {
return this.innerValue;
}
/** 外部设置模型值 */
public setValue(value: string): void {
if (value === this.innerValue) {
return;
}
this.innerValue = value;
this.innerLines = ExpressionEditorParser.deserialize(value);
this.syncEditorValue();
}
/** 同步选中状态 */
public setFocus(focus: boolean): void {
if (this.innerFocus === focus) {
return;
}
this.innerFocus = focus;
if (focus) {
// 首次选中时主动触发选区事件,主动触发变量推荐
this.select(this.lines);
} else if (this.innerValue !== '' && this.editor.children.length !== 0) {
// 触发失焦且编辑器内容不为空,则重置选区
Transforms.select(this.editor, Editor.start(this.editor, []));
}
}
/** 注册事件 */
public on<T extends ExpressionEditorEvent>(
event: T,
callback: (params: ExpressionEditorEventParams<T>) => void,
): ExpressionEditorEventDisposer {
this.emitter.on(event, callback);
return () => {
this.emitter.off(event, callback);
};
}
/** 数据变更事件 */
public change(lines: ExpressionEditorLine[]): void {
const isAstChange = this.editor.operations.some(
op => 'set_selection' !== op.type,
);
if (!isAstChange) {
return;
}
this.innerLines = lines;
this.innerValue = ExpressionEditorParser.serialize(lines);
this.emitter.emit(ExpressionEditorEvent.Change, {
lines,
value: this.innerValue,
});
}
/** 选中事件 */
public select(lines: ExpressionEditorLine[]): void {
const { selection } = this.editor;
if (!selection || !Range.isCollapsed(selection)) {
return;
}
if (
selection.anchor.offset !== selection.focus.offset ||
selection.anchor.path[0] !== selection.focus.path[0] ||
selection.anchor.path[1] !== selection.focus.path[1]
) {
// 框选
this.emitter.emit(ExpressionEditorEvent.Select, {
content: '',
offset: -1,
});
return;
}
const cursorOffset = selection.anchor.offset;
const lineIndex = selection.anchor.path[0];
const contentIndex = selection.anchor.path[1];
const line = lines[lineIndex];
if (!line) {
return;
}
const content = line.children[contentIndex];
const cursorContent = content?.text;
if (typeof cursorContent !== 'string') {
return;
}
this.emitter.emit(ExpressionEditorEvent.Select, {
content: cursorContent,
offset: cursorOffset,
path: selection.anchor.path,
});
}
/** 键盘事件 */
public keydown(
event: Parameters<KeyboardEventHandler<HTMLDivElement>>[0],
): void {
if (event.key === ExpressionEditorToken.Start) {
event.preventDefault();
Transforms.insertText(
this.editor,
ExpressionEditorToken.FullStart + ExpressionEditorToken.FullEnd,
);
Transforms.move(this.editor, {
distance: 2,
reverse: true,
});
setTimeout(() => {
// slate UI 渲染滞后
this.select(this.innerLines);
}, 0);
}
if (event.ctrlKey || event.metaKey) {
switch (event.key) {
case 'a':
event.preventDefault();
Transforms.select(this.editor, {
anchor: Editor.start(this.editor, []),
focus: Editor.end(this.editor, []),
});
return;
default:
return;
}
}
}
/** 开始输入拼音 */
public compositionStart(
event: CompositionEventHandler<HTMLDivElement>,
): void {
this.emitter.emit(ExpressionEditorEvent.CompositionStart, {
event,
});
}
/** 装饰叶子节点 */
public get decorate(): ([node, path]: NodeEntry) => ExpressionEditorRange[] {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
const decorateFn = ([node, path]: NodeEntry): ExpressionEditorRange[] => {
const ranges: ExpressionEditorRange[] = [];
if (!Text.isText(node)) {
return ranges;
}
// 计算表达式合法/非法
const validateList = ExpressionEditorValidator.lineTextValidate({
lineText: node.text,
tree: self.innerVariableTree,
});
validateList.forEach(validateData => {
const { start, end, valid } = validateData;
const rangePath = {
anchor: { path, offset: start },
focus: { path, offset: end },
};
if (valid) {
ranges.push({
type: ExpressionEditorSignal.Valid,
...rangePath,
});
} else {
ranges.push({
type: ExpressionEditorSignal.Invalid,
...rangePath,
});
}
});
if (!this.innerFocus) {
return ranges;
}
// 以下是计算当前选中表达式逻辑
const selectedItem = self.isValidateSelectPath([node, path]);
const selectedValidItem = validateList.find(
validateData =>
validateData.valid &&
validateData.start === selectedItem?.start &&
validateData.end === selectedItem?.end,
);
if (selectedItem && selectedValidItem) {
ranges.push({
type: ExpressionEditorSignal.SelectedValid,
anchor: { path, offset: selectedItem.start },
focus: { path, offset: selectedItem.end },
});
} else if (selectedItem && !selectedValidItem) {
ranges.push({
type: ExpressionEditorSignal.SelectedInvalid,
anchor: { path, offset: selectedItem.start },
focus: { path, offset: selectedItem.end },
});
}
return ranges;
};
return decorateFn;
}
/**
* 同步编辑器实例内容
* > **NOTICE:** *为确保不影响性能,应仅在外部值变更导致编辑器值与模型值不一致时调用*
*/
private syncEditorValue(): void {
// 删除编辑器内所有行
this.editor.children.forEach((line, index) => {
Transforms.removeNodes(this.editor, {
at: [index],
});
});
// 重新在编辑器插入当前行内容
this.lines.forEach((line, index) => {
Transforms.insertNodes(this.editor, line, {
at: [this.editor.children.length],
});
});
}
private isValidateSelectPath([node, path]: NodeEntry):
| ExpressionEditorValidateData
| undefined {
if (!Text.isText(node)) {
return;
}
const { selection } = this.editor;
if (!selection) {
return;
}
const cursorOffset = selection.anchor.offset;
const lineIndex = selection.anchor.path[0];
const contentIndex = selection.anchor.path[1];
if (lineIndex !== path[0] || contentIndex !== path[1]) {
return;
}
const lineContent = node.text;
const lineOffset = cursorOffset;
const parsedData = ExpressionEditorParser.parse({
lineContent,
lineOffset,
});
if (!parsedData) {
return;
}
return {
start: parsedData.offset.lastStart - 1,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
end: parsedData.offset.firstEnd + 2,
valid: true,
};
}
}

View File

@@ -0,0 +1,509 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'reflect-metadata';
import { type ExpressionEditorParseData } from '../type';
import { ExpressionEditorSegmentType } from '../constant';
import { ExpressionEditorParser, ExpressionEditorParserBuiltin } from '.';
describe('ExpressionEditorParserBuiltin', () => {
it('tokenIndex', () => {
const result = ExpressionEditorParserBuiltin.tokenOffset({
lineContent: 'test input: {{Earth.Asia.China.Hangzhou}}',
lineOffset: 39,
});
expect(result).toEqual({
lastStartTokenOffset: 13,
firstEndTokenOffset: 39,
});
});
it('extractContent', () => {
const result = ExpressionEditorParserBuiltin.extractContent({
lineContent: 'test input: {{Earth.Asia.China.Hangzhou}}',
lineOffset: 39,
lastStartTokenOffset: 13,
firstEndTokenOffset: 39,
});
expect(result).toEqual({
content: 'Earth.Asia.China.Hangzhou',
offset: 25,
});
});
it('sliceReachable', () => {
const result = ExpressionEditorParserBuiltin.sliceReachable({
content: 'China.Hangzhou',
offset: 6,
});
expect(result).toEqual({
reachable: 'China.',
unreachable: 'Hangzhou',
});
});
describe('splitPath', () => {
it('pure object keys', () => {
const result = ExpressionEditorParserBuiltin.splitText(
'Earth.Asia.China.Hangzhou',
);
expect(result).toEqual(['Earth', 'Asia', 'China', 'Hangzhou']);
});
it('pure object keys with number type', () => {
const result = ExpressionEditorParserBuiltin.splitText(
'Earth.Asia.China.0.Hangzhou',
);
expect(result).toEqual(['Earth', 'Asia', 'China', '0', 'Hangzhou']);
});
it('object keys and array indexes', () => {
const result = ExpressionEditorParserBuiltin.splitText(
'Earth.Asia.China[0].Hangzhou',
);
expect(result).toEqual(['Earth', 'Asia', 'China', '[0]', 'Hangzhou']);
});
it('object keys and individual array indexes', () => {
const result = ExpressionEditorParserBuiltin.splitText(
'Earth.Asia.China.[0].Hangzhou',
);
expect(result).toEqual(['Earth', 'Asia', 'China', '', '[0]', 'Hangzhou']);
});
it('continues array index', () => {
const result =
ExpressionEditorParserBuiltin.splitText('Hangzhou[0][0][0]');
expect(result).toEqual(['Hangzhou[0][0]', '[0]']);
});
it('continues array index start with separator', () => {
const result =
ExpressionEditorParserBuiltin.splitText('Hangzhou.[0][0][0]');
expect(result).toEqual(['Hangzhou', '[0][0]', '[0]']);
});
it('start with array index', () => {
const result = ExpressionEditorParserBuiltin.splitText('[0].Hangzhou');
expect(result).toEqual(['', '[0]', 'Hangzhou']);
});
it('object keys with empty', () => {
const result =
ExpressionEditorParserBuiltin.splitText('Earth...Hangzhou');
expect(result).toEqual(['Earth', '', '', 'Hangzhou']);
});
it('object keys and end with empty', () => {
const result = ExpressionEditorParserBuiltin.splitText('China.Hangzhou.');
expect(result).toEqual(['China', 'Hangzhou', '']);
});
it('object keys and start with empty', () => {
const result = ExpressionEditorParserBuiltin.splitText('.China.Hangzhou');
expect(result).toEqual(['', 'China', 'Hangzhou']);
});
it('all empty', () => {
const result = ExpressionEditorParserBuiltin.splitText('..');
expect(result).toEqual(['', '', '']);
});
});
describe('textToPath', () => {
it('pure object keys', () => {
const result = ExpressionEditorParserBuiltin.toSegments('China.Hangzhou');
expect(result).toEqual([
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'China',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: 'Hangzhou',
},
]);
});
it('pure object keys with number type', () => {
const result =
ExpressionEditorParserBuiltin.toSegments('China.0.Hangzhou');
expect(result).toEqual([
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'China',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: '0',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: 'Hangzhou',
},
]);
});
it('object keys and array indexes', () => {
const result =
ExpressionEditorParserBuiltin.toSegments('China[0].Hangzhou');
expect(result).toEqual([
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'China',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 1,
arrayIndex: 0,
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: 'Hangzhou',
},
]);
});
it('object keys and end with empty', () => {
const result = ExpressionEditorParserBuiltin.toSegments(
'China_Zhejiang.Hangzhou.',
);
expect(result).toEqual([
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'China_Zhejiang',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: 'Hangzhou',
},
{
type: ExpressionEditorSegmentType.EndEmpty,
index: 2,
},
]);
});
it('should be undefined', () => {
const invalidPatterns = [
'foo..bar',
'..',
'.foo',
'foo[]',
'foo.[]',
'foo.[0]',
'foo[0',
'foo[0.',
'foo[0].{a}',
'foo[0][0]',
'foo[0].[0]',
'foo.[0].[0]',
'[]foo',
'.[]foo',
'[.]foo',
'[].foo',
'[0].foo',
'.[0].foo',
'{a}',
'foo-bar',
'😊[0]',
];
invalidPatterns.forEach(pattern => {
const result = ExpressionEditorParserBuiltin.toSegments(pattern);
expect(result).toBeUndefined();
});
});
});
});
describe('textToPath unicode', () => {
it('pure object keys', () => {
const result = ExpressionEditorParserBuiltin.toSegments('主题.名称');
expect(result).toEqual([
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: '主题',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: '名称',
},
]);
});
it('pure object keys with number type', () => {
const result = ExpressionEditorParserBuiltin.toSegments('主题.0.名称');
expect(result).toEqual([
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: '主题',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: '0',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: '名称',
},
]);
});
it('object keys and array indexes', () => {
const result = ExpressionEditorParserBuiltin.toSegments('主题[0].名称');
expect(result).toEqual([
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: '主题',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 1,
arrayIndex: 0,
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: '名称',
},
]);
});
it('object keys and end with empty', () => {
const result = ExpressionEditorParserBuiltin.toSegments('主题.名称.');
expect(result).toEqual([
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: '主题',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: '名称',
},
{
type: ExpressionEditorSegmentType.EndEmpty,
index: 2,
},
]);
});
});
describe('ExpressionEditorParser parse should be successful', () => {
it('parse object keys', () => {
const result = ExpressionEditorParser.parse({
lineContent: 'test: {{foo.bar}}',
lineOffset: 15,
});
const expected: ExpressionEditorParseData = {
content: {
line: 'test: {{foo.bar}}',
inline: 'foo.bar',
reachable: 'foo.bar',
unreachable: '',
},
offset: {
line: 15,
inline: 7,
lastStart: 7,
firstEnd: 15,
},
segments: {
inline: [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: 'bar',
},
],
reachable: [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: 'bar',
},
],
},
};
expect(result).toEqual(expected);
});
it('parse array indexes', () => {
const result = ExpressionEditorParser.parse({
lineContent: 'test: {{foo[0].bar}}',
lineOffset: 18,
});
const expected: ExpressionEditorParseData = {
content: {
line: 'test: {{foo[0].bar}}',
inline: 'foo[0].bar',
reachable: 'foo[0].bar',
unreachable: '',
},
offset: {
line: 18,
inline: 10,
lastStart: 7,
firstEnd: 18,
},
segments: {
inline: [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 1,
arrayIndex: 0,
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: 'bar',
},
],
reachable: [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 1,
arrayIndex: 0,
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: 'bar',
},
],
},
};
expect(result).toEqual(expected);
});
it('parse end with empty', () => {
const result = ExpressionEditorParser.parse({
lineContent: 'test: {{foo.bar}}',
lineOffset: 12,
});
const expected: ExpressionEditorParseData = {
content: {
line: 'test: {{foo.bar}}',
inline: 'foo.bar',
reachable: 'foo.',
unreachable: 'bar',
},
offset: { line: 12, inline: 4, lastStart: 7, firstEnd: 15 },
segments: {
inline: [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: 'bar',
},
],
reachable: [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{ type: ExpressionEditorSegmentType.EndEmpty, index: 1 },
],
},
};
expect(result).toEqual(expected);
});
it('empty {{content}}', () => {
const result = ExpressionEditorParser.parse({
lineContent: 'test: {{}}',
lineOffset: 8,
});
const expected: ExpressionEditorParseData = {
content: {
line: 'test: {{}}',
inline: '',
reachable: '',
unreachable: '',
},
offset: { line: 8, inline: 0, lastStart: 7, firstEnd: 8 },
segments: {
inline: [{ type: ExpressionEditorSegmentType.EndEmpty, index: 0 }],
reachable: [{ type: ExpressionEditorSegmentType.EndEmpty, index: 0 }],
},
};
expect(result).toEqual(expected);
});
it('only empty {{content}}', () => {
const result = ExpressionEditorParser.parse({
lineContent: '{{}}',
lineOffset: 2,
});
const expected: ExpressionEditorParseData = {
content: { line: '{{}}', inline: '', reachable: '', unreachable: '' },
offset: { line: 2, inline: 0, lastStart: 1, firstEnd: 2 },
segments: {
inline: [{ type: ExpressionEditorSegmentType.EndEmpty, index: 0 }],
reachable: [{ type: ExpressionEditorSegmentType.EndEmpty, index: 0 }],
},
};
expect(result).toEqual(expected);
});
});
describe('ExpressionEditorParser parse should be fail', () => {
it('out of bucket', () => {
const result = ExpressionEditorParser.parse({
lineContent: 'test: {{foo.bar}}',
lineOffset: 7,
});
expect(result).toBeUndefined();
});
it('dangling null pointer', () => {
const result = ExpressionEditorParser.parse({
lineContent: '{{foo.bar}}',
lineOffset: 12,
});
expect(result).toBeUndefined();
});
it('empty content with not zero offset', () => {
const result = ExpressionEditorParser.parse({
lineContent: '',
lineOffset: 1,
});
expect(result).toBeUndefined();
});
it('invalid char', () => {
const result = ExpressionEditorParser.parse({
lineContent: '{{foo(0).bar}}',
lineOffset: 12,
});
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,272 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/no-namespace */
import { Node } from 'slate';
import type {
ExpressionEditorLine,
ExpressionEditorParseData,
ExpressionEditorSegment,
} from '../type';
import {
ExpressionEditorSegmentType,
ExpressionEditorSignal,
ExpressionEditorToken,
} from '../constant';
export namespace ExpressionEditorParserBuiltin {
/** 计算开始和结束标识的序号 */
export const tokenOffset = (line: {
lineContent: string;
lineOffset: number;
}):
| {
lastStartTokenOffset: number;
firstEndTokenOffset: number;
}
| undefined => {
const { lineContent: content, lineOffset: offset } = line;
const firstEndTokenOffset = content.indexOf(
ExpressionEditorToken.End,
offset,
);
const endChars = content.slice(
firstEndTokenOffset,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
firstEndTokenOffset + 2,
);
if (endChars !== ExpressionEditorToken.FullEnd) {
// 结束符号 "}}" 不完整
return;
}
const lastStartTokenOffset = content.lastIndexOf(
ExpressionEditorToken.Start,
offset - 1,
);
const startChars = content.slice(
lastStartTokenOffset - 1,
lastStartTokenOffset + 1,
);
if (startChars !== ExpressionEditorToken.FullStart) {
// 开始符号 "{{" 不完整
return;
}
return {
lastStartTokenOffset,
firstEndTokenOffset,
};
};
/** 从行内容提取内容 */
export const extractContent = (params: {
lineContent: string;
lineOffset: number;
lastStartTokenOffset: number;
firstEndTokenOffset: number;
}):
| {
content: string;
offset: number;
}
| undefined => {
const {
lineContent,
lineOffset,
lastStartTokenOffset,
firstEndTokenOffset,
} = params;
const content = lineContent.slice(
lastStartTokenOffset + 1,
firstEndTokenOffset,
);
const offset = lineOffset - lastStartTokenOffset - 1;
return {
content,
offset,
};
};
/** 根据 offset 将文本内容切分为可用与不可用 */
export const sliceReachable = (params: {
content: string;
offset: number;
}): {
reachable: string;
unreachable: string;
} => {
const { content, offset } = params;
const reachable = content.slice(0, offset);
const unreachable = content.slice(offset, content.length);
return {
reachable,
unreachable,
};
};
/** 切分文本 */
export const splitText = (pathString: string): string[] => {
// 得到的分割数组,初始为原字符串以"."分割的结果
const segments = pathString.split(ExpressionEditorToken.Separator);
// 定义结果数组,并处理连续的"."导致的空字符串
const result: string[] = [];
segments.forEach(segment => {
if (!segment.match(/\[\d+\]/)) {
// 如果不是数组索引,直接加入结果数组,即使是空字符串也加入以保持正确的分割
result.push(segment);
return;
}
// 如果当前段是数组索引,将前面的字符串和当前数组索引分别加入结果数组
const lastSegmentIndex = segment.lastIndexOf(
ExpressionEditorToken.ArrayStart,
);
const key = segment.substring(0, lastSegmentIndex);
const index = segment.substring(lastSegmentIndex);
// {{array[0]}} 中的 array
result.push(key);
// {{array[0]}} 中的 [0]
result.push(index);
return;
});
return result;
};
/** 字符串解析为路径 */
export const toSegments = (
text: string,
): ExpressionEditorSegment[] | undefined => {
const textSegments = ExpressionEditorParserBuiltin.splitText(text);
const segments: ExpressionEditorSegment[] = [];
const validate = textSegments.every((textSegment, index) => {
// 数组下标
if (
textSegment.startsWith(ExpressionEditorToken.ArrayStart) &&
textSegment.endsWith(ExpressionEditorToken.ArrayEnd)
) {
const arrayIndexString = textSegment.slice(1, -1);
const arrayIndex = Number(arrayIndexString);
if (arrayIndexString === '' || Number.isNaN(arrayIndex)) {
// index 必须是数字
return false;
}
const lastSegment = segments[segments.length - 1];
if (
!lastSegment ||
lastSegment.type !== ExpressionEditorSegmentType.ObjectKey
) {
// 数组索引必须在 key 之后
return false;
}
segments.push({
type: ExpressionEditorSegmentType.ArrayIndex,
index,
arrayIndex,
});
}
// 最后一行空文本
else if (index === textSegments.length - 1 && textSegment === '') {
segments.push({
type: ExpressionEditorSegmentType.EndEmpty,
index,
});
} else {
if (!textSegment || !/^[\u4e00-\u9fa5_a-zA-Z0-9]*$/.test(textSegment)) {
return false;
}
segments.push({
type: ExpressionEditorSegmentType.ObjectKey,
index,
objectKey: textSegment,
});
}
return true;
});
if (!validate) {
return undefined;
}
return segments;
};
}
export namespace ExpressionEditorParser {
/** 序列化 */
export const serialize = (value: ExpressionEditorLine[]) =>
value.map(n => Node.string(n)).join('\n');
/** 反序列化 */
export const deserialize = (text: string): ExpressionEditorLine[] => {
const lines = text.split('\n');
return lines.map(line => ({
type: ExpressionEditorSignal.Line,
children: [{ text: line || '' }],
}));
};
export const parse = (line: {
lineContent: string;
lineOffset: number;
}): ExpressionEditorParseData | undefined => {
const { lineContent, lineOffset } = line;
const tokenOffsets = ExpressionEditorParserBuiltin.tokenOffset(line);
if (!tokenOffsets) {
return;
}
const { lastStartTokenOffset, firstEndTokenOffset } = tokenOffsets;
const extractedContent = ExpressionEditorParserBuiltin.extractContent({
...line,
...tokenOffsets,
});
if (!extractedContent) {
return;
}
const { content, offset } = extractedContent;
const slicedReachable =
ExpressionEditorParserBuiltin.sliceReachable(extractedContent);
if (!slicedReachable) {
return;
}
const reachableSegments = ExpressionEditorParserBuiltin.toSegments(
slicedReachable.reachable,
);
const inlineSegments = ExpressionEditorParserBuiltin.toSegments(content);
if (!reachableSegments) {
return;
}
return {
content: {
line: lineContent,
inline: content,
reachable: slicedReachable.reachable,
unreachable: slicedReachable.unreachable,
},
offset: {
line: lineOffset,
inline: offset,
lastStart: lastStartTokenOffset,
firstEnd: firstEndTokenOffset,
},
segments: {
inline: inlineSegments,
reachable: reachableSegments,
},
};
};
}

View File

@@ -0,0 +1,570 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import 'reflect-metadata';
import { ViewVariableType } from '@coze-workflow/base';
import type {
ExpressionEditorTreeNode,
ExpressionEditorSegment,
ExpressionEditorVariable,
} from '../type';
import { ExpressionEditorSegmentType } from '../constant';
import { ExpressionEditorTreeHelper } from '.';
vi.mock('@coze-workflow/base', () => {
enum VariableType {
String = 1,
Integer,
Boolean,
Number,
Object = 6,
ArrayString = 99,
ArrayInteger,
ArrayBoolean,
ArrayNumber,
ArrayObject,
}
return {
ViewVariableType: {
...VariableType,
isArrayType: (type: VariableType): boolean => {
const arrayTypes = [
VariableType.ArrayString,
VariableType.ArrayInteger,
VariableType.ArrayBoolean,
VariableType.ArrayNumber,
VariableType.ArrayObject,
];
return arrayTypes.includes(type);
},
},
};
});
describe('ExpressionEditorTreeHelper pruning', () => {
let defaultTree: ExpressionEditorTreeNode[];
let defaultSegments: ExpressionEditorSegment[];
beforeEach(() => {
defaultTree = [
{
label: 'foo',
value: 'foo',
key: 'foo',
variable: {
type: ViewVariableType.ArrayObject,
} as ExpressionEditorVariable,
children: [
{
label: 'bar',
value: 'bar',
key: 'bar',
variable: {
type: ViewVariableType.Object,
} as ExpressionEditorVariable,
children: [
{
label: 'baz',
value: 'baz',
key: 'baz',
variable: {
type: ViewVariableType.String,
} as ExpressionEditorVariable,
},
],
},
],
},
];
defaultSegments = [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: 'bar',
},
{ type: ExpressionEditorSegmentType.EndEmpty, index: 2 },
];
});
it('should pruning', () => {
const result = ExpressionEditorTreeHelper.pruning({
tree: defaultTree,
segments: defaultSegments,
});
expect(result).toEqual([
{
label: 'baz',
value: 'baz',
key: 'baz',
variable: {
type: ViewVariableType.String,
} as ExpressionEditorVariable,
},
]);
});
it('should not pruning', () => {
const result = ExpressionEditorTreeHelper.pruning({
tree: defaultTree,
segments: [],
});
expect(result).toEqual(defaultTree);
});
it('should be empty', () => {
const result = ExpressionEditorTreeHelper.pruning({
tree: defaultTree,
segments: [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'trs',
},
],
});
expect(result).toEqual(defaultTree);
});
it('should pruning and ignore array index segments', () => {
const result = ExpressionEditorTreeHelper.pruning({
tree: defaultTree,
segments: [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 1,
arrayIndex: 10,
},
],
});
expect(result).toEqual(defaultTree);
});
});
describe('ExpressionEditorTreeHelper fullPath without segments', () => {
it('return node full path', () => {
const node = {
label: 'bar',
value: 'bar',
key: 'bar',
variable: {} as ExpressionEditorVariable,
parent: {
label: 'foo',
value: 'foo',
key: 'foo',
variable: {} as ExpressionEditorVariable,
},
};
const fullString = ExpressionEditorTreeHelper.concatFullPath({
node,
segments: [],
});
expect(fullString).toEqual('foo.bar');
});
});
describe('ExpressionEditorTreeHelper fullPath with segments', () => {
it('return node full path', () => {
const node = {
label: 'bar',
value: 'bar',
key: 'bar',
variable: {} as ExpressionEditorVariable,
parent: {
label: 'foo',
value: 'foo',
key: 'foo',
variable: {
type: ViewVariableType.ArrayObject,
} as ExpressionEditorVariable,
},
};
const segments: ExpressionEditorSegment[] = [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 1,
arrayIndex: 10,
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: 'bar',
},
{ type: ExpressionEditorSegmentType.EndEmpty, index: 3 },
];
const fullString = ExpressionEditorTreeHelper.concatFullPath({
node,
segments,
});
expect(fullString).toEqual('foo[10].bar');
});
});
describe('ExpressionEditorTreeHelper matchBranch', () => {
it('match tree branch', () => {
const tree: ExpressionEditorTreeNode[] = [
{
label: 'foo',
value: 'foo',
key: 'foo',
variable: {} as ExpressionEditorVariable,
children: [
{
label: 'bar',
value: 'bar',
key: 'bar',
variable: {
type: ViewVariableType.ArrayObject,
} as ExpressionEditorVariable,
children: [
{
label: 'baz',
value: 'baz',
key: 'baz',
variable: {} as ExpressionEditorVariable,
},
],
},
],
},
];
const segments: ExpressionEditorSegment[] = [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: 'bar',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 2,
arrayIndex: 10,
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: 'baz',
},
{ type: ExpressionEditorSegmentType.EndEmpty, index: 3 },
];
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
tree,
segments,
});
expect(treeBranch).not.toBeUndefined();
});
it('match tree branch failed with incorrect array index', () => {
const tree: ExpressionEditorTreeNode[] = [
{
label: 'foo',
value: 'foo',
key: 'foo',
variable: {
type: ViewVariableType.String,
} as ExpressionEditorVariable,
children: [
{
label: 'bar',
value: 'bar',
key: 'bar',
variable: {
type: ViewVariableType.String,
} as ExpressionEditorVariable,
},
],
},
];
const segments: ExpressionEditorSegment[] = [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 1,
arrayIndex: 10,
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: 'bar',
},
{ type: ExpressionEditorSegmentType.EndEmpty, index: 3 },
];
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
tree,
segments,
});
expect(treeBranch).toBeUndefined();
});
it('match tree branch failed, array object without index before sub item', () => {
const tree: ExpressionEditorTreeNode[] = [
{
label: 'foo',
value: 'foo',
key: 'foo',
variable: {
type: ViewVariableType.ArrayObject,
} as ExpressionEditorVariable,
children: [
{
label: 'bar',
value: 'bar',
key: 'bar',
variable: {
type: ViewVariableType.String,
} as ExpressionEditorVariable,
},
],
},
];
const segments: ExpressionEditorSegment[] = [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: 'bar',
},
{ type: ExpressionEditorSegmentType.EndEmpty, index: 3 },
];
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
tree,
segments,
});
expect(treeBranch).toBeUndefined();
});
it('match tree branch failed with constant follow array index', () => {
const tree: ExpressionEditorTreeNode[] = [
{
label: 'foo',
value: 'foo',
key: 'foo',
},
];
const segments: ExpressionEditorSegment[] = [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 1,
arrayIndex: 10,
},
];
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
tree,
segments,
});
expect(treeBranch).toBeUndefined();
});
});
describe('ExpressionEditorTreeHelper findAvailableVariables & createVariableTree', () => {
let variables: ExpressionEditorVariable[];
let inputs: {
name: string;
keyPath?: string[];
}[];
let availableVariables: ExpressionEditorTreeHelper.AvailableVariable[];
beforeEach(() => {
variables = [
{
key: 'G3UiXFzKjTefY_iu8U59Z',
type: 6,
name: 'obj',
children: [
{
key: 'klAhNVg0xasVuN-l3bZdw',
type: 1,
name: 'str',
},
{
key: 'j8Hp-0mQhGfW618h35Pql',
type: 4,
name: 'num',
},
],
nodeTitle: 'Code',
nodeId: '112561',
},
{
key: 'UVxP2tcAXIe2DXIeT1C-o',
type: 103,
name: 'arr_obj',
children: [
{
key: '7-id-zYuO7aBPiC48Jkk4',
type: 1,
name: 'str',
},
{
key: 'QHB4k7Z3k2VyTipg8rjlL',
type: 4,
name: 'num',
},
],
nodeTitle: 'Code',
nodeId: '112561',
},
{
key: 'GX1IupmKt-gaMKC54d1a4',
type: 99,
name: 'arr_str',
nodeTitle: 'Code',
nodeId: '112561',
},
];
inputs = [
{
name: 'ref_obj',
keyPath: ['112561', 'G3UiXFzKjTefY_iu8U59Z'],
},
{
name: 'ref_arr_obj',
keyPath: ['112561', 'UVxP2tcAXIe2DXIeT1C-o'],
},
{
name: 'test_ref',
keyPath: ['112561', 'G3UiXFzKjTefY_iu8U59Z', 'klAhNVg0xasVuN-l3bZdw'],
},
{
name: 'ref_arr_str',
keyPath: ['112561', 'GX1IupmKt-gaMKC54d1a4'],
},
{
name: 'constant',
keyPath: [],
},
];
availableVariables = [
{
name: 'ref_obj',
keyPath: ['G3UiXFzKjTefY_iu8U59Z'],
variable: {
key: 'G3UiXFzKjTefY_iu8U59Z',
type: 6,
name: 'obj',
children: [
{
key: 'klAhNVg0xasVuN-l3bZdw',
type: 1,
name: 'str',
},
{
key: 'j8Hp-0mQhGfW618h35Pql',
type: 4,
name: 'num',
},
],
nodeTitle: 'Code',
nodeId: '112561',
},
},
{
name: 'ref_arr_obj',
keyPath: ['UVxP2tcAXIe2DXIeT1C-o'],
variable: {
key: 'UVxP2tcAXIe2DXIeT1C-o',
type: 103,
name: 'arr_obj',
children: [
{
key: '7-id-zYuO7aBPiC48Jkk4',
type: 1,
name: 'str',
},
{
key: 'QHB4k7Z3k2VyTipg8rjlL',
type: 4,
name: 'num',
},
],
nodeTitle: 'Code',
nodeId: '112561',
},
},
{
name: 'test_ref',
keyPath: ['G3UiXFzKjTefY_iu8U59Z', 'klAhNVg0xasVuN-l3bZdw'],
variable: {
key: 'klAhNVg0xasVuN-l3bZdw',
type: 1,
name: 'str',
},
},
{
name: 'ref_arr_str',
keyPath: ['GX1IupmKt-gaMKC54d1a4'],
variable: {
key: 'GX1IupmKt-gaMKC54d1a4',
type: 99,
name: 'arr_str',
nodeTitle: 'Code',
nodeId: '112561',
},
},
{
name: 'constant',
},
];
});
it('find available variables', () => {
const results = ExpressionEditorTreeHelper.findAvailableVariables({
variables,
inputs,
});
expect(results).toEqual(availableVariables);
});
it('create variable tree', () => {
const results =
ExpressionEditorTreeHelper.createVariableTree(availableVariables);
expect(results.length).toEqual(5);
});
});

View File

@@ -0,0 +1,296 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/no-namespace */
/* eslint-disable @typescript-eslint/no-magic-numbers */
import { ViewVariableType } from '@coze-workflow/base/types';
import type {
ExpressionEditorTreeNode,
ExpressionEditorSegment,
ExpressionEditorVariable,
} from '../type';
import { ExpressionEditorSegmentType } from '../constant';
export namespace ExpressionEditorTreeHelper {
export interface Input {
name: string;
keyPath?: string[];
children?: Input[];
}
export interface AvailableVariable extends Input {
variable?: ExpressionEditorVariable;
children?: AvailableVariable[];
}
const findAvailableVariable = (params: {
variables: ExpressionEditorVariable[];
input: Input;
}): AvailableVariable => {
const { variables, input } = params;
if (!input.keyPath) {
return {
name: input.name,
};
}
const nodeId = input.keyPath.shift();
const nodePath = input.keyPath;
const nodeVariables = variables.filter(
variable => variable.nodeId === nodeId,
);
let variable: ExpressionEditorVariable | undefined;
nodePath.reduce(
(
availableVariables: ExpressionEditorVariable[],
path: string,
index: number,
) => {
const targetVariable = availableVariables.find(
availableVariable => availableVariable.key === path,
);
if (index === nodePath.length - 1) {
variable = targetVariable;
}
if (targetVariable && targetVariable.children) {
return targetVariable.children;
}
return [];
},
nodeVariables,
);
if (!variable) {
return {
name: input.name,
};
}
return {
name: input.name,
keyPath: input.keyPath,
variable,
};
};
export const findAvailableVariables = (params: {
variables: ExpressionEditorVariable[];
inputs: Input[];
}): AvailableVariable[] => {
const { variables, inputs } = params;
return inputs.map(input => {
const availableVariable = findAvailableVariable({ input, variables });
if (input.children?.length) {
availableVariable.children = findAvailableVariables({
variables,
inputs: input.children || [],
});
}
return availableVariable;
});
};
const createVariableLeaves = (
variables: ExpressionEditorVariable[],
parent: ExpressionEditorTreeNode,
): ExpressionEditorTreeNode[] =>
variables.map(
(variable: ExpressionEditorVariable): ExpressionEditorTreeNode => {
const node: ExpressionEditorTreeNode = {
label: variable.name,
value: `${parent.value}.${variable.key}`,
key: `${parent.value}.${variable.key}`,
variable,
parent,
};
node.children = createVariableLeaves(variable.children || [], node);
return node;
},
);
export const createVariableTree = (
availableVariables: AvailableVariable[],
parent?: ExpressionEditorTreeNode,
): ExpressionEditorTreeNode[] =>
availableVariables.map(
(availableVariable: AvailableVariable): ExpressionEditorTreeNode => {
const path = parent
? `${parent.key}.${availableVariable.name}`
: availableVariable.name;
const node: ExpressionEditorTreeNode = {
label: availableVariable.name,
value: availableVariable.keyPath?.join('.') || path,
key: path,
keyPath: availableVariable.keyPath,
variable: availableVariable.variable,
parent,
};
if (availableVariable.children?.length) {
node.children = createVariableTree(availableVariable.children, node);
} else {
node.children = createVariableLeaves(
availableVariable.variable?.children || [],
node,
);
}
return node;
},
);
export const pruning = (params: {
tree: ExpressionEditorTreeNode[];
segments: ExpressionEditorSegment[];
}): ExpressionEditorTreeNode[] => {
const { tree, segments } = params;
if (segments.length === 0) {
return tree;
}
const lastSegment = segments[segments.length - 1];
const segmentsRemovedLast =
lastSegment.type === ExpressionEditorSegmentType.ArrayIndex
? segments.slice(0, segments.length - 2) // 数组索引属于上一层级,需要去除两层
: segments.slice(0, segments.length - 1);
let treeLayer = tree;
segmentsRemovedLast.forEach(segment => {
if (segment.type !== ExpressionEditorSegmentType.ObjectKey) {
return;
}
const treeChild = treeLayer.find(
node => node.label === segment.objectKey,
);
if (treeChild) {
treeLayer = treeChild.children || [];
} else {
treeLayer = [];
}
});
return treeLayer;
};
export const concatFullPath = (params: {
node: ExpressionEditorTreeNode;
segments: ExpressionEditorSegment[];
}): string => {
const { node, segments } = params;
let current: ExpressionEditorTreeNode | undefined = node;
const pathList: { objectKey: string; arrayIndex?: number }[] = [];
while (current) {
if (current.variable?.type === ViewVariableType.ArrayObject) {
// 默认第0个
pathList.unshift({
objectKey: current.label,
arrayIndex: 0,
});
} else {
pathList.unshift({
objectKey: current.label,
});
}
current = current.parent;
}
let pathIndex = 0;
segments.find((segment, index) => {
if (segment.type !== ExpressionEditorSegmentType.ObjectKey) {
return false;
}
const pathItem = pathList[pathIndex];
pathIndex++;
if (pathItem.objectKey !== segment.objectKey) {
// 退出循环
return true;
}
const nextSegment = segments[index + 1];
if (
typeof pathItem.arrayIndex === 'number' &&
nextSegment?.type === ExpressionEditorSegmentType.ArrayIndex
) {
pathItem.arrayIndex = nextSegment.arrayIndex;
}
return false;
});
return pathList
.map((pathItem, index) => {
const isLastPathItem = index === pathList.length - 1;
if (typeof pathItem.arrayIndex === 'number' && !isLastPathItem) {
return `${pathItem.objectKey}[${pathItem.arrayIndex}]`;
}
return pathItem.objectKey;
})
.join('.');
};
export const matchTreeBranch = (params: {
tree: ExpressionEditorTreeNode[];
segments: ExpressionEditorSegment[];
}): ExpressionEditorTreeNode[] | undefined => {
const { tree, segments } = params;
const treeBranch: (ExpressionEditorTreeNode | null)[] = [];
let treeLayer = tree;
const invalid = segments.find((segment, index) => {
const itemInvalid = (): boolean => {
treeBranch.push(null);
return true;
};
const itemValid = (treeNode?: ExpressionEditorTreeNode): boolean => {
treeBranch.push(treeNode || null);
return false;
};
const beforeTreeNode = treeBranch[treeBranch.length - 1];
// 确认非法情况:是否对非数组类型使用数组索引
if (
segment.type === ExpressionEditorSegmentType.ArrayIndex &&
beforeTreeNode &&
(!beforeTreeNode.variable ||
!ViewVariableType.isArrayType(beforeTreeNode.variable.type))
) {
return itemInvalid();
}
// 确认非法情况:数组只能跟随数组下标
if (
beforeTreeNode?.variable?.type &&
ViewVariableType.isArrayType(beforeTreeNode.variable.type) &&
segment.type !== ExpressionEditorSegmentType.ArrayIndex
) {
return itemInvalid();
}
// 忽略
if (segment.type !== ExpressionEditorSegmentType.ObjectKey) {
return itemValid();
}
const treeNode = treeLayer.find(node => node.label === segment.objectKey);
// 确认非法情况:每一个 object key 必须对应一个 variable node
if (!treeNode) {
return itemInvalid();
}
treeLayer = treeNode.children || [];
return itemValid(treeNode);
});
const filteredTreeBranch = treeBranch.filter(
Boolean,
) as ExpressionEditorTreeNode[];
const filteredSegments = segments.filter(
segment => segment.type === ExpressionEditorSegmentType.ObjectKey,
);
if (invalid || filteredSegments.length !== filteredTreeBranch.length) {
return;
}
return filteredTreeBranch;
};
}

View File

@@ -0,0 +1,124 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { CompositionEventHandler } from 'react';
import type { BaseElement, BaseRange } from 'slate';
import {
type StandardNodeType,
type ViewVariableMeta,
} from '@coze-workflow/base';
import type { TreeNodeData } from '@coze-arch/bot-semi/Tree';
import type {
ExpressionEditorEvent,
ExpressionEditorSegmentType,
ExpressionEditorSignal,
} from './constant';
export type ExpressionEditorEventParams<T extends ExpressionEditorEvent> = {
[ExpressionEditorEvent.Change]: {
lines: ExpressionEditorLine[];
value: string;
};
[ExpressionEditorEvent.Select]: {
content: string;
offset: number;
path: number[];
};
[ExpressionEditorEvent.Dispose]: undefined;
[ExpressionEditorEvent.CompositionStart]: {
event: CompositionEventHandler<HTMLDivElement>;
};
}[T];
export type ExpressionEditorEventDisposer = () => void;
export type ExpressionEditorSegment<
T extends ExpressionEditorSegmentType = ExpressionEditorSegmentType,
> = {
[ExpressionEditorSegmentType.ObjectKey]: {
type: ExpressionEditorSegmentType.ObjectKey;
index: number;
objectKey: string;
};
[ExpressionEditorSegmentType.ArrayIndex]: {
type: ExpressionEditorSegmentType.ArrayIndex;
index: number;
arrayIndex: number;
};
[ExpressionEditorSegmentType.EndEmpty]: {
type: ExpressionEditorSegmentType.EndEmpty;
index: number;
};
}[T];
export interface ExpressionEditorVariable extends ViewVariableMeta {
nodeTitle?: string;
nodeId?: string;
nodeType?: StandardNodeType;
}
export interface ExpressionEditorTreeNode extends TreeNodeData {
label: string;
value: string;
key: string;
keyPath?: string[];
variable?: ExpressionEditorVariable;
children?: ExpressionEditorTreeNode[];
parent?: ExpressionEditorTreeNode;
}
export interface ExpressionEditorParseData {
content: {
line: string;
inline: string;
reachable: string;
unreachable: string;
};
offset: {
line: number;
inline: number;
lastStart: number;
firstEnd: number;
};
segments: {
inline?: ExpressionEditorSegment[];
reachable: ExpressionEditorSegment[];
};
}
export interface ExpressionEditorLine extends BaseElement {
type: ExpressionEditorSignal.Line;
children: {
text: string;
}[];
}
export interface ExpressionEditorValidateData {
start: number;
end: number;
valid: boolean;
message?: string;
}
export interface ExpressionEditorRange extends BaseRange {
type:
| ExpressionEditorSignal.Valid
| ExpressionEditorSignal.Invalid
| ExpressionEditorSignal.SelectedValid
| ExpressionEditorSignal.SelectedInvalid;
}

View File

@@ -0,0 +1,160 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import 'reflect-metadata';
import type {
ExpressionEditorTreeNode,
ExpressionEditorVariable,
} from '../type';
import { ExpressionEditorValidator } from './index';
enum ViewVariableType {
String = 1,
Integer,
Boolean,
Number,
Object = 6,
ArrayString = 99,
ArrayInteger,
ArrayBoolean,
ArrayNumber,
ArrayObject,
}
vi.mock('@coze-arch/logger', () => ({
logger: {
createLoggerWith: vi.fn(),
},
reporter: {
createReporterWithPreset: vi.fn(),
},
}));
vi.mock('@coze-workflow/base', () => {
enum VariableType {
String = 1,
Integer,
Boolean,
Number,
Object = 6,
ArrayString = 99,
ArrayInteger,
ArrayBoolean,
ArrayNumber,
ArrayObject,
}
return {
ViewVariableType: {
...VariableType,
isArrayType: (type: VariableType): boolean => {
const arrayTypes = [
VariableType.ArrayString,
VariableType.ArrayInteger,
VariableType.ArrayBoolean,
VariableType.ArrayNumber,
VariableType.ArrayObject,
];
return arrayTypes.includes(type);
},
},
};
});
describe('ExpressionEditorValidatorBuiltin', () => {
describe('findPatterns', () => {
it('findPatterns should work', () => {
const patterns = ExpressionEditorValidator.findPatterns(
'first {{foo1.bar1}} second {{foo2.bar2}}',
);
expect(patterns).toEqual([
{
start: 6,
end: 19,
content: 'foo1.bar1',
},
{
start: 27,
end: 40,
content: 'foo2.bar2',
},
]);
});
it('findPatterns with empty content', () => {
const patterns = ExpressionEditorValidator.findPatterns('{{}}');
expect(patterns).toEqual([
{
start: 0,
end: 4,
content: '',
},
]);
});
it('findPatterns satisfies length', () => {
const lengthTests = {
'first {{foo1.bar1}}': 1,
'first {{foo1.bar1}} second {{foo2.bar2}}': 2,
'first {{foo1.bar1}} second {{foo2.bar2}} third {{foo3.bar3}}': 3,
'first| {{foo1.bar1}}': 1,
'first{} {{foo1.bar1}}': 1,
'first{} {{foo1.bar1}} {}': 1,
'{} {} {} {{ {{}} }{}{}{': 1,
};
for (const [input, expected] of Object.entries(lengthTests)) {
const patterns = ExpressionEditorValidator.findPatterns(input);
expect(patterns.length).toEqual(expected);
}
});
});
});
describe('ExpressionEditorValidator lineTextValidator', () => {
let tree: ExpressionEditorTreeNode[];
beforeEach(() => {
tree = [
{
label: 'foo',
value: 'foo',
key: 'foo',
variable: {
type: ViewVariableType.ArrayObject,
} as ExpressionEditorVariable,
children: [
{
label: 'bar',
value: 'bar',
key: 'bar',
variable: {
type: ViewVariableType.String,
} as ExpressionEditorVariable,
},
],
},
];
});
it('line text validator correctly', () => {
const validateList = ExpressionEditorValidator.lineTextValidate({
lineText: 'first {{foo[0].bar}} second {{foo[1].bar}}',
tree,
});
expect(validateList).toEqual([
{ start: 6, end: 20, valid: true },
{ start: 28, end: 42, valid: true },
]);
});
});

View File

@@ -0,0 +1,123 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/no-namespace */
/* eslint-disable no-cond-assign */
import { Node } from 'slate';
import type {
ExpressionEditorLine,
ExpressionEditorTreeNode,
ExpressionEditorValidateData,
} from '../type';
import { ExpressionEditorTreeHelper } from '../tree-helper';
import { ExpressionEditorParserBuiltin } from '../parser';
import { ExpressionEditorSegmentType } from '../constant';
export namespace ExpressionEditorValidator {
interface ExpressionEditorPattern {
start: number;
end: number;
content: string;
}
export const findPatterns = (text: string): ExpressionEditorPattern[] => {
const matches: ExpressionEditorPattern[] = [];
const regex = /{{(.*?)}}/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
const start: number = match.index;
const end: number = regex.lastIndex;
const content: string = match[1];
matches.push({ start, end, content });
}
return matches;
};
const patternValidate = (params: {
pattern: ExpressionEditorPattern;
tree: ExpressionEditorTreeNode[];
}) => {
const { pattern, tree } = params;
// 1. content to segments
const segments = ExpressionEditorParserBuiltin.toSegments(pattern.content);
if (!segments) {
return {
start: pattern.start,
end: pattern.end,
valid: false,
message: 'invalid variable path',
};
}
if (
segments[segments.length - 1].type ===
ExpressionEditorSegmentType.EndEmpty
) {
return {
start: pattern.start,
end: pattern.end,
valid: false,
message: 'empty with empty',
};
}
// 2. segments mix variable tree, match tree branch
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
tree,
segments,
});
if (!treeBranch) {
return {
start: pattern.start,
end: pattern.end,
valid: false,
message: 'no match variable path',
};
}
// 3. if full segments path could match one tree branch, the pattern is valid
return {
start: pattern.start,
end: pattern.end,
valid: true,
};
};
export const lineTextValidate = (params: {
lineText: string;
tree: ExpressionEditorTreeNode[];
}): ExpressionEditorValidateData[] => {
const { lineText, tree } = params;
// find patterns {{content}}, record start / end offset
const patterns: ExpressionEditorPattern[] = findPatterns(lineText);
const validateList: ExpressionEditorValidateData[] = patterns.map(pattern =>
patternValidate({ pattern, tree }),
);
return validateList;
};
export const validate = (params: {
lines: ExpressionEditorLine[];
tree: ExpressionEditorTreeNode[];
}): ExpressionEditorValidateData[] => {
const { lines, tree } = params;
const textLines: string[] = lines.map(n => Node.string(n));
const validateList: ExpressionEditorValidateData[] = textLines
.map((lineText: string, lineIndex: number) =>
ExpressionEditorValidator.lineTextValidate({
lineText,
tree,
}),
)
.flat();
return validateList;
};
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const isMacOS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(
navigator.userAgent,
);
export const SHORTCUTS = {
CTRL: isMacOS ? '⌘' : 'Ctrl',
SHIFT: isMacOS ? '⇧' : '⇧',
ALT: isMacOS ? '⌥' : 'Alt',
};

View File

@@ -0,0 +1,24 @@
/* stylelint-disable selector-class-pattern */
.item {
display: flex;
font-size: 14px;
color: #1D1C23;
}
.itemTitle {
display: flex;
flex: 1;
align-items: center;
}
.itemContent {
display: flex;
align-items: center;
}
.close {
cursor: pointer;
position: absolute;
top: 24px;
right: 24px;
}

View File

@@ -0,0 +1,209 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { type ReactNode } from 'react';
import { I18n } from '@coze-arch/i18n';
import { IconCozCross } from '@coze-arch/coze-design/icons';
import { Divider, Typography, Tag } from '@coze-arch/coze-design';
import { InteractiveType } from '@coze-common/mouse-pad-selector';
import { getIsIPad } from '../utils';
import { SHORTCUTS } from './constants';
import s from './index.module.less';
interface ShortcutItemProps {
title: ReactNode;
children?: ReactNode;
}
function ShortcutItem({ title, children }: ShortcutItemProps) {
return (
<div className={s.item}>
<div className={s.itemTitle}>{title}</div>
<div className={s.itemContent}>{children}</div>
</div>
);
}
function DividerWithMargin() {
return <Divider style={{ margin: '12px 0' }} />;
}
function ShortcutTag({ children }: { children: ReactNode }) {
return (
<Tag className="mx-1 text-[14px]" prefixIcon={null} color="primary">
{children}
</Tag>
);
}
interface FlowShortcutsHelpProps {
closable?: boolean;
onClose?: () => void;
isAgentFlow?: boolean;
interactiveType?: InteractiveType;
}
const isIPad = getIsIPad();
function FlowShortcutsHelp(props: FlowShortcutsHelpProps) {
const {
closable = false,
onClose,
isAgentFlow = false,
interactiveType,
} = props;
const isMouseFriendly = interactiveType === InteractiveType.Mouse;
return (
<>
{closable ? (
<div className={s.close} onClick={() => onClose?.()}>
<IconCozCross />
</div>
) : null}
<Typography.Title heading={5} style={{ marginBottom: 16 }}>
{I18n.t('flowcanvas_shortcuts_shortcuts')}
</Typography.Title>
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_move_canvas')}>
{isMouseFriendly ? (
<>
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_drag')}</ShortcutTag>
</>
) : (
<>
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_space')}</ShortcutTag>
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_drag')}</ShortcutTag>
</>
)}
</ShortcutItem>
<DividerWithMargin />
<ShortcutItem title={I18n.t('workflow_area_select')}>
{isMouseFriendly ? (
<>
<ShortcutTag>{SHORTCUTS.SHIFT}</ShortcutTag>
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_drag')}</ShortcutTag>
</>
) : (
<>
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_drag')}</ShortcutTag>
</>
)}
</ShortcutItem>
<DividerWithMargin />
<ShortcutItem
title={
<>
{I18n.t('flowcanvas_shortcuts_multiple_select')}/
{I18n.t('flowcanvas_shortcuts_multiple_deselect')}
</>
}
>
<ShortcutTag>
{SHORTCUTS.CTRL}/{SHORTCUTS.SHIFT}
</ShortcutTag>
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_click')}</ShortcutTag>
</ShortcutItem>
<DividerWithMargin />
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_zoom_in')}>
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
<ShortcutTag>+</ShortcutTag>
<span style={{ margin: '0 6px' }}>
{I18n.t('flowcanvas_shortcuts_or')}
</span>
{!isMouseFriendly ? <ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag> : null}
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_scroll')}</ShortcutTag>
</ShortcutItem>
<DividerWithMargin />
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_zoom_out')}>
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
<ShortcutTag>-</ShortcutTag>
<span style={{ margin: '0 6px' }}>
{I18n.t('flowcanvas_shortcuts_or')}
</span>
{!isMouseFriendly ? <ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag> : null}
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_scroll')}</ShortcutTag>
</ShortcutItem>
<DividerWithMargin />
{isIPad || isAgentFlow ? null : (
<>
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_duplicate')}>
<ShortcutTag>{SHORTCUTS.ALT}</ShortcutTag>
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_drag')}</ShortcutTag>
</ShortcutItem>
<DividerWithMargin />
</>
)}
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_copy')}>
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
<ShortcutTag>C</ShortcutTag>
</ShortcutItem>
<DividerWithMargin />
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_paste')}>
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
<ShortcutTag>V</ShortcutTag>
</ShortcutItem>
<DividerWithMargin />
<UndoRedoShortcuts />
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_delete')}>
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_backspace')}</ShortcutTag>
</ShortcutItem>
</>
);
}
function UndoRedoShortcuts() {
return (
<>
<ShortcutItem title={I18n.t('workflow_detail_undo_tooltip')}>
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
<ShortcutTag>Z</ShortcutTag>
</ShortcutItem>
<DividerWithMargin />
<ShortcutItem title={I18n.t('workflow_detail_redo_tooltip')}>
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
<ShortcutTag>{SHORTCUTS.SHIFT}</ShortcutTag>
<ShortcutTag>Z</ShortcutTag>
</ShortcutItem>
<DividerWithMargin />
</>
);
}
export { FlowShortcutsHelp };

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DynamicParams } from '@coze-arch/bot-typings/teamspace';
import { useParams } from 'react-router-dom';
export const useOpenWorkflowDetail = () => {
const { bot_id: botId } = useParams<DynamicParams>();
/** 打开流程详情页 */
const openWorkflowDetailPage = ({
workflowId,
spaceId,
projectId,
ideNavigate,
}: {
workflowId: string;
spaceId: string;
projectId?: string;
ideNavigate?: (uri: string) => void;
}) => {
if (projectId && ideNavigate) {
ideNavigate(`/workflow/${workflowId}?from=createSuccess`);
} else {
const query = new URLSearchParams();
botId && query.append('bot_id', botId);
query.append('space_id', spaceId ?? '');
query.append('workflow_id', workflowId);
query.append('from', 'createSuccess');
window.open(`/work_flow?${query.toString()}`, '_blank');
}
};
return openWorkflowDetailPage;
};

View File

@@ -0,0 +1,535 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable max-lines-per-function */
/* eslint-disable @coze-arch/max-line-per-function */
import {
type Dispatch,
type SetStateAction,
useCallback,
useMemo,
useState,
} from 'react';
import { omit } from 'lodash-es';
import {
useInfiniteQuery,
type UseInfiniteQueryResult,
} from '@tanstack/react-query';
import {
WorkflowMode,
type GetWorkFlowListRequest,
type GetExampleWorkFlowListRequest,
type GetWorkFlowListResponse,
type GetExampleWorkFlowListResponse,
WorkFlowType,
DeleteType,
workflowApi,
DeleteAction,
type WorkflowListByBindBizRequest,
SchemaType,
BindBizType,
CheckType,
} from '@coze-workflow/base/api';
import { I18n } from '@coze-arch/i18n';
import { CustomError } from '@coze-arch/bot-error';
import { Toast } from '@coze-arch/coze-design';
import { reporter, wait } from '../utils';
import { type WorkflowInfo, WorkflowModalFrom } from '../types';
interface FetchWorkflowListResult {
total: number;
workflow_list: WorkflowInfo[];
}
interface WorkflowListReturn {
flowType: WorkFlowType;
setFlowType: Dispatch<SetStateAction<WorkFlowType>>;
flowMode: WorkflowMode;
setFlowMode: Dispatch<SetStateAction<WorkflowMode>>;
spaceId: string;
setSpaceId: Dispatch<SetStateAction<string>>;
status: GetWorkFlowListRequest['status'] | undefined;
setStatus: Dispatch<
SetStateAction<GetWorkFlowListRequest['status'] | undefined>
>;
name: GetWorkFlowListRequest['name'] | undefined;
setName: Dispatch<SetStateAction<GetWorkFlowListRequest['name'] | undefined>>;
tags: GetWorkFlowListRequest['tags'] | undefined;
setTags: Dispatch<SetStateAction<GetWorkFlowListRequest['tags'] | undefined>>;
orderBy: GetWorkFlowListRequest['order_by'] | undefined;
setOrderBy: Dispatch<
SetStateAction<GetWorkFlowListRequest['order_by'] | undefined>
>;
loginUserCreate: boolean | undefined;
setLoginUserCreate: Dispatch<SetStateAction<boolean | undefined>>;
updatePageParam: (
newParam: Partial<GetWorkFlowListRequest & WorkflowListByBindBizRequest>,
) => void;
workflowList: WorkflowInfo[];
total: number;
queryError: UseInfiniteQueryResult['error'];
fetchNextPage: UseInfiniteQueryResult['fetchNextPage'];
hasNextPage: UseInfiniteQueryResult['hasNextPage'];
isFetching: UseInfiniteQueryResult['isFetching'];
isFetchingNextPage: UseInfiniteQueryResult['isFetchingNextPage'];
loadingStatus: UseInfiniteQueryResult['status'];
refetch: UseInfiniteQueryResult['refetch'];
handleCopy: (item: WorkflowInfo) => Promise<void>;
handleDelete: (item: WorkflowInfo) => Promise<{
canDelete: boolean;
deleteType: DeleteType;
handleDelete:
| ((params?: { needDeleteBlockwise: boolean }) => Promise<void>)
| undefined;
}>;
}
const defaultPageSize = 20;
/**
* 流程列表
*/
export function useWorkflowList({
pageSize = defaultPageSize,
enabled = false,
from,
fetchWorkflowListApi = workflowApi.GetWorkFlowList.bind(workflowApi),
}: {
pageSize?: number;
/** 是否开启数据获取 */
enabled?: boolean;
from?: WorkflowModalFrom;
fetchWorkflowListApi?: (
params: GetWorkFlowListRequest | GetExampleWorkFlowListRequest,
) => Promise<GetWorkFlowListResponse | GetExampleWorkFlowListResponse>;
} = {}): Readonly<WorkflowListReturn> {
const [flowMode, setFlowMode] = useState<WorkflowMode>(WorkflowMode.All);
const [flowType, setFlowType] = useState<WorkFlowType>(WorkFlowType.User);
const [spaceId, setSpaceId] = useState<string>('');
const [name, setName] = useState<GetWorkFlowListRequest['name']>();
const [status, setStatus] = useState<GetWorkFlowListRequest['status']>();
const [orderBy, setOrderBy] = useState<GetWorkFlowListRequest['order_by']>();
const [tags, setTags] = useState<GetWorkFlowListRequest['tags']>();
const [bindBizId, setBindBizId] =
useState<WorkflowListByBindBizRequest['bind_biz_id']>();
const [bindBizType, setBindBizType] =
useState<WorkflowListByBindBizRequest['bind_biz_type']>();
const [loginUserCreate, setLoginUserCreate] =
useState<GetWorkFlowListRequest['login_user_create']>();
const [projectId, setProjectId] =
useState<GetWorkFlowListRequest['project_id']>('');
const initialPageParam = useMemo<GetWorkFlowListRequest>(
() => ({
page: 1,
size: pageSize,
type: flowType,
name,
space_id: spaceId,
status,
tags,
order_by: orderBy,
login_user_create: loginUserCreate,
flow_mode: flowMode,
bind_biz_id: bindBizId,
bind_biz_type: bindBizType,
project_id: projectId,
}),
[
flowType,
status,
name,
flowMode,
orderBy,
spaceId,
loginUserCreate,
tags,
bindBizId,
bindBizType,
projectId,
],
);
const updatePageParam = useCallback(
(newParam: Partial<GetWorkFlowListRequest>) => {
[
{ key: 'type', func: setFlowType, defaultValue: WorkFlowType.User },
{ key: 'name', func: setName },
{ key: 'space_id', func: setSpaceId, defaultValue: '' },
{ key: 'status', func: setStatus },
{ key: 'tags', func: setTags },
{ key: 'order_by', func: setOrderBy },
{ key: 'login_user_create', func: setLoginUserCreate },
{
key: 'flow_mode',
func: setFlowMode,
defaultValue: WorkflowMode.All,
},
{ key: 'bind_biz_id', func: setBindBizId },
{ key: 'bind_biz_type', func: setBindBizType },
{ key: 'project_id', func: setProjectId },
]
.filter(({ key }) => key in newParam)
.forEach(({ key, defaultValue, func }) =>
func?.(newParam[key] ?? defaultValue),
);
},
[],
);
const fetchWorkflowList = async (
params: GetWorkFlowListRequest & WorkflowListByBindBizRequest,
): Promise<FetchWorkflowListResult> => {
try {
reporter.info({
message: 'workflow_list_get_list',
});
const result: FetchWorkflowListResult = {
total: 0,
workflow_list: [],
};
if (params.bind_biz_type === BindBizType.Scene && params.bind_biz_id) {
const resp = await workflowApi.WorkflowListByBindBiz(params);
result.total = (resp.data.total as number) ?? 0;
// 设置流程权限
result.workflow_list = (resp.data.workflow_list ?? []).map(
(item): WorkflowInfo => {
const authInfo = {
can_edit: true,
can_copy: true,
can_delete: !!item?.creator?.self,
};
return {
...item,
authInfo,
};
},
);
} else {
// 多人协作场景DEV 模式需要展示 Blockwise workflow除了流程列表引用
Object.assign(params, {
schema_type_list: [SchemaType.FDL],
checker:
from === WorkflowModalFrom.WorkflowAgent
? [CheckType.BotAgent]
: undefined,
});
const isDouyinBot = params.bind_biz_type === BindBizType.DouYinBot;
// 如果不是抖音分身模式,搜索参数不携带 bind_biz_id 参数
// 否则会导致某个工作流关联到 Agent 后0之后在该工作流添加子工作流时看不到工作流列表
const fetchParams = isDouyinBot
? params
: omit(params, ['bind_biz_id']);
const resp = await fetchWorkflowListApi(fetchParams);
result.total = (resp.data.total as number) ?? 0;
// 设置流程权限
result.workflow_list = (resp.data.workflow_list ?? []).map(
(item): WorkflowInfo => {
let authInfo = {
can_edit: true,
can_copy: true,
can_delete: !!item?.creator?.self,
};
const authItem = (resp.data.auth_list ?? []).find(
it => it.workflow_id === item.workflow_id,
);
if (authItem) {
authInfo = { ...authInfo, ...authItem.auth };
}
return {
...item,
authInfo,
};
},
);
}
reporter.info({
message: 'workflow_list_get_list_success',
meta: {
currentPage: params.page,
pageSize: params.size,
order_by: params.order_by,
name: params.name,
total: result.total,
},
});
return result;
} catch (error) {
reporter.error({
message: 'workflow_list_get_list_fail',
error,
});
throw error;
}
};
const {
data: pageData,
error: queryError,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
status: loadingStatus,
refetch,
} = useInfiniteQuery({
enabled: enabled && !!spaceId,
queryKey: ['space_workflow_list', 'vcs', JSON.stringify(initialPageParam)],
queryFn: ({ pageParam }) => fetchWorkflowList(pageParam),
initialPageParam,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
if ((lastPageParam.page ?? 1) * pageSize > lastPage.total) {
return null;
}
return {
...lastPageParam,
page: (lastPageParam.page ?? 1) + 1,
};
},
});
const workflowList = useMemo(() => {
const result: WorkflowInfo[] = [];
const idMap: Record<string, boolean> = {};
pageData?.pages.forEach(page => {
page.workflow_list.forEach(workflow => {
if (!workflow.workflow_id) {
return;
}
if (!idMap[workflow.workflow_id]) {
result.push(workflow);
}
idMap[workflow.workflow_id] = true;
});
});
return result;
}, [pageData]);
const total = useMemo(() => {
if (
!pageData?.pages ||
!Array.isArray(pageData.pages) ||
pageData.pages.length === 0
) {
return 0;
}
return pageData.pages[pageData.pages.length - 1].total ?? 0;
}, [pageData]);
// 复制
const handleCopy = async (item: WorkflowInfo) => {
if (!item.workflow_id || !spaceId) {
throw new CustomError('normal_error', 'miss workflowId or spaceID');
}
// 检查复制权限
if (!item.authInfo.can_copy) {
throw new CustomError('normal_error', 'no copy permission');
}
reporter.info({
message: 'workflow_list_copy_row',
meta: {
workflowId: item.workflow_id,
},
});
try {
let isError = false;
const { data } = await workflowApi.CopyWorkflow({
space_id: spaceId,
workflow_id: item.workflow_id,
});
isError = !data?.workflow_id;
if (isError) {
Toast.error(I18n.t('workflow_detail_toast_createcopy_failed'));
reporter.error({
message: 'workflow_list_copy_row_fail',
error: new CustomError('normal_error', 'result no workflow'),
});
return;
}
Toast.success({
content:
flowMode === WorkflowMode.Imageflow
? I18n.t('imageflow_detail_toast_createcopy_succeed')
: I18n.t('workflow_detail_toast_createcopy_succeed'),
showClose: false,
});
reporter.info({
message: 'workflow_list_copy_row_success',
meta: {
workflowId: item.workflow_id,
},
});
// 兜底服务主从延迟
await wait(300);
// 刷新列表
refetch();
} catch (error) {
reporter.error({
message: 'workflow_list_copy_row_fail',
error,
});
Toast.error(I18n.t('workflow_detail_toast_createcopy_failed'));
}
};
// 删除
const handleDelete = async (item: WorkflowInfo) => {
if (!item.workflow_id || !spaceId) {
throw new CustomError('normal_error', 'miss workflowId or spaceID');
}
// 先检查删除权限
if (!item.authInfo.can_delete) {
throw new CustomError('normal_error', 'no delete permission');
}
reporter.info({
message: 'workflow_list_delete_row',
meta: {
workflowId: item.workflow_id,
},
});
let deleteType = DeleteType.CanDelete;
// 从服务端查询删除模式
const resp = await workflowApi.GetDeleteStrategy({
space_id: spaceId,
workflow_id: item.workflow_id,
});
deleteType = resp.data;
const canDelete = [
DeleteType.CanDelete,
DeleteType.RejectProductDraft,
].includes(deleteType);
const deleteFuc = async (deleteParams?: {
needDeleteBlockwise: boolean;
}) => {
const needDeleteBlockwise = deleteParams?.needDeleteBlockwise;
const action = needDeleteBlockwise
? DeleteAction.BlockwiseDelete
: DeleteAction.BlockwiseUnbind;
if (!item.workflow_id || !spaceId) {
throw new CustomError('normal_error', 'miss workflowId or spaceID');
}
try {
await workflowApi.DeleteWorkflow({
space_id: spaceId,
workflow_id: item.workflow_id,
action,
});
Toast.success({
content: I18n.t('workflow_add_delete_success'),
showClose: false,
});
reporter.info({
message: 'workflow_list_delete_row_success',
});
// 兜底服务主从延迟
await wait(300);
// 刷新列表
refetch();
} catch (error) {
reporter.error({
message: 'workflow_list_delete_row_fail',
error,
});
Toast.error({
content: I18n.t('workflow_add_delete_fail'),
showClose: false,
});
}
};
return {
/** 是否可删除 */
canDelete,
/** 删除策略 */
deleteType,
/** 删除方法 */
handleDelete: canDelete ? deleteFuc : undefined,
};
};
return {
// 列表筛选状态
flowType,
setFlowType,
flowMode,
setFlowMode,
spaceId,
setSpaceId,
status,
setStatus,
name,
setName,
tags,
setTags,
orderBy,
setOrderBy,
loginUserCreate,
setLoginUserCreate,
/** 更新筛选参数 */
updatePageParam,
// 列表获取
/** 流程列表数据 */
workflowList,
/** 流程总数 */
total,
/** 获取列表请求错误 */
queryError,
/** 拉取下一页数据 */
fetchNextPage,
/** 是否有下一页 */
hasNextPage,
/** 获取数据中 */
isFetching,
/** 获取下一页数据中 */
isFetchingNextPage,
/** 加载状态 */
loadingStatus,
/** 重新加载 */
refetch,
// 列表操作
/** 复制流程 */
handleCopy,
// /** 删除流程 */
handleDelete,
} as const;
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type ReactNode } from 'react';
import { useBoolean } from 'ahooks';
import { type WorkFlowModalModeProps } from '../workflow-modal/type';
import WorkflowModal from '../workflow-modal';
interface UseWorkFlowListReturnValue {
node: ReactNode;
open: () => void;
close: () => void;
}
export const useWorkflowModal = (
props?: WorkFlowModalModeProps,
): UseWorkFlowListReturnValue => {
const { onClose, ...restProps } = props || {};
const [visible, { setTrue: showModal, setFalse: hideModal }] =
useBoolean(false);
const closeModal = () => {
onClose?.();
hideModal();
};
return {
node: visible ? (
<WorkflowModal visible onClose={closeModal} {...restProps} />
) : null,
close: closeModal,
open: showModal,
};
};

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useWorkflowResourceMenuActions } from './use-workflow-resource-menu-actions';
import { useWorkflowResourceClick } from './use-workflow-resource-click';
import { useCreateWorkflowModal } from './use-create-workflow-modal';
import {
type UseWorkflowResourceAction,
type WorkflowResourceActionProps,
type WorkflowResourceActionReturn,
} from './type';
export { useWorkflowPublishEntry } from './use-workflow-publish-entry';
export const useWorkflowResourceAction: UseWorkflowResourceAction = props => {
const { spaceId, userId, getCommonActions } = props;
const { handleWorkflowResourceClick, goWorkflowDetail } =
useWorkflowResourceClick(spaceId);
const {
openCreateModal,
workflowModal,
createWorkflowModal,
handleEditWorkflow,
} = useCreateWorkflowModal({ ...props, goWorkflowDetail });
const { renderWorkflowResourceActions, modals } =
useWorkflowResourceMenuActions({
...props,
userId,
onEditWorkflowInfo: handleEditWorkflow,
getCommonActions,
});
return {
workflowResourceModals: [createWorkflowModal, workflowModal, ...modals],
openCreateModal,
handleWorkflowResourceClick,
renderWorkflowResourceActions,
};
};
export {
type WorkflowResourceActionProps,
type WorkflowResourceActionReturn,
useCreateWorkflowModal,
useWorkflowResourceClick,
useWorkflowResourceMenuActions,
};

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type ReactNode } from 'react';
import {
type WorkflowMode,
type ProductDraftStatus,
type SchemaType,
} from '@coze-workflow/base';
import { type TableActionProps } from '@coze-arch/coze-design';
import { type ResourceInfo } from '@coze-arch/bot-api/plugin_develop';
export { type ResourceInfo };
export interface WorkflowResourceActionProps {
/* 刷新列表函数 */
refreshPage?: () => void;
spaceId?: string;
/* 当前登录用户 id */
userId?: string;
getCommonActions?: (
libraryResource: ResourceInfo,
) => NonNullable<TableActionProps['actionList']>;
}
export interface WorkflowResourceActionReturn {
/* 打开 workflow 创建弹窗 */
openCreateModal: (flowMode?: WorkflowMode) => void;
/* 创建、删除等操作的全局弹窗,直接挂载到列表父容器上 */
workflowResourceModals: ReactNode[];
/* 在 Table 组件的 columns 的 render 里调用,返回 Table.TableAction 组件 */
renderWorkflowResourceActions: (record: ResourceInfo) => ReactNode;
/* 资源 item 点击 */
handleWorkflowResourceClick: (record: ResourceInfo) => void;
}
export type UseWorkflowResourceAction = (
props: WorkflowResourceActionProps,
) => WorkflowResourceActionReturn;
export interface WorkflowResourceBizExtend {
product_draft_status: ProductDraftStatus;
external_flow_info?: string;
schema_type: SchemaType;
plugin_id?: string;
icon_uri: string;
url: string;
}
export interface DeleteModalConfig {
title: string;
desc: string;
okText: string;
okHandle: () => void;
cancelText: string;
}
export interface CommonActionProps extends WorkflowResourceActionProps {
userId?: string;
}
export interface CommonActionReturn {
actionHandler: (record: ResourceInfo) => void;
}
export interface DeleteActionReturn extends CommonActionReturn {
deleteModal?: ReactNode;
}
export interface PublishActionReturn extends CommonActionReturn {
publishModal: ReactNode;
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { workflowApi } from '@coze-workflow/base';
import { I18n } from '@coze-arch/i18n';
import { Toast } from '@coze-arch/coze-design';
import { WorkflowMode } from '@coze-arch/bot-api/workflow_api';
import { type ResourceInfo } from '@coze-arch/bot-api/plugin_develop';
export const useChatflowSwitch = ({
spaceId,
refreshPage,
}: {
spaceId: string;
refreshPage?: () => void;
}) => {
const changeFlowMode = async (flowMode: WorkflowMode, workflowId: string) => {
await workflowApi.UpdateWorkflowMeta({
space_id: spaceId,
workflow_id: workflowId,
flow_mode: flowMode,
});
Toast.success(
I18n.t('wf_chatflow_123', {
Chatflow: I18n.t(
flowMode === WorkflowMode.ChatFlow ? 'wf_chatflow_76' : 'Workflow',
),
}),
);
await new Promise(resolve => setTimeout(resolve, 300));
refreshPage?.();
};
const switchToWorkflow = async (record: ResourceInfo) =>
changeFlowMode(WorkflowMode.Workflow, record.res_id ?? '');
const switchToChatflow = async (record: ResourceInfo) =>
changeFlowMode(WorkflowMode.ChatFlow, record.res_id ?? '');
return { switchToWorkflow, switchToChatflow };
};

View File

@@ -0,0 +1,91 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { workflowApi } from '@coze-workflow/base';
import { I18n } from '@coze-arch/i18n';
import { Toast } from '@coze-arch/coze-design';
import { CustomError } from '@coze-arch/bot-error';
import { type ResourceInfo, ResType } from '@coze-arch/bot-api/plugin_develop';
import { useNavigate } from 'react-router-dom';
import { reporter, wait } from '@/utils';
import { type CommonActionProps, type CommonActionReturn } from './type';
export const useCopyAction = (props: CommonActionProps): CommonActionReturn => {
const { spaceId } = props;
const navigate = useNavigate();
// 复制
const handleCopy = async (item: ResourceInfo) => {
if (!item.res_id || !spaceId) {
throw new CustomError('normal_error', 'miss workflowId or spaceID');
}
reporter.info({
message: 'workflow_list_copy_row',
meta: {
workflowId: item.res_id,
},
});
try {
let isError = false;
const { data } = await workflowApi.CopyWorkflow({
space_id: spaceId,
workflow_id: item.res_id,
});
isError = !data?.workflow_id;
if (isError) {
Toast.error(I18n.t('workflow_detail_toast_createcopy_failed'));
reporter.error({
message: 'workflow_list_copy_row_fail',
error: new CustomError('normal_error', 'result no workflow'),
});
return;
}
Toast.success({
content:
item.res_type === ResType.Imageflow
? I18n.t('imageflow_detail_toast_createcopy_succeed')
: I18n.t('workflow_detail_toast_createcopy_succeed'),
showClose: false,
});
reporter.info({
message: 'workflow_list_copy_row_success',
meta: {
workflowId: item.res_id,
},
});
// 兜底服务主从延迟
await wait(300);
// 复制后跳转到详情页
navigate(
`/work_flow?workflow_id=${data.workflow_id}&space_id=${spaceId}`,
);
} catch (error) {
reporter.error({
message: 'workflow_list_copy_row_fail',
error,
});
Toast.error(I18n.t('workflow_detail_toast_createcopy_failed'));
}
};
return { actionHandler: handleCopy };
};

View File

@@ -0,0 +1,174 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo, useState } from 'react';
import { useBoolean } from 'ahooks';
import {
type FrontWorkflowInfo,
WorkflowMode,
isGeneralWorkflow,
type BindBizType,
} from '@coze-workflow/base';
import { I18n } from '@coze-arch/i18n';
import { useFlags } from '@coze-arch/bot-flags';
import { CustomError } from '@coze-arch/bot-error';
import { DataSourceType, WorkflowModalFrom } from '@/workflow-modal';
import { CreateWorkflowModal, type RuleItem } from '@/workflow-edit';
import { reporter } from '@/utils';
import { useWorkflowModal } from '@/hooks/use-workflow-modal';
import { type WorkflowResourceActionProps } from './type';
export const useCreateWorkflowModal = ({
from = WorkflowModalFrom.SpaceWorkflowList,
bindBizType,
bindBizId,
refreshPage,
spaceId,
goWorkflowDetail,
projectId,
onCreateSuccess,
hiddenTemplateEntry,
nameValidators,
}: WorkflowResourceActionProps & {
from?: WorkflowModalFrom;
/** 当前项目 id只在项目内的 workflow 有该字段 */
projectId?: string;
bindBizType?: BindBizType;
bindBizId?: string;
onCreateSuccess?: ({ workflowId }: { workflowId: string }) => void;
goWorkflowDetail?: (workflowId?: string, spaceId?: string) => void;
/** 隐藏通过模板创建入口 */
hiddenTemplateEntry?: boolean;
nameValidators?: RuleItem[];
}) => {
const [currentWorkflow, setCurrentWorkflow] = useState<FrontWorkflowInfo>();
const [formMode, setFormMode] = useState<'add' | 'update'>('add');
const [flowMode, setFlowMode] = useState<WorkflowMode>(WorkflowMode.Workflow);
const [createModalVisible, { setTrue, setFalse: closeCreateModal }] =
useBoolean(false);
const [FLAGS] = useFlags();
const openCreateModal = (mode?: WorkflowMode) => {
setFormMode('add');
setFlowMode(mode || WorkflowMode.Workflow);
reporter.info({
message: 'workflow_list_open_create_modal',
});
setTrue();
};
const openEditModal = () => {
setFormMode('update');
reporter.info({
message: 'workflow_list_open_create_modal',
});
setTrue();
};
const handleEditWorkflow = (partialWorkflowInfo: FrontWorkflowInfo) => {
setCurrentWorkflow(partialWorkflowInfo);
setFlowMode(partialWorkflowInfo.flow_mode || WorkflowMode.Workflow);
openEditModal();
};
const workflowModalInitState = useMemo(() => {
// 社区版暂不支持该功能
if (isWorkflowMode || FLAGS['bot.community.store_imageflow']) {
return {
productCategory: 'all',
isSpaceWorkflow: false,
dataSourceType: DataSourceType.Product,
};
}
return {
workflowTag: 1,
isSpaceWorkflow: false,
dataSource: DataSourceType.Workflow,
};
}, [FLAGS, flowMode]);
const { node: workflowModal } = useWorkflowModal({
from,
flowMode,
dupText: I18n.t('Copy'),
hiddenCreate: true,
hiddenSpaceList: true,
initState: workflowModalInitState,
projectId,
onAdd: () => {
refreshPage?.();
},
onDupSuccess: val => {
window.open(
`/work_flow?space_id=${spaceId}&workflow_id=${val.workflow_id}&from=dupSuccess`,
);
},
});
const isWorkflowMode = useMemo(() => isGeneralWorkflow(flowMode), [flowMode]);
return {
openCreateModal,
handleEditWorkflow,
workflowModal,
createWorkflowModal: (
<CreateWorkflowModal
initConfirmDisabled
mode={formMode}
flowMode={flowMode}
projectId={projectId}
visible={createModalVisible}
onCancel={closeCreateModal}
bindBizType={bindBizType}
bindBizId={bindBizId}
workFlow={formMode === 'update' ? currentWorkflow : undefined}
getLatestWorkflowJson={undefined}
customTitleRender={undefined}
onSuccess={({ workflowId }) => {
closeCreateModal();
if (!workflowId) {
throw new CustomError(
'[Workflow] create failed',
'create workflow failed, no workflow id',
);
}
if (onCreateSuccess && formMode === 'add') {
onCreateSuccess({ workflowId });
return;
}
// 编辑模式,不跳转,刷新当前列表
if (formMode === 'update') {
refreshPage?.();
return;
}
const navigateDelay = 500;
// 由于后端数据同步慢这里delay 500 ms
setTimeout(() => {
goWorkflowDetail?.(workflowId, spaceId);
}, navigateDelay);
}}
spaceID={spaceId}
nameValidators={nameValidators}
/>
),
};
};

View File

@@ -0,0 +1,205 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState } from 'react';
import { DeleteAction, DeleteType, workflowApi } from '@coze-workflow/base';
import { I18n } from '@coze-arch/i18n';
import { CustomError } from '@coze-arch/bot-error';
import { type ResourceInfo, ResType } from '@coze-arch/bot-api/plugin_develop';
import { Modal, Toast } from '@coze-arch/coze-design';
import { reporter, wait } from '@/utils';
import {
type CommonActionProps,
type DeleteActionReturn,
type DeleteModalConfig,
} from './type';
// eslint-disable-next-line @coze-arch/max-line-per-function
export const useDeleteAction = (
props: CommonActionProps,
): DeleteActionReturn => {
const { spaceId, userId, refreshPage } = props;
const [modalVisible, setModalVisible] = useState(false);
const [deleteModalConfig, setDeleteModalConfig] =
useState<DeleteModalConfig>();
/**
* 逻辑来自 useWorkflowList@coze-workflow/components,由于入参变了,不再复用
* @param item
*/
const handleDeleteWorkflowResource = async (item: ResourceInfo) => {
if (!item.res_id || !spaceId) {
throw new CustomError('normal_error', 'miss workflowId or spaceID');
}
reporter.info({
message: 'workflow_list_delete_row',
meta: {
workflowId: item.res_id,
},
});
let deleteType = DeleteType.CanDelete;
// 从服务端查询删除模式
const resp = await workflowApi.GetDeleteStrategy({
space_id: spaceId,
workflow_id: item.res_id,
});
deleteType = resp.data;
const canDelete = [
DeleteType.CanDelete,
DeleteType.RejectProductDraft,
].includes(deleteType);
const deleteFuc = async (deleteParams?: {
needDeleteBlockwise: boolean;
}) => {
const needDeleteBlockwise = deleteParams?.needDeleteBlockwise;
const action = needDeleteBlockwise
? DeleteAction.BlockwiseDelete
: DeleteAction.BlockwiseUnbind;
if (!item.res_id || !spaceId) {
throw new CustomError('normal_error', 'miss workflowId or spaceID');
}
try {
await workflowApi.DeleteWorkflow({
space_id: spaceId,
workflow_id: item.res_id,
action,
});
Toast.success({
content: I18n.t('workflow_add_delete_success'),
showClose: false,
});
reporter.info({
message: 'workflow_list_delete_row_success',
});
// 兜底服务主从延迟
await wait(300);
// 刷新列表
refreshPage?.();
} catch (error) {
reporter.error({
message: 'workflow_list_delete_row_fail',
error,
});
Toast.error({
content: I18n.t('workflow_add_delete_fail'),
showClose: false,
});
}
};
return {
canDelete,
deleteType,
handleDelete: canDelete ? deleteFuc : undefined,
};
};
const deleteAction = async (record: ResourceInfo) => {
const isSelfCreator = record.creator_id === userId;
const deleteConfig = await handleDeleteWorkflowResource(record);
let title = I18n.t('delete_title');
if (deleteConfig.deleteType === DeleteType.UnListProduct) {
title = I18n.t('workflowstore_unable_to_delete_workflow');
}
let desc = I18n.t('library_delete_desc');
if (deleteConfig.deleteType === DeleteType.UnListProduct) {
if (isSelfCreator) {
desc = I18n.t('workflowstore_the_workflow_has_been');
} else {
desc = I18n.t('workflowstore_delete_permission');
}
}
let okText = deleteConfig.canDelete
? I18n.t('confirm')
: I18n.t('workflowstore_remove_wf');
if (
deleteConfig.deleteType === DeleteType.UnListProduct &&
!isSelfCreator
) {
okText = '';
}
let cancelText = I18n.t('cancel');
if (
deleteConfig.deleteType === DeleteType.UnListProduct &&
!isSelfCreator
) {
cancelText = I18n.t('confirm');
}
setDeleteModalConfig({
title,
desc,
cancelText,
okText,
okHandle: () => {
if (deleteConfig.canDelete) {
deleteConfig?.handleDelete?.({
needDeleteBlockwise: false,
});
} else {
if (
deleteConfig.deleteType === DeleteType.UnListProduct &&
isSelfCreator
) {
window.open(
`/store/${
record.res_type === ResType.Workflow ? 'workflow' : 'imageflow'
}/${record.res_id}?entity_id=true`,
'_blank',
);
}
}
},
});
setModalVisible(true);
};
const deleteModal = (
<Modal
maskClosable={false}
centered={true}
visible={modalVisible}
title={deleteModalConfig?.title ?? ''}
onOk={() => {
setModalVisible(false);
deleteModalConfig?.okHandle();
}}
onCancel={() => {
setModalVisible(false);
}}
closeOnEsc={true}
cancelText={deleteModalConfig?.cancelText}
okText={deleteModalConfig?.okText}
okButtonColor={'red'}
>
<div className="coz-common-content">{deleteModalConfig?.desc ?? ''}</div>
</Modal>
);
return { deleteModal, actionHandler: deleteAction };
};

View File

@@ -0,0 +1,69 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState } from 'react';
import { WorkflowMode } from '@coze-workflow/base';
import { AddWorkflowToStoreEntry } from '@coze-arch/bot-tea';
import { type ResourceInfo, ResType } from '@coze-arch/bot-api/plugin_develop';
import {
PublishWorkflowModal,
usePublishWorkflowModal,
} from '@coze-workflow/resources-adapter';
import { type CommonActionProps, type PublishActionReturn } from './type';
export const usePublishAction = ({
spaceId = '',
refreshPage,
}: CommonActionProps): PublishActionReturn => {
const [flowMode, setFlowMode] = useState<WorkflowMode>(WorkflowMode.Workflow);
const publishWorkflowModalHook = usePublishWorkflowModal({
onPublishSuccess: () => {
refreshPage?.();
},
fromSpace: true,
flowMode,
});
/**
* NOTICE: 此函数由商店侧维护, 可联系 @gaoding
* 发布/更新流程商品
*/
const onPublishStore = (item: ResourceInfo) => {
setFlowMode(
item.res_type === ResType.Imageflow
? WorkflowMode.Imageflow
: WorkflowMode.Workflow,
);
// 商店渲染流程需要 spaceId 信息, 在这个场景需要手动设置对应信息
publishWorkflowModalHook.setSpace(spaceId);
publishWorkflowModalHook.showModal({
type: PublishWorkflowModal.WORKFLOW_INFO,
product: {
meta_info: {
entity_id: item.res_id,
name: item.name,
},
},
source: AddWorkflowToStoreEntry.WORKFLOW_PERSONAL_LIST,
});
};
return {
actionHandler: onPublishStore,
publishModal: publishWorkflowModalHook.ModalComponent,
};
};

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { useWorkflowPublishEntry } from '@coze-workflow/resources-adapter';

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ResourceInfo } from '@coze-arch/bot-api/plugin_develop';
import { useNavigate } from 'react-router-dom';
import { reporter } from '@/utils';
export const useWorkflowResourceClick = (spaceId?: string) => {
const navigate = useNavigate();
const onEditWorkFlow = (workflowId?: string) => {
reporter.info({
message: 'workflow_list_edit_row',
meta: {
workflowId,
},
});
goWorkflowDetail(workflowId, spaceId);
};
/** 打开流程编辑页 */
const goWorkflowDetail = (workflowId?: string, sId?: string) => {
if (!workflowId || !sId) {
return;
}
reporter.info({
message: 'workflow_list_navigate_to_detail',
meta: {
workflowId,
},
});
navigate(`/work_flow?workflow_id=${workflowId}&space_id=${sId}`);
};
const handleWorkflowResourceClick = (record: ResourceInfo) => {
reporter.info({
message: 'workflow_list_click_row',
});
onEditWorkFlow(record?.res_id);
};
return { handleWorkflowResourceClick, goWorkflowDetail };
};

View File

@@ -0,0 +1,161 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ReactNode } from 'react';
import {
ProductDraftStatus,
type FrontWorkflowInfo,
} from '@coze-workflow/base';
import { I18n } from '@coze-arch/i18n';
import { Table, type TableActionProps } from '@coze-arch/coze-design';
import { useFlags } from '@coze-arch/bot-flags';
import {
resource_resource_common,
type ResourceInfo,
ResType,
} from '@coze-arch/bot-api/plugin_develop';
import {
parseWorkflowResourceBizExtend,
transformResourceToWorkflowEditInfo,
} from './utils';
import { useWorkflowPublishEntry } from './use-workflow-publish-entry';
import { usePublishAction } from './use-publish-action';
import { useDeleteAction } from './use-delete-action';
import { useCopyAction } from './use-copy-action';
import { useChatflowSwitch } from './use-chatflow-switch';
import {
type WorkflowResourceActionProps,
type WorkflowResourceActionReturn,
} from './type';
const { ActionKey } = resource_resource_common;
type ActionItemProps = NonNullable<TableActionProps['actionList']>[number];
export const useWorkflowResourceMenuActions = (
props: WorkflowResourceActionProps & {
userId?: string;
onEditWorkflowInfo: (partialWorkflowInfo: FrontWorkflowInfo) => void;
},
): Pick<WorkflowResourceActionReturn, 'renderWorkflowResourceActions'> & {
modals: ReactNode[];
} => {
const [FLAGS] = useFlags();
const { userId, onEditWorkflowInfo, getCommonActions } = props;
const { actionHandler: deleteAction, deleteModal } = useDeleteAction(props);
const { actionHandler: copyAction } = useCopyAction(props);
const { actionHandler: publishAction, publishModal } =
usePublishAction(props);
const { switchToChatflow, switchToWorkflow } = useChatflowSwitch({
spaceId: props.spaceId ?? '',
refreshPage: props.refreshPage,
});
const actionMap = {
[ActionKey.Copy]: copyAction,
[ActionKey.Delete]: deleteAction,
[ActionKey.Edit]: (record: ResourceInfo) => {
const workflowPartialInfo = transformResourceToWorkflowEditInfo(record);
onEditWorkflowInfo(workflowPartialInfo as FrontWorkflowInfo);
},
[ActionKey.SwitchToFuncflow]: switchToWorkflow,
[ActionKey.SwitchToChatflow]: switchToChatflow,
};
const { enablePublishEntry } = useWorkflowPublishEntry();
// eslint-disable-next-line complexity
const renderWorkflowResourceActions = (record: ResourceInfo): ReactNode => {
const bizExtend = parseWorkflowResourceBizExtend(record.biz_extend);
const productDraftStatus = bizExtend?.product_draft_status;
const isImageFlow = record.res_type === ResType.Imageflow;
const { actions } = record;
const deleteActionConfig = actions?.find(
action => action.key === ActionKey.Delete,
);
const copyActionConfig = actions?.find(
action => action.key === ActionKey.Copy,
);
const editConfig = actions?.find(action => action.key === ActionKey.Edit);
const chatflowConfig = actions?.find(
action => action.key === ActionKey.SwitchToChatflow,
);
const workflowConfig = actions?.find(
action => action.key === ActionKey.SwitchToFuncflow,
);
const isSelfCreator = record.creator_id === userId;
const extraActions: ActionItemProps[] = [
{
hide: !editConfig,
disabled: editConfig?.enable === false,
actionKey: 'edit',
actionText: I18n.t('Edit'),
handler: () => actionMap?.[ActionKey.Edit]?.(record),
},
{
hide: !chatflowConfig,
disabled: chatflowConfig?.enable === false,
actionKey: 'switchChatflow',
actionText: I18n.t('wf_chatflow_121', {
flowMode: I18n.t('wf_chatflow_76'),
}),
handler: () => actionMap?.[ActionKey.SwitchToChatflow]?.(record),
},
{
hide: !workflowConfig,
disabled: workflowConfig?.enable === false,
actionKey: 'switchWorkflow',
actionText: I18n.t('wf_chatflow_121', { flowMode: I18n.t('Workflow') }),
handler: () => actionMap?.[ActionKey.SwitchToFuncflow]?.(record),
},
...(getCommonActions?.(record) ?? []),
{
hide:
!enablePublishEntry || // 上架入口加白
// 社区版暂不支持该功能
(!FLAGS['bot.community.store_imageflow'] && isImageFlow) || // Imageflow 不支持商店
!isSelfCreator ||
bizExtend?.plugin_id === '0',
actionKey: 'publishWorkflowProduct',
actionText:
productDraftStatus === ProductDraftStatus.Default
? I18n.t('workflowstore_submit')
: I18n.t('workflowstore_submit_update'),
handler: () => {
publishAction?.(record);
},
},
];
return (
<Table.TableAction
deleteProps={{
hide: !deleteActionConfig,
disabled: deleteActionConfig?.enable === false,
disableConfirm: true,
handler: () => actionMap[ActionKey.Delete]?.(record),
}}
copyProps={{
hide: !copyActionConfig,
disabled: copyActionConfig?.enable === false,
handler: () => actionMap[ActionKey.Copy]?.(record),
}}
actionList={extraActions}
/>
);
};
return { renderWorkflowResourceActions, modals: [deleteModal, publishModal] };
};

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ProductDraftStatus,
SchemaType,
type FrontWorkflowInfo,
} from '@coze-workflow/base';
import { type ResourceInfo } from '@coze-arch/bot-api/plugin_develop';
import { type WorkflowResourceBizExtend } from './type';
export const parseWorkflowResourceBizExtend = (
bizExtend?: Record<string, string>,
): WorkflowResourceBizExtend | undefined => {
if (!bizExtend) {
return undefined;
}
return {
product_draft_status:
bizExtend.product_draft_status !== undefined
? parseInt(bizExtend.product_draft_status || '0')
: ProductDraftStatus.Default,
external_flow_info: bizExtend.external_flow_info,
schema_type:
bizExtend.schema_type !== undefined
? parseInt(bizExtend.schema_type || '0')
: SchemaType.DAG,
plugin_id: bizExtend.plugin_id,
icon_uri: bizExtend.icon_uri,
url: bizExtend.url,
};
};
/**
* 转换 ResourceInfo 为编辑 workflow 所需的 WorkflowInfoLocal 结构
* @param resource
*/
export const transformResourceToWorkflowEditInfo = (
resource: ResourceInfo,
): Pick<
FrontWorkflowInfo,
| 'workflow_id'
| 'url'
| 'icon_uri'
| 'name'
| 'desc'
| 'schema_type'
| 'external_flow_info'
| 'space_id'
> => {
const bizExtend = parseWorkflowResourceBizExtend(resource.biz_extend);
return {
workflow_id: resource.res_id,
url: bizExtend?.url,
icon_uri: bizExtend?.icon_uri,
name: resource.name,
desc: resource.desc,
schema_type: bizExtend?.schema_type,
external_flow_info: bizExtend?.external_flow_info,
space_id: resource.space_id,
};
};

View File

@@ -0,0 +1,489 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { workflowApi } from '@coze-workflow/base/api';
import { REPORT_EVENTS } from '@coze-arch/report-events';
import { I18n } from '@coze-arch/i18n';
import { upLoadFile } from '@coze-arch/bot-utils';
import { CustomError } from '@coze-arch/bot-error';
import { FileBizType } from '@coze-arch/bot-api/developer_api';
import { DeveloperApi } from '@coze-arch/bot-api';
/** 图片上传错误码 */
export enum ImgUploadErrNo {
Success = 0,
/** 缺少文件 */
NoFile,
/** 上传失败 */
UploadFail,
/** 上传超时 */
UploadTimeout,
/** 获取 URL 失败 */
GetUrlFail,
/** 校验异常, 但是不明确具体异常 */
ValidateError,
/** 文件尺寸超出限制 */
MaxSizeError,
/** 文件类型不支持 */
SuffixError,
/** 最大宽度限制 */
MaxWidthError,
/** 最大高度限制 */
MaxHeightError,
/** 最小宽度限制 */
MinWidthError,
/** 最小高度限制 */
MinHeightError,
/** 固定宽高比 */
AspectRatioError,
}
export interface ImageRule {
/** 文件大小限制, 单位 b, 1M = 1 * 1024 * 1024 */
maxSize?: number;
/** 文件后缀 */
suffix?: string[];
/** 最大宽度限制 */
maxWidth?: number;
/** 最大高度限制 */
maxHeight?: number;
/** 最小宽度限制 */
minWidth?: number;
/** 最小高度限制 */
minHeight?: number;
/** 固定宽高比 */
aspectRatio?: number;
}
type UploadResult =
| {
isSuccess: false;
errNo: ImgUploadErrNo;
msg: string;
}
| {
isSuccess: true;
errNo: ImgUploadErrNo.Success;
uri: string;
url: string;
};
/**
* Workflow 图片上传
*/
class ImageUploader {
/** 任务 ID, 用于避免 ABA 问题 */
private taskId = 0;
/**
* 上传模式
* - api 直接使用接口上传
* - uploader 上传到视频云服务, 走 workflow 服务. !海外版未经过测试
*/
mode: 'uploader' | 'api' = 'uploader';
/** 校验规则 */
rules?: ImageRule;
/** 上传的文件 */
file?: File;
/** 展示 Url, 添加文件后生成, 用于预览 */
displayUrl?: string;
/** 上传状态 */
isUploading = false;
/** 超时时间 */
timeout?: number;
/** 校验结果 */
validateResult?: {
isSuccess: boolean;
errNo: ImgUploadErrNo;
msg?: string;
};
/** 上传结果 */
uploadResult?: UploadResult;
constructor(config?: {
rules?: ImageRule;
mode?: ImageUploader['mode'];
timeout?: number;
}) {
this.rules = config?.rules ?? this.rules;
this.mode = config?.mode ?? this.mode;
this.timeout = config?.timeout ?? this.timeout;
}
/** 选择待上传文件 */
async select(file: File) {
if (!file) {
throw new CustomError('normal_error', '选择文件为空');
}
this.reset();
this.file = file;
this.displayUrl = URL.createObjectURL(this.file);
await this.validate().catch(() => {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.ValidateError,
msg: I18n.t('imageflow_upload_error'),
};
});
}
/** 上传图片 */
async upload() {
// 未选择文件或文件不符合要求
if (!this.file || !this.validateResult?.isSuccess || this.isUploading) {
return;
}
this.isUploading = true;
// 添加任务 ID,避免 ABA 问题
this.taskId += 1;
const currentId = this.taskId;
let uploadResult: UploadResult;
if (this.mode === 'api') {
uploadResult = await this.uploadByApi(this.file);
} else if (this.mode === 'uploader') {
uploadResult = await this.uploadByUploader(this.file);
} else {
throw new CustomError('normal_error', 'ImageUploader mode error');
}
if (currentId !== this.taskId) {
return;
}
this.uploadResult = uploadResult;
this.isUploading = false;
}
private uploadByUploader(file: File): Promise<UploadResult> {
return new Promise(resolve => {
const timer =
this.timeout &&
setTimeout(
() =>
resolve({
isSuccess: false,
errNo: ImgUploadErrNo.UploadTimeout,
msg: I18n.t('imageflow_upload_error7'),
}),
this.timeout,
);
const doUpload = async () => {
const uri = await upLoadFile({
biz: 'workflow',
file,
fileType: 'image',
})
.then(result => {
if (!result) {
throw new CustomError('normal_error', 'no uri');
}
return result;
})
.catch(() => {
resolve({
isSuccess: false,
errNo: ImgUploadErrNo.UploadFail,
msg: I18n.t('imageflow_upload_error'),
});
return '';
});
if (!uri) {
return;
}
// 获取 url
const resp = await workflowApi
.SignImageURL(
{
uri,
},
{
__disableErrorToast: true,
},
)
.catch(() => null);
const url = resp?.url || '';
if (url) {
resolve({
isSuccess: true,
errNo: ImgUploadErrNo.Success,
uri,
url,
});
} else {
resolve({
isSuccess: false,
errNo: ImgUploadErrNo.GetUrlFail,
msg: I18n.t('imageflow_upload_error'),
});
}
};
doUpload().finally(() => {
clearTimeout(timer);
});
});
}
private uploadByApi(file: File): Promise<UploadResult> {
return new Promise(resolve => {
const timer =
this.timeout &&
setTimeout(
() =>
resolve({
isSuccess: false,
errNo: ImgUploadErrNo.UploadTimeout,
msg: I18n.t('imageflow_upload_error7'),
}),
this.timeout,
);
const doUpload = async function () {
const base64 = await getBase64(file).catch(() => '');
if (!base64) {
resolve({
isSuccess: false,
errNo: ImgUploadErrNo.UploadFail,
msg: I18n.t('imageflow_upload_error'),
});
return;
}
await DeveloperApi.UploadFile({
file_head: {
file_type: getFileExtension(file.name),
biz_type: FileBizType.BIZ_BOT_WORKFLOW,
},
data: base64,
})
.then(result => {
resolve({
isSuccess: true,
errNo: ImgUploadErrNo.Success,
uri: result.data?.upload_uri || '',
url: result.data?.upload_url || '',
});
})
.catch(() => {
resolve({
isSuccess: false,
errNo: ImgUploadErrNo.UploadFail,
msg: I18n.t('imageflow_upload_error'),
});
});
};
doUpload().finally(() => {
clearTimeout(timer);
});
});
}
reset() {
this.file = undefined;
if (this.displayUrl) {
// 是内部链接
URL.revokeObjectURL(this.displayUrl);
this.displayUrl = undefined;
}
this.isUploading = false;
this.uploadResult = undefined;
this.validateResult = undefined;
this.taskId += 1;
}
// eslint-disable-next-line complexity
private async validate() {
if (!this.file || !this.displayUrl) {
return;
}
const rules = this.rules || {};
// 文件尺寸
if (rules.maxSize) {
if (this.file.size > rules.maxSize) {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.MaxSizeError,
msg: I18n.t('imageflow_upload_exceed', {
size: formatBytes(rules.maxSize),
}),
};
return;
}
}
// 文件后缀
if (Array.isArray(rules.suffix) && rules.suffix.length > 0) {
const fileExtension = getFileExtension(this.file.name);
if (!rules.suffix.includes(fileExtension)) {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.SuffixError,
msg: I18n.t('imageflow_upload_error_type', {
type: `${rules.suffix.filter(Boolean).join('/')}`,
}),
};
return;
}
}
// 图片尺寸
const { width, height } = await getImageSize(this.displayUrl);
if (!width || !height) {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.ValidateError,
msg: I18n.t('imageflow_upload_error6'),
};
return;
}
if (rules.maxWidth) {
if (width > rules.maxWidth) {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.MaxWidthError,
msg: I18n.t('imageflow_upload_error5', {
value: `${rules.maxWidth}px`,
}),
};
return;
}
}
if (rules.maxHeight) {
if (height > rules.maxHeight) {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.MaxHeightError,
msg: I18n.t('imageflow_upload_error4', {
value: `${rules.maxHeight}px`,
}),
};
return;
}
}
if (rules.minWidth) {
if (width < rules.minWidth) {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.MinWidthError,
msg: I18n.t('imageflow_upload_error3', {
value: `${rules.minWidth}px`,
}),
};
return;
}
}
if (rules.minHeight) {
if (height < rules.minHeight) {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.MinHeightError,
msg: I18n.t('imageflow_upload_error2', {
value: `${rules.minHeight}px`,
}),
};
return;
}
}
if (rules.aspectRatio) {
if (width / height - rules.aspectRatio > Number.MIN_VALUE) {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.AspectRatioError,
msg: I18n.t('imageflow_upload_error1'),
};
return;
}
}
this.validateResult = {
isSuccess: true,
errNo: ImgUploadErrNo.Success,
msg: 'success',
};
}
}
export default ImageUploader;
function getBase64(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = event => {
const result = event.target?.result;
if (!result || typeof result !== 'string') {
reject(
new CustomError(REPORT_EVENTS.parmasValidation, 'file read fail'),
);
return;
}
resolve(result.replace(/^.*?,/, ''));
};
fileReader.readAsDataURL(file);
});
}
/** 获取文件名后缀 */
function getFileExtension(name: string) {
const index = name.lastIndexOf('.');
return name.slice(index + 1).toLowerCase();
}
/**
* @param url 获取图片宽高
*/
function getImageSize(url: string): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new window.Image();
img.onload = () =>
resolve({
width: img.naturalWidth,
height: img.naturalHeight,
});
img.onerror = e => reject(e);
img.src = url;
});
}
/**
* 格式化文件大小
* @param bytes 文件大小
* @param decimals 小数位数, 默认 2 位
* @example
* formatBytes(1024); // 1KB
* formatBytes('1024'); // 1KB
* formatBytes(1234); // 1.21KB
* formatBytes(1234, 3); // 1.205KB
*/
function formatBytes(bytes: number, decimals = 2) {
if (bytes === 0) {
return '0 Bytes';
}
const k = 1024,
dm = decimals,
sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))}${sizes[i]}`;
}

View File

@@ -0,0 +1,89 @@
.image-uploader {
&:global(.semi-input-wrapper) {
&:hover {
background-color: var(--semi-color-white);
}
}
&.can-action:global(.semi-input-wrapper) {
transition: all 0.1s;
&:hover {
background: rgba(46, 46, 56, 8%);
}
&:active {
background: rgba(46, 46, 56, 12%);
}
}
.action {
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
font-size: 12px;
color: rgba(29, 28, 35, 60%);
border-radius: 4px;
transition: all 0.2s;
&:hover {
background-color: rgba(46, 46, 56, 8%);
}
&:active {
background-color: rgba(46, 46, 56, 12%);
}
&.disabled {
cursor: not-allowed;
background-color: rgba(46, 46, 56, 8%);
}
}
.input-img-thumb {
overflow: hidden;
display: inline-flex;
flex-grow: 0;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 0.125rem;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
object-position: center;
border-radius: 0.125rem;
}
:global {
.semi-image-status {
background: none;
svg {
width: 17px;
color: rgba(6, 7, 9, 30%);
}
}
}
}
}
.img-popover-content {
padding: 8px;
}

View File

@@ -0,0 +1,401 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @coze-arch/max-line-per-function */
import {
type CSSProperties,
type FC,
useRef,
useMemo,
useState,
useEffect,
} from 'react';
import classNames from 'classnames';
import { useBoolean } from 'ahooks';
import { I18n } from '@coze-arch/i18n';
import {
IconCozTrashCan,
IconCozRefresh,
IconCozUpload,
IconCozImageBroken,
} from '@coze-arch/coze-design/icons';
import { type SelectProps } from '@coze-arch/bot-semi/Select';
import {
Image,
ImagePreview,
Popover,
Space,
Spin,
} from '@coze-arch/bot-semi';
import useImageUploader from './use-image-uploader';
import { type ImageRule, ImgUploadErrNo } from './image-uploader';
import s from './index.module.less';
interface ImageUploaderProps {
className?: string;
style?: CSSProperties;
readonly?: boolean;
disabled?: boolean;
/** 图片上传限制 */
rules?: ImageRule;
value?: { url: string; uri: string } | undefined;
validateStatus?: SelectProps['validateStatus'];
onChange?: (value?: { uri: string; url: string }) => void;
onBlur?: () => void;
}
interface ImagePopoverWrapperProps {
/** 图片地址 */
url?: string;
maxWidth?: number;
maxHeight?: number;
minWidth?: number;
minHeight?: number;
/** 是否支持预览 */
enablePreview?: boolean;
children?: React.ReactElement;
}
const ImagePopoverWrapper: FC<ImagePopoverWrapperProps> = ({
url,
children,
maxWidth,
maxHeight,
minWidth,
minHeight,
enablePreview,
}) => {
const [visible, { setTrue: showImagePreview, setFalse: closeImagePreview }] =
useBoolean(false);
const [loadError, setLoadError] = useState(false);
useEffect(() => {
setLoadError(false);
}, [url]);
if (!url) {
return children || null;
}
const content = loadError ? (
<div
className="flex flex-col items-center justify-center"
style={{ width: 225, height: 125 }}
>
<IconCozImageBroken className="w-8 coz-fg-dim" />
<div className="mt-1 coz-fg-primary text-sm font-medium">
{I18n.t('inifinit_list_load_fail')}
</div>
</div>
) : (
<div
className={classNames(
'flex flex-col items-center justify-center rounded-lg overflow-hidden',
enablePreview && !loadError ? 'cursor-zoom-in' : 'cursor-default',
)}
style={{
minWidth,
minHeight,
background: 'rgba(46, 46, 56, 0.08)',
}}
onClick={() => {
if (loadError) {
return;
}
showImagePreview();
}}
>
<img
className={classNames('object-contain object-center rounded-sm')}
style={{ maxWidth, maxHeight }}
src={url}
alt=""
onLoad={() => {
setLoadError(false);
}}
onError={() => {
setLoadError(true);
}}
/>
</div>
);
return (
<>
<Popover
className={s['img-popover-content']}
content={content}
showArrow
position="top"
>
{children}
</Popover>
{enablePreview ? (
<ImagePreview
src={url}
visible={visible}
onVisibleChange={closeImagePreview}
getPopupContainer={() => document.body}
/>
) : null}
</>
);
};
const ImageUploaderBtn: FC<{
visible?: boolean;
disabled?: boolean;
children?: React.ReactElement;
onClick?: () => void;
}> = ({ visible = true, disabled = false, onClick, children }) => {
if (!visible) {
return null;
}
return (
<div
className={classNames(s.action, disabled && s.disabled)}
onClick={e => {
if (disabled) {
return;
}
e.stopPropagation();
onClick?.();
}}
>
{children}
</div>
);
};
const ImageUploader: FC<ImageUploaderProps> = ({
className,
style,
value,
rules,
onChange,
onBlur,
disabled = false,
readonly = false,
validateStatus,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const {
uri,
url,
fileName,
isError,
loading,
setImgValue,
uploadImg,
clearImg,
retryUploadImg,
} = useImageUploader({
rules,
});
const acceptAttr = useMemo(() => {
if ((rules?.suffix || []).length > 0) {
return (rules?.suffix || []).map(item => `.${item}`).join(',');
}
return 'image/*';
}, [rules?.suffix]);
/** 整体区域支持交互 */
const wrapCanAction = useMemo(
() => !uri && !loading && !isError && !disabled && !readonly,
[uri, loading, isError, disabled, readonly],
);
useEffect(() => {
setImgValue({ uri: value?.uri, url: value?.url });
}, [value?.uri, value?.url]);
const selectImage = () => {
if (loading || disabled || !inputRef.current || readonly || isError) {
return;
}
inputRef.current.click();
};
const renderContent = () => {
if (loading) {
return (
<>
<Spin
style={{ width: 20, height: 20, lineHeight: '20px' }}
spinning
/>
<span
className="truncate min-w-0 ml-1"
style={{ color: 'rgba(29, 28, 35, 0.35)' }}
>
{I18n.t('datasets_unit_upload_state')}
</span>
</>
);
}
if (isError) {
return (
<span
className="truncate min-w-0"
style={{ color: 'rgba(29, 28, 35, 0.35)' }}
title={fileName}
>
{fileName || I18n.t('Upload_failed')}
</span>
);
}
if (url) {
return (
<>
{/* <div
className="inline-flex items-center justify-center flex-shrink-0 flex-grow-0 overflow-hidden rounded-sm"
style={{ width: 20, height: 20 }}
>
<img
className="object-contain object-center rounded-sm max-w-full max-h-full"
src={url}
alt="img"
/>
</div> */}
<Image
className={classNames(s['input-img-thumb'])}
src={url}
alt="img"
preview={false}
fallback={<IconCozImageBroken />}
/>
<div className="truncate min-w-0 ml-1" title={fileName}>
{fileName}
</div>
</>
);
}
return (
<span
className="truncate min-w-0"
style={{ color: 'rgba(29, 28, 35, 0.35)' }}
>
{I18n.t('imageflow_input_upload_placeholder')}
</span>
);
};
return (
<div
className={classNames(
s['image-uploader'],
'semi-input-wrapper semi-input-wrapper-default',
'min-w-0 cursor-default',
(isError || validateStatus === 'error') && 'semi-input-wrapper-error',
wrapCanAction && s['can-action'],
className,
)}
style={style}
>
<ImagePopoverWrapper
url={url}
minWidth={100}
minHeight={75}
maxWidth={400}
maxHeight={300}
enablePreview
>
<div
className={classNames(
'semi-input',
'flex items-center h-full',
!uri &&
!loading &&
!isError &&
!disabled &&
!readonly &&
'cursor-pointer',
)}
style={{ paddingRight: 6 }}
onClick={e => {
e.stopPropagation();
if (wrapCanAction) {
selectImage();
}
}}
>
<>{renderContent()}</>
<div className="flex-1" />
{!readonly && (
<Space spacing={4}>
<ImageUploaderBtn
visible={!uri && !loading && !isError}
disabled={disabled}
onClick={selectImage}
>
<IconCozUpload />
</ImageUploaderBtn>
<ImageUploaderBtn
visible={isError}
disabled={disabled}
onClick={async () => {
const result = await retryUploadImg();
if (result?.isSuccess) {
onChange?.({ uri: result.uri, url: result.url });
}
onBlur?.();
}}
>
<IconCozRefresh />
</ImageUploaderBtn>
<ImageUploaderBtn
visible={Boolean(uri || url)}
disabled={disabled}
onClick={() => {
clearImg();
onChange?.();
onBlur?.();
}}
>
<IconCozTrashCan />
</ImageUploaderBtn>
</Space>
)}
</div>
</ImagePopoverWrapper>
<input
ref={inputRef}
className="hidden"
type="file"
accept={acceptAttr}
onChange={async e => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) {
const result = await uploadImg(file);
if (result?.isSuccess) {
onChange?.({ uri: result.uri, url: result.url });
}
}
}}
/>
</div>
);
};
export default ImageUploader;
export { ImgUploadErrNo, ImageRule, useImageUploader, ImageUploader };

View File

@@ -0,0 +1,230 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @coze-arch/max-line-per-function */
import { useCallback, useEffect, useRef, useState } from 'react';
import { useUnmount } from 'ahooks';
import { I18n } from '@coze-arch/i18n';
import { Toast } from '@coze-arch/coze-design';
import ImageUploader, { ImgUploadErrNo } from './image-uploader';
interface UseImageUploaderParams {
/** 图片限制条件 */
rules?: ImageUploader['rules'];
/** 上传模式 */
mode?: ImageUploader['mode'];
/** 上传配置 */
timeout?: ImageUploader['timeout'];
}
interface UseImageUploaderReturn {
/** 图片标识, 用于提交给服务 */
uri: string;
/** 图片展示地址 */
url: string;
/** 文件名 */
fileName: string;
/** 上传中状态 */
loading: boolean;
/** 上传失败状态 */
isError: boolean;
/** 上传图片 */
uploadImg: (file: File) => Promise<ImageUploader['uploadResult']>;
/** 清除已上传图片 */
clearImg: () => void;
/** 上传失败后重试 */
retryUploadImg: () => Promise<ImageUploader['uploadResult']>;
/**
* 设置初始状态, 用于回显服务下发的数据
*
* @param val 对应值
* @param isMerge 是否 merge 模式, merge 模式仅更新传入字段. 默认 false
*/
setImgValue: (
val: { uri?: string; url?: string; fileName?: string },
isMerge?: boolean,
) => void;
}
/** 缓存文件名 */
const fileNameCache: Record<string, string> = Object.create(null);
// eslint-disable-next-line max-lines-per-function
export default function useImageUploader(
params?: UseImageUploaderParams,
): UseImageUploaderReturn {
const { rules, mode, timeout } = params || {};
const uploaderRef = useRef<ImageUploader>(
new ImageUploader({ rules, mode, timeout }),
);
const [loading, setLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [uri, setUri] = useState('');
const [url, setUrl] = useState('');
const [fileName, setFileName] = useState('');
useUnmount(() => {
uploaderRef.current?.reset();
});
useEffect(() => {
uploaderRef.current.rules = rules;
uploaderRef.current.mode = mode ?? uploaderRef.current.mode;
}, [rules, mode]);
const setImgValue: UseImageUploaderReturn['setImgValue'] = useCallback(
(
{ url: targetDisplayUrl, uri: targetUri, fileName: targetFileName },
isMerge = false,
) => {
if (typeof targetUri !== 'undefined') {
setUri(targetUri);
}
if (typeof targetDisplayUrl !== 'undefined') {
setUrl(targetDisplayUrl);
}
if (typeof targetFileName !== 'undefined') {
setFileName(targetFileName);
}
// 非 Merge 模式, 未设置的值清空
if (!isMerge) {
setUrl(targetDisplayUrl ?? '');
setUri(targetUri ?? '');
setFileName(targetFileName ?? '');
}
// 文件名特殊逻辑, 根据 uri 从缓存重映射文件名
if (!targetFileName) {
if (targetUri && fileNameCache[targetUri]) {
setFileName(fileNameCache[targetUri]);
} else if (!targetUri) {
setFileName('');
}
}
if (typeof targetUri !== 'undefined' || !isMerge) {
setLoading(false);
setIsError(false);
uploaderRef.current?.reset();
}
},
[],
);
const uploadImg = useCallback(
async (file: File): Promise<ImageUploader['uploadResult'] | undefined> => {
await uploaderRef.current.select(file);
// 图片校验不通过
if (!uploaderRef.current.validateResult?.isSuccess) {
Toast.error(
uploaderRef.current.validateResult?.msg || '图片不符合要求',
);
// @ts-expect-error 此处 validateResult.isSuccess 为 false
return uploaderRef.current.validateResult;
}
setIsError(false);
setLoading(true);
setUrl(uploaderRef.current.displayUrl || '');
setFileName(file.name || '');
await uploaderRef.current.upload();
setLoading(false);
// 上传结果
const { uploadResult } = uploaderRef.current;
// 无上传结果说明上传取消
if (!uploadResult) {
return;
}
setIsError(!uploadResult.isSuccess);
if (uploadResult.isSuccess) {
Toast.success(I18n.t('file_upload_success'));
setUri(uploadResult.uri);
// FIXME: 合理的设计应该用 uri 进行缓存, 但是 Imageflow 初期只存储了 url, 使用 url 作为临时方案
fileNameCache[uploadResult.url] = `${file.name}`;
} else {
Toast.error(uploadResult.msg);
}
return uploadResult;
},
[],
);
const retryUploadImg = useCallback(async (): Promise<
ImageUploader['uploadResult']
> => {
// 重传前置检查, 有文件且校验通过
if (
!uploaderRef.current?.file ||
!uploaderRef.current?.validateResult?.isSuccess
) {
Toast.error(I18n.t('imageflow_upload_action'));
return {
isSuccess: false,
errNo: ImgUploadErrNo.NoFile,
msg: '请选择文件',
};
}
setLoading(true);
setIsError(false);
await uploaderRef.current.upload();
setLoading(false);
// 上传结果
const uploadResult = uploaderRef.current.uploadResult || {
isSuccess: false,
errNo: ImgUploadErrNo.UploadFail,
msg: '无上传结果',
};
setIsError(!uploadResult.isSuccess);
if (uploadResult.isSuccess) {
Toast.success(I18n.t('file_upload_success'));
setUri(uploadResult.uri);
fileNameCache[uploadResult.url] = uploaderRef.current.file.name;
} else {
Toast.error(uploadResult.msg);
}
return uploadResult;
}, []);
const clearImg = useCallback(() => {
setUri('');
setUrl('');
setFileName('');
setLoading(false);
setIsError(false);
uploaderRef.current?.reset();
}, []);
return {
uri,
url,
fileName,
loading,
isError,
uploadImg,
clearImg,
retryUploadImg,
setImgValue,
};
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @coze-arch/no-batch-import-or-export */
export { CreateWorkflowModal } from './workflow-edit';
export { FlowShortcutsHelp } from './flow-shortcuts-help';
export { WorkflowCommitList } from './workflow-commit-list';
export * from './expression-editor';
export { useWorkflowModal } from './hooks/use-workflow-modal';
export { useWorkflowList } from './hooks/use-workflow-list';
import WorkflowModalContext from './workflow-modal/workflow-modal-context';
import { type WorkflowModalContextValue } from './workflow-modal/workflow-modal-context';
import { type BotPluginWorkFlowItem } from './workflow-modal/type';
import WorkflowModal from './workflow-modal';
export { WorkflowModal, BotPluginWorkFlowItem };
export {
useWorkflowModalParts,
DataSourceType,
MineActiveEnum,
WorkflowModalFrom,
WorkflowModalProps,
WorkFlowModalModeProps,
WorkflowModalState,
WORKFLOW_LIST_STATUS_ALL,
isSelectProjectCategory,
WorkflowCategory,
} from './workflow-modal';
export * from './utils';
export * from './image-uploader';
export { SizeSelect, type SizeSelectProps } from './size-select';
export { Text } from './text';
export { Expression } from './expression-editor-next';
export {
useWorkflowResourceAction,
useWorkflowPublishEntry,
useCreateWorkflowModal,
useWorkflowResourceClick,
useWorkflowResourceMenuActions,
} from './hooks/use-workflow-resource-action';
export {
WorkflowResourceActionProps,
WorkflowResourceActionReturn,
WorkflowResourceBizExtend,
} from './hooks/use-workflow-resource-action/type';
export { useWorkflowProductList } from './workflow-modal/hooks/use-workflow-product-list';
export { useWorkflowAction } from './workflow-modal/hooks/use-workflow-action';
export { WorkflowModalContext, WorkflowModalContextValue };
export { useOpenWorkflowDetail } from './hooks/use-open-workflow-detail';
export { VoiceSelect } from './voice-select';

View File

@@ -0,0 +1,209 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect, useState, type FC } from 'react';
import { I18n } from '@coze-arch/i18n';
import { CozInputNumber, Select } from '@coze-arch/coze-design';
export interface SizeSelectProps {
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
value?: {
width?: number;
height?: number;
};
defaultValue?: {
width?: number;
height?: number;
};
onChange: (value: { width?: number; height?: number }) => void;
readonly?: boolean;
options?: {
label: string;
value: {
width: number;
height: number;
};
disabled?: boolean;
}[];
selectClassName?: string;
layoutStyle?: 'vertical' | 'horizontal';
}
interface SizeOption {
label: string;
originValue?: {
width: number;
height: number;
};
value: string;
disabled?: boolean;
}
function isNumber(value) {
return typeof value === 'number' && !isNaN(value);
}
export const SizeSelect: FC<SizeSelectProps> = props => {
const {
options = [],
value,
defaultValue,
minWidth = 0,
maxWidth = Infinity,
minHeight = 0,
maxHeight = Infinity,
onChange,
readonly,
selectClassName = '',
layoutStyle = 'horizontal',
} = props;
const width = value?.width ?? defaultValue?.width ?? 0;
const height = value?.height ?? defaultValue?.height ?? 0;
const [sizeOptionList, setSizeOptionList] = useState<SizeOption[]>([]);
const [sizeValue, setSizeValue] = useState<string>();
const stringToWidthHeight = (str: string) => ({
width: Number(str.split('x')[0]),
height: Number(str.split('x')[1]),
});
const widthHeightToString = (v: { width: number; height: number }) =>
`${v.width}x${v.height}`;
useEffect(() => {
const _sizeValue = widthHeightToString({ width, height });
const _options: SizeOption[] = options.map(d => ({
...d,
originValue: d.value,
value: widthHeightToString(d.value),
}));
const selected = _options.find(d => d.value === _sizeValue);
if (!selected) {
_options.push({
label: I18n.t('customize_key_1'),
value: 'custom',
});
}
setSizeValue(selected?.value ?? 'custom');
setSizeOptionList(_options);
}, [width, height, options]);
return (
<div className="flex flex-wrap gap-[12px]">
<Select
onChange={v => {
if (v === 'custom') {
return;
}
const wh = stringToWidthHeight(v as string);
onChange(wh);
}}
disabled={readonly}
className={`${selectClassName} ${layoutStyle === 'horizontal' ? '' : 'w-full'}`}
value={sizeValue}
optionList={sizeOptionList}
size="small"
/>
<div className="flex-1 flex items-center">
<CozInputNumber
size="small"
prefix={I18n.t('imageflow_canvas_width')}
hideButtons
onNumberChange={w => {
if (isNaN(w as number)) {
return;
}
onChange({
width: Number(w),
height,
});
}}
onBlur={e => {
if (
e.target.value === '' ||
(isNumber(minWidth) && Number(e.target.value) < minWidth)
) {
onChange({
width: minWidth ?? 0,
height,
});
} else if (Number(e.target.value) > maxWidth) {
onChange({
width: maxWidth,
height,
});
}
}}
value={width}
disabled={readonly}
min={minWidth}
max={maxWidth}
className="flex-1"
/>
</div>
<div className="flex-1 flex items-center">
<CozInputNumber
prefix={I18n.t('imageflow_canvas_height')}
size="small"
hideButtons
onNumberChange={h => {
if (isNaN(h as number)) {
return;
}
onChange({
height: Number(h),
width,
});
}}
onBlur={e => {
if (
e.target.value === '' ||
(isNumber(minHeight) && Number(e.target.value) < minHeight)
) {
onChange({
width,
height: minHeight ?? 0,
});
} else if (
isNumber(maxHeight) &&
Number(e.target.value) > maxHeight
) {
onChange({
width,
height: maxHeight,
});
}
}}
value={height}
disabled={readonly}
min={minHeight}
max={maxHeight}
className="flex-1"
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** Text 组件,超出自动 ... 并且展示 tooltip */
import { type FC } from 'react';
import { Typography } from '@coze-arch/coze-design';
import { type Position } from '@coze-arch/bot-semi/Tooltip';
interface IText {
text?: string;
rows?: number;
className?: string;
tooltipPosition?: Position;
}
export const Text: FC<IText> = props => {
const { text = '', rows = 1, className, tooltipPosition } = props;
return (
<Typography.Paragraph
ellipsis={{
rows,
showTooltip: {
type: 'tooltip',
opts: {
style: {
width: '100%',
wordBreak: 'break-word',
},
position: tooltipPosition,
},
},
}}
className={className}
>
{text}
</Typography.Paragraph>
);
};

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// eslint-disable-next-line @coze-arch/no-batch-import-or-export
export * from './workflow-list';

View File

@@ -0,0 +1,60 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type ResourceActionAuth,
type Workflow,
} from '@coze-workflow/base/api';
interface Auth {
authInfo: ResourceActionAuth;
}
export type WorkflowInfo = Workflow & Auth;
/**
* 打开弹窗场景, 主要用于 log
*
* WorkflowAddNode 场景有特殊处理
*/
export enum WorkflowModalFrom {
/** 流程详情添加子流程 */
WorkflowAddNode = 'workflow_addNode',
/** 在 bot skills 打开 */
BotSkills = 'bot_skills',
/** 在抖音分身场景的 ide 打开 */
BotSkillsDouyin = 'bot_skills_douyin_ide',
/** 在 bot 多 agent skills 打开 */
BotMultiSkills = 'bot_multi_skills',
/** 在 bot triggers 打开 */
BotTrigger = 'bot_trigger',
/** bot 快捷方式打开 */
BotShortcut = 'bot_shortcut',
/** 空间下流程列表 */
SpaceWorkflowList = 'space_workflow_list',
/** 来自 workflow as agent */
WorkflowAgent = 'workflow_agent',
/** 社会场景 workflow 列表 */
SocialSceneHost = 'social_scene_host',
/** 项目引入资源库文件 */
ProjectImportLibrary = 'project_import_library',
/** 项目内 workflow 画布添加子流程 */
ProjectWorkflowAddNode = 'project_workflow_addNode',
/**
* 项目内 workflow 资源列表添加 workflow 资源
*/
ProjectAddWorkflowResource = 'project_add_workflow_resource',
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference types='@coze-arch/bot-typings' />

View File

@@ -0,0 +1,94 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import cronstrue from 'cronstrue/i18n';
import { I18n } from '@coze-arch/i18n';
const langMap = {
// 简体中文
'zh-CN': 'zh_CN',
zh: 'zh-CN',
// 繁体中文
zh_TW: 'zh_TW',
// 英语
'en-US': 'en',
en: 'en',
// 日语
'ja-JP': 'ja',
ja: 'ja',
// 韩语
'ko-KR': 'ko',
ko: 'ko',
// 法语
'fr-FR': 'fr',
fr: 'fr',
// 德语
'de-DE': 'de',
de: 'de',
// 意大利语
'it-IT': 'it',
it: 'it',
// 西班牙语
'es-ES': 'es',
es: 'es',
};
// 校验使用 cronjob 翻译结果
export const isCronJobVerify = cronJob => {
// 仅支持 6 位 cronjob后端限制
const parts = cronJob?.split(' ');
if (parts?.length !== 6) {
return false;
}
try {
const rs = cronstrue.toString(cronJob, {
locale: langMap['zh-CN'],
throwExceptionOnParseError: true,
});
// 额外校验一下字符串是否包含 null undefined
// 1 2 3 31 1- 1 在上午 03:02:01, 限每月 31 号, 或者为星期一, 一月至undefined
// 1 2 3 31 1 1#6 在上午 03:02:01, 限每月 31 号, 限每月的null 星期一, 仅于一月份
if (rs.includes('null') || rs.includes('undefined')) {
return false;
}
return true;
} catch (error) {
return false;
}
};
export const cronJobTranslator = (
cronJob?: string,
errorMsg?: string,
): string => {
if (!cronJob) {
return '';
}
const lang = I18n.getLanguages();
if (isCronJobVerify(cronJob)) {
return cronstrue.toString(cronJob, {
locale: langMap[lang[0]] ?? langMap['zh-CN'],
use24HourTimeFormat: true,
});
}
return (
errorMsg ??
I18n.t('workflow_trigger_param_unvalid_cron', {}, '参数为非法 cron 表达式')
);
};

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @coze-arch/no-batch-import-or-export */
export * from './utils';
export { cronJobTranslator, isCronJobVerify } from './cronjob-translator';

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import dayjs from 'dayjs';
let getIsIPadCache: boolean | undefined;
/**
* gpt-4 提供的代码
*/
export const getIsIPad = () => {
if (typeof getIsIPadCache === 'undefined') {
const { userAgent } = navigator;
const isIPadDevice = /iPad/.test(userAgent);
const isIPadOS =
userAgent.includes('Macintosh') &&
'ontouchstart' in document.documentElement;
getIsIPadCache = isIPadDevice || isIPadOS;
}
return getIsIPadCache;
};
/* 时间戳转文本,并省略年份或日期*/
export const formatOmittedDateTime = time => {
if (!time) {
return '';
}
const day = dayjs(time);
const today = dayjs();
let formatStr: string;
if (!today.isSame(day, 'year')) {
// 不是当年,展示年份
formatStr = 'YYYY-MM-DD HH:mm';
} else if (!today.isSame(day, 'day')) {
// 不是当天, 展示日期
formatStr = 'MM-DD HH:mm';
} else {
// 当天只展示时间
formatStr = 'HH:mm';
}
return day.format(formatStr);
};
/** 等待 */
export const wait = (ms: number) =>
new Promise(r => {
setTimeout(r, ms);
});
import { reporter as infraReporter } from '@coze-arch/logger';
/**
* 流程使用的 slardar 上报实例
*/
export const reporter = infraReporter.createReporterWithPreset({
namespace: 'workflow',
});

View File

@@ -0,0 +1,201 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect, useState } from 'react';
import { useRequest } from 'ahooks';
import { I18n } from '@coze-arch/i18n';
import {
IconCozPauseFill,
IconCozPlus,
IconCozTrashCan,
IconCozVolume,
} from '@coze-arch/coze-design/icons';
import { Avatar, Button, IconButton } from '@coze-arch/coze-design';
import { useSpaceStore } from '@coze-arch/bot-studio-store';
import { type VoiceDetail } from '@coze-arch/bot-api/multimedia_api';
import { MultimediaApi } from '@coze-arch/bot-api';
import {
useSelectVoiceModal,
useAudioPlayer,
} from '@coze-workflow/resources-adapter';
interface CardProps {
voice: VoiceDetail | null;
onDelete?: () => void;
disabled?: boolean;
}
const VoiceCard = ({ voice, onDelete, disabled }: CardProps) => {
const { isPlaying, togglePlayPause } = useAudioPlayer(voice?.preview_audio);
if (!voice) {
return null;
}
return (
<div
style={{
backgroundColor: 'var(--coz-mg-card-hovered)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
height: '44px',
border: '1px solid var(--coz-stroke-primary)',
padding: '6px',
borderRadius: 'var(--coze-8)',
cursor: 'pointer',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Avatar shape={'square'} size="small" src={voice.icon_url} />
<span style={{ marginLeft: '8px', marginRight: '2px' }}>
{voice.voice_name}
</span>
{isPlaying ? (
<IconButton
theme={'borderless'}
disabled={disabled}
onClick={togglePlayPause}
size="small"
color="secondary"
icon={<IconCozPauseFill color="#4E40E5" />}
/>
) : (
<IconButton
theme={'borderless'}
disabled={disabled}
onClick={togglePlayPause}
size="small"
color="secondary"
icon={<IconCozVolume color="#4E40E5" />}
/>
)}
</div>
<IconButton
disabled={disabled}
onClick={onDelete}
size="small"
color="secondary"
icon={<IconCozTrashCan />}
/>
</div>
);
};
const useVoiceSource = (id?: string) => {
const [voice, setVoice] = useState<VoiceDetail | null | undefined>(null);
const { loading } = useRequest(
() => {
if (!id) {
return Promise.resolve(null).then(() => {
setVoice(null);
return null;
});
}
return MultimediaApi.APIMGetVoice({
voice_ids: [id],
})
.then(data => {
const v = data?.data?.voices?.[0];
setVoice(data?.data?.voices?.[0]);
return v;
})
.catch(() => {
setVoice(null);
return null;
});
},
{
refreshDeps: [id],
},
);
return {
voice,
setVoice,
loading,
};
};
interface Props {
value?: string;
onChange?: (v?: string) => void;
disabled?: boolean;
}
const VoiceSelect: React.FC<Props> = props => {
const { value, onChange, disabled } = props;
const [voiceId, setVoiceId] = useState<string | undefined>(value);
const spaceId = useSpaceStore(store => store.space.id) || '';
const { voice, setVoice } = useVoiceSource(voiceId);
useEffect(() => {
setVoiceId(value);
}, [value]);
const { open: openSelectVoiceModal, modal: selectVoiceModal } =
useSelectVoiceModal({
spaceId,
onSelectVoice: v => {
setVoice(v);
onChange?.(v.voice_id);
},
});
return (
<>
{!voice?.voice_id ? (
<Button
disabled={disabled}
style={{ width: '100%', fontWeight: '500' }}
size={'small'}
icon={<IconCozPlus />}
color="primary"
onClick={openSelectVoiceModal}
>
{I18n.t('workflow_variable_select_voice')}
</Button>
) : (
<VoiceCard
disabled={disabled}
voice={voice}
onDelete={() => {
setVoice(null);
onChange?.(undefined);
}}
/>
)}
{selectVoiceModal}
</>
);
};
export { VoiceSelect };

View File

@@ -0,0 +1,223 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @coze-arch/max-line-per-function */
import { useMemo, type FC } from 'react';
import semver from 'semver';
import dayjs from 'dayjs';
import classNames from 'classnames';
import { type VersionMetaInfo, OperateType } from '@coze-workflow/base/api';
import { I18n } from '@coze-arch/i18n';
import { IconCozMore } from '@coze-arch/coze-design/icons';
import {
Avatar,
IconButton,
Menu,
Space,
Tag,
Typography,
} from '@coze-arch/coze-design';
import { type WorkflowCommitListProps } from './type';
export interface CommitItemProps {
className?: string;
data: VersionMetaInfo;
/** 是否选中 */
isActive?: boolean;
readonly?: WorkflowCommitListProps['readonly'];
enablePublishPPE?: WorkflowCommitListProps['enablePublishPPE'];
onClick?: WorkflowCommitListProps['onItemClick'];
onPublishPPE?: WorkflowCommitListProps['onPublishPPE'];
onResetToCommit?: WorkflowCommitListProps['onResetToCommit'];
onShowCommit?: WorkflowCommitListProps['onShowCommit'];
/** 隐藏操作下拉菜单 */
hiddenActionMenu?: boolean;
/** 隐藏 commitId */
hideCommitId?: boolean;
}
const { Text } = Typography;
// eslint-disable-next-line complexity
export const CommitItem: FC<CommitItemProps> = ({
className,
data,
readonly,
isActive,
hiddenActionMenu,
enablePublishPPE,
onClick,
onPublishPPE,
onResetToCommit,
onShowCommit,
hideCommitId,
}) => {
const action = hiddenActionMenu ? null : (
<Menu
className="min-w-[96px] mb-2px flex-shrink-0"
trigger="hover"
stopPropagation={true}
position="bottomRight"
render={
<Menu.SubMenu mode="menu">
<Menu.Item
onClick={(_, e) => {
e.stopPropagation();
onShowCommit?.(data);
}}
>
{I18n.t('bmv_view_version')}
</Menu.Item>
<Menu.Item
onClick={(_, e) => {
e.stopPropagation();
onResetToCommit?.(data);
}}
>
{I18n.t('bmv_load_to_draft')}
</Menu.Item>
{enablePublishPPE ? (
<Menu.Item
onClick={(_, e) => {
e.stopPropagation();
onPublishPPE?.(data);
}}
>
{I18n.t('bmv_pre_release_to_lane')}
</Menu.Item>
) : null}
</Menu.SubMenu>
}
>
<IconButton
color="secondary"
iconSize="small"
icon={
<>
<IconCozMore className="rotate-90" />
</>
}
/>
</Menu>
);
const time = useMemo(
() =>
data.create_time
? dayjs(data.create_time).format('YYYY-MM-DD HH:mm:ss')
: '',
[data],
);
return (
<div
className={classNames(
'commit-item',
isActive && 'active',
'p-2 rounded-mini',
isActive && 'coz-mg-hglt',
!readonly && !isActive && 'hover:coz-mg-secondary',
!readonly && 'cursor-pointer',
className,
)}
onClick={() => onClick?.(data)}
>
<div className="mb-2">
{data.type === OperateType.SubmitOperate && (
<Tag size="small">
{I18n.t('workflow_publish_multibranch_submitted_title')}
</Tag>
)}
{data.type === OperateType.PubPPEOperate && (
<Tag size="small" color={data.offline ? 'primary' : 'green'}>
{data.env}
</Tag>
)}
{data.type === OperateType.PublishOperate && (
<Tag size="small" color={data.offline ? 'primary' : 'green'}>
{semver.valid(data.version)
? data.version
: I18n.t('bmv_official_version')}
</Tag>
)}
</div>
{!hideCommitId && (
<Space className="w-full items-start" vertical spacing={4}>
<Text
ellipsis={{
showTooltip: {
opts: { content: data.submit_commit_id || data.commit_id },
},
}}
>
<span className="font-bold mr-2">{I18n.t('bmv_submit_id')}:</span>
{data.submit_commit_id || data.commit_id}
</Text>
{data.type !== OperateType.SubmitOperate &&
data.offline &&
data.update_time ? (
<Text
ellipsis={{
showTooltip: {
opts: {
content: dayjs(data.update_time as number).format(
'YYYY-MM-DD HH:mm',
),
},
},
}}
>
<span className="font-bold mr-2">
{I18n.t('bmv_offline_time')}:
</span>
{dayjs(data.update_time as number).format('YYYY-MM-DD HH:mm')}
</Text>
) : null}
</Space>
)}
{data.type === OperateType.PublishOperate && data.desc ? (
<div>
<Text ellipsis={{ rows: 4, showTooltip: true }}>{data.desc}</Text>
</div>
) : null}
<div className="flex items-end mt-2">
<div>
<div className="min-w-0 flex items-center mb-1">
<Avatar
className="mr-2 flex-shrink-0"
size="extra-extra-small"
src={data.user?.user_avatar}
alt="avatar"
/>
<Text ellipsis fontSize="12px">
{data.user?.user_name}
</Text>
</div>
<Text type="secondary" fontSize="12px">
{time}
</Text>
</div>
<div className="flex-1" />
{action}
</div>
</div>
);
};

View File

@@ -0,0 +1,3 @@
.history-item {
padding-bottom: 16px;
}

View File

@@ -0,0 +1,217 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @coze-arch/max-line-per-function */
import { useEffect, type FC, useRef } from 'react';
import classNames from 'classnames';
import { useInViewport, useUpdateEffect } from 'ahooks';
import {
OperateType,
type VersionMetaInfo,
withQueryClient,
} from '@coze-workflow/base';
import { I18n } from '@coze-arch/i18n';
import { IconCozFocus } from '@coze-arch/coze-design/icons';
import {
EmptyState,
Spin,
Timeline,
Typography,
} from '@coze-arch/coze-design';
import { useVersionHistory } from './use-version-history';
import { type WorkflowCommitListProps } from './type';
import { CommitItem } from './commit-item';
import css from './history-list.module.less';
const { Text } = Typography;
const currentKey = 'current';
const WorkflowCommitListComp: FC<WorkflowCommitListProps> = withQueryClient(
({
className,
spaceId,
value,
workflowId,
readonly,
type,
enablePublishPPE,
showCurrent,
onItemClick,
onPublishPPE,
onResetToCommit,
onShowCommit,
onCurrentClick,
hideCommitId,
}) => {
const {
queryParams,
updatePageParam,
list,
loadingStatus,
fetchNextPage,
isFetching,
hasNextPage,
} = useVersionHistory({
spaceId,
workflowId,
type,
enabled: true,
});
/** scroll的container */
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
/** 监听触底的observer */
const intersectionObserverDom = useRef<HTMLDivElement>(null);
// 是否触底
const [inViewPort] = useInViewport(intersectionObserverDom, {
root: () => scrollContainerRef.current,
threshold: 0.8,
});
useEffect(() => {
updatePageParam({ type });
}, [type, updatePageParam]);
// 首次effect不执行这个是切换状态的effect
useUpdateEffect(() => {
// 当筛选项改变时,回到顶部
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: 0,
});
}
}, [queryParams]);
// 获取下一页逻辑
useEffect(() => {
if (!inViewPort) {
return;
}
if (loadingStatus !== 'success' || isFetching || !hasNextPage) {
return;
}
fetchNextPage();
}, [inViewPort, loadingStatus, isFetching, hasNextPage]);
if (loadingStatus === 'error') {
return (
<div className="flex justify-center items-center min-h-[300px] w-full">
<EmptyState
title="An error occurred"
description="Please try again later"
/>
</div>
);
}
if (loadingStatus === 'pending') {
return (
<Spin wrapperClassName="flex justify-center items-center min-h-[300px] w-full" />
);
}
if (loadingStatus === 'success' && !list.length && !showCurrent) {
return (
<div className="flex justify-center items-center min-h-[300px] w-full">
<EmptyState
title={I18n.t('query_data_empty')}
description={I18n.t('bwc_no_version_record')}
/>
</div>
);
}
const timelineType = (data: VersionMetaInfo, index) => {
// PPE 历史, 在线激活
if (type === OperateType.PubPPEOperate) {
return !data.offline ? 'ongoing' : 'default';
}
// 提交历史和发布历史, 最新的激活
return index === 0 ? 'ongoing' : 'default';
};
return (
<div ref={scrollContainerRef} className={className}>
<Timeline>
{showCurrent ? (
<Timeline.Item
className={css['history-item']}
type="warning"
dot={value === currentKey ? <IconCozFocus /> : undefined}
>
<div
className={classNames(
'relative top-[-8px] p-2 rounded-mini',
value === currentKey
? 'coz-mg-hglt'
: 'hover:coz-mg-secondary',
!readonly && 'cursor-pointer',
)}
onClick={() => onCurrentClick?.(currentKey)}
>
<Text className="font-bold">
{I18n.t('devops_publish_multibranch_Current')}
</Text>
</div>
</Timeline.Item>
) : null}
{list.map((item, index) => (
<Timeline.Item
className={css['history-item']}
key={item.commit_id}
type={timelineType(item, index)}
dot={value === item.commit_id ? <IconCozFocus /> : undefined}
>
<CommitItem
className="relative top-[-8px]"
data={item}
readonly={readonly}
isActive={value === item.commit_id}
enablePublishPPE={enablePublishPPE}
onClick={!readonly ? onItemClick : undefined}
onPublishPPE={onPublishPPE}
onResetToCommit={onResetToCommit}
onShowCommit={onShowCommit}
hideCommitId={hideCommitId}
/>
</Timeline.Item>
))}
</Timeline>
{hasNextPage ? (
<div
className="flex justify-center py-1"
ref={intersectionObserverDom}
>
<Spin spinning wrapperClassName="mr-2" />
<div className="coz-fg-primary">{I18n.t('Loading')}</div>
</div>
) : null}
</div>
);
},
);
export const WorkflowCommitList = Object.assign(WorkflowCommitListComp, {
Item: CommitItem,
});

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type VersionMetaInfo,
type OperateType,
} from '@coze-workflow/base/api';
/** 流程提交历史列表组件 */
export interface WorkflowCommitListProps {
className?: string;
spaceId: string;
workflowId: string;
/** 操作类型 */
type: OperateType;
/** 只读模式, 只读历史卡片不可点, 不影响 action */
readonly?: boolean;
/** 每页拉取数量, 默认 10 */
limit?: number;
/** 当前选中项 */
value?: string;
/** 是否展示当前节点 */
showCurrent?: boolean;
/** 是否支持发布到 PPE 功能 */
enablePublishPPE?: boolean;
/** 隐藏 commitId (commitId可读性较差非专业用户不需要感知) */
hideCommitId?: boolean;
/** 卡片点击 */
onItemClick?: (item: VersionMetaInfo) => void;
/** 恢复到某版本点击 */
onResetToCommit?: (item: VersionMetaInfo) => void;
/** 查看某版本点击 */
onShowCommit?: (item: VersionMetaInfo) => void;
/** 发布到多环境点击 */
onPublishPPE?: (item: VersionMetaInfo) => void;
/** 点击[现在] */
onCurrentClick?: (currentKey: string) => void;
}

View File

@@ -0,0 +1,155 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, useMemo, useState } from 'react';
import {
type UseInfiniteQueryResult,
useInfiniteQuery,
} from '@tanstack/react-query';
import {
type VersionMetaInfo,
type VersionHistoryListRequest,
type OperateType,
workflowApi,
} from '@coze-workflow/base/api';
type VersionHistoryQueryParams = Omit<
VersionHistoryListRequest,
'limit' | 'cursor'
>;
interface VersionHistoryParams {
spaceId: string;
workflowId: string;
type: OperateType;
/** 每页请求数量, 默认 10 */
pageSize?: number;
/** 是否启动请求, 默认 false */
enabled?: boolean;
}
interface VersionHistoryReturn {
queryParams: VersionHistoryQueryParams;
updatePageParam: (newParam: Partial<VersionHistoryListRequest>) => void;
list: VersionMetaInfo[];
queryError: UseInfiniteQueryResult['error'];
loadingStatus: UseInfiniteQueryResult['status'];
refetch: UseInfiniteQueryResult['refetch'];
fetchNextPage: UseInfiniteQueryResult['fetchNextPage'];
isFetching: UseInfiniteQueryResult['isFetching'];
isFetchingNextPage: UseInfiniteQueryResult['isFetchingNextPage'];
hasNextPage: UseInfiniteQueryResult['hasNextPage'];
}
export function useVersionHistory({
spaceId,
workflowId,
type,
pageSize = 10,
enabled = false,
}: VersionHistoryParams): Readonly<VersionHistoryReturn> {
const [queryParams, setQueryParams] = useState<VersionHistoryQueryParams>({
space_id: spaceId,
workflow_id: workflowId,
type,
});
const initialPageParam = useMemo<VersionHistoryListRequest>(
() => ({
...queryParams,
limit: pageSize,
last_commit_id: '',
}),
[queryParams, pageSize],
);
const updatePageParam = useCallback(
(newParam: Partial<VersionHistoryQueryParams>) => {
setQueryParams(prevParams => ({
...prevParams,
...newParam,
}));
},
[],
);
const queryKey = useMemo(
() => ['workflowApi_OperateList', JSON.stringify(initialPageParam)],
[initialPageParam],
);
const fetchList = async (params: VersionHistoryListRequest) => {
const resp = await workflowApi.VersionHistoryList(params);
return resp.data;
};
const {
data: pageData,
error: queryError,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
status: loadingStatus,
refetch,
} = useInfiniteQuery({
enabled: Boolean(spaceId && workflowId && enabled),
queryKey,
queryFn: ({ pageParam }) => fetchList(pageParam),
initialPageParam,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
if (!lastPage?.has_more) {
return null;
}
return {
...lastPageParam,
cursor: lastPage.cursor || '',
};
},
});
const targetList = useMemo(() => {
const result: VersionMetaInfo[] = [];
const idMap: Record<string, boolean> = {};
pageData?.pages.forEach(page => {
page?.version_list?.forEach(item => {
const key = item.commit_id || '';
if (!idMap[key]) {
result.push(item);
}
idMap[key] = true;
});
});
return result;
}, [pageData]);
return {
queryParams,
updatePageParam,
list: targetList,
queryError,
loadingStatus,
refetch,
fetchNextPage,
isFetching,
isFetchingNextPage,
hasNextPage,
} as const;
}

View File

@@ -0,0 +1,51 @@
/* stylelint-disable block-no-empty */
.upload-form {
.textarea-single-line {
:global {
.semi-input-textarea-counter {
position: absolute;
top: 4px;
right: 0;
}
}
}
.textarea-multi-line {
margin-bottom: 16px;
:global {
.semi-input-textarea-counter {}
}
}
.conversation-field {
padding: 0;
}
.upload-field {
padding-top: 0;
}
.schema_type {
padding-top: 0;
}
}
.upload-form-item {
:global {
.semi-form-field-label-text {
display: none;
}
}
}
.add-card {
background-color: white;
border-radius: 8px;
}
.add-card-inner {
display: flex;
flex-direction: column;
justify-content: center;
}

View File

@@ -0,0 +1,483 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @coze-arch/max-line-per-function */
/* eslint-disable max-lines-per-function -- refactor later */
import { useMemo, useRef, useState } from 'react';
import { isFunction } from 'lodash-es';
import { type WorkflowJSON } from '@flowgram-adapter/free-layout-editor';
import { type FrontWorkflowInfo } from '@coze-workflow/base/types';
import {
type BindBizType,
type SchemaType,
workflowApi,
WorkflowMode,
} from '@coze-workflow/base/api';
import {
WORKFLOW_NAME_MAX_LEN,
WORKFLOW_NAME_REGEX,
} from '@coze-workflow/base';
import { PictureUpload } from '@coze-common/biz-components/picture-upload';
import { type UploadValue } from '@coze-common/biz-components';
import { REPORT_EVENTS } from '@coze-arch/report-events';
import { reporter } from '@coze-arch/logger';
import { I18n } from '@coze-arch/i18n';
import { useUserInfo } from '@coze-arch/foundation-sdk';
import { IconCozInfoCircle } from '@coze-arch/coze-design/icons';
import {
Button,
LoadingButton,
Space,
Toast,
Tooltip,
} from '@coze-arch/coze-design';
import { useSpaceStore } from '@coze-arch/bot-studio-store';
import { Form, Typography, UIFormTextArea, UIModal } from '@coze-arch/bot-semi';
import { CustomError } from '@coze-arch/bot-error';
import { FileBizType, IconType } from '@coze-arch/bot-api/developer_api';
import s from './index.module.less';
/** 输入合规校验异常的错误码 */
const sensitiveWordsErrorCode = ['702095075', '702095081'];
const { Checkbox } = Form;
export interface RuleItem {
validator: (rules: unknown[], value: string) => boolean | Error;
}
interface EditWorkFlowPropsInner {
/** 流程类型 */
flowMode?: WorkflowMode;
mode: 'update' | 'add';
visible: boolean;
// 默认confirm的disabled
/** 自定义弹窗标题 */
customTitleRender?: (title: React.ReactNode) => React.ReactNode;
initConfirmDisabled?: boolean;
workFlow?: FrontWorkflowInfo;
onSuccess?: (val: {
workflowId?: string;
flowMode?: EditWorkFlowPropsInner['flowMode'];
}) => void;
onCancel?: () => void;
/** @deprecated 未使用 */
spaceID?: string;
getLatestWorkflowJson?: () => Promise<WorkflowJSON>;
bindBizId?: string;
bindBizType?: BindBizType;
/** 当前项目 id只在项目内的 workflow 有该字段 */
projectId?: string;
nameValidators?: RuleItem[];
}
/** 表单值 */
interface FormValue {
icon_uri: UploadValue;
name: string;
target: string;
schema_type: SchemaType;
create_conversation?: boolean;
}
/** 获取弹窗标题 */
function getModalTitle(
mode: EditWorkFlowPropsInner['mode'],
flowMode: EditWorkFlowPropsInner['flowMode'],
): string {
switch (flowMode) {
case WorkflowMode.Imageflow:
return mode === 'add'
? I18n.t('imageflow_create')
: I18n.t('imageflow_edit');
case WorkflowMode.Workflow:
return mode === 'add'
? I18n.t('workflow_list_create_modal_title')
: I18n.t('workflow_list_edit_modal_title');
case WorkflowMode.ChatFlow:
return mode === 'add'
? I18n.t('wf_chatflow_81')
: I18n.t('wf_chatflow_84');
default:
return mode === 'add'
? I18n.t('workflow_list_create_modal_title')
: I18n.t('workflow_list_edit_modal_title');
}
}
const getPictureUploadInitValue = (
workFlow?: FrontWorkflowInfo,
): UploadValue | undefined => {
if (!workFlow) {
return;
}
return [
{
url: workFlow.url || '',
uid: workFlow.icon_uri || '',
},
];
};
export function CreateWorkflowModal({
flowMode = WorkflowMode.Workflow,
mode,
bindBizId,
bindBizType,
projectId,
visible,
workFlow,
initConfirmDisabled = false,
customTitleRender,
onSuccess,
onCancel,
nameValidators = [],
}: EditWorkFlowPropsInner) {
const formRef = useRef<Form<Partial<FormValue>>>(null);
const [confirmDisabled, setConfirmDisabled] = useState(initConfirmDisabled);
const [sensitiveTip, setSensitiveTip] = useState<string | undefined>();
const userInfo = useUserInfo();
const currentLocale = userInfo?.locale ?? navigator.language ?? 'en-US';
const getValues = async () => {
const formApi = formRef.current?.formApi;
await formApi?.validate(['name']);
return formApi?.getValues() as Partial<FormValue> | undefined;
};
const handleCancel = () => {
reporter.info({
message: 'workflow_info_modal_cancel',
namespace: 'workflow',
});
setSensitiveTip(undefined);
onCancel?.();
};
const handleError = (error: Error & { code?: string; msg?: string }) => {
if (sensitiveWordsErrorCode.includes(error.code || '')) {
setConfirmDisabled(true);
setSensitiveTip(error.msg);
return;
}
handleCancel();
throw error;
};
const handleUpdateWorkflow = async () => {
const workflowId = workFlow?.workflow_id;
reporter.info({
message: 'workflow_info_modal_confirm_update',
namespace: 'workflow',
meta: {
workflowId,
},
});
if (!workflowId) {
const msg = I18n.t('workflow_list_create_modal_workflow_id_empty');
throw new CustomError(REPORT_EVENTS.parmasValidation, msg);
}
const values = await getValues();
const updateParams = {
workflow_id: workflowId,
icon_uri: values?.icon_uri?.[0].uid || '',
name: values?.name,
desc: values?.target ? values.target : '',
space_id: workFlow.space_id || '',
// 更新头像等信息不需要重新test run
ignore_status_transfer: true,
schema_type: values?.schema_type || workFlow?.schema_type,
};
try {
await workflowApi.UpdateWorkflowMeta(updateParams);
reporter.info({
message: 'workflow_info_modal_update_success',
namespace: 'workflow',
});
Toast.success({
content: I18n.t('workflow_list_update_success'),
showClose: false,
});
await onSuccess?.({
workflowId,
flowMode,
});
} catch (error) {
reporter.error({
message: 'workflow_info_modal_update_fail',
namespace: 'workflow',
error,
});
handleError(error);
}
};
const handleCreateWorkflow = async () => {
reporter.info({
message: 'workflow_info_modal_confirm_create',
namespace: 'workflow',
});
const values = await getValues();
try {
const reqParams = {
...values,
space_id: useSpaceStore.getState().getSpaceId(),
name: values?.name || '',
desc: values?.target || '',
icon_uri: values?.icon_uri?.[0]?.uid || '',
flow_mode: flowMode,
bind_biz_id: bindBizId,
bind_biz_type: bindBizType,
project_id: projectId,
create_conversation: projectId
? values?.create_conversation
: undefined,
};
const resp = await workflowApi.CreateWorkflow(reqParams, {
headers: {
'x-locale': currentLocale,
},
});
const content =
flowMode === WorkflowMode.Imageflow
? I18n.t('imageflow_create_toast_success')
: flowMode === WorkflowMode.ChatFlow
? I18n.t('wf_chatflow_95')
: I18n.t('workflow_list_create_success');
Toast.success({
content,
showClose: false,
});
await onSuccess?.({
workflowId: resp.data?.workflow_id,
flowMode,
});
reporter.info({
message: 'workflow_info_modal_create_success',
namespace: 'workflow',
});
} catch (error) {
reporter.error({
message: 'workflow_info_modal_create_fail',
namespace: 'workflow',
error,
});
handleError(error);
}
};
const title = useMemo(() => {
const modelTitle = getModalTitle(mode, flowMode);
if (customTitleRender && isFunction(customTitleRender)) {
return customTitleRender(modelTitle);
}
return modelTitle;
}, [mode, flowMode, customTitleRender]);
const labels = useMemo<{
nameLabel?: string;
namePlaceholder?: string;
descLabel?: string;
descPlaceholder?: string;
nameFormatRuleLabel?: string;
nameRequiredLabel?: string;
descRequiredLabel?: string;
}>(() => {
if (flowMode === WorkflowMode.Imageflow) {
return {
nameLabel: I18n.t('imageflow_create_name'),
namePlaceholder: I18n.t('imageflow_create_name_placeholder'),
descLabel: I18n.t('imageflow_create_description'),
descPlaceholder: I18n.t('imageflow_create_description_placeholder'),
nameRequiredLabel: I18n.t('imageflow_create_name_placeholder'),
nameFormatRuleLabel: I18n.t('imageflow_create_name_wrong_format'),
descRequiredLabel: I18n.t('imageflow_create_description_placeholder'),
};
}
if (flowMode === WorkflowMode.ChatFlow) {
return {
nameLabel: I18n.t('wf_chatflow_85'),
namePlaceholder: I18n.t('wf_chatflow_91'),
descLabel: I18n.t('wf_chatflow_86'),
descPlaceholder: I18n.t('wf_chatflow_92'),
nameRequiredLabel: I18n.t('wf_chatflow_93'),
nameFormatRuleLabel: I18n.t('wf_chatflow_94'),
descRequiredLabel: I18n.t('wf_chatflow_122'),
};
}
return {
nameLabel: I18n.t('workflow_list_create_modal_name_label'),
namePlaceholder: I18n.t('workflow_list_create_modal_name_placeholder'),
descLabel: I18n.t('workflow_list_create_modal_description_label'),
descPlaceholder: I18n.t(
'workflow_list_create_modal_description_placeholder',
),
nameRequiredLabel: I18n.t(
'workflow_list_create_modal_name_rule_required',
),
nameFormatRuleLabel: I18n.t('workflow_list_create_modal_name_rule_reg'),
descRequiredLabel: I18n.t(
'workflow_list_create_modal_description_rule_required',
),
};
}, [flowMode]);
const iconType = useMemo(() => {
switch (flowMode) {
case WorkflowMode.Imageflow:
return IconType.Imageflow;
case WorkflowMode.Workflow:
return IconType.Workflow;
case WorkflowMode.ChatFlow:
return IconType.ChatFlow;
default:
return IconType.Workflow;
}
}, [flowMode]);
return (
<UIModal
type="action-small"
keepDOM={false}
icon={null}
visible={visible}
onCancel={handleCancel}
title={title}
footer={
<Space>
<Button
className="min-w-[96px]"
color="primary"
onClick={handleCancel}
data-testid="workflow.list.create.cancel"
>
{I18n.t('workflow_list_create_modal_footer_cancel')}
</Button>
<LoadingButton
className="min-w-[96px]"
color="hgltplus"
disabled={confirmDisabled}
onClick={
mode === 'add' ? handleCreateWorkflow : handleUpdateWorkflow
}
data-testid="workflow.list.create.submit"
>
{I18n.t('workflow_list_create_modal_footer_confirm')}
</LoadingButton>
</Space>
}
>
<Form<Partial<FormValue>>
ref={formRef}
showValidateIcon={false}
className={s['upload-form']}
onValueChange={({ name, target }) => {
setSensitiveTip(undefined);
setConfirmDisabled(!name?.trim() || !target?.trim());
}}
>
<PictureUpload
noLabel
fieldClassName={s['upload-field']}
field="icon_uri"
initValue={getPictureUploadInitValue(workFlow)}
iconType={iconType}
fileBizType={FileBizType.BIZ_BOT_WORKFLOW}
/>
<UIFormTextArea
stopValidateWithError
className={s['textarea-single-line']}
field="name"
placeholder={labels.namePlaceholder}
label={labels.nameLabel}
// noErrorMessage
initValue={workFlow?.name}
rows={1}
maxCount={WORKFLOW_NAME_MAX_LEN}
maxLength={WORKFLOW_NAME_MAX_LEN}
rules={[
{
required: true,
message: labels.nameRequiredLabel,
},
{
validator(_, value) {
if (!WORKFLOW_NAME_REGEX.test(value)) {
return new CustomError(
REPORT_EVENTS.formValidation,
labels.nameFormatRuleLabel ?? '',
);
}
return true;
},
},
...nameValidators,
]}
data-testid="workflow.list.create.name.input"
/>
{/* 只有项目内创建 Chatflow 时才可以绑定会话 */}
{mode === 'add' && projectId && flowMode === WorkflowMode.ChatFlow ? (
<Checkbox
fieldClassName={s['conversation-field']}
noLabel
initValue={true}
field="create_conversation"
>
<Typography.Text className="coz-fg-primary">
{I18n.t('wf_chatflow_87')}
</Typography.Text>
<Tooltip
position="top"
theme="dark"
style={{ width: 278 }}
content={I18n.t('wf_chatflow_82')}
>
<IconCozInfoCircle className="text-[16px] ml-1.5 coz-fg-dim" />
</Tooltip>
</Checkbox>
) : null}
<UIFormTextArea
field="target"
className={s['textarea-multi-line']}
label={labels.descLabel}
placeholder={labels.descPlaceholder}
initValue={workFlow?.desc}
maxCount={600}
maxLength={600}
rules={[
{
required: true,
message: labels.descRequiredLabel,
},
]}
data-testid="workflow.list.create.desc.input"
/>
{typeof sensitiveTip === 'string' ? (
<Form.ErrorMessage error={sensitiveTip} />
) : null}
</Form>
</UIModal>
);
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { OrderBy, WorkFlowListStatus } from '@coze-workflow/base/api';
import { I18n } from '@coze-arch/i18n';
import { WORKFLOW_LIST_STATUS_ALL } from '@/workflow-modal/type';
/** 流程所有者选项, 全部/我的 */
export const scopeOptions = [
{
label: I18n.t('workflow_list_scope_all'),
value: 'all',
},
{
label: I18n.t('workflow_list_scope_mine'),
value: 'me',
},
];
/** 流程状态选项, 全部/已发布/未发布 */
export const statusOptions = [
{
label: I18n.t('workflow_list_status_all'),
value: WORKFLOW_LIST_STATUS_ALL,
},
{
label: I18n.t('workflow_list_status_published'),
value: WorkFlowListStatus.HadPublished,
},
{
label: I18n.t('workflow_list_status_unpublished'),
value: WorkFlowListStatus.UnPublished,
},
];
/** 流程排序选项, 创建时间/更新时间 */
export const sortOptions = [
{
label: I18n.t('workflow_list_sort_create_time'),
value: OrderBy.CreateTime,
},
{
label: I18n.t('workflow_list_sort_edit_time'),
value: OrderBy.UpdateTime,
},
];

View File

@@ -0,0 +1,15 @@
/* stylelint-disable selector-class-pattern */
button.button {
min-width: 76px;
&.moreLevel {
color: var(--light-usage-primary-color-primary-disabled, #b4baf6);
background: var(--light-usage-bg-color-bg-0, #fff);
border: none;
}
&.mouseIn {
color: #fff;
background-color: rgba(var(--coze-red-5), 1) !important;
}
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { type FC } from 'react';
import classNames from 'classnames';
import { useBoolean } from 'ahooks';
import { I18n } from '@coze-arch/i18n';
import { Button, type ButtonProps } from '@coze-arch/coze-design';
import { useI18nText } from '@/workflow-modal/hooks/use-i18n-text';
import styles from './index.module.less';
type WorkflowAddedButtonProps = ButtonProps;
export const WorkflowAddedButton: FC<
WorkflowAddedButtonProps
> = buttonProps => {
const [isMouseIn, { setFalse, setTrue }] = useBoolean(false);
const onMouseEnter = () => {
setTrue();
};
const onMouseLeave = () => {
setFalse();
};
const { i18nText, ModalI18nKey } = useI18nText();
return (
<Button
{...buttonProps}
color={isMouseIn ? 'red' : 'primary'}
className={classNames({
[styles.button]: true,
[styles.moreLevel]: true,
})}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
data-testid="workflow.modal.button.added"
>
{isMouseIn
? i18nText(ModalI18nKey.ListItemRemove)
: I18n.t('workflow_add_list_added')}
</Button>
);
};

View File

@@ -0,0 +1,46 @@
/* stylelint-disable selector-class-pattern */
.font-normal {
cursor: text;
font-size: 12px;
font-weight: 400;
font-style: normal;
line-height: 16px;
}
.container {
margin-left: 8px;
.button {
cursor: pointer;
width: 76px;
}
}
.not_publish_tooltip {
padding: 12px;
border-radius: 6px;
.content {
.font-normal();
line-height: 20px;
color: #fff;
}
}
.workflow_count_span {
display: inline-block;
width: 16px;
height: 16px;
margin-left: 6px;
font-size: 10px;
line-height: 17px;
color: #fff;
vertical-align: 1px;
background-color: rgba(77, 83, 232, 100%);
border-radius: 8px;
}

View File

@@ -0,0 +1,180 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { type FC, useContext, useMemo, useState } from 'react';
import classNames from 'classnames';
import { I18n, type I18nKeysNoOptionsType } from '@coze-arch/i18n';
import { LoadingButton } from '@coze-arch/coze-design';
import { Popconfirm, Tooltip } from '@coze-arch/bot-semi';
import { CheckType, type CheckResult } from '@coze-arch/bot-api/workflow_api';
import { type WorkflowNodeJSON } from '@flowgram-adapter/free-layout-editor';
import WorkflowModalContext from '../../../workflow-modal-context';
import { isSelectProjectCategory } from '../../../utils';
import { type WorkflowInfo, WorkflowModalFrom } from '../../../type';
import { useI18nText } from '../../../hooks/use-i18n-text';
import { WorkflowAddedButton } from './added-button';
import styles from './index.module.less';
interface WorkflowBotButtonProps {
data?: WorkflowInfo;
isAdded?: boolean;
from?: WorkflowModalFrom;
loading?: boolean;
workflowNodes?: WorkflowNodeJSON[];
onAdd: () => Promise<boolean>;
onRemove: () => void;
className?: string;
style?: React.CSSProperties;
}
export const WorkflowBotButton: FC<WorkflowBotButtonProps> = ({
data,
style,
isAdded,
onAdd,
onRemove,
className,
from,
workflowNodes,
loading,
}) => {
const { plugin_id } = data || {};
const isPublished = plugin_id !== '0';
const isFromWorkflow =
from === WorkflowModalFrom.WorkflowAddNode ||
from === WorkflowModalFrom.ProjectWorkflowAddNode;
const context = useContext(WorkflowModalContext);
const isAddProjectWorkflow = isSelectProjectCategory(context?.modalState);
const canAdd = isPublished || isAddProjectWorkflow;
const isFromSocialScene = from === WorkflowModalFrom.SocialSceneHost;
const [count, setCount] = useState((workflowNodes || []).length);
const isFromWorkflowAgent = from === WorkflowModalFrom.WorkflowAgent;
const botAgentCheckResult = useMemo<CheckResult | undefined>(
() => data?.check_result?.find(check => check.type === CheckType.BotAgent),
[data],
);
const { i18nText, ModalI18nKey } = useI18nText();
const renderContent = () => {
if (isFromWorkflowAgent) {
if (botAgentCheckResult && !botAgentCheckResult.is_pass) {
return (
<Tooltip
position="top"
className={styles.not_publish_tooltip}
content={
<span className={styles.content}>
{botAgentCheckResult.reason}
</span>
}
>
<LoadingButton
disabled
color="primary"
className={styles.button}
data-testid="workflow.modal.add"
>
{I18n.t('workflow_add_list_add')}
</LoadingButton>
</Tooltip>
);
}
}
// 如果已添加,展示已添加按钮
if (isAdded) {
return (
<Popconfirm
title={i18nText(ModalI18nKey.ListItemRemoveConfirmTitle)}
content={i18nText(ModalI18nKey.ListItemRemoveConfirmDescription)}
okType="danger"
position="topRight"
onConfirm={onRemove}
zIndex={9999}
okText={I18n.t('neutral_age_gate_confirm', {}, 'Confirm')}
cancelText={I18n.t('workflow_240218_17', {}, 'Cancel')}
>
<div>
<WorkflowAddedButton />
</div>
</Popconfirm>
);
}
// 未添加,判断发布状态
// 未发布,展示下面的按钮
if (!canAdd) {
let key: I18nKeysNoOptionsType = 'workflow_add_not_allow_before_publish';
if (isFromWorkflow) {
key = 'wf_node_add_wf_modal_tip_must_publish_to_add';
} else if (isFromSocialScene) {
key = 'scene_workflow_popup_add_forbidden';
}
return (
<Tooltip
position="top"
className={styles.not_publish_tooltip}
content={<span className={styles.content}>{I18n.t(key)}</span>}
>
<LoadingButton
disabled
color="primary"
className={styles.button}
data-testid="workflow.modal.add"
>
{I18n.t('workflow_add_list_add')}
</LoadingButton>
</Tooltip>
);
}
// 已发布并且未添加,展示添加按钮
if (!isAdded) {
return (
<LoadingButton
onClick={async () => {
const isSuccess = await onAdd?.();
if (isSuccess) {
setCount(prev => prev + 1);
}
}}
color="primary"
className={styles.button}
data-testid="workflow.modal.add"
>
{I18n.t('workflow_add_list_add')}
{isFromWorkflow && count !== 0 ? (
<span className={styles.workflow_count_span}>{count}</span>
) : null}
</LoadingButton>
);
}
return null;
};
return (
<div
className={classNames(styles.container, className)}
style={style}
onClick={e => e.stopPropagation()}
>
{renderContent()}
</div>
);
};

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, type MouseEvent } from 'react';
import { logger } from '@coze-arch/logger';
import { I18n } from '@coze-arch/i18n';
import { IconCozTrashCan } from '@coze-arch/coze-design/icons';
import { IconButton, Popconfirm } from '@coze-arch/coze-design';
export const DeleteButton = ({
className,
onDelete,
}: {
className?: string;
onDelete?: () => Promise<void>;
}) => {
const [modalVisible, setModalVisible] = useState(false);
const handleClose = () => setModalVisible(false);
const showDeleteConfirm = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
setModalVisible(true);
};
const handleDelete = () =>
// 使用 promise 让按钮出现 loading 的效果,参见
// https://semi.design/zh-CN/feedback/popconfirm
new Promise((resolve, reject) => {
onDelete?.()
.then(() => {
handleClose();
resolve(true);
})
.catch(error => {
// 处理错误
logger.error({
error: error as Error,
eventName: 'delete workflow error',
});
reject(error);
});
});
return (
<div className={className} onClick={e => e.stopPropagation()}>
<Popconfirm
visible={modalVisible}
title={I18n.t('scene_workflow_popup_delete_confirm_title')}
content={I18n.t('scene_workflow_popup_delete_confirm_subtitle')}
okText={I18n.t('shortcut_modal_confirm')}
cancelText={I18n.t('shortcut_modal_cancel')}
trigger="click"
position="bottomRight"
onConfirm={handleDelete}
onCancel={handleClose}
okButtonColor="red"
>
<IconButton
icon={<IconCozTrashCan />}
type="primary"
onClick={showDeleteConfirm}
/>
</Popconfirm>
</div>
);
};

View File

@@ -0,0 +1,201 @@
/* stylelint-disable max-nesting-depth */
.font-normal {
font-size: 12px;
font-weight: 400;
font-style: normal;
line-height: 16px;
}
.container {
cursor: pointer;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 16px 14px;
border-top: 1px solid
var(--light-usage-border-color-border, rgba(28, 29, 35, 12%));
&:first-child {
border-top: 1px solid transparent;
}
&:hover {
background: var(--light-usage-fill-color-fill-0, rgba(46, 47, 56, 4%));
border-top: 1px solid transparent;
border-radius: 8px;
& + div {
border-top: 1px solid transparent;
}
}
.left {
margin-right: 16px;
.icon {
width: 36px;
height: 36px;
img {
width: 36px;
height: 36px;
}
}
}
.center {
display: flex;
flex: 1;
flex-direction: column;
width: 0;
.header {
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
padding-bottom: 2px;
.title_wrapper {
cursor: pointer;
display: flex;
flex: 1;
align-items: center;
align-self: stretch;
width: 0;
.title {
.font-normal();
font-size: 14px;
font-weight: 600;
line-height: 20px;
color: var(--coz-fg-primary, #060709);
}
.status {
display: flex;
flex-direction: row;
align-items: center;
margin-left: 8px;
.text {
margin-left: 4px;
color: var(--coz-fg-primary, #060709);
.font-normal();
}
}
}
}
.content {
cursor: pointer;
display: flex;
flex-direction: column;
width: 100%;
margin-top: 4px;
.desc {
.font-normal();
font-size: 12px;
line-height: 16px;
color: var(--coz-fg-secondary, #06070980);
}
}
.footer {
display: flex;
flex-direction: column;
width: 100%;
.creator {
cursor: pointer;
display: flex;
align-items: center;
border-radius: 3px;
.avatar {
display: flex;
width: 12px;
height: 12px;
& img {
width: 12px;
height: 12px;
}
}
.name {
.font-normal();
max-width: 106px;
margin-left: 4px;
color: var(--coz-fg-secondary, #06070980);
word-break: break-word;
}
}
.info {
cursor: pointer;
display: inline-flex;
align-items: center;
.creator {
background: unset;
&-avatar {
width: 12px;
height: 12px;
}
&-name {
max-width: 70px;
margin-left: 4px;
.font-normal();
color: var(--coz-fg-secondary, #06070980);
}
}
.symbol {
.font-normal();
margin: 0 8px;
line-height: 16px;
color: var(--coz-stroke-primary);
// color: var(--coz-fg-dim, #06070966);
}
.date {
.font-normal();
line-height: 16px;
color: var(--coz-fg-dim, #06070966);
}
}
}
}
.right {
display: flex;
flex-direction: column;
margin-left: 16px;
.buttons {
display: flex;
align-items: center;
align-self: stretch;
}
}
}

View File

@@ -0,0 +1,346 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @coze-arch/max-line-per-function */
import React, { type FC, useContext } from 'react';
import isNil from 'lodash-es/isNil';
import { unix } from 'dayjs';
import classNames from 'classnames';
import { OrderBy, WorkFlowListStatus } from '@coze-workflow/base/api';
import { I18n } from '@coze-arch/i18n';
import {
IconCozClockFill,
IconCozCheckMarkCircleFill,
} from '@coze-arch/coze-design/icons';
import { Typography, LoadingButton } from '@coze-arch/coze-design';
import { Avatar, Image, Tooltip } from '@coze-arch/bot-semi';
import { CheckType } from '@coze-arch/bot-api/workflow_api';
import { type Int64, SpaceType } from '@coze-arch/bot-api/developer_api';
import { LibButton } from '@/workflow-modal/content/card/lib-button';
import WorkflowModalContext from '../../workflow-modal-context';
import { isSelectProjectCategory } from '../../utils';
import {
DataSourceType,
MineActiveEnum,
type ProductInfo,
WorkflowCategory,
type WorkflowInfo,
WorkflowModalFrom,
} from '../../type';
import {
useWorkflowAction,
type WorkflowCardProps,
} from '../../hooks/use-workflow-action';
import { WorkflowParameters } from './parameters';
import { DeleteButton } from './delete-button';
import { WorkflowBotButton } from './bot-button';
import styles from './index.module.less';
const { Text } = Typography;
const formatTime = (time?: Int64) => unix(Number(time)).format('YYYY-MM-DD');
const defaultWorkFlowList = [];
export const WorkflowCard: FC<WorkflowCardProps> = props => {
const {
data,
workFlowList = defaultWorkFlowList,
from,
workflowNodes,
dupText,
itemShowDelete,
} = props;
const context = useContext(WorkflowModalContext);
const isProfessionalTemplate = (data as ProductInfo)?.meta_info
?.is_professional;
const {
dupWorkflowTpl,
addWorkflow,
removeWorkflow,
deleteWorkflow,
itemClick,
} = useWorkflowAction({ ...props, isProfessionalTemplate });
if (!context) {
return null;
}
const StatusMap = {
unpublished: {
label: I18n.t('workflow_add_status_unpublished'),
icon: <IconCozClockFill className="coz-fg-dim text-xs" />,
},
published: {
label: I18n.t('workflow_add_status_published'),
icon: (
<IconCozCheckMarkCircleFill className="text-xs coz-fg-hglt-green" />
),
},
};
const { orderBy, spaceType } = context;
const {
creator: creator,
status,
isSpaceWorkflow,
workflowCategory,
} = context.modalState;
const isTeam = spaceType === SpaceType.Team;
function isTypeWorkflow(
target: WorkflowInfo | ProductInfo,
): target is WorkflowInfo {
return context?.modalState.dataSourceType === DataSourceType.Workflow;
}
const pluginId = isTypeWorkflow(data) ? data.plugin_id : '';
const statusValue =
!isNil(pluginId) && isSpaceWorkflow
? StatusMap[pluginId === '0' ? 'unpublished' : 'published']
: undefined;
const renderStatusValue = () => {
// 添加项目里的工作流节点、官方示例不展示发布状态
if (
isSelectProjectCategory(context?.modalState) ||
workflowCategory === WorkflowCategory.Example
) {
return null;
}
if (statusValue) {
return (
<div className={classNames(styles.status)}>
{statusValue.icon}
<span className={styles.text}>{statusValue.label}</span>
</div>
);
}
return null;
};
const renderBottomLeftDesc = () => {
// 商品底部
if (!isTypeWorkflow(data)) {
const timeRender = `${I18n.t('workflow_add_list_updated')} ${formatTime(
data.meta_info.listed_at,
)}`;
return (
<div className={styles.info}>
<div className={styles.creator}>
<Avatar
className={styles['creator-avatar']}
src={data.meta_info.user_info?.avatar_url}
/>
<Text
ellipsis={{ showTooltip: true }}
className={styles['creator-name']}
>
{data.meta_info.user_info?.name ??
I18n.t('workflow_add_list_unknown')}
</Text>
<span className={styles.symbol}>|</span>
</div>
<span className={styles.date}>{timeRender}</span>
{(Number(data?.workflow_extra?.duplicate_count) || 0) > 0 ? (
<>
<span className={styles.symbol}>|</span>
<Text className={styles.date}>
{Number(data?.workflow_extra?.duplicate_count) || 0}{' '}
{I18n.t('workflowstore_card_duplicate')}
</Text>
</>
) : null}
</div>
);
}
// 用户创建的,展示修改时间
if (isSpaceWorkflow || workflowCategory === WorkflowCategory.Example) {
const showCreator =
(creator !== MineActiveEnum.Mine && isTeam) ||
from === WorkflowModalFrom.ProjectImportLibrary;
const timeRender =
orderBy === OrderBy.CreateTime
? `${I18n.t('workflow_add_list_created')} ${formatTime(
data.create_time,
)}`
: status === WorkFlowListStatus.HadPublished
? `${I18n.t('workflow_add_list_publised')} ${formatTime(
data.update_time,
)}`
: `${I18n.t('workflow_add_list_updated')} ${formatTime(
data.update_time,
)}`;
return (
<div className={styles.info}>
{showCreator ? (
<div className={styles.creator}>
<Avatar
className={styles['creator-avatar']}
src={data.creator?.avatar_url}
/>
<Text
ellipsis={{ showTooltip: true }}
className={styles['creator-name']}
>
{data.creator?.name ?? I18n.t('workflow_add_list_unknown')}
</Text>
<span className={styles.symbol}>|</span>
</div>
) : null}
<span className={styles.date}>{timeRender}</span>
</div>
);
}
// 官方模板,展示创作者
if (!isSpaceWorkflow) {
return (
<div className={styles.creator}>
<Image
preview={false}
src={data.template_author_picture_url}
className={styles.avatar}
/>
<Text ellipsis={{ showTooltip: true }} className={styles.name}>
{data.template_author_name || '-'}
</Text>
</div>
);
}
return null;
};
const renderBotButton = () => {
if (workflowCategory === WorkflowCategory.Example && isTypeWorkflow(data)) {
const botAgentCheckResult = data?.check_result?.find(
check => check.type === CheckType.BotAgent,
);
const ButtonContent = (
<LoadingButton
color="primary"
data-testid="workflow.modal.add"
disabled={botAgentCheckResult && !botAgentCheckResult?.is_pass}
onClick={async e => {
e.stopPropagation();
await dupWorkflowTpl();
}}
>
{dupText || I18n.t('workflowstore_duplicate_and_add')}
</LoadingButton>
);
if (
botAgentCheckResult &&
!botAgentCheckResult.is_pass &&
botAgentCheckResult.reason
) {
return (
<Tooltip content={botAgentCheckResult.reason}>
{ButtonContent}
</Tooltip>
);
}
return ButtonContent;
}
if (from === WorkflowModalFrom.ProjectImportLibrary) {
return (
<LibButton data={data as WorkflowInfo} onImport={props.onImport} />
);
}
return (
<>
<WorkflowBotButton
isAdded={workFlowList.some(
workflow =>
workflow.workflow_id === (data as WorkflowInfo)?.workflow_id,
)}
workflowNodes={workflowNodes}
from={from}
data={data as WorkflowInfo}
onAdd={() => addWorkflow()}
onRemove={() => {
removeWorkflow();
}}
/>
{itemShowDelete ? (
<DeleteButton className="ml-[4px]" onDelete={deleteWorkflow} />
) : null}
</>
);
};
return (
<div
className={styles.container}
onClick={() => {
itemClick();
}}
>
<div className={styles.left}>
<div className={styles.icon}>
<Image
preview={false}
src={isTypeWorkflow(data) ? data.url : data.meta_info.icon_url}
/>
</div>
</div>
<div className={styles.center}>
<div className={styles.header}>
<div className={styles.title_wrapper}>
<Text ellipsis={{ showTooltip: true }} className={styles.title}>
{isTypeWorkflow(data) ? data.name : data.meta_info.name}
</Text>
{renderStatusValue()}
</div>
</div>
<div className={styles.content}>
<Text
ellipsis={{
showTooltip: {
opts: {
style: {
maxWidth: 600,
wordBreak: 'break-word',
},
},
},
}}
className={styles.desc}
>
{(isTypeWorkflow(data) ? data.desc : data.meta_info.description) ||
''}
</Text>
</div>
<div className={styles.footer}>
<WorkflowParameters data={data} />
{renderBottomLeftDesc()}
</div>
</div>
<div className={styles.right}>
<div className={styles.buttons}>{renderBotButton()}</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { I18n } from '@coze-arch/i18n';
import { Button, Tooltip } from '@coze-arch/coze-design';
import { type WorkFlowModalModeProps, type WorkflowInfo } from '../../type';
export type LibButtonProps = Pick<WorkFlowModalModeProps, 'onImport'> & {
data?: WorkflowInfo;
};
export const LibButton: React.FC<LibButtonProps> = ({ data, onImport }) => {
const isPublished = data?.plugin_id && data?.plugin_id !== '0';
const content = (
<div onClick={e => e.stopPropagation()}>
<Button
disabled={!isPublished}
color="primary"
data-testid="workflow.modal.add"
onClick={event => {
event.stopPropagation();
data?.workflow_id &&
onImport?.({
workflow_id: data.workflow_id,
name: data.name || '',
});
}}
>
{I18n.t('project_resource_modal_copy_to_project')}
</Button>
</div>
);
if (isPublished) {
return content;
}
return (
<Tooltip
position="top"
content={I18n.t('project_toast_only_published_resources_can_be_imported')}
>
{content}
</Tooltip>
);
};

View File

@@ -0,0 +1,87 @@
/* stylelint-disable selector-class-pattern */
.font-normal {
/* Paragraph/small/EN-Regular */
font-size: 12px;
font-weight: 400;
font-style: normal;
line-height: 16px;
color: rgba(28, 31, 35, 80%);
}
.container {
width: 100%;
margin: 4px 0 8px;
.wrapper {
cursor: pointer;
position: relative;
display: flex;
flex-direction: column;
.popover_help_block {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}
}
.popover {
overflow: auto;
width: 260px;
max-height: 400px;
padding: 12px;
border-radius: 6px;
box-shadow: 0 4px 14px 0 rgba(0, 0, 0, 10%), 0 0 1px 0 rgba(0, 0, 0, 30%);
.item {
display: flex;
flex-direction: column;
margin-top: 4px;
&:first-child {
margin-top: 0;
}
.header {
display: flex;
align-items: center;
.name {
.font-normal();
font-weight: 700;
color: #1c1f23;
word-break: break-word;
}
.type {
.font-normal();
margin-left: 12px;
}
.required {
.font-normal();
margin-left: 12px;
color: #fc8800;
}
}
.footer {
.font-normal();
width: 100%;
margin-top: 4px;
color: rgba(28, 31, 35, 60%);
word-break: break-word;
}
}
}

View File

@@ -0,0 +1,227 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext, type FC } from 'react';
import classNames from 'classnames';
import {
PARAM_TYPE_LABEL_MAP,
STRING_ASSIST_TYPE_LABEL_MAP,
} from '@coze-workflow/base/types';
import { InputType, PluginParamTypeFormat } from '@coze-workflow/base/api';
import { I18n } from '@coze-arch/i18n';
import { Tag, Typography } from '@coze-arch/coze-design';
import { type OverflowListProps } from '@coze-arch/bot-semi/OverflowList';
import { Popover, OverflowList } from '@coze-arch/bot-semi';
import WorkflowModalContext from '../../../workflow-modal-context';
import {
DataSourceType,
type ProductInfo,
type WorkflowInfo,
} from '../../../type';
import { type WorkflowCardProps } from '../../../hooks/use-workflow-action';
import styles from './index.module.less';
const { Paragraph, Text } = Typography;
const getInputType = ({ type, format, assist_type }) => {
let inputType = '';
if (type) {
if (
type === InputType.String &&
format === PluginParamTypeFormat.ImageUrl
) {
inputType = 'Image';
} else if (type === InputType.String && assist_type) {
inputType = STRING_ASSIST_TYPE_LABEL_MAP[assist_type];
} else {
inputType = PARAM_TYPE_LABEL_MAP[type as InputType];
}
}
return inputType;
};
export interface WorkflowParameterItem {
name?: string;
desc?: string;
required?: boolean;
type?: string;
}
type WorkflowParametersProps = Pick<WorkflowCardProps, 'data'> & {
className?: string;
style?: React.CSSProperties;
};
interface CustomParameterPopoverProps {
items: WorkflowParameterItem[];
children: React.ReactNode;
}
const CustomParameterPopover: FC<CustomParameterPopoverProps> = ({
children,
items,
}) => (
<Popover
stopPropagation
position="top"
spacing={0}
content={
<div className={styles.popover}>
{items.map((item, index) => (
<div key={`item${index}`} className={styles.item}>
<div className={styles.header}>
<Text
ellipsis={{
showTooltip: {
opts: {
content: item.name || '',
position: 'top',
style: {
wordBreak: 'break-word',
},
},
},
}}
>
<span className={styles.name}>{item.name || '-'}</span>
</Text>
<span className={styles.type}>{item.type || '-'}</span>
{Boolean(item.required) && (
<span className={styles.required}>
{I18n.t('workflow_add_parameter_required')}
</span>
)}
</div>
<div className={styles.footer}>
<Paragraph
ellipsis={{
rows: 2,
showTooltip: {
opts: {
content: item.desc || '',
position: 'top',
style: {
wordBreak: 'break-word',
},
},
},
}}
>
<span className={styles.footer}>{item.desc || '-'}</span>
</Paragraph>
</div>
</div>
))}
</div>
}
>
{children}
</Popover>
);
export const WorkflowParameters: FC<WorkflowParametersProps> = ({
data,
style,
className,
}) => {
const context = useContext(WorkflowModalContext);
function isTypeWorkflow(
target: WorkflowInfo | ProductInfo,
): target is WorkflowInfo {
return context?.modalState.dataSourceType === DataSourceType.Workflow;
}
const getParameters = (): Array<WorkflowParameterItem> => {
// 这么拆分虽然有点冗余, 但是可以正确进行类型推导
if (isTypeWorkflow(data)) {
return (
data.start_node?.node_param?.input_parameters?.map(item => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const inputType = getInputType(item as any);
return {
name: item.name,
desc: item.desc,
required: item.required,
type: inputType,
};
}) || []
);
}
return (
data?.workflow_extra?.start_node?.node_param?.input_parameters?.map(
item => {
const inputType = getInputType({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(item as any),
type: item.input_type,
});
return {
name: item.name,
desc: item.desc,
required: item.is_required,
type: inputType,
};
},
) || []
);
};
const items = getParameters();
const overflowRenderer: OverflowListProps['overflowRenderer'] = (
overflowItems: Array<WorkflowParameterItem>,
) => {
const slicedItems = overflowItems.slice(overflowItems.length * -1);
return slicedItems.length ? (
<CustomParameterPopover items={items}>
<div>
<Tag style={{ flex: '0 0 auto' }} size="mini" color="primary">
+{slicedItems.length}
</Tag>
</div>
</CustomParameterPopover>
) : null;
};
const visibleItemRenderer: OverflowListProps['visibleItemRenderer'] = (
item: WorkflowParameterItem,
) => (
<CustomParameterPopover items={items}>
<div style={{ marginRight: 8 }}>
<Tag size="mini" color="primary">
{item.name}
</Tag>
</div>
</CustomParameterPopover>
);
return (
<div className={classNames(styles.container, className)} style={style}>
<div className={styles.wrapper}>
<OverflowList
items={items}
overflowRenderer={overflowRenderer}
visibleItemRenderer={visibleItemRenderer}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,37 @@
/* stylelint-disable selector-class-pattern */
.container {
width: 100%;
height: 100%;
padding: 0 24px;
}
.scroll_load_more {
padding: 8px;
&.empty {
height: 0;
padding: 0;
}
}
.workflow-content {
overflow: auto;
width: 100%;
height: 100%;
padding-bottom: 12px;
.loading-more {
text-align: center;
}
}
.spin {
width: 100%;
height: 100%;
:global {
.semi-spin-children {
height: 100%;
}
}
}

View File

@@ -0,0 +1,404 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable complexity */
/* eslint-disable @coze-arch/max-line-per-function */
import { type FC, useContext, useEffect, useMemo, useRef } from 'react';
import { useShallow } from 'zustand/react/shallow';
import groupBy from 'lodash-es/groupBy';
import { useInViewport, useUpdateEffect } from 'ahooks';
import { StandardNodeType } from '@coze-workflow/base/types';
import { useWorkflowStore } from '@coze-workflow/base/store';
import {
WorkflowMode,
WorkFlowType,
Tag,
BindBizType,
} from '@coze-workflow/base/api';
import { isGeneralWorkflow, workflowApi } from '@coze-workflow/base';
import { SearchNoResult } from '@coze-studio/components/search-no-result';
import { I18n } from '@coze-arch/i18n';
import { IconCozLoading } from '@coze-arch/coze-design/icons';
import { Spin } from '@coze-arch/coze-design';
import { UICompositionModalMain, UIEmpty } from '@coze-arch/bot-semi';
import {
ProductEntityType,
ProductListSource,
} from '@coze-arch/bot-api/product_api';
import { useWorkflowList } from '@/hooks/use-workflow-list';
import WorkflowModalContext from '../workflow-modal-context';
import { isSelectProjectCategory } from '../utils';
import {
DataSourceType,
MineActiveEnum,
type ProductInfo,
WORKFLOW_LIST_STATUS_ALL,
WorkflowCategory,
type WorkflowInfo,
WorkflowModalFrom,
type WorkFlowModalModeProps,
type WorkflowModalState,
} from '../type';
import { useWorkflowProductList } from '../hooks/use-workflow-product-list';
import { useI18nText } from '../hooks/use-i18n-text';
import { WorkflowCard } from './card';
import s from './index.module.less';
// eslint-disable-next-line max-lines-per-function
const WorkflowModalContent: FC<WorkFlowModalModeProps> = props => {
const { excludedWorkflowIds, from, projectId } = props;
const context = useContext(WorkflowModalContext);
const { i18nText, ModalI18nKey } = useI18nText();
const {
updatePageParam: updateWorkflowPageParam,
isFetching,
workflowList,
fetchNextPage,
loadingStatus,
refetch,
hasNextPage,
handleDelete,
} = useWorkflowList({
pageSize: 10,
enabled: context?.modalState.dataSourceType === DataSourceType.Workflow,
from,
fetchWorkflowListApi:
context?.modalState?.workflowCategory !== WorkflowCategory.Example
? workflowApi.GetWorkFlowList.bind(workflowApi)
: workflowApi.GetExampleWorkFlowList.bind(workflowApi),
});
const {
workflowProductList,
updatePageParam: updateProductPageParam,
fetchNextPage: fetchNextProductPage,
isFetching: productIsFetching,
loadingStatus: productLoadingStatus,
hasNextPage: productHasNextPage,
copyProduct,
} = useWorkflowProductList({
pageSize: 10,
enabled: context?.modalState.dataSourceType === DataSourceType.Product,
});
// 转换筛选参数
useEffect(() => {
if (!context) {
return;
}
const { modalState, flowMode } = context;
if (modalState.dataSourceType === DataSourceType.Workflow) {
const isAddProjectWorkflow = isSelectProjectCategory(modalState);
let targetTags;
if (!modalState.isSpaceWorkflow) {
if (modalState.query) {
targetTags = 1;
} else {
targetTags = modalState.workflowTag;
}
}
let type: WorkFlowType;
if (modalState.workflowCategory === WorkflowCategory.Example) {
targetTags = Tag.All;
type = WorkFlowType.GuanFang;
} else {
type = modalState.isSpaceWorkflow
? WorkFlowType.User
: WorkFlowType.GuanFang;
}
let status: WorkflowModalState['status'] | undefined = undefined;
if (modalState.isSpaceWorkflow) {
status =
// isAddProjectWorkflow项目里添加子工作流没有发布状态概念筛选状态传 undefined
modalState.status === WORKFLOW_LIST_STATUS_ALL || isAddProjectWorkflow
? undefined
: modalState.status;
}
updateWorkflowPageParam({
space_id: context.spaceId,
flow_mode: modalState.listFlowMode,
name: modalState.query,
order_by: modalState.isSpaceWorkflow ? context.orderBy : undefined,
status,
type,
project_id: isSelectProjectCategory(modalState) ? projectId : undefined,
login_user_create: modalState.isSpaceWorkflow
? modalState.creator === MineActiveEnum.Mine
: undefined,
tags: targetTags,
bind_biz_type: context.bindBizType,
bind_biz_id: context.bindBizId,
});
} else {
if (modalState.productCategory === 'recommend') {
updateProductPageParam({
keyword: modalState.query,
sort_type: modalState.sortType,
category_id: undefined,
source: ProductListSource.Recommend,
entity_type: isGeneralWorkflow(flowMode)
? ProductEntityType.WorkflowTemplateV2
: ProductEntityType.ImageflowTemplateV2,
});
} else if (modalState.productCategory === 'all') {
updateProductPageParam({
keyword: modalState.query,
sort_type: modalState.sortType,
category_id: undefined,
source: undefined,
entity_type: isGeneralWorkflow(flowMode)
? ProductEntityType.WorkflowTemplateV2
: ProductEntityType.ImageflowTemplateV2,
});
} else {
updateProductPageParam({
keyword: modalState.query,
sort_type: modalState.sortType,
category_id: modalState.productCategory,
source: undefined,
entity_type: isGeneralWorkflow(flowMode)
? ProductEntityType.WorkflowTemplateV2
: ProductEntityType.ImageflowTemplateV2,
});
}
}
}, [context]);
const { nodes } = useWorkflowStore(
useShallow(state => ({
nodes: state.nodes,
})),
);
// 子流程节点 map例如 { 'workflowId': [node1, node2, ...] }
const workflowNodesMap = useMemo(() => {
const subFlowNodes = nodes.filter(
v => v.type === StandardNodeType.SubWorkflow,
);
const groups = groupBy(
subFlowNodes,
item => item?.data?.inputs?.workflowId,
);
return groups;
}, [nodes]);
const targetWorkflowList = useMemo(() => {
if (!excludedWorkflowIds) {
return workflowList;
}
return workflowList.filter(
v => !excludedWorkflowIds.includes(v.workflow_id || ''),
);
}, [excludedWorkflowIds, workflowList]);
/** scroll的container */
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
/** 监听触底的observer */
const intersectionObserverDom = useRef<HTMLDivElement>(null);
// 是否触底
const [inViewPort] = useInViewport(intersectionObserverDom, {
root: () => scrollContainerRef.current,
threshold: 0.8,
});
// 首次effect不执行这个是切换状态的effect
useUpdateEffect(() => {
// 当筛选项改变时,回到顶部
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: 0,
});
}
// 只要是query中非page改变就执行此effect
}, [context?.modalState]);
// 获取下一页逻辑
useEffect(() => {
if (!inViewPort) {
return;
}
if (dataSourceType === DataSourceType.Workflow) {
if (loadingStatus !== 'success' || isFetching || !hasNextPage) {
return;
}
fetchNextPage();
} else {
if (
productLoadingStatus !== 'success' ||
productIsFetching ||
!productHasNextPage
) {
return;
}
fetchNextProductPage();
}
}, [
inViewPort,
loadingStatus,
isFetching,
hasNextPage,
productLoadingStatus,
productIsFetching,
productHasNextPage,
]);
useEffect(() => {
if (!context?.modalState.isSpaceWorkflow) {
return;
}
const visibilityChangeHandler = () => {
const needRefresh = document.visibilityState === 'visible';
if (needRefresh) {
refetch();
}
};
document.addEventListener('visibilitychange', visibilityChangeHandler);
return () => {
document.removeEventListener('visibilitychange', visibilityChangeHandler);
};
}, [context?.modalState.isSpaceWorkflow, refetch]);
if (!context) {
return null;
}
function isTypeWorkflow(
_target: WorkflowInfo | ProductInfo,
): _target is WorkflowInfo {
return context?.modalState.dataSourceType === DataSourceType.Workflow;
}
const { modalState, flowMode } = context;
const { dataSourceType } = context.modalState;
const targetLoadingStatus =
dataSourceType === DataSourceType.Workflow
? loadingStatus
: productLoadingStatus;
const targetHasNextPage =
dataSourceType === DataSourceType.Workflow
? hasNextPage
: productHasNextPage;
const targetList =
dataSourceType === DataSourceType.Workflow
? targetWorkflowList
: workflowProductList;
const isAgentWorkflow = from === WorkflowModalFrom.WorkflowAgent;
const renderEmpty = () => {
const isNotFound = Boolean(modalState.query);
if (flowMode === WorkflowMode.SceneFlow) {
return (
<SearchNoResult
title={i18nText(ModalI18nKey.CreatedListEmptyTitle)}
type={'social-scene-flow'}
isNotFound={isNotFound}
notFound={isNotFound ? i18nText(ModalI18nKey.ListEmptyTitle) : ''}
/>
);
} else {
return (
<UIEmpty
isNotFound={isNotFound}
notFound={{
title: i18nText(ModalI18nKey.ListEmptyTitle),
}}
empty={{
title: i18nText(ModalI18nKey.CreatedListEmptyTitle),
description: i18nText(ModalI18nKey.CreatedListEmptyDescription),
}}
/>
);
}
};
return (
<UICompositionModalMain>
<Spin
spinning={targetLoadingStatus === 'pending'}
wrapperClassName={s.spin}
style={{ height: '100%', width: '100%' }}
>
{/* Workflow as agent 支持添加带自定义入参的对话流 */}
{/* {isAgentWorkflow ? (
<div className="coz-mg-hglt px-[36px] py-[8px] mx-[24px] my-[0] rounded-[8px]">
{I18n.t('wf_chatflow_133')}
</div>
) : null} */}
<div
className={`${s['workflow-content']} new-workflow-modal-content`}
ref={scrollContainerRef}
>
{/* 内容渲染 */}
{targetLoadingStatus !== 'pending' && targetList.length > 0 && (
<UICompositionModalMain.Content
style={{
minHeight: '100%',
paddingBottom: isAgentWorkflow ? '60px' : 0,
}}
>
{/* 数据呈现样式, 列表样式/卡片样式. 展示图像流商品列表时使用卡片样式 */}
<>
{targetList.map((item: WorkflowInfo | ProductInfo) => (
<WorkflowCard
key={
isTypeWorkflow(item)
? item.workflow_id
: item.meta_info.entity_id
}
data={item}
itemShowDelete={
context?.bindBizType === BindBizType.DouYinBot
}
workflowNodes={
isTypeWorkflow(item)
? (workflowNodesMap[item.workflow_id || ''] ?? [])
: []
}
handleDeleteWorkflow={handleDelete}
copyProductHandle={copyProduct}
{...props}
/>
))}
</>
{targetHasNextPage ? (
<div ref={intersectionObserverDom}>
<div className={s['loading-more']}>
<IconCozLoading className="animate-spin coz-fg-dim mr-[4px]" />
<div>{I18n.t('Loading')}</div>
</div>
</div>
) : null}
</UICompositionModalMain.Content>
)}
{targetLoadingStatus === 'success' &&
targetList.length === 0 &&
renderEmpty()}
</div>
</Spin>
</UICompositionModalMain>
);
};
export { WorkflowModalContent };

View File

@@ -0,0 +1,101 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC, useContext } from 'react';
import { I18n, type I18nKeysNoOptionsType } from '@coze-arch/i18n';
import { Banner, Select } from '@coze-arch/coze-design';
import WorkflowModalContext from '../workflow-modal-context';
import {
WORKFLOW_LIST_STATUS_ALL,
type WorkFlowModalModeProps,
type WorkflowModalState,
} from '../type';
import { CreateWorkflowBtn } from '../sider/create-workflow-btn';
import styles from '../index.module.less';
import { useWorkflowSearch } from '../hooks/use-workflow-search';
import {
ModalI18nKey,
WORKFLOW_MODAL_I18N_KEY_MAP,
} from '../hooks/use-i18n-text';
import { statusOptions } from '../constants';
const getStatusOptions = (showAll?: boolean) =>
showAll
? statusOptions
: statusOptions.filter(item => item.value !== WORKFLOW_LIST_STATUS_ALL);
const WorkflowModalFilterForDouyin: FC<WorkFlowModalModeProps> = props => {
const context = useContext(WorkflowModalContext);
const searchNode = useWorkflowSearch();
if (!context) {
return null;
}
const { updateModalState, flowMode } = context;
const { status } = context.modalState;
const { filterOptionShowAll = false } = props;
const title = I18n.t(
WORKFLOW_MODAL_I18N_KEY_MAP[flowMode]?.[
ModalI18nKey.Title
] as I18nKeysNoOptionsType,
);
return (
<div className="w-full">
<div className="flex items-center w-full justify-between mt-[-4px]">
<div className="flex items-center gap-[24px]">
<div className={styles.titleForAvatar}>{title}</div>
<Select
insetLabel={I18n.t('publish_list_header_status')}
showClear={false}
value={status}
optionList={getStatusOptions(filterOptionShowAll)}
onChange={value => {
updateModalState({
status: value as WorkflowModalState['status'],
});
}}
/>
</div>
<div className="flex items-center gap-[12px] mr-[12px]">
<div className="w-[208px]">{searchNode}</div>
<div className="flex items-center">
<CreateWorkflowBtn
onCreateSuccess={props.onCreateSuccess}
nameValidators={props.nameValidators}
/>
</div>
</div>
</div>
<Banner
type="info"
className="mt-[16px] pt-[7px] pb-[7px] rounded-lg"
description={I18n.t('dy_avatar_add_workflow_limit')}
closeIcon={null}
/>
</div>
);
};
export { WorkflowModalFilterForDouyin };

View File

@@ -0,0 +1,32 @@
/* stylelint-disable selector-class-pattern */
.header {
display: flex;
gap: 12px;
align-items: center;
}
.workflow-status-radio {
:global(.semi-radio-buttonRadioGroup) {
padding: 0;
}
:global(.semi-radio-buttonRadioGroup:first-child .semi-radio-addon-buttonRadio) {
border-radius: 0;
border-right: 1px solid var(--semi-color-fill-2);
}
:global(.semi-radio-addon-buttonRadio) {
font-size: 14px;
padding: 0 16px;
color: rgb(28 31 35 / 40%);
}
:global(.semi-radio-addon-buttonRadio-hover) {
background-color: transparent;
}
:global(.semi-radio-addon-buttonRadio-checked) {
background-color: transparent;
color: var(--light-usage-text-color-text-0, #1c1d23)
}
}

View File

@@ -0,0 +1,174 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC, useContext, useMemo } from 'react';
import { I18n } from '@coze-arch/i18n';
import { UISelect } from '@coze-arch/bot-semi';
import { WorkflowMode } from '@coze-arch/bot-api/workflow_api';
import { SpaceType } from '@coze-arch/bot-api/playground_api';
import WorkflowModalContext from '../workflow-modal-context';
import { isSelectProjectCategory } from '../utils';
import {
DataSourceType,
MineActiveEnum,
WORKFLOW_LIST_STATUS_ALL,
type WorkFlowModalModeProps,
type WorkflowModalState,
WorkflowCategory,
} from '../type';
import { CreateWorkflowBtn } from '../sider/create-workflow-btn';
import { useI18nText } from '../hooks/use-i18n-text';
import { statusOptions } from '../constants';
import { SortTypeSelect } from './sort-type-select';
import styles from './index.module.less';
const getStatusOptions = (showAll?: boolean) =>
showAll
? statusOptions
: statusOptions.filter(item => item.value !== WORKFLOW_LIST_STATUS_ALL);
const flowModeOptions = [
{
label: I18n.t('filter_all'),
value: WorkflowMode.All,
},
{
label: I18n.t('library_resource_type_workflow'),
value: WorkflowMode.Workflow,
},
{
label: I18n.t('wf_chatflow_76'),
value: WorkflowMode.ChatFlow,
},
].filter(item => {
// 社区版本暂不支持对话流
if (item.value === WorkflowMode.ChatFlow && IS_OPEN_SOURCE) {
return false;
}
return true;
});
const WorkflowModalFilter: FC<WorkFlowModalModeProps> = props => {
const context = useContext(WorkflowModalContext);
const { i18nText, ModalI18nKey } = useI18nText();
const scopeOptions = useMemo(() => {
if (!context) {
return [];
}
return [
{
label: i18nText(ModalI18nKey.TabAll),
value: MineActiveEnum.All,
},
{
label: i18nText(ModalI18nKey.TabMine),
value: MineActiveEnum.Mine,
},
];
}, []);
if (!context) {
return null;
}
const { spaceType, updateModalState, modalState } = context;
const {
dataSourceType,
isSpaceWorkflow,
status,
creator,
listFlowMode,
workflowCategory,
} = context.modalState;
const {
hideSider,
hiddenCreate,
filterOptionShowAll = false,
hideCreatorSelect = false,
hiddenListFlowModeFilter = false,
} = props;
const isExampleWorkflow = workflowCategory === WorkflowCategory.Example;
const isAddProjectWorkflow = isSelectProjectCategory(modalState);
return (
<div
className={`${styles.header} ${
hideSider ? 'w-full justify-between' : ''
}`}
>
{(isSpaceWorkflow || isExampleWorkflow) &&
dataSourceType === DataSourceType.Workflow ? (
<>
{!hiddenListFlowModeFilter ? (
<UISelect
label={I18n.t('Type')}
showClear={false}
value={listFlowMode}
optionList={flowModeOptions}
onChange={value => {
updateModalState({
listFlowMode: value as WorkflowMode,
});
}}
/>
) : null}
{isAddProjectWorkflow || isExampleWorkflow ? null : (
<UISelect
label={I18n.t('publish_list_header_status')}
showClear={false}
value={status}
optionList={getStatusOptions(filterOptionShowAll)}
onChange={value => {
updateModalState({
status: value as WorkflowModalState['status'],
});
}}
/>
)}
{spaceType === SpaceType.Team &&
!hideCreatorSelect &&
!isExampleWorkflow && (
<UISelect
label={I18n.t('Creator')}
showClear={false}
value={creator}
onChange={value => {
updateModalState({ creator: value as MineActiveEnum });
}}
optionList={scopeOptions}
/>
)}
</>
) : null}
{hideSider ? (
<div className="flex items-center mr-[-24px]">
{!hiddenCreate && (
<CreateWorkflowBtn
className="ml-12px"
onCreateSuccess={props.onCreateSuccess}
nameValidators={props.nameValidators}
/>
)}
</div>
) : null}
{dataSourceType === DataSourceType.Product ? <SortTypeSelect /> : null}
</div>
);
};
export { WorkflowModalFilter };

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useContext } from 'react';
import { SortType } from '@coze-arch/idl/product_api';
import { I18n } from '@coze-arch/i18n';
import { UISelect } from '@coze-arch/bot-semi';
import WorkflowModalContext, {
type WorkflowModalContextValue,
} from '../workflow-modal-context';
const defaultDataSource = [
{
label: I18n.t('Popular', {}, '最受欢迎'),
value: SortType.Heat,
},
{
label: I18n.t('mkpl_published', {}, '最近发布'),
value: SortType.Newest,
},
];
const queryDataSource = [
{
label: I18n.t('store_search_rank_default', {}, '相关性'),
value: SortType.Relative,
},
].concat(defaultDataSource);
export const SortTypeSelect = () => {
const context = useContext(WorkflowModalContext);
const { updateModalState } = context as WorkflowModalContextValue;
const { query, sortType } = context?.modalState || {};
const handleOnChange = value => {
updateModalState({ sortType: value as SortType });
};
return (
<UISelect
label={I18n.t('Sort')}
value={sortType}
optionList={query ? queryDataSource : defaultDataSource}
onChange={handleOnChange}
/>
);
};

Some files were not shown because too many files have changed in this diff Show More