chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:24:55 +08:00
commit 7baeb17845
15305 changed files with 2077301 additions and 0 deletions
@@ -0,0 +1,34 @@
/*
* 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 { createContext } from 'react';
import { type ActionController, type ActionSize } from '../types';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const ActionBarContext = createContext<{
controller: ActionController;
size: ActionSize;
}>({
controller: {
// eslint-disable-next-line @typescript-eslint/no-empty-function
hideActionBar: () => {},
// reposition
// eslint-disable-next-line @typescript-eslint/no-empty-function
rePosition: () => {},
},
size: 'small',
});
@@ -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 { useActionBarPreference } from './use-action-bar-perference';
@@ -0,0 +1,30 @@
/*
* 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 { type ActionController, type ActionSize } from '../types';
import { ActionBarContext } from '../context';
interface ActionBarPreference {
size: ActionSize;
controller: ActionController;
}
export const useActionBarPreference = (): ActionBarPreference => {
const { size, controller } = useContext(ActionBarContext);
return { size, controller };
};
@@ -0,0 +1,21 @@
/*
* 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 { ActionBar } from './ui-component';
export { ActionBarContext } from './context';
export { useActionBarPreference } from './hooks/use-action-bar-perference';
export type { ActionController, ActionSize } from './types';
export type { SelectionInfo } from '../types';
@@ -0,0 +1,23 @@
/*
* 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 ButtonProps } from '@coze-arch/coze-design';
export interface ActionController {
hideActionBar: () => void;
rePosition: (position?: 'topLeft' | 'bottomRight') => void;
}
export type ActionSize = ButtonProps['size'];
@@ -0,0 +1,186 @@
/*
* 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 PropsWithChildren,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import cls from 'classnames';
import {
PositionMirror,
useEditor,
useInjector,
} from '@coze-editor/editor/react';
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import { Popover } from '@coze-arch/coze-design';
import { drawSelection, EditorView } from '@codemirror/view';
import { type SelectionInfo } from '../types';
import { ThemeExtension } from '../theme';
import { useReadonly } from '../shared/hooks/use-editor-readonly';
import { type ActionController } from './types';
import { ActionBarContext } from './context';
interface ActionBarProps {
className?: string;
size?: 'default' | 'small' | 'large';
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
trigger?: 'custom' | 'selection';
}
export const ActionBar: React.FC<PropsWithChildren<ActionBarProps>> = props => {
const {
className,
size = 'small',
children,
visible,
onVisibleChange,
trigger = 'selection',
} = props;
const [internalVisible, setInternalVisible] = useState(false);
const [reposKey, setReposKey] = useState('');
const [popoverPosition, setPopoverPosition] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const isReadOnly = useReadonly();
const [selection, setSelection] = useState<SelectionInfo>({
from: 0,
to: 0,
anchor: 0,
head: 0,
});
const editor = useEditor<EditorAPI>();
const injector = useInjector();
const [position, setPosition] = useState<
'topLeft' | 'bottomRight' | undefined
>();
useEffect(() => {
setPosition(
selection?.head > selection?.anchor ? 'bottomRight' : 'topLeft',
);
}, [selection]);
const controller: ActionController = {
hideActionBar: () => {
onVisibleChange?.(false);
setInternalVisible(false);
},
rePosition: (newPosition?: 'topLeft' | 'bottomRight') => {
setReposKey(String(Math.random()));
newPosition && setPosition(newPosition);
},
};
useLayoutEffect(() => injector.inject([drawSelection()]), [injector]);
useEffect(() => {
if (!editor) {
return;
}
function handleMousedown() {
onVisibleChange?.(false);
setInternalVisible(false);
setPopoverPosition(-1);
}
function handleMouseup(e: MouseEvent) {
if (containerRef.current?.contains(e.target as Node)) {
return;
}
const selectionRange = editor.getSelection();
setSelection(selectionRange);
if (!selectionRange) {
onVisibleChange?.(false);
setPopoverPosition(-1);
setInternalVisible(false);
return;
}
const isSelection = selectionRange.from !== selectionRange.to;
setSelection(selectionRange);
onVisibleChange?.(isSelection);
setInternalVisible(isSelection);
setPopoverPosition(selectionRange.head);
}
function handleSelectionChange() {
onVisibleChange?.(false);
setPopoverPosition(-1);
setInternalVisible(false);
}
// function handleBlur() {
// onVisibleChange?.(false);
// setInternalVisible(false);
// editor.$view.dispatch({
// selection: { anchor: editor.$view.state.selection.main.head },
// });
// }
editor.$on('mousedown', handleMousedown);
// Do not use editor. $on to listen for mouseup events because the mouse may not be in the editor
document.addEventListener('mouseup', handleMouseup);
editor.$on('selectionChange', handleSelectionChange);
// editor.$on('blur', handleBlur);
return () => {
editor.$off('mousedown', handleMousedown);
document.removeEventListener('mouseup', handleMouseup);
editor.$off('selectionChange', handleSelectionChange);
// editor.$off('blur', handleBlur);
};
}, [editor]);
if (isReadOnly) {
return null;
}
return (
<>
<Popover
rePosKey={reposKey}
visible={trigger === 'custom' ? visible : internalVisible}
trigger="custom"
position={position}
autoAdjustOverflow
className="rounded"
content={
<ActionBarContext.Provider value={{ controller, size }}>
<div className={cls('flex gap-1', className)} ref={containerRef}>
{children}
</div>
</ActionBarContext.Provider>
}
>
<PositionMirror
position={popoverPosition}
onChange={() => setReposKey(String(Math.random()))}
/>
</Popover>
<ThemeExtension
themes={[
EditorView.theme({
'.cm-selectionBackground': {
backgroundColor: 'rgba(148, 152, 247, 0.44)',
},
}),
]}
/>
</>
);
};
@@ -0,0 +1,37 @@
/*
* 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 { useEditor } from '@coze-editor/editor/react';
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import { I18n } from '@coze-arch/i18n';
import { IconCozCopy } from '@coze-arch/coze-design/icons';
import { Button, Toast } from '@coze-arch/coze-design';
export const CopyAction = () => {
const editor = useEditor<EditorAPI>();
return (
<Button
icon={<IconCozCopy />}
color="primary"
size="small"
className="w-6 h-6"
onClick={() => {
const text = editor?.$view.state.doc.toString();
navigator.clipboard.writeText(text);
Toast.success(I18n.t('prompt_library_prompt_copied_successfully'));
}}
/>
);
};
@@ -0,0 +1,19 @@
/*
* 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 { InsertInputSlotAction } from './insert-input-slot';
export { insertInputSlot } from '../input-slot/action/insert-input-slot-action';
export { CopyAction } from './copy';
@@ -0,0 +1,77 @@
/*
* 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 cls from 'classnames';
import { useEditor } from '@coze-editor/editor/react';
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import { I18n } from '@coze-arch/i18n';
import { IconCozInputSlot } from '@coze-arch/coze-design/icons';
import { IconButton, Tooltip } from '@coze-arch/coze-design';
import { type ButtonProps } from '@coze-arch/coze-design';
import { insertInputSlot } from '../../input-slot/action/insert-input-slot-action';
import {
useSelectionInInputSlot,
useSelectionInJinjaRaw,
} from '../../input-slot';
import { useActionBarPreference } from '../../action-bar/hooks/use-action-bar-perference';
type InsertInputSlotProps = Pick<ButtonProps, 'className'>;
export const InsertInputSlotAction: React.FC<InsertInputSlotProps> = props => {
const editor = useEditor<EditorAPI>();
const { className } = props;
const { controller, size } = useActionBarPreference();
const inInputSlot = useSelectionInInputSlot();
const inJinjaRaw = useSelectionInJinjaRaw();
if (inInputSlot || inJinjaRaw) {
return null;
}
return (
<div
className={cls(
'hover:coz-mg-secondary-hovered coz-icon-button rounded-little',
)}
>
<Tooltip
content={I18n.t('edit_block_set_as_edit_block')}
position="bottom"
>
<IconButton
color="primary"
icon={<IconCozInputSlot />}
className={cls('!bg-transparent', className)}
size={size}
onMouseDown={e => {
e.preventDefault();
e.stopPropagation();
}}
onClick={e => {
e.preventDefault();
e.stopPropagation();
controller?.hideActionBar();
if (!editor) {
return;
}
insertInputSlot(editor);
}}
/>
</Tooltip>
</div>
);
};
@@ -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 { InsertInputSlotAction } from './component';
@@ -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',
}
@@ -0,0 +1,32 @@
/*
* 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 {
ExpressionEditorSegment,
Variable,
ExpressionEditorTreeNode,
ExpressionEditorParseData,
} from './type';
export { ViewVariableType as VariableType } from '../variable-types';
export { ExpressionEditorParser } from './parser';
export { ExpressionEditorTreeHelper } from './tree-helper';
@@ -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 { describe, it, expect } from 'vitest';
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();
});
});
@@ -0,0 +1,256 @@
/*
* 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 type {
ExpressionEditorParseData,
ExpressionEditorSegment,
} from '../type';
import {
ExpressionEditorSegmentType,
ExpressionEditorToken,
} from '../constant';
export namespace ExpressionEditorParserBuiltin {
/** Calculate the serial number of the start and end tags */
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 no-magic-numbers
firstEndTokenOffset + 2,
);
if (endChars !== ExpressionEditorToken.FullEnd) {
// End symbol "}}" is incomplete
return;
}
const lastStartTokenOffset = content.lastIndexOf(
ExpressionEditorToken.Start,
offset - 1,
);
const startChars = content.slice(
lastStartTokenOffset - 1,
lastStartTokenOffset + 1,
);
if (startChars !== ExpressionEditorToken.FullStart) {
// The opening symbol "{{" is incomplete
return;
}
return {
lastStartTokenOffset,
firstEndTokenOffset,
};
};
/** Extract content from line content */
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,
};
};
/** Split text content into available and unavailable by 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,
};
};
/** Split text */
export const splitText = (pathString: string): string[] => {
// The obtained split array, initially the result of splitting the original string with "."
const segments = pathString.split(ExpressionEditorToken.Separator);
// Define the result array and handle empty strings resulting from consecutive "."
const result: string[] = [];
segments.forEach(segment => {
if (!segment.match(/\[\d+\]/)) {
// If it is not an array index, add the result array directly, even if it is an empty string, to maintain the correct segmentation
result.push(segment);
return;
}
// If the current segment is an array index, add the previous string and the current array index to the result array, respectively
const lastSegmentIndex = segment.lastIndexOf(
ExpressionEditorToken.ArrayStart,
);
const key = segment.substring(0, lastSegmentIndex);
const index = segment.substring(lastSegmentIndex);
// Array in {{array [0]}}
result.push(key);
// [0] in {{array [0]}}
result.push(index);
return;
});
return result;
};
/** String parsed as path */
export const toSegments = (
text: string,
): ExpressionEditorSegment[] | undefined => {
const textSegments = ExpressionEditorParserBuiltin.splitText(text);
const segments: ExpressionEditorSegment[] = [];
const validate = textSegments.every((textSegment, index) => {
// array subscript
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 must be a number
return false;
}
const lastSegment = segments[segments.length - 1];
if (
!lastSegment ||
lastSegment.type !== ExpressionEditorSegmentType.ObjectKey
) {
// The array index must be after the key
return false;
}
segments.push({
type: ExpressionEditorSegmentType.ArrayIndex,
index,
arrayIndex,
});
}
// The last empty line of text
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 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,
},
};
};
}
@@ -0,0 +1,372 @@
/*
* 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 { describe, it, beforeEach, expect } from 'vitest';
import type {
ExpressionEditorTreeNode,
ExpressionEditorSegment,
Variable,
} from '../type';
import { ExpressionEditorSegmentType } from '../constant';
import { ExpressionEditorTreeHelper } from '.';
import { ViewVariableType } from '../../variable-types';
describe('ExpressionEditorTreeHelper pruning', () => {
let defaultTree: ExpressionEditorTreeNode[];
let defaultSegments: ExpressionEditorSegment[];
beforeEach(() => {
defaultTree = [
{
label: 'foo',
value: 'foo',
key: 'foo',
variable: {
type: ViewVariableType.ArrayObject,
} as Variable,
children: [
{
label: 'bar',
value: 'bar',
key: 'bar',
variable: {
type: ViewVariableType.Object,
} as Variable,
children: [
{
label: 'baz',
value: 'baz',
key: 'baz',
variable: {
type: ViewVariableType.String,
} as Variable,
},
],
},
],
},
];
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 Variable,
},
]);
});
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 Variable,
parent: {
label: 'foo',
value: 'foo',
key: 'foo',
variable: {} as Variable,
},
};
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 Variable,
parent: {
label: 'foo',
value: 'foo',
key: 'foo',
variable: {
type: ViewVariableType.ArrayObject,
} as Variable,
},
};
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 Variable,
children: [
{
label: 'bar',
value: 'bar',
key: 'bar',
variable: {
type: ViewVariableType.ArrayObject,
} as Variable,
children: [
{
label: 'baz',
value: 'baz',
key: 'baz',
variable: {} as Variable,
},
],
},
],
},
];
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 Variable,
children: [
{
label: 'bar',
value: 'bar',
key: 'bar',
variable: {
type: ViewVariableType.String,
} as Variable,
},
],
},
];
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 Variable,
children: [
{
label: 'bar',
value: 'bar',
key: 'bar',
variable: {
type: ViewVariableType.String,
} as Variable,
},
],
},
];
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();
});
});
@@ -0,0 +1,182 @@
/*
* 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 type {
ExpressionEditorTreeNode,
ExpressionEditorSegment,
Variable,
} from '../type';
import { ExpressionEditorSegmentType } from '../constant';
import { ViewVariableType, isArrayType } from '../../variable-types';
export namespace ExpressionEditorTreeHelper {
export interface AvailableVariable {
name: string;
keyPath?: string[];
variable?: Variable;
}
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) // The array index belongs to the previous level, and two layers need to be removed.
: segments.slice(0, segments.length - 1);
let treeLayer = tree;
segmentsRemovedLast.forEach(segment => {
if (segment.type !== ExpressionEditorSegmentType.ObjectKey) {
return;
}
const treeChildren = treeLayer.filter(
node => node.label === segment.objectKey,
);
// Compatible variable names are duplicated, but the subfields are different
if (treeChildren?.length) {
treeLayer = treeChildren.reduce(
(pre: ExpressionEditorTreeNode[], cur: ExpressionEditorTreeNode) => {
if (cur.children?.length) {
return [...pre, ...cur.children];
}
return pre;
},
[],
);
} 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) {
// Default 0th
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) {
// exit the loop
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];
// Verify Illegal Case: Whether to Use Array Indexing for Non-Array Types
if (
segment.type === ExpressionEditorSegmentType.ArrayIndex &&
beforeTreeNode &&
(!beforeTreeNode.variable || !isArrayType(beforeTreeNode.variable.type))
) {
return itemInvalid();
}
// Confirm illegal condition: Array can only follow array subscript
if (
beforeTreeNode?.variable?.type &&
isArrayType(beforeTreeNode.variable.type) &&
segment.type !== ExpressionEditorSegmentType.ArrayIndex
) {
return itemInvalid();
}
// ignore
if (segment.type !== ExpressionEditorSegmentType.ObjectKey) {
return itemValid();
}
const treeNode = treeLayer.find(node => node.label === segment.objectKey);
// Verify illegal condition: each object key must correspond to a 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;
};
}
@@ -0,0 +1,77 @@
/*
* 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 { TreeNodeData } from '@coze-arch/bot-semi/Tree';
import { type ViewVariableType as VariableType } from '../variable-types';
import type { ExpressionEditorSegmentType } from './constant';
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 Variable {
key: string;
type: VariableType;
name: string;
children?: Variable[];
// user-defined node name display
label?: string;
}
export interface ExpressionEditorTreeNode extends TreeNodeData {
label: string;
value: string;
key: string;
keyPath?: string[];
variable?: Variable;
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[];
};
}
@@ -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.
*/
import { useLayoutEffect } from 'react';
import { useInjector } from '@coze-editor/editor/react';
import { astDecorator } from '@coze-editor/editor';
import { EditorView } from '@codemirror/view';
function HighlightExpressionOnActive() {
const injector = useInjector();
useLayoutEffect(() =>
injector.inject([
[
astDecorator.fromCursor.of((cursor, state) => {
const { anchor } = state.selection.main;
const pos = anchor;
if (
cursor.name === 'JinjaExpression' &&
cursor.node.firstChild?.name === 'JinjaExpressionStart' &&
cursor.node.lastChild?.name === 'JinjaExpressionEnd' &&
pos >= cursor.node.firstChild.to &&
pos <= cursor.node.lastChild.from &&
state.sliceDoc(
cursor.node.lastChild.from,
cursor.node.lastChild.to,
) === '}}'
) {
return {
type: 'background',
className: 'cm-decoration-interpolation-active',
from: cursor.node.firstChild.from,
to: cursor.node.lastChild.to,
};
}
}),
EditorView.theme({
'.cm-decoration-interpolation-active': {
borderRadius: '2px',
backgroundColor:
'var(--light-usage-fill-color-fill-1, rgba(46, 46, 56, 0.08))',
},
}),
],
]),
);
return null;
}
export { HighlightExpressionOnActive };
@@ -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.
*/
export { Popover as Completion } from './popover';
export { Validation } from './validation';
export { HighlightExpressionOnActive } from './highlight';
export type {
ExpressionEditorTreeNode as VariableNode,
Variable,
VariableType,
} from './core';
@@ -0,0 +1,27 @@
/*
* 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 { useCharacterTriggerContext } from './use-character-trigger-context';
export { useEmptyContent } from './use-empty-content';
export { useDrillVariableTree } from './use-drill-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';
@@ -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 CompletionContext {
from: number;
to: number;
text: string;
offset: number;
textBefore: string;
}
export type { CompletionContext };
@@ -0,0 +1,220 @@
/*
* 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 @coze-arch/max-line-per-function: "warn" */
import { useLayoutEffect, useState } from 'react';
import { useInjector, useLatest } from '@coze-editor/editor/react';
import { ViewPlugin, type ViewUpdate } from '@codemirror/view';
import { EditorSelection, type EditorState } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
type ComposeTransaction,
isSkipSelectionChangeUserEvent,
} from '../shared';
import { type CompletionContext } from './types';
interface TriggerContext extends CompletionContext {
validate: (state: EditorState) => boolean;
composeTransaction?: ComposeTransaction;
}
function useCharacterTriggerContext(disableUpdateTrigger?: boolean) {
const [triggerContext, setTriggerContext] = useState<
TriggerContext | undefined
>();
const setTriggerContextRef = useLatest(setTriggerContext);
const injector = useInjector();
useLayoutEffect(() => {
function getNodeAtCursor(state: EditorState) {
const pos = state.selection.main.from;
const tree = syntaxTree(state);
const node = tree.resolve(pos);
return node;
}
return injector.inject([
ViewPlugin.fromClass(
class {
triggerContext: TriggerContext | undefined = undefined;
update(update: ViewUpdate) {
let triggerCharacter:
| { content: string; from: number; to: number }
| undefined;
if (!disableUpdateTrigger && update.docChanged) {
// eslint-disable-next-line max-params
update.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
const insertString = inserted.toString();
if (
fromA === toA &&
(insertString === '{' || insertString === '{}')
) {
triggerCharacter = {
from: fromB,
to: toB,
content: insertString,
};
}
});
}
// when user input { or {}, show all available variables
if (triggerCharacter) {
const node = getNodeAtCursor(update.state);
// for case like {{<cursor>content, shall show variables
if (
node &&
node.name === 'JinjaExpression' &&
node.lastChild?.name === 'JinjaExpressionEnd' &&
node.lastChild.to - node.lastChild.from === 2
) {
// {<cursor>} -> {{<cursor>}}
// skip here, handover to use-interpolation-content
this.triggerContext = undefined;
} else {
// <cursor>content -> {<cursor>content
// <cursor> -> {<cursor>}
const from = triggerCharacter.from + 1;
const { to } = update.state.selection.main;
this.triggerContext = {
from,
to,
offset: 0,
text: '',
textBefore: '',
validate(state) {
if (!triggerCharacter) {
return true;
}
// check { still exists
if (
state.sliceDoc(
triggerCharacter.from,
triggerCharacter.from + 1,
) !== '{'
) {
return false;
}
// check character after cursor is still }
// edge caseuser may press Delete, cause the } disappears
if (
triggerCharacter.content === '{}' &&
state.sliceDoc(
state.selection.main.to,
state.selection.main.to + 1,
) !== '}'
) {
return false;
}
return true;
},
composeTransaction: (tr, state) => {
if (!triggerCharacter) {
return [];
}
// already exists { before {, we shall reuse it
const characterBefore = state.sliceDoc(
Math.max(0, triggerCharacter.from - 1),
triggerCharacter.from,
);
const hasExtraLeftBrace = characterBefore === '{';
if (triggerCharacter.content.length === 1) {
return [
{
changes: [
{
from: tr.changes.from,
to: tr.changes.to,
insert: `${hasExtraLeftBrace ? '' : '{'}${
tr.changes.insert
}}}`,
},
],
selection: EditorSelection.cursor(
tr.cursorPosition + (hasExtraLeftBrace ? 0 : 1),
),
userEvent: tr.userEvent,
scrollIntoView: true,
},
];
}
return [
{
changes: [
{
from: tr.changes.from,
to: tr.changes.to,
insert: `{${tr.changes.insert}}`,
},
],
selection: EditorSelection.cursor(
tr.cursorPosition + 1,
),
userEvent: tr.userEvent,
scrollIntoView: true,
},
];
},
};
}
} else if (
update.transactions.some(tr => tr.isUserEvent('select'))
) {
this.triggerContext = undefined;
} else if (
update.transactions.some(tr => isSkipSelectionChangeUserEvent(tr))
) {
this.triggerContext = undefined;
} else if (this.triggerContext && update.docChanged) {
if (!this.triggerContext.validate(update.state)) {
this.triggerContext = undefined;
} else {
const { from } = this.triggerContext;
const { to } = update.state.selection.main;
const text = update.state.sliceDoc(from, to);
this.triggerContext = {
...this.triggerContext,
from,
to,
offset: from - to,
text,
textBefore: text,
};
}
}
setTriggerContextRef.current(this.triggerContext);
}
},
),
]);
}, [injector]);
return triggerContext;
}
export { useCharacterTriggerContext };
@@ -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.
*/
import { useMemo } from 'react';
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
import { ExpressionEditorParserBuiltin } from '../../core/parser';
import {
ExpressionEditorTreeHelper,
type ExpressionEditorTreeNode,
} from '../../core';
import { type CompletionContext } from './types';
function useDrillVariableTree(
editor: ExpressionEditorAPI | undefined,
variableTree: ExpressionEditorTreeNode[],
context: CompletionContext | undefined,
): ExpressionEditorTreeNode[] {
return useMemo(() => {
if (!editor || !context) {
return [];
}
const segments = ExpressionEditorParserBuiltin.toSegments(
context.textBefore,
);
if (!segments) {
return [];
}
const prunedVariableTree = ExpressionEditorTreeHelper.pruning({
tree: variableTree,
segments,
});
return prunedVariableTree;
}, [editor, variableTree, context]);
}
export { useDrillVariableTree };
@@ -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 { useMemo } from 'react';
import { I18n } from '@coze-arch/i18n';
import { ExpressionEditorParserBuiltin } from '../../core/parser';
import {
ExpressionEditorTreeHelper,
type ExpressionEditorTreeNode,
} from '../../core';
import { type CompletionContext } from './types';
function isEmpty(value: unknown) {
return !value || !Array.isArray(value) || value.length === 0;
}
function useEmptyContent(
fullVariableTree: ExpressionEditorTreeNode[] | undefined,
variableTree: ExpressionEditorTreeNode[] | undefined,
context: CompletionContext | undefined,
) {
return useMemo(() => {
if (!context) {
return;
}
if (isEmpty(fullVariableTree)) {
if (context.textBefore === '') {
return I18n.t('workflow_variable_refer_no_input');
}
return;
}
if (isEmpty(variableTree)) {
if (context.text === '') {
return I18n.t('workflow_variable_refer_no_input');
}
const segments = ExpressionEditorParserBuiltin.toSegments(
context.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, context]);
}
export { useEmptyContent };
@@ -0,0 +1,44 @@
/*
* 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 { getSearchValue } from '../../shared';
import { type ExpressionEditorTreeNode } from '../../core';
import { type CompletionContext } from './types';
function useFilteredVariableTree(
context: CompletionContext | undefined,
drilledVariableTree: ExpressionEditorTreeNode[],
) {
return useMemo(() => {
if (!drilledVariableTree) {
return [];
}
if (!context) {
return drilledVariableTree;
}
const searchValue = getSearchValue(context.textBefore);
return drilledVariableTree.filter(variable =>
variable.label.startsWith(searchValue),
);
}, [context, drilledVariableTree]);
}
export { useFilteredVariableTree };
@@ -0,0 +1,49 @@
/*
* 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 } from '@coze-editor/editor/preset-expression';
function useFocused(editor: EditorAPI | undefined) {
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 };
@@ -0,0 +1,76 @@
/*
* 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 CompletionContext } from './types';
function getInterpolationContentAtPos(view: EditorView, pos: number) {
const tree = syntaxTree(view.state);
const cursor = tree.cursorAt(pos);
do {
if (
(cursor.node.type.name === 'JinjaExpression' &&
cursor.node.firstChild?.name === 'JinjaExpressionStart' &&
cursor.node.lastChild?.name === 'JinjaExpressionEnd' &&
pos >= cursor.node.firstChild.to &&
pos <= cursor.node.lastChild.from &&
view.state.sliceDoc(
cursor.node.lastChild.from,
cursor.node.lastChild.to,
) === '}}') ||
(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,
): CompletionContext | undefined {
return useMemo(() => {
if (!editor || typeof pos === 'undefined') {
return;
}
const view = editor.$view;
return getInterpolationContentAtPos(view, pos);
}, [editor, pos]);
}
export { useInterpolationContent };
@@ -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: KeyboardEvent) {
const callback = keymapRef.current[e.key];
if (typeof callback === 'function') {
callback(e);
}
}
document.addEventListener('keydown', handleKeydown, false);
return () => {
document.removeEventListener('keydown', handleKeydown, false);
};
}, [enable, keymapRef]);
}
export { useKeyboard };
@@ -0,0 +1,107 @@
/*
* 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 Tree } from '@coze-arch/bot-semi';
import { applyNode, getOptionInfoFromDOM, selectNodeByIndex } from '../shared';
import { useLatest } from '../../shared';
import { type ExpressionEditorTreeNode } from '../../core';
import { type CompletionContext } from './types';
// eslint-disable-next-line max-params
function useOptionsOperations(
editor: ExpressionEditorAPI | undefined,
context: CompletionContext | undefined,
treeContainerRef: React.MutableRefObject<HTMLDivElement | null>,
treeRef: React.MutableRefObject<Tree | null>,
) {
const editorRef = useLatest(editor);
const contextRef = useLatest(context);
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 (!contextRef.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 as ExpressionEditorTreeNode,
contextRef.current,
);
}
return {
prev,
next,
apply,
};
}, []);
}
export { useOptionsOperations };
@@ -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 '../../core/parser';
import {
ExpressionEditorTreeHelper,
type ExpressionEditorTreeNode,
} from '../../core';
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 };
@@ -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) {
// Ignore the selection change caused by replaceTextByRange (effect: no selection change is triggered, and the recommendation panel is not displayed)
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 };
@@ -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.
*/
import { useState, useEffect, type MutableRefObject } from 'react';
import { type Tree } from '@coze-arch/bot-semi';
import { generateUniqueId, getSearchValue, useLatest } from '../../shared';
import { type ExpressionEditorTreeNode } from '../../core';
import { type CompletionContext } from './types';
// Force the Tree component to re-render after the data update
function useTreeRefresh(filteredVariableTree: ExpressionEditorTreeNode[]) {
const [treeRefreshKey, setTreeRefreshKey] = useState('');
useEffect(() => {
setTreeRefreshKey(generateUniqueId());
}, [filteredVariableTree]);
return treeRefreshKey;
}
// Search after the Tree component is re-rendered
// eslint-disable-next-line max-params
function useTreeSearch(
treeRefreshKey: string,
treeRef: MutableRefObject<Tree | null>,
context: CompletionContext | undefined,
callback: () => void,
) {
const contextRef = useLatest(context);
useEffect(() => {
if (treeRef.current && contextRef.current) {
const searchValue = getSearchValue(contextRef.current.textBefore);
treeRef.current.search(searchValue);
callback();
}
}, [treeRefreshKey, context]);
}
export { useTreeRefresh, useTreeSearch };
@@ -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';
@@ -0,0 +1,153 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable max-nesting-depth */
/* stylelint-disable declaration-no-important */
.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;
}
}
}
.expression-editor-suggestion-keyboard-selected-new {
&:global.semi-tree-option {
background: var(--semi-color-fill-0);
}
}
@@ -0,0 +1,295 @@
/*
* 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,
useMemo,
} 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 { generateUniqueId, useDeepEqualMemo, useLatest } from '../shared';
import { type ExpressionEditorTreeNode } from '../core';
import { applyNode, getOptionInfoFromDOM, selectNodeByIndex } from './shared';
import {
useEmptyContent,
useFilteredVariableTree,
useFocused,
useInterpolationContent,
useCharacterTriggerContext,
useDrillVariableTree,
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;
customApplyNode?: (
data: TreeNodeData,
context: {
from: number;
to: number;
},
editor: ExpressionEditorAPI,
) => void;
disableUpdateTrigger?: boolean;
disabled?: boolean;
customInterpolationRule?: (content?: string, textBefore?: string) => boolean;
}
// eslint-disable-next-line @coze-arch/max-line-per-function
function Popover({
getPopupContainer,
variableTree: vTree,
className,
onVisibilityChange,
customApplyNode,
disableUpdateTrigger,
disabled = false,
customInterpolationRule,
}: 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 baseInterpolationContent = useInterpolationContent(
editor,
selection?.anchor,
);
const interpolationContent = useMemo(() => {
if (!customInterpolationRule) {
return baseInterpolationContent;
}
if (
customInterpolationRule?.(
baseInterpolationContent?.text,
baseInterpolationContent?.textBefore,
)
) {
return baseInterpolationContent;
}
return undefined;
}, [baseInterpolationContent, customInterpolationRule]);
const triggerContext = useCharacterTriggerContext(disableUpdateTrigger);
const completionContext = interpolationContent ?? triggerContext;
const drilledVariableTree = useDrillVariableTree(
editor,
variableTree,
completionContext,
);
const filteredVariableTree = useFilteredVariableTree(
completionContext,
drilledVariableTree,
);
const emptyContent = useEmptyContent(
variableTree,
drilledVariableTree,
completionContext,
);
const treeRefreshKey = useTreeRefresh(filteredVariableTree);
useTreeSearch(treeRefreshKey, treeRef, completionContext, () => {
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
if (!optionsInfo) {
return;
}
const { elements } = optionsInfo;
selectNodeByIndex(elements, 0);
});
// Selected is only used to display the blue selection effect for the corresponding item of the Tree component, and has no other purpose.
const selected = useSelectedValue(completionContext?.text, variableTree);
// Replace content in {{}} based on user selection
const handleSelect = useCallback(
// eslint-disable-next-line @typescript-eslint/naming-convention
(_: string, __: boolean, node: TreeNodeData) => {
if (!editor || !completionContext) {
return;
}
if (customApplyNode) {
customApplyNode(node, completionContext, editor);
return;
}
applyNode(editor, node as ExpressionEditorTreeNode, completionContext);
},
[editor, completionContext],
);
const internalVisible =
focused &&
((Boolean(completionContext) && filteredVariableTree.length > 0) ||
Boolean(emptyContent));
const [allowVisible, setAllowVisible] = useState(false);
// Clear the lock effect when the selection changes
useEffect(() => {
setAllowVisible(true);
}, [selection]);
const visible = internalVisible && allowVisible && !disabled;
const { prev, next, apply } = useOptionsOperations(
editor,
completionContext,
treeContainerRef,
treeRef,
);
// Press the up and down keys to switch the recommended items, and press Enter to fill in.
useKeyboard(visible, {
ArrowUp: prev,
ArrowDown: next,
Enter: apply,
});
// ESC Close
useKeyboard(visible, {
// eslint-disable-next-line @typescript-eslint/naming-convention
Escape() {
setAllowVisible(false);
},
});
// When the recommendation panel appears, disable the default behavior of ArrowUp/ArrowDown/Enter (the behavior is changed to up and down keys to switch recommendations & enter to insert)
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' }}
>
<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={drilledVariableTree}
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, TreeContainer, EmptyContent };
@@ -0,0 +1,194 @@
/*
* 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 { getFlags } from '@coze-arch/bot-flags';
import {
EditorSelection,
type EditorState,
type TransactionSpec,
type Transaction,
} from '@codemirror/state';
import { ExpressionEditorParserBuiltin } from '../core/parser';
import {
ExpressionEditorTreeHelper,
type ExpressionEditorTreeNode,
} from '../core';
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'];
const SELECTED_OPTION_CLASSNAME_NEW =
styles['expression-editor-suggestion-keyboard-selected-new'];
const getSelectedClassName = () => {
const FLAGS = getFlags();
const isHitLLMPromptSkills = FLAGS?.['bot.automation.llm_prompt_skills'];
if (isHitLLMPromptSkills) {
return SELECTED_OPTION_CLASSNAME_NEW;
}
return SELECTED_OPTION_CLASSNAME;
};
// 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;
}
// Get all option elements
const foundNodes = root.querySelectorAll(
'.semi-tree-option-list .semi-tree-option',
);
if (foundNodes.length === 0) {
return;
}
const optionElements = [...foundNodes];
// Find the currently highlighted option
const selectedIndex = optionElements.findIndex(element =>
element.classList.contains(getSelectedClassName()),
);
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(getSelectedClassName())) {
element.classList.remove(getSelectedClassName());
}
});
newSelectedElement.classList.add(getSelectedClassName());
newSelectedElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
export type ComposeTransaction = (
tr: {
changes: { from: number; to: number; insert: string };
cursorPosition: number;
userEvent: string;
},
state: EditorState,
) => TransactionSpec[];
interface ApplyNodeOptions {
from: number;
to: number;
textBefore: string;
composeTransaction?: ComposeTransaction;
}
function isLeafNode(node: ExpressionEditorTreeNode) {
return !node.children || node.children.length === 0;
}
function applyNode(
editor: ExpressionEditorAPI | undefined,
node: ExpressionEditorTreeNode,
options: ApplyNodeOptions,
) {
if (!editor) {
return;
}
const { from, to, textBefore, composeTransaction } = options;
const text = getInsertTextFromNode(node, textBefore);
if (!text) {
return;
}
const cursorOffset = isLeafNode(node) ? '}}'.length : 0;
const cursorPosition = from + text.length + cursorOffset;
const transaction = {
changes: {
from,
to,
insert: text,
},
selection: EditorSelection.cursor(cursorPosition),
userEvent: SKIP_SELECTION_CHANGE_USER_EVENT,
};
let transactions: TransactionSpec[] = [transaction];
if (typeof composeTransaction === 'function') {
transactions = composeTransaction(
{
changes: transaction.changes,
userEvent: transaction.userEvent,
cursorPosition,
},
editor.$view.state,
);
}
editor.$view.dispatch(...transactions);
}
function isSkipSelectionChangeUserEvent(tr: Transaction) {
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,
};
@@ -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 './core/parser';
import { ExpressionEditorSegmentType } from './core';
function useLatest<T>(value: T): MutableRefObject<T> {
const ref = useRef(value);
ref.current = value;
return ref;
}
// Remove circular dependencies caused by parents (otherwise no deep comparison is possible)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function cloneWithout(target: any, keys: string[]) {
// An error is thrown when target is 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] // The array index belongs to the previous level and needs to be removed to prevent it from affecting the search value
: segments[segments.length - 1];
if (
!lastSegment ||
lastSegment.type !== ExpressionEditorSegmentType.ObjectKey
) {
return '';
}
return lastSegment.objectKey;
}
export { useLatest, useDeepEqualMemo, getSearchValue, generateUniqueId };
@@ -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 { useEffect, useLayoutEffect } from 'react';
import { useEditor, useInjector } from '@coze-editor/editor/react';
import { type EditorAPI } from '@coze-editor/editor/preset-expression';
import { astDecorator } from '@coze-editor/editor';
import { EditorView } from '@codemirror/view';
import { useDeepEqualMemo, useLatest } from '../shared';
import { type ExpressionEditorTreeNode } from '../core';
import { validateExpression } from './validate';
interface Props {
variableTree: ExpressionEditorTreeNode[];
}
function Validation({ variableTree }: Props) {
const editor = useEditor<EditorAPI>();
const editorRef = useLatest(editor);
const injector = useInjector();
const variableTreeRef = useLatest(variableTree);
const changedVariableTree = useDeepEqualMemo(variableTree);
useEffect(() => {
if (!editor) {
return;
}
function handleFocus() {
editor.updateWholeDecorations();
}
editor.$on('focus', handleFocus);
return () => {
editor.$off('focus', handleFocus);
};
}, [editor]);
useEffect(() => {
if (!editorRef.current) {
return;
}
editorRef.current.updateWholeDecorations();
}, [changedVariableTree]);
useLayoutEffect(() =>
injector.inject([
[
astDecorator.whole.of((cursor, state) => {
if (!variableTreeRef.current) {
return;
}
if (
cursor.name === 'JinjaExpression' &&
// Due to the fault tolerance of the parser
// It is possible that the missing right curly brace is also parsed normally as Interpolation
// Such as: {{variable
cursor.node.firstChild?.name === 'JinjaExpressionStart' &&
cursor.node.lastChild?.name === 'JinjaExpressionEnd'
) {
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.theme({
'.cm-decoration-interpolation-valid': {
color: '#6675D9',
caretColor: '#6675D9',
},
'.cm-decoration-interpolation-invalid': {
color: '#060709CC',
},
}),
],
]),
);
return null;
}
export { Validation };
@@ -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 '../core/parser';
import {
ExpressionEditorSegmentType,
ExpressionEditorTreeHelper,
type ExpressionEditorTreeNode,
} from '../core';
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 };
@@ -0,0 +1,88 @@
/*
* 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.
*/
// modified from @coze-workflow/nodes
/* eslint-disable @typescript-eslint/naming-convention */
/**
* Front-end variable type
*/
export enum ViewVariableType {
String = 1,
Integer,
Boolean,
Number,
Object = 6,
Image,
File,
Doc,
Code,
Ppt,
Txt,
Excel,
Audio,
Zip,
Video,
Svg,
Voice,
Time,
// The above is the InputType defined in the api. The following is the integrated one. Start from 99 to avoid collisions with the backend definition.
ArrayString = 99,
ArrayInteger,
ArrayBoolean,
ArrayNumber,
ArrayObject,
ArrayImage,
ArrayFile,
ArrayDoc,
ArrayCode,
ArrayPpt,
ArrayTxt,
ArrayExcel,
ArrayAudio,
ArrayZip,
ArrayVideo,
ArraySvg,
ArrayVoice,
ArrayTime,
}
const BASE_ARRAY_PAIR: [ViewVariableType, ViewVariableType][] = [
[ViewVariableType.String, ViewVariableType.ArrayString],
[ViewVariableType.Integer, ViewVariableType.ArrayInteger],
[ViewVariableType.Boolean, ViewVariableType.ArrayBoolean],
[ViewVariableType.Number, ViewVariableType.ArrayNumber],
[ViewVariableType.Object, ViewVariableType.ArrayObject],
[ViewVariableType.Image, ViewVariableType.ArrayImage],
[ViewVariableType.File, ViewVariableType.ArrayFile],
[ViewVariableType.Doc, ViewVariableType.ArrayDoc],
[ViewVariableType.Code, ViewVariableType.ArrayCode],
[ViewVariableType.Ppt, ViewVariableType.ArrayPpt],
[ViewVariableType.Txt, ViewVariableType.ArrayTxt],
[ViewVariableType.Excel, ViewVariableType.ArrayExcel],
[ViewVariableType.Audio, ViewVariableType.ArrayAudio],
[ViewVariableType.Zip, ViewVariableType.ArrayZip],
[ViewVariableType.Video, ViewVariableType.ArrayVideo],
[ViewVariableType.Svg, ViewVariableType.ArraySvg],
[ViewVariableType.Voice, ViewVariableType.ArrayVoice],
[ViewVariableType.Time, ViewVariableType.ArrayTime],
];
const ArrayTypes = BASE_ARRAY_PAIR.map(_pair => _pair[1]);
export function isArrayType(type: ViewVariableType): boolean {
return ArrayTypes.includes(type);
}
@@ -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 { type Extension } from './types';
@@ -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.
*/
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import { I18n } from '@coze-arch/i18n';
import { EditorSelection } from '@codemirror/state';
import { TemplateParser } from '../../shared/utils/template-parser';
const templateParser = new TemplateParser({ mark: 'InputSlot' });
export const insertInputSlot = (
editor: EditorAPI,
options?: {
mode?: 'input' | 'configurable';
placeholder?: string;
},
) => {
if (!editor) {
return;
}
const {
mode = 'input',
placeholder = I18n.t('edit_block_guidance_text_placeholder'),
} = options ?? {};
const { selection } = editor.$view.state;
const selectionRange = editor.$view.state.selection.main;
const content = editor.$view.state.sliceDoc(
selectionRange.from,
selectionRange.to,
);
const extractedContent = templateParser.extractTemplateContent(content);
const { open, template, textContent } = templateParser.generateTemplateJson({
content: extractedContent,
data: {
placeholder,
mode,
},
});
const from = selectionRange.from + open.length;
const to = from + textContent.length;
editor.$view.dispatch({
changes: {
from: selectionRange.from,
to: selectionRange.to,
insert: template,
},
});
setTimeout(() => {
editor.$view.dispatch({
selection: selection.replaceRange(EditorSelection.range(from, to)),
});
}, 100);
};
@@ -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 { useEffect, useState } from 'react';
import { useEditor } from '@coze-editor/editor/react';
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import { type ViewUpdate } from '@codemirror/view';
import { TemplateParser } from '../../shared/utils/template-parser';
const templateParser = new TemplateParser({ mark: 'InputSlot' });
export const useCursorInInputSlot = () => {
const editor = useEditor<EditorAPI>();
const [inInputSlot, setInInputSlot] = useState(false);
useEffect(() => {
if (!editor) {
return;
}
const handleViewUpdate = (update: ViewUpdate) => {
if (update.selectionSet) {
const markRangeInfo = templateParser.getSelectionInMarkNodeRange(
update.state.selection.main,
update.state,
);
if (markRangeInfo) {
setInInputSlot(true);
return;
}
setInInputSlot(false);
}
};
editor.$on('viewUpdate', handleViewUpdate);
return () => {
editor.$off('viewUpdate', handleViewUpdate);
};
}, [editor]);
return inInputSlot;
};
export const useSelectionInInputSlot = () => {
const editor = useEditor<EditorAPI>();
const [inInputSlot, setInInputSlot] = useState(false);
useEffect(() => {
if (!editor) {
return;
}
const handleViewUpdate = (update: ViewUpdate) => {
if (!update.state.selection.main.empty) {
const markRangeInfo = templateParser.getSelectionInMarkNodeRange(
update.state.selection.main,
update.state,
);
if (markRangeInfo) {
setInInputSlot(true);
return;
}
setInInputSlot(false);
}
};
editor.$on('viewUpdate', handleViewUpdate);
return () => {
editor.$off('viewUpdate', handleViewUpdate);
};
}, [editor]);
return inInputSlot;
};
@@ -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 { useEffect, useState } from 'react';
import { useEditor } from '@coze-editor/editor/react';
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import { syntaxTree } from '@codemirror/language';
export const useSelectionInJinjaRaw = () => {
const editor = useEditor<EditorAPI>();
const [inJinjaRaw, setInJinjaRaw] = useState(false);
useEffect(() => {
if (!editor) {
return;
}
const checkInJinjaRaw = () => {
const selection = editor.getSelection();
if (!selection) {
setInJinjaRaw(false);
return;
}
const { state } = editor.$view;
const tree = syntaxTree(state);
const cursor = tree.cursor();
let isInRaw = false;
do {
if (cursor.name === 'RawText') {
const isSelectionWithinNode =
cursor.from <= selection.from && cursor.to >= selection.to;
if (isSelectionWithinNode) {
isInRaw = true;
break;
}
}
} while (cursor.next());
setInJinjaRaw(isInRaw);
};
editor.$on('viewUpdate', checkInJinjaRaw);
// Initial inspection
checkInJinjaRaw();
return () => {
editor.$off('viewUpdate', checkInJinjaRaw);
};
}, [editor]);
return inJinjaRaw;
};
@@ -0,0 +1,23 @@
/*
* 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 { InputSlotWidget } from './input-slot-widget';
export { insertInputSlot } from './action/insert-input-slot-action';
export {
useCursorInInputSlot,
useSelectionInInputSlot,
} from './hooks/use-in-input-slot';
export { useSelectionInJinjaRaw } from './hooks/use-in-jinja-raw';
@@ -0,0 +1,80 @@
/*
* 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 { PositionMirror } from '@coze-editor/editor/react';
import { I18n } from '@coze-arch/i18n';
import { Popover, Input, type PopoverProps } from '@coze-arch/coze-design';
interface InputConfigPopoverProps {
visible: boolean;
onVisibleChange?: (visible: boolean) => void;
positon: number;
direction?: PopoverProps['position'];
placeholder: string;
value: string;
onPlaceholderChange?: (placeholder: string) => void;
onValueChange?: (value: string) => void;
}
export const InputConfigPopover = (props: InputConfigPopoverProps) => {
const [reposKey, setReposKey] = useState('');
const { direction, placeholder, value } = props;
return (
<>
<Popover
rePosKey={reposKey}
visible={props.visible}
trigger="custom"
position={direction}
autoAdjustOverflow
content={
<div className="flex flex-col gap-2 pt-3 pb-4 px-4 w-[320px]">
<div>
<div>{I18n.t('edit_block_guidance_text_when_empty')}</div>
<Input
value={placeholder}
placeholder={I18n.t('edit_block_guidance_text_placeholder')}
onChange={v => props.onPlaceholderChange?.(v)}
onBlur={() => {
if (!placeholder) {
props.onPlaceholderChange?.(
I18n.t('edit_block_guidance_text_placeholder'),
);
}
}}
/>
</div>
<div>
<div>{I18n.t('edit_block_prefilled_text')}</div>
<Input
value={value}
placeholder={I18n.t('edit_block_default_guidance_text')}
onChange={v => props.onValueChange?.(v)}
/>
</div>
</div>
}
>
<PositionMirror
position={props.positon}
onChange={() => setReposKey(String(Math.random()))}
/>
</Popover>
</>
);
};
@@ -0,0 +1,106 @@
/*
* 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 } from 'react';
import { useEditor } from '@coze-editor/editor/react';
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import { type ViewUpdate } from '@codemirror/view';
import { InputConfigPopover } from '../input-config-popover';
import { type TemplateParser } from '../../shared/utils/template-parser';
export const ConfigModeWidgetPopover = (props: {
direction: 'bottomLeft' | 'topLeft' | 'bottomRight' | 'topRight';
templateParser: TemplateParser;
}) => {
const { direction, templateParser } = props;
const editor = useEditor<EditorAPI>();
const [placeholder, setPlaceholder] = useState('');
const [value, setValue] = useState('');
const [configPopoverVisible, setConfigPopoverVisible] = useState(false);
const [popoverPosition, setPopoverPosition] = useState(-1);
useEffect(() => {
if (!editor) {
return;
}
const handleViewUpdate = (e: ViewUpdate) => {
if (e.docChanged) {
// Determine whether the current cursor is in the slot node
const { state } = e;
const range = templateParser.getCursorInMarkNodeRange(state);
if (!range) {
return;
}
const content = state.sliceDoc(range.open.to, range.close.from);
if (content === value) {
return;
}
setValue(content);
}
if (e.selectionSet) {
const { state } = e;
const range = templateParser.getCursorInMarkNodeRange(state);
if (!range) {
setPopoverPosition(-1);
setConfigPopoverVisible(false);
return;
}
const content = templateParser.getCursorTemplateContent(editor);
const { placeholder: configPlaceholder } =
templateParser.getCursorTemplateData(state) ?? {};
setPlaceholder(configPlaceholder);
setValue(content ?? '');
setPopoverPosition(range.open.from);
setConfigPopoverVisible(true);
}
};
editor.$on('viewUpdate', handleViewUpdate);
return () => {
editor.$off('viewUpdate', handleViewUpdate);
};
}, [editor, value]);
const handlePlaceholderChange = (configPlaceholder: string) => {
if (!editor || !configPopoverVisible) {
return;
}
setPlaceholder(configPlaceholder);
templateParser.updateCursorTemplateData(editor, {
placeholder: configPlaceholder,
});
};
const handleValueChange = (configValue: string) => {
if (!editor || !configPopoverVisible) {
return;
}
setValue(configValue);
templateParser.updateCursorTemplateContent(editor, configValue);
};
return (
<InputConfigPopover
visible={configPopoverVisible}
positon={popoverPosition}
direction={direction}
placeholder={placeholder}
value={value}
onPlaceholderChange={handlePlaceholderChange}
onValueChange={handleValueChange}
/>
);
};
@@ -0,0 +1,34 @@
.cm-content {
.slot-content {
color: #4E40E5;
word-break: break-all;
background-color: rgba(186, 192, 255, 20%);
/* padding: 2px 0; */
}
.slot-side-left {
/* padding: 2px 0 2px 5px; */
padding-left: 5px;
background-color: rgba(186, 192, 255, 20%);
border-radius: 4px 0 0 4px;
}
.slot-side-right {
margin-right: 4px;
/* padding: 2px 5px 2px 0; */
padding-right: 5px;
background-color: rgba(186, 192, 255, 20%);
border-radius: 0 5px 5px 0;
}
.slot-placeholder {
color: rgba(148, 152, 247, 70%);
word-break: break-all;
background-color: rgba(186, 192, 255, 20%);
/* padding: 2px 0; */
}
}
@@ -0,0 +1,169 @@
/*
* 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 { ConfigModeWidgetPopover } from './config-mode-popover';
import './index.css';
import { useEffect, useLayoutEffect } from 'react';
import { useEditor, useInjector } from '@coze-editor/editor/react';
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import {
astDecorator,
SpanWidget,
autoSelectRanges,
selectionEnlarger,
deletionEnlarger,
} from '@coze-editor/editor';
import { type ViewUpdate } from '@codemirror/view';
import {
type MarkRangeInfo,
TemplateParser,
} from '../../shared/utils/template-parser';
import { useReadonly } from '../../shared/hooks/use-editor-readonly';
interface InputSlotWidgetProps {
mode?: 'input' | 'configurable';
onSelectionInInputSlot?: (selection: MarkRangeInfo | undefined) => void;
}
const templateParser = new TemplateParser({ mark: 'InputSlot' });
export const InputSlotWidget = (props: InputSlotWidgetProps) => {
const { mode, onSelectionInInputSlot } = props;
const injector = useInjector();
const editor = useEditor<EditorAPI>();
const readonly = useReadonly();
useLayoutEffect(() => {
const { markInfoField } = templateParser;
return injector.inject([
astDecorator.whole.of((cursor, state) => {
if (templateParser.isOpenNode(cursor.node, state)) {
const open = cursor.node;
const close = templateParser.findCloseNode(open, state);
if (close) {
const openTemplate = state.sliceDoc(open.from, open.to);
const data = templateParser.getData(openTemplate);
const from = open.to;
const to = close.from;
if (from === to) {
return [
{
type: 'replace',
widget: new SpanWidget({
className: 'slot-side-left',
}),
atomicRange: true,
from: open.from,
to: open.to,
},
{
type: 'widget',
widget: new SpanWidget({
text: data?.placeholder || '',
className: 'slot-placeholder',
}),
from,
atomicRange: true,
side: 1,
},
{
type: 'replace',
widget: new SpanWidget({
className: 'slot-side-right',
}),
atomicRange: true,
from: close.from,
to: close.to,
},
];
}
return [
{
type: 'replace',
widget: new SpanWidget({
className: 'slot-side-left',
}),
atomicRange: true,
from: open.from,
to: open.to,
},
{
type: 'className',
className: 'slot-content',
from,
to,
},
{
type: 'replace',
widget: new SpanWidget({ className: 'slot-side-right' }),
atomicRange: true,
from: close.from,
to: close.to,
},
];
}
}
}),
markInfoField,
autoSelectRanges.of(state => state.field(markInfoField).contents),
selectionEnlarger.of(state => state.field(markInfoField).specs),
deletionEnlarger.of(state => state.field(markInfoField).specs),
]);
}, [injector]);
useEffect(() => {
if (!editor) {
return;
}
const handleViewUpdate = (update: ViewUpdate) => {
if (!update.state.selection.main.empty) {
const markRangeInfo = templateParser.getSelectionInMarkNodeRange(
update.state.selection.main,
update.state,
);
if (markRangeInfo) {
onSelectionInInputSlot?.(markRangeInfo);
return;
}
onSelectionInInputSlot?.(undefined);
}
};
editor.$on('viewUpdate', handleViewUpdate);
return () => {
editor.$off('viewUpdate', handleViewUpdate);
};
}, [editor]);
if (mode === 'configurable' && !readonly) {
return (
<ConfigModeWidgetPopover
direction="bottomLeft"
templateParser={templateParser}
/>
);
}
return null;
};
@@ -0,0 +1,31 @@
/*
* 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 { useLayoutEffect } from 'react';
import { useInjector } from '@coze-editor/editor/react';
import { languageSupport } from '@coze-editor/editor/preset-prompt';
// eslint-disable-next-line @typescript-eslint/naming-convention
function LanguageSupport() {
const injector = useInjector();
useLayoutEffect(() => injector.inject([languageSupport]), [injector]);
return null;
}
export { LanguageSupport };
@@ -0,0 +1,9 @@
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="coz_chat">
<g id="Union">
<path d="M5.24283 5.83325C5.24283 5.51109 5.504 5.24992 5.82617 5.24992H9.9095C10.2317 5.24992 10.4928 5.51109 10.4928 5.83325C10.4928 6.15542 10.2317 6.41659 9.9095 6.41659H5.82617C5.504 6.41659 5.24283 6.15542 5.24283 5.83325Z" fill="#5147FF"/>
<path d="M5.82617 7.58325C5.504 7.58325 5.24283 7.84442 5.24283 8.16659C5.24283 8.48875 5.504 8.74992 5.82617 8.74992H8.1595C8.48167 8.74992 8.74283 8.48875 8.74283 8.16659C8.74283 7.84442 8.48167 7.58325 8.1595 7.58325H5.82617Z" fill="#5147FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.9928 6.99992C13.9928 10.5437 11.12 13.4166 7.57617 13.4166H3.27002C2.83638 13.4166 2.55434 12.9602 2.74827 12.5724L3.19186 11.6852C1.94111 10.5143 1.1595 8.84839 1.1595 6.99992C1.1595 3.45609 4.03234 0.583252 7.57617 0.583252C11.12 0.583252 13.9928 3.45609 13.9928 6.99992ZM4.62464 11.4284L4.21387 12.2499H7.57617C10.4757 12.2499 12.8262 9.89941 12.8262 6.99992C12.8262 4.10042 10.4757 1.74992 7.57617 1.74992C4.67667 1.74992 2.32617 4.10042 2.32617 6.99992C2.32617 8.51237 2.96421 9.87396 3.98918 10.8335L4.62464 11.4284Z" fill="#5147FF"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,9 @@
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Component 2">
<g id="Union">
<path d="M11.7377 5.08875V10.3246C11.7377 10.4212 11.6594 10.4996 11.5627 10.4996H8.39925L8.39859 10.4996H3.41023C3.25433 10.4996 3.17625 10.3111 3.28649 10.2008L5.78067 7.70667C5.84901 7.63833 5.95982 7.63833 6.02816 7.70667L7.36272 9.04124L11.439 4.965C11.5492 4.85476 11.7377 4.93284 11.7377 5.08875Z" fill="#4E40E5"/>
<path d="M4.73779 5.83293C5.38213 5.83293 5.90446 5.31059 5.90446 4.66626C5.90446 4.02193 5.38213 3.49959 4.73779 3.49959C4.09346 3.49959 3.57113 4.02193 3.57113 4.66626C3.57113 5.31059 4.09346 5.83293 4.73779 5.83293Z" fill="#4E40E5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.23779 2.33293C1.23779 1.69126 1.76279 1.16626 2.40446 1.16626H12.9045C13.5461 1.16626 14.0711 1.69126 14.0711 2.33293V11.6663C14.0711 12.3079 13.5461 12.8329 12.9045 12.8329H2.40446C1.76279 12.8329 1.23779 12.3079 1.23779 11.6663V2.33293ZM12.9045 2.33293H2.40446V11.6663H12.9045V2.33293Z" fill="#4E40E5"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,12 @@
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Component 2">
<g id="Union">
<path d="M1.47082 1.39238C1.72422 1.27387 1.92817 1.06992 2.04668 0.816521C2.05886 0.790463 2.07015 0.763881 2.08049 0.736816C2.08663 0.720755 2.09243 0.704523 2.0979 0.688128L2.25921 0.204192C2.34996 -0.0680642 2.73506 -0.0680639 2.82581 0.204192L2.98712 0.688129C2.99259 0.704523 2.99839 0.720755 3.00453 0.736816C3.01487 0.763881 3.02615 0.790463 3.03834 0.816521C3.15685 1.06992 3.3608 1.27387 3.6142 1.39238C3.64026 1.40457 3.66684 1.41585 3.6939 1.42619C3.70997 1.43233 3.7262 1.43813 3.74259 1.4436L4.22653 1.60491C4.49878 1.69566 4.49878 2.08076 4.22653 2.17151L3.74259 2.33283C3.7262 2.33829 3.70997 2.3441 3.6939 2.35023C3.66684 2.36057 3.64026 2.37186 3.6142 2.38404C3.40047 2.484 3.22192 2.64474 3.10015 2.84466C3.07754 2.88177 3.05689 2.92023 3.03834 2.9599C3.02615 2.98596 3.01487 3.01254 3.00453 3.03961C2.99839 3.05567 2.99259 3.0719 2.98712 3.08829L2.82581 3.57223C2.78611 3.69134 2.69006 3.75834 2.58698 3.77323C2.45444 3.79238 2.31026 3.72538 2.25921 3.57223L2.0979 3.08829C2.09243 3.0719 2.08662 3.05567 2.08049 3.03961C2.07015 3.01254 2.05886 2.98596 2.04668 2.9599C1.92816 2.7065 1.72422 2.50256 1.47082 2.38404C1.44476 2.37186 1.41818 2.36057 1.39111 2.35023C1.37505 2.3441 1.35882 2.33829 1.34243 2.33282L0.858488 2.17151C0.586233 2.08076 0.586233 1.69566 0.858489 1.60491L1.34243 1.4436C1.35882 1.43813 1.37505 1.43233 1.39111 1.42619C1.41818 1.41585 1.44476 1.40457 1.47082 1.39238Z" fill="#4E40E5"/>
<path d="M11.6944 11.616C11.9478 11.4974 12.1517 11.2935 12.2702 11.0401C12.2824 11.014 12.2937 10.9875 12.3041 10.9604C12.3102 10.9443 12.316 10.9281 12.3215 10.9117L12.4828 10.4278C12.5735 10.1555 12.9586 10.1555 13.0494 10.4278L13.2107 10.9117C13.2162 10.9281 13.222 10.9443 13.2281 10.9604C13.2384 10.9875 13.2497 11.014 13.2619 11.0401C13.3804 11.2935 13.5844 11.4974 13.8378 11.616C13.8638 11.6281 13.8904 11.6394 13.9175 11.6498C13.9335 11.6559 13.9498 11.6617 13.9662 11.6672L14.4501 11.8285C14.7224 11.9192 14.7224 12.3043 14.4501 12.3951L13.9662 12.5564C13.9498 12.5619 13.9335 12.5677 13.9175 12.5738C13.8904 12.5841 13.8638 12.5954 13.8378 12.6076C13.624 12.7076 13.4455 12.8683 13.3237 13.0682C13.3011 13.1053 13.2805 13.1438 13.2619 13.1835C13.2497 13.2095 13.2384 13.2361 13.2281 13.2632C13.222 13.2792 13.2162 13.2955 13.2107 13.3119L13.0494 13.7958C13.0097 13.9149 12.9136 13.9819 12.8105 13.9968C12.678 14.0159 12.5338 13.9489 12.4828 13.7958L12.3215 13.3119C12.316 13.2955 12.3102 13.2792 12.3041 13.2632C12.2937 13.2361 12.2824 13.2095 12.2702 13.1835C12.1517 12.9301 11.9478 12.7261 11.6944 12.6076C11.6683 12.5954 11.6417 12.5841 11.6147 12.5738C11.5986 12.5677 11.5824 12.5619 11.566 12.5564L11.0821 12.3951C10.8098 12.3043 10.8098 11.9192 11.0821 11.8285L11.566 11.6672C11.5824 11.6617 11.5986 11.6559 11.6147 11.6498C11.6417 11.6394 11.6683 11.6281 11.6944 11.616Z" fill="#4E40E5"/>
<path d="M2.54396 4.39184C2.36112 4.39184 2.18523 4.33824 2.03515 4.23662C1.95199 4.18032 1.88005 4.1116 1.82096 4.03265V11.6667C1.82096 12.3083 2.34596 12.8333 2.98763 12.8333H10.6203C10.5422 12.7745 10.4743 12.7031 10.4186 12.6208C10.3171 12.4708 10.2633 12.2949 10.2633 12.1119C10.2633 11.9542 10.3032 11.8018 10.3792 11.6667H2.98763V4.27615C2.98011 4.28037 2.97254 4.28448 2.96492 4.28847C2.83574 4.3562 2.69174 4.39168 2.54306 4.39168L2.54396 4.39184Z" fill="#4E40E5"/>
<path d="M13.4876 2.33333V9.96567C13.347 9.77914 13.1375 9.65251 12.8977 9.61784C12.8545 9.61165 12.8104 9.60848 12.7667 9.60848C12.6088 9.60848 12.4562 9.64838 12.321 9.7245V2.33333H4.93076C5.00684 2.19813 5.04667 2.04568 5.04667 1.88807C5.04667 1.70508 4.99307 1.52919 4.89161 1.37926C4.83597 1.29697 4.7681 1.22563 4.69018 1.16688L12.321 1.16667C12.9626 1.16667 13.4876 1.69167 13.4876 2.33333Z" fill="#4E40E5"/>
<path d="M6.56987 10.4932L4.07594 10.4872C3.97929 10.487 3.90113 10.4084 3.90136 10.3118C3.90147 10.2655 3.9199 10.2212 3.95262 10.1884L6.36342 7.77765C6.45454 7.68652 6.60228 7.68652 6.6934 7.77765L7.80586 8.89011L10.756 5.94C10.8471 5.84888 10.9948 5.84888 11.086 5.94C11.1297 5.98376 11.1543 6.04311 11.1543 6.10499V10.325C11.1543 10.4217 11.0759 10.5 10.9793 10.5H6.61845C6.6016 10.5 6.5853 10.4976 6.56987 10.4932Z" fill="#4E40E5"/>
<path d="M4.1543 3.5H5.9043V5.25H4.1543V3.5Z" fill="#4E40E5"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,23 @@
/*
* 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 { default as pluginIcon } from './plugin.svg';
export { default as workflowIcon } from './workflow.svg';
export { default as imageflowIcon } from './imageflow.svg';
export { default as tableIcon } from './table.svg';
export { default as textIcon } from './text.svg';
export { default as imageIcon } from './image.svg';
export { default as chatflowIcon } from './chatflow.svg';
@@ -0,0 +1,5 @@
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Component 2">
<path id="Union" fill-rule="evenodd" clip-rule="evenodd" d="M6.90598 0.696144C7.36911 0.428759 7.9397 0.428759 8.40282 0.696144L12.7393 3.19983C13.2025 3.46722 13.4878 3.96136 13.4878 4.49613V9.50351C13.4878 10.0383 13.2025 10.5324 12.7393 10.7998L8.40282 13.3035C7.9397 13.5709 7.3691 13.5709 6.90598 13.3035L2.56946 10.7998C2.10634 10.5324 1.82104 10.0383 1.82104 9.50351V4.49613C1.82104 3.96136 2.10634 3.46722 2.56946 3.19983L6.90598 0.696144ZM11.9063 4.06604L7.81949 1.70651C7.71733 1.64753 7.59147 1.64753 7.48931 1.70651L3.40043 4.06722L7.65235 6.52207L11.9063 4.06604ZM2.98771 5.17609V9.50351C2.98771 9.62147 3.05064 9.73047 3.1528 9.78945L7.06901 12.0505V7.53243L2.98771 5.17609ZM8.23568 12.0528L12.156 9.78945C12.2582 9.73047 12.3211 9.62147 12.3211 9.50351V5.17372L8.23568 7.53243V12.0528Z" fill="#4E40E5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 948 B

@@ -0,0 +1,5 @@
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Component 2">
<path id="Union" d="M1.82104 2.33342C1.82104 1.68908 2.34338 1.16675 2.98771 1.16675H12.321C12.9654 1.16675 13.4877 1.68908 13.4877 2.33341V11.6667C13.4877 12.3111 12.9654 12.8334 12.321 12.8334H2.98771C2.34338 12.8334 1.82104 12.3111 1.82104 11.6667V2.33342ZM6.19605 2.33341V4.66675L12.321 4.66675V2.33341H6.19605ZM6.19605 5.83341V8.16675L12.321 8.16675V5.83342L6.19605 5.83341ZM5.02938 8.16675V5.83341H2.98771V8.16675H5.02938ZM2.98771 9.33342V11.6667H5.02938V9.33342H2.98771ZM6.19605 9.33342V11.6667H12.321V9.33342H6.19605ZM5.02938 2.33341H2.98771V4.66675L5.02938 4.66675V2.33341Z" fill="#4E40E5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 730 B

@@ -0,0 +1,10 @@
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Component 2">
<g id="Union">
<path d="M4.73763 4.9585C4.73763 4.63633 4.9988 4.37516 5.32096 4.37516H7.6543C7.97646 4.37516 8.23763 4.63633 8.23763 4.9585C8.23763 5.28066 7.97646 5.54183 7.6543 5.54183H5.32096C4.9988 5.54183 4.73763 5.28066 4.73763 4.9585Z" fill="#4E40E5"/>
<path d="M4.73763 7.29183C4.73763 6.96966 4.9988 6.7085 5.32096 6.7085H9.98763C10.3098 6.7085 10.571 6.96966 10.571 7.29183C10.571 7.614 10.3098 7.87516 9.98763 7.87516H5.32096C4.9988 7.87516 4.73763 7.614 4.73763 7.29183Z" fill="#4E40E5"/>
<path d="M4.73763 9.62516C4.73763 9.303 4.9988 9.04183 5.32096 9.04183H9.98763C10.3098 9.04183 10.571 9.303 10.571 9.62516C10.571 9.94733 10.3098 10.2085 9.98763 10.2085H5.32096C4.9988 10.2085 4.73763 9.94733 4.73763 9.62516Z" fill="#4E40E5"/>
<path d="M2.4043 1.75016C2.4043 1.10583 2.92663 0.583496 3.57096 0.583496H11.7376C12.382 0.583496 12.9043 1.10583 12.9043 1.75016V12.2502C12.9043 12.8945 12.382 13.4168 11.7376 13.4168H3.57096C2.92663 13.4168 2.4043 12.8945 2.4043 12.2502V1.75016ZM3.57096 1.75016V12.2502H11.7376V1.75016H3.57096Z" fill="#4E40E5"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,5 @@
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Component 2">
<path id="Union" fill-rule="evenodd" clip-rule="evenodd" d="M9.06505 5.38087C9.27846 5.65615 9.61235 5.83333 9.98763 5.83333H12.6126C13.257 5.83333 13.7793 5.311 13.7793 4.66667V2.04167C13.7793 1.39733 13.257 0.875 12.6126 0.875H9.98763C9.3433 0.875 8.82096 1.39733 8.82096 2.04167V4.19269L8.80691 4.16985L6.48763 5.5971V5.25C6.48763 4.60567 5.9653 4.08333 5.32096 4.08333L2.69596 4.08333C2.05163 4.08333 1.5293 4.60567 1.5293 5.25V8.75C1.5293 9.39433 2.05163 9.91667 2.69596 9.91667H5.32096C5.9653 9.91667 6.48763 9.39433 6.48763 8.75V8.36035L8.82096 9.72859V11.9583C8.82096 12.6027 9.3433 13.125 9.98763 13.125H12.6126C13.257 13.125 13.7793 12.6027 13.7793 11.9583V9.33333C13.7793 8.689 13.257 8.16667 12.6126 8.16667H9.98763C9.64375 8.16667 9.33463 8.31544 9.12111 8.55214L6.48763 7.00789V6.96697L9.06505 5.38087ZM9.98763 2.04167V4.66667H12.6126V2.04167H9.98763ZM9.98763 9.33333V11.9583H12.6126V9.33333H9.98763ZM2.69596 8.75L2.69596 5.25L5.32096 5.25L5.32096 8.75H2.69596Z" fill="#4E40E5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,30 @@
/*
* 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 { useLayoutEffect } from 'react';
import { useInjector } from '@coze-editor/editor/react';
import { keymap, type EditorView } from '@codemirror/view';
export const useKeymap = (
keyMap: string[],
run: (view: EditorView) => boolean,
) => {
const injector = useInjector();
useLayoutEffect(
() => injector.inject([keymap.of(keyMap.map(key => ({ key, run })))]),
[injector, keyMap, run],
);
};
@@ -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 { LibraryBlockWidget } from './library-block-widget';
export { LibrarySearchPopover } from './library-search-popover';
export {
type ILibraryList,
type ILibraryItems,
type ILibraryItem,
type LibraryBlockInfo,
type LibraryType,
getReferenceType,
} from './types';
@@ -0,0 +1,31 @@
.cm-content {
.library-block-container {
cursor: default;
display: inline-block;
margin-right: 4px;
padding: 0 5px;
color: #4E40E5;
background-color: rgba(186, 192, 255, 20%);
border-radius: 4px;
}
.library-block-icon {
margin-right: 2px;
vertical-align: sub;
}
.library-block-content {
word-break: break-all;
}
}
.library-block-tooltip {
.semi-tooltip-content {
width: 100%;
}
}
@@ -0,0 +1,146 @@
/*
* 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, useLayoutEffect, useRef } from 'react';
import { useInjector, useEditor } from '@coze-editor/editor/react';
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import { astDecorator } from '@coze-editor/editor';
import { getLibraryStatus } from '../utils/get-library-status';
import {
getLibraryBlockInfoFromTemplate,
getLibraryInfoByBlockInfo,
} from '../utils/get-library-info';
import type { ILibraryItem, ILibraryList } from '../types';
import { TemplateParser } from '../../shared/utils/template-parser';
import { LibraryBlockWidgetType } from './library-block-widget-type';
import './index.css';
const templateParser = new TemplateParser({ mark: 'LibraryBlock' });
function useLatest<T>(value: T) {
const ref = useRef<T>(value);
ref.current = value;
return ref;
}
interface LibraryBlockWidgetProps {
librarys: ILibraryList;
readonly?: boolean;
spaceId?: string;
className?: string;
onAddLibrary?: (
library: ILibraryItem,
pos?: { from: number; to: number },
) => void;
projectId?: string;
avatarBotId?: string;
onRename?: (pos: { from: number; to: number }) => void;
disabledTooltips?: boolean;
}
export const LibraryBlockWidget = (props: LibraryBlockWidgetProps) => {
const ref = useLatest(props);
const editor = useEditor<EditorAPI>();
const editorRef = useLatest(editor);
const injector = useInjector();
useLayoutEffect(
() =>
injector.inject([
astDecorator.whole.of((cursor, state) => {
const {
librarys,
readonly = false,
spaceId,
className,
projectId,
avatarBotId,
onRename,
disabledTooltips,
} = ref.current;
if (templateParser.isOpenNode(cursor.node, state)) {
const open = cursor.node;
const close = templateParser.findCloseNode(open, state);
if (close) {
const openTemplate = state.sliceDoc(open.from, open.to);
const contentFrom = open.to;
const contentTo = close.from;
const content = state.sliceDoc(contentFrom, contentTo);
const dataInfo = getLibraryBlockInfoFromTemplate({
template: openTemplate,
templateParser,
});
const { libraryStatus } = getLibraryStatus({
librarys,
libraryBlockInfo: dataInfo,
content,
});
const libraryInfo = dataInfo
? getLibraryInfoByBlockInfo(librarys, dataInfo)
: null;
return [
{
type: 'replace',
widget: new LibraryBlockWidgetType({
editorRef,
blockDataInfo: dataInfo,
libraryItem: libraryInfo,
readonly,
content,
spaceId,
className,
hightlight: libraryStatus === 'existing',
libraryStatus,
onAddLibrary(library) {
if (typeof ref.current.onAddLibrary === 'function') {
ref.current.onAddLibrary(library, {
from: open.from,
to: close.to,
});
}
},
range: {
left: open.to,
right: close.from,
},
projectId,
avatarBotId,
onRename,
disabledTooltips,
}),
atomicRange: true,
from: open.from,
to: close.to,
},
];
}
}
}),
templateParser.markInfoField,
]),
[injector],
);
useEffect(() => {
if (!editor) {
return;
}
editor?.updateWholeDecorations();
}, [editor, props.librarys]);
return null;
};
@@ -0,0 +1,289 @@
/*
* 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 RefObject, useLayoutEffect, useState } from 'react';
import { type Root, createRoot } from 'react-dom/client';
import cls from 'classnames';
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import { Tooltip } from '@coze-arch/coze-design';
import { WidgetType } from '@codemirror/view';
import { getLibraryTooltip } from '../utils/get-library-tooltip';
import { type LibraryStatus } from '../utils/get-library-status';
import { type LibraryBlockInfo, type ILibraryItem } from '../types';
interface LibraryBlockWidgetOptions {
editorRef: RefObject<EditorAPI>;
blockDataInfo: LibraryBlockInfo | null;
libraryItem: ILibraryItem | null;
content: string;
hightlight: boolean;
libraryStatus: LibraryStatus;
readonly: boolean;
spaceId?: string;
className?: string;
onAddLibrary?: (library: ILibraryItem) => void;
range: {
left: number;
right: number;
};
projectId?: string;
avatarBotId?: string;
onRename?: (pos: { from: number; to: number }) => void;
disabledTooltips?: boolean;
}
function createElement(
name: string,
attributes: Record<string, string>,
children: (HTMLElement | string)[] = [],
) {
const el = document.createElement(name);
for (const [key, value] of Object.entries(attributes)) {
el.setAttribute(key, value);
}
for (const child of children) {
if (typeof child === 'string') {
const text = document.createTextNode(child);
el.appendChild(text);
} else {
el.appendChild(child);
}
}
return el;
}
export class LibraryBlockWidgetType extends WidgetType {
private options: LibraryBlockWidgetOptions | null;
private container: HTMLSpanElement;
private root: Root | null;
private dom: HTMLSpanElement | undefined;
private mounted: boolean;
constructor(options: LibraryBlockWidgetOptions | null) {
super();
this.options = options;
this.container = document.createElement('span');
this.root = null;
this.mounted = false;
}
toDOM() {
if (!this.options) {
return this.container;
}
if (this.root) {
this.destroy();
}
if (!this.mounted) {
// Synchronized rendering to avoid jitter
this.renderLibraryBlock(this.options);
this.renderTooltip(this.options);
this.mounted = true;
}
return this.container;
}
renderLibraryBlock(options: LibraryBlockWidgetOptions) {
const dom = createElement(
'span',
{
class: cls('library-block-container leading-5', options.className, {
'!coz-mg-hglt !text-[#9498F7] !text-opacity-70': !options.hightlight,
}),
},
[
createElement('img', {
src: options.blockDataInfo?.icon || '',
class: cls('library-block-icon', {
'!opacity-70': !options.hightlight,
}),
}),
createElement(
'span',
{
class: 'library-block-content',
},
[options.content],
),
],
);
this.dom = dom;
this.container.appendChild(dom);
}
renderTooltip(options: LibraryBlockWidgetOptions) {
const tooltipTriggerDOM = document.createElement('span');
this.root = createRoot(tooltipTriggerDOM);
this.container.appendChild(tooltipTriggerDOM);
this.root.render(
<LibraryBlockWidgetReactCom
editorRef={options.editorRef}
blockDataInfo={options?.blockDataInfo}
libraryItem={options.libraryItem}
content={options.content}
hightlight={options.hightlight}
readonly={options.readonly}
libraryStatus={options.libraryStatus}
onAddLibrary={options.onAddLibrary}
onRename={options.onRename}
spaceId={options.spaceId ?? ''}
range={options.range}
beforeMount={() => {
const element = this.dom;
if (element?.parentNode) {
element.parentNode.removeChild(element);
}
}}
projectId={options.projectId}
avatarBotId={options.avatarBotId}
disabledTooltips={options.disabledTooltips}
/>,
);
}
destroy() {
this.mounted = false;
if (this.root) {
/**
* Fix React warning: Attempted to synchronously unmount a root while React was already rendering
* https://stackoverflow.com/questions/73043828/how-to-unmount-something-created-with-createroot-properly
*/
setTimeout(() => {
this.root?.unmount();
}, 0);
this.root = null;
}
if (this.dom) {
this.dom.remove();
this.dom = undefined;
}
this.options = null;
}
}
export const LibraryBlockWidgetReactCom = (props: {
editorRef: RefObject<EditorAPI>;
blockDataInfo: LibraryBlockInfo | null;
libraryItem: ILibraryItem | null;
content: string;
hightlight?: boolean;
readonly: boolean;
libraryStatus: LibraryStatus;
onAddLibrary?: (library: ILibraryItem) => void;
spaceId: string;
range: {
left: number;
right: number;
};
beforeMount: () => void;
className?: string;
projectId?: string;
avatarBotId?: string;
onRename?: (pos: { from: number; to: number }) => void;
disabledTooltips?: boolean;
}) => {
const {
blockDataInfo,
libraryItem,
content,
hightlight = true,
libraryStatus,
onAddLibrary,
readonly,
spaceId,
range,
editorRef,
beforeMount,
className,
projectId,
avatarBotId,
onRename,
disabledTooltips,
} = props;
const [tooltipConfig, setTooltipConfig] = useState<
React.ComponentProps<typeof Tooltip> | undefined
>(undefined);
const loadTooltipConfig = async () => {
if (!editorRef.current) {
return;
}
const res = await getLibraryTooltip({
editorRef,
libraryStatus,
readonly,
libraryItem,
blockDataInfo,
onAddLibrary,
spaceId,
range,
projectId,
avatarBotId,
onRename,
disabled: disabledTooltips,
});
setTooltipConfig(res.tooltipConfig);
};
useLayoutEffect(() => {
beforeMount();
}, []);
const baseElement = (
<span
className={cls('library-block-container leading-5', className, {
'!coz-mg-hglt !text-[#9498F7] !text-opacity-70': !hightlight,
})}
onMouseEnter={loadTooltipConfig}
>
<img
src={blockDataInfo?.icon || ''}
className={cls('library-block-icon', {
'!opacity-70': !hightlight,
})}
/>
<span className="library-block-content">{content}</span>
</span>
);
// Render Tooltip only if tooltipConfig exists
if (!tooltipConfig) {
return baseElement;
}
return (
<Tooltip
position="bottomLeft"
spacing={{ y: 4, x: 0 }}
showArrow={false}
{...tooltipConfig}
>
{baseElement}
</Tooltip>
);
};
@@ -0,0 +1,33 @@
/*
* 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 { I18n } from '@coze-arch/i18n';
import { Button } from '@coze-arch/coze-design';
import { type ILibraryItem } from '../../types';
interface AddLibraryActionProps {
library: ILibraryItem;
onClick: (library: ILibraryItem) => void;
}
export const AddLibraryAction = ({
library,
onClick,
}: AddLibraryActionProps) => (
<Button onClick={() => onClick(library)} color="primary" size="small">
{I18n.t('add')}
</Button>
);
@@ -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 { type RefObject } from 'react';
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import { I18n } from '@coze-arch/i18n';
import { Button } from '@coze-arch/coze-design';
import { type ILibraryItem } from '../../types';
interface RenameLibraryActionProps {
editorRef: RefObject<EditorAPI>;
library: ILibraryItem;
range: {
left: number;
right: number;
};
onRename?: (pos: { from: number; to: number }) => void;
}
export const RenameLibraryAction = ({
editorRef,
library,
range,
onRename,
}: RenameLibraryActionProps) => {
const handleRename = () => {
if (!editorRef.current) {
return;
}
editorRef.current?.$view.dispatch({
changes: {
from: range.left,
to: range.right,
insert: library.name,
},
});
onRename?.({ from: range.left, to: range.right });
};
return (
<Button onClick={handleRename} color="primary" size="small">
{I18n.t('edit_block_api_rename')}
</Button>
);
};
@@ -0,0 +1,140 @@
/*
* 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 } from 'react';
import { nanoid } from 'nanoid';
import {
PositionMirror,
useChangeListener,
useEditor,
} from '@coze-editor/editor/react';
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import { I18n } from '@coze-arch/i18n';
import { Popover } from '@coze-arch/coze-design';
import { type ViewUpdate } from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import type { ILibraryList } from '../types';
import { TemplateParser } from '../../shared/utils/template-parser';
import { LibraryList } from './library-list';
interface LibrarySearchPopoverProps {
librarys: ILibraryList;
direction?: React.ComponentProps<typeof Popover>['position'];
}
const templateParser = new TemplateParser({ mark: 'LibraryBlock' });
export const LibrarySearchPopover = ({
librarys,
direction = 'bottomLeft',
}: LibrarySearchPopoverProps) => {
const [reposKey, setReposKey] = useState('');
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState(-1);
const [toPosition, setToPosition] = useState(-1);
const emptyLibrary = librarys.map(item => item.items).flat().length === 0;
const editor = useEditor<EditorAPI>();
useChangeListener(e => {
const [_fromA, _toA, fromB, toB, inserted] = e.change;
if (['{}', '{'].includes(inserted.toString())) {
const node = syntaxTree(e.view.state).resolve(
inserted.toString() === '{}' ? toB - 1 : toB,
);
if (node.name === 'JinjaText') {
setPosition(fromB);
setToPosition(toB);
setVisible(true);
} else {
// Some scene input {should not pop up the prompt panel
// For example, the cursor is within {{ }} , or within {% %} , or within {# #}
setVisible(false);
}
} else {
setVisible(false);
}
});
useEffect(() => {
if (!editor) {
return;
}
function handleViewUpdate(update: ViewUpdate) {
if (update.transactions.some(tr => tr.isUserEvent('select'))) {
setVisible(false);
}
}
editor.$on('viewUpdate', handleViewUpdate);
return () => {
editor.$off('viewUpdate', handleViewUpdate);
};
}, [editor]);
return (
<>
<Popover
rePosKey={reposKey}
visible={visible}
trigger="custom"
position={emptyLibrary ? 'bottomLeft' : direction}
onClickOutSide={() => setVisible(false)}
autoAdjustOverflow
content={
!emptyLibrary ? (
<div className="flex flex-col p-1 w-[352px] max-h-[330px] overflow-y-auto">
<LibraryList
librarys={librarys}
onInsert={insertLibrary => {
const { name = '', id = '', type } = insertLibrary;
const uuid = nanoid();
const template = templateParser.generateTemplate({
content: name,
data: {
id,
uuid,
type,
...(type === 'plugin' && {
apiId: insertLibrary.api_id,
}),
},
});
// Remove the inserted shortcut, because the original was automatically added
templateParser.insertTemplateByRange(editor, template, {
from: position,
to: toPosition,
});
setVisible(false);
}}
/>
</div>
) : (
<div className="coz-fg-primary text-sm font-medium px-3 py-2">
{I18n.t('edit_block_api_empty')}
</div>
)
}
>
<PositionMirror
position={position}
onChange={() => setReposKey(String(Math.random()))}
/>
</Popover>
</>
);
};
@@ -0,0 +1,97 @@
/*
* 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, type ReactNode } from 'react';
import classNames from 'classnames';
import { Typography, Highlight } from '@coze-arch/coze-design';
interface LibraryItemProps {
title: string;
description?: string;
avatar: string;
icons?: ReactNode;
actions?: ReactNode;
className?: string;
searchWords?: string[];
}
export const LibraryItem: FC<LibraryItemProps> = ({
title,
description,
avatar,
icons,
actions,
className,
searchWords,
}) => (
<div
className={classNames(
'w-full flex flex-row items-center coz-bg-max rounded-[8px]',
' gap-3',
className,
)}
>
<div
className={classNames(
'flex flex-row flex-1 min-w-[0px] justify-center items-center ',
)}
>
{avatar ? (
<img
src={avatar}
className={classNames(
'w-[24px] h-[24px] rounded-[5px]',
'overflow-hidden',
)}
/>
) : null}
<div
className={classNames(
'ml-[8px]',
'flex flex-col flex-1 min-w-[0px] w-0',
)}
>
<div className="flex flex-row items-center overflow-hidden">
<Typography.Paragraph
ellipsis={{
showTooltip: true,
}}
className={classNames(
'text-[14px] leading-[20px]',
'coz-fg-primary truncate flex-1 font-medium',
)}
>
<Highlight
sourceString={title}
searchWords={searchWords}
highlightStyle={{
color: 'var(--coz-fg-hglt-yellow, #FF7300)',
backgroundColor: 'transparent',
}}
/>
</Typography.Paragraph>
<div className="justify-self-end grid grid-flow-col gap-x-[2px]">
{icons}
</div>
</div>
</div>
</div>
<div className={classNames('grid grid-flow-col gap-x-[2px]')}>
{actions}
</div>
</div>
);
@@ -0,0 +1,84 @@
/*
* 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 classnames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Typography } from '@coze-arch/coze-design';
import {
type ILibraryList,
type ILibraryItem,
type LibraryType,
} from '../types';
import { LibraryItem } from './library-item';
import { AddLibraryAction } from './actions/add-library-action';
interface LibraryListProps {
librarys: ILibraryList;
onInsert?: (library: ILibraryItem) => void;
libraryItemClassName?: string;
searchWords?: string[];
}
const LibraryTypeTextMap: Record<LibraryType, string> = {
plugin: I18n.t('edit_block_api_plugin'),
workflow: I18n.t('edit_block_api_workflow'),
imageflow: I18n.t('edit_block_api_imageflow'),
text: I18n.t('edit_block_api_knowledge_text'),
image: I18n.t('edit_block_api_knowledge_image'),
table: I18n.t('edit_block_api_knowledge_table'),
};
export const LibraryList = ({
librarys,
onInsert,
libraryItemClassName,
searchWords,
}: LibraryListProps) => (
<div className="flex flex-col gap-2">
{Object.values(librarys).map(library => {
const { items, type } = library;
if (items.length === 0) {
return null;
}
return (
<div key={type} className="flex flex-col">
<Typography.Text className="coz-fg-tertiary text-xs mb-1 px-2 pt-2 pb-1">
{LibraryTypeTextMap[type]}
</Typography.Text>
{items.map(item => {
const { name, desc, icon_url } = item;
return (
<LibraryItem
searchWords={searchWords}
key={name}
title={name || ''}
description={desc || ''}
avatar={icon_url || ''}
className={classnames('p-[8px]', libraryItemClassName)}
actions={
<AddLibraryAction
library={{ ...item, type }}
onClick={libraryItem => {
onInsert?.(libraryItem);
}}
/>
}
/>
);
})}
</div>
);
})}
</div>
);
@@ -0,0 +1,70 @@
/*
* 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 {
PromptReferenceType,
type PromptReferenceInfo,
} from '@coze-arch/idl/playground_api';
export type LibraryType =
| 'plugin'
| 'workflow'
| 'imageflow'
| 'table'
| 'text'
| 'image';
export interface ILibraryItems {
type: LibraryType;
items: ILibraryItem[];
}
export type ILibraryList = ILibraryItems[];
export type ILibraryItem = PromptReferenceInfo & {
type: LibraryType;
id: string;
icon_url: string;
name: string;
desc: string;
};
export const getReferenceType = (type: LibraryType): PromptReferenceType => {
switch (type) {
case 'plugin':
return PromptReferenceType.Plugin;
case 'workflow':
return PromptReferenceType.Workflow;
case 'imageflow':
return PromptReferenceType.ImageFlow;
case 'text':
return PromptReferenceType.Knowledge;
case 'image':
return PromptReferenceType.Knowledge;
case 'table':
return PromptReferenceType.Knowledge;
default:
return PromptReferenceType.Plugin;
}
};
export interface LibraryBlockInfo {
[key: string]: string | undefined;
icon: string;
type: LibraryType;
id: string;
uuid: string;
apiId?: string;
}
@@ -0,0 +1,93 @@
/*
* 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 { merge } from 'lodash-es';
import {
type LibraryType,
type ILibraryList,
type ILibraryItem,
type LibraryBlockInfo,
} from '../types';
import {
pluginIcon,
workflowIcon,
imageflowIcon,
tableIcon,
textIcon,
imageIcon,
} from '../assets';
import { type TemplateParser } from '../../shared/utils/template-parser';
import { checkLibraryId } from './library-validate';
const defaultLibraryBlockInfo: Record<
LibraryType,
{
icon: string;
}
> = {
plugin: {
icon: pluginIcon,
},
workflow: {
icon: workflowIcon,
},
imageflow: {
icon: imageflowIcon,
},
table: {
icon: tableIcon,
},
text: {
icon: textIcon,
},
image: {
icon: imageIcon,
},
};
// Get the corresponding information according to the resource type
export const getLibraryBlockInfoFromTemplate = (props: {
template: string;
templateParser: TemplateParser;
}): LibraryBlockInfo | null => {
const { template, templateParser } = props;
const data = templateParser.getData(template);
if (!data) {
return null;
}
const { type, ...rest } = data as LibraryBlockInfo;
const libraryBlockInfo = merge({}, defaultLibraryBlockInfo[type], {
type,
...rest,
});
return libraryBlockInfo;
};
export const getLibraryInfoByBlockInfo = (
librarys: ILibraryList,
blockInfo: LibraryBlockInfo,
): ILibraryItem | null => {
if (!librarys || !blockInfo) {
return null;
}
const libraryTypeList = librarys.find(
library => library.type === blockInfo.type,
);
return (
(libraryTypeList?.items as ILibraryItem[])?.find(item =>
checkLibraryId(item, blockInfo),
) ?? null
);
};
@@ -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 { type ILibraryList, type LibraryBlockInfo } from '../types';
import { findTargetLibrary, isLibraryNameOutDate } from './library-validate';
interface LibraryBlockTooltipProps {
librarys: ILibraryList;
libraryBlockInfo: LibraryBlockInfo | null;
content: string;
}
export type LibraryStatus = 'disabled' | 'existing' | 'outdated';
export const getLibraryStatus = ({
librarys,
libraryBlockInfo,
content,
}: LibraryBlockTooltipProps): {
libraryStatus: LibraryStatus;
} => {
let libraryStatus: LibraryStatus = 'disabled';
if (!libraryBlockInfo) {
return {
libraryStatus: 'disabled',
};
}
const targetLibrary = findTargetLibrary(librarys, libraryBlockInfo);
if (!targetLibrary) {
return {
libraryStatus: 'disabled',
};
}
const isOutdated = isLibraryNameOutDate(content, targetLibrary);
if (isOutdated) {
libraryStatus = 'outdated';
} else {
libraryStatus = 'existing';
}
return {
libraryStatus,
};
};
@@ -0,0 +1,247 @@
/*
* 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 RefObject } from 'react';
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import { I18n } from '@coze-arch/i18n';
import { type Tooltip } from '@coze-arch/coze-design';
import { type ILibraryItem, type LibraryBlockInfo } from '../types';
import { LibraryItem } from '../library-search-popover/library-item';
import { RenameLibraryAction } from '../library-search-popover/actions/rename-library-action';
import { AddLibraryAction } from '../library-search-popover/actions/add-library-action';
import { requestLibraryInfo } from './library-validate';
import type { LibraryStatus } from './get-library-status';
interface GetLibraryTooltipProps {
editorRef: RefObject<EditorAPI>;
libraryStatus: LibraryStatus;
libraryItem: ILibraryItem | null;
readonly: boolean;
blockDataInfo: LibraryBlockInfo | null;
spaceId: string;
onAddLibrary?: (library: ILibraryItem) => void;
range: {
left: number;
right: number;
};
projectId?: string;
avatarBotId?: string;
onRename?: (pos: { from: number; to: number }) => void;
disabled?: boolean;
}
export const getLibraryTooltip = async ({
editorRef,
libraryStatus,
libraryItem,
blockDataInfo,
onAddLibrary,
readonly,
spaceId,
range,
projectId,
avatarBotId,
onRename,
disabled,
}: GetLibraryTooltipProps): Promise<{
tooltipConfig?: React.ComponentProps<typeof Tooltip>;
}> => {
try {
if (disabled) {
return getHiddenLibraryTooltip();
}
if (readonly) {
return getDisabledLibraryTooltip();
}
const { type, id } = blockDataInfo ?? {};
if (!blockDataInfo || !type || !id) {
return getDisabledLibraryTooltip();
}
if (libraryStatus === 'disabled' || !libraryItem) {
return await getPublicLibraryTooltip({
blockDataInfo,
spaceId,
onAddLibrary,
projectId,
avatarBotId,
});
}
if (libraryStatus === 'outdated') {
return getOutdatedLibraryTooltip({
editorRef,
item: libraryItem,
range,
onRename,
});
}
if (libraryStatus === 'existing') {
return getExistingLibraryTooltip(libraryItem);
}
return getDisabledLibraryTooltip();
} catch (error) {
const errorMsg = (error as { msg: string }).msg;
if (errorMsg) {
return {
tooltipConfig: {
content: errorMsg,
},
};
}
return getDisabledLibraryTooltip();
}
};
const getExistingLibraryTooltip = (
libraryItem: ILibraryItem,
): {
tooltipConfig?: React.ComponentProps<typeof Tooltip>;
} => ({
tooltipConfig: {
content: (
<LibraryItem
title={libraryItem.name ?? ''}
description={libraryItem.desc ?? ''}
avatar={libraryItem.icon_url ?? ''}
className="!p-0"
/>
),
className: 'library-block-tooltip !w-[310px] !p-2',
},
});
const getDisabledLibraryTooltip = (
msg?: string,
): {
tooltipConfig?: React.ComponentProps<typeof Tooltip>;
} => ({
tooltipConfig: {
content: msg ?? I18n.t('edit_block_api_disable_tooltips'),
},
});
const getHiddenLibraryTooltip = (): {
tooltipConfig?: React.ComponentProps<typeof Tooltip>;
} => ({
tooltipConfig: {
visible: false,
trigger: 'custom',
},
});
const getOutdatedLibraryTooltip = ({
item,
range,
onRename,
editorRef,
}: {
editorRef: RefObject<EditorAPI>;
item: ILibraryItem;
range: {
left: number;
right: number;
};
onRename?: (pos: { from: number; to: number }) => void;
}): {
tooltipConfig?: React.ComponentProps<typeof Tooltip>;
} => ({
tooltipConfig: {
content: (
<LibraryItem
title={item.name ?? ''}
description={item.desc ?? ''}
avatar={item.icon_url ?? ''}
className="!p-0"
actions={
<RenameLibraryAction
library={item}
range={range}
editorRef={editorRef}
onRename={onRename}
/>
}
/>
),
className: 'library-block-tooltip !w-[310px] !p-2',
},
});
const getPublicLibraryTooltip = async ({
blockDataInfo,
spaceId,
onAddLibrary,
projectId,
avatarBotId,
}: {
blockDataInfo: LibraryBlockInfo | null;
spaceId: string;
onAddLibrary?: (library: ILibraryItem) => void;
projectId?: string;
avatarBotId?: string;
}): Promise<{
tooltipConfig?: React.ComponentProps<typeof Tooltip>;
}> => {
const { id, type } = blockDataInfo ?? {};
if (!id || !type) {
return getDisabledLibraryTooltip();
}
try {
const libraryInfo = await requestLibraryInfo({
id,
type,
...(type === 'plugin' && {
apiId: blockDataInfo?.apiId,
}),
spaceId,
projectId,
avatarBotId,
});
if (!libraryInfo) {
return getDisabledLibraryTooltip();
}
return {
tooltipConfig: {
content: (
<LibraryItem
title={libraryInfo.name ?? ''}
description={libraryInfo.desc ?? ''}
avatar={libraryInfo.icon_url ?? ''}
className="!p-0"
actions={
onAddLibrary ? (
<AddLibraryAction
library={libraryInfo}
onClick={onAddLibrary}
/>
) : undefined
}
/>
),
className: 'library-block-tooltip !w-[310px] !p-2',
showArrow: false,
},
};
} catch (error) {
return getDisabledLibraryTooltip((error as { msg: string }).msg);
}
};
@@ -0,0 +1,96 @@
/*
* 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 { PlaygroundApi } from '@coze-arch/bot-api';
import {
type ILibraryItem,
type ILibraryList,
type LibraryBlockInfo,
getReferenceType,
type LibraryType,
} from '../types';
export const findTargetLibrary = (
librarys: ILibraryList,
libraryCheck: LibraryBlockInfo,
): ILibraryItem | undefined => {
const { type: checkType } = libraryCheck;
const libraryGroup = librarys.find(
({ type, items }) =>
type === checkType &&
items.some(item => checkLibraryId(item, libraryCheck)),
);
const targetItem = libraryGroup?.items.find(item =>
checkLibraryId(item, libraryCheck),
);
if (!targetItem) {
return undefined;
}
return {
...targetItem,
type: checkType,
};
};
export const checkLibraryId = (
library: ILibraryItem,
libraryCheck: LibraryBlockInfo,
) => {
const { apiId: checkApiId, id: checkId } = libraryCheck;
if (checkApiId) {
return library.api_id === checkApiId;
}
return library.id === checkId;
};
export const isLibraryNameOutDate = (
content: string,
latestLibrary: ILibraryItem,
): boolean => content !== latestLibrary.name;
export const requestLibraryInfo = async (props: {
id: string;
type: LibraryType;
spaceId?: string;
apiId?: string;
projectId?: string;
avatarBotId?: string;
}): Promise<ILibraryItem | undefined> => {
const { id, type, apiId, spaceId, projectId, avatarBotId } = props;
const { data } = await PlaygroundApi.GetPromptReferenceInfo(
{
reference_id: id,
reference_type: getReferenceType(type),
api_id: apiId,
space_id: spaceId ?? '',
project_id: projectId,
avatar_bot_id: avatarBotId,
},
{
__disableErrorToast: true,
},
);
if (!data) {
return undefined;
}
return {
...(data as ILibraryItem),
type,
};
};
@@ -0,0 +1,179 @@
/*
* 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 {
CursorMirror,
SelectionSide,
useEditor,
} from '@coze-editor/editor/react';
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import { I18n } from '@coze-arch/i18n';
import {
Tabs,
TabPane,
RadioGroup,
Radio,
Popover,
} from '@coze-arch/coze-design';
import { type ContentSearchPopoverProps } from '../types';
import { generateUniqueId, useLatest } from '../../expression/shared';
import {
useCharacterTriggerContext,
useInterpolationContent,
useSelection,
} from '../../expression/popover/hooks';
import useVariablesTree from './variable/use-variables-tree';
import useLibraryList from './library/use-library-list';
import styles from './style.module.less';
enum TabEnum {
Variables = '1',
Skills = '2',
}
const genLibraryInsertPosition = (from?: number, to?: number) => {
const _from = (from ?? 0) - 1 >= 0 ? (from ?? 0) - 1 : 0;
return {
from: _from,
to: (to ?? _from) + 1,
};
};
export const ContentSearchPopover = ({
libraries = [],
direction = 'bottomLeft',
readonly,
onInsert,
variableTree = [],
}: ContentSearchPopoverProps) => {
const [activeTab, setActiveTab] = useState<string>(TabEnum.Variables);
const [reposKey, setReposKey] = useState('');
const emptyLibrary = libraries.map(item => item.items).flat().length === 0;
const editor = useEditor<EditorAPI>();
const editorRef = useLatest(editor);
const selection = useSelection(editor);
const interpolationContent = useInterpolationContent(
editor,
selection?.anchor,
);
const triggerContext = useCharacterTriggerContext();
const completionContext = interpolationContent ?? triggerContext;
const visible = Boolean(completionContext);
const insertPosition = genLibraryInsertPosition(
completionContext?.from,
completionContext?.to,
);
const { libraryListPanel } = useLibraryList({
libraries,
editor,
onInsert,
insertPosition,
enableKeyboard: visible && activeTab === TabEnum.Skills,
filterText: completionContext?.text,
});
const { variablesTreePanel } = useVariablesTree({
variableTree,
enableKeyboard: visible && activeTab === TabEnum.Variables,
completionContext,
});
useEffect(() => {
if (visible) {
editorRef.current?.disableKeybindings(['ArrowUp', 'ArrowDown', 'Enter']);
} else {
editorRef.current?.disableKeybindings([]);
}
}, [visible]);
return (
<>
<Popover
style={{
width: '360px',
padding: '4px',
minHeight: '240px',
maxHeight: '400px',
}}
rePosKey={reposKey}
visible={!readonly && visible}
trigger="custom"
position={emptyLibrary ? 'bottomLeft' : direction}
autoAdjustOverflow
content={
<Tabs
activeKey={activeTab}
onChange={key => {
setActiveTab(key);
// Make sure the cursor is still positioned on the editor after switching tabs
setTimeout(() => {
editorRef.current?.$view.focus();
}, 0);
}}
renderTabBar={tabBarProps => (
<div className={styles['tab-bar-wrapper']}>
<RadioGroup
value={tabBarProps.activeKey}
type="button"
buttonSize="middle"
>
{tabBarProps.list?.map(tab => (
<span
onClick={event =>
tabBarProps.onTabClick?.(tab.itemKey, event)
}
>
<Radio value={tab.itemKey}>{tab.tab}</Radio>
</span>
))}
</RadioGroup>
</div>
)}
>
<TabPane
tab={I18n.t('workflow_prompt_editor_variable')}
itemKey={TabEnum.Variables}
>
{variablesTreePanel}
</TabPane>
<TabPane
tab={I18n.t('workflow_prompt_editor_skill')}
itemKey={TabEnum.Skills}
>
{libraryListPanel}
</TabPane>
</Tabs>
}
>
<CursorMirror
side={SelectionSide.Anchor}
onChange={() => setReposKey(generateUniqueId())}
/>
</Popover>
</>
);
};
@@ -0,0 +1,146 @@
/*
* 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, { forwardRef, useMemo, useRef } from 'react';
import { nanoid } from 'nanoid';
import type { EditorAPI } from '@coze-editor/editor/preset-prompt';
import { I18n } from '@coze-arch/i18n';
import styles from '../style.module.less';
import { TemplateParser } from '../../../shared/utils/template-parser';
import { LibraryList as DefaultLibraryList } from '../../../library-insert/library-search-popover/library-list';
import type { ILibraryItem, ILibraryList } from '../../../library-insert';
import { useKeyboard } from '../../../expression/popover/hooks';
import useOptionsOperations from './use-options-operations';
interface LibraryListProps {
libraries: ILibraryList;
onInsert?: (library: ILibraryItem) => void;
searchWords?: string[];
}
const LibraryList = forwardRef<HTMLDivElement, LibraryListProps>(
({ libraries, onInsert, searchWords }, ref) => {
const emptyLibrary = libraries.map(item => item.items).flat().length === 0;
return !emptyLibrary ? (
<div
ref={ref}
className="flex flex-col p-1 w-[352px] max-h-[330px] overflow-y-auto"
>
<DefaultLibraryList
librarys={libraries}
onInsert={onInsert}
libraryItemClassName={styles['library-item']}
searchWords={searchWords}
/>
</div>
) : (
<div ref={ref} className="coz-fg-primary text-sm font-medium px-3 py-2">
{I18n.t('edit_block_api_empty')}
</div>
);
},
);
interface Props {
editor: EditorAPI;
insertPosition: {
from: number;
to: number;
};
onInsert?: (insertPosition: { from: number; to: number }) => void;
enableKeyboard: boolean;
filterText?: string;
libraries: ILibraryList;
}
const templateParser = new TemplateParser({ mark: 'LibraryBlock' });
export default function useLibraryList({
libraries,
editor,
insertPosition,
onInsert,
enableKeyboard,
filterText,
}: Props) {
const libraryListRef = useRef<HTMLDivElement>(null);
const filterLibraries = useMemo(() => {
if (!filterText) {
return libraries;
}
return libraries
.map(item => ({
...item,
items: item.items.filter(i =>
i.name.toLowerCase().includes(filterText.toLowerCase()),
),
}))
.filter(item => item.items?.length);
}, [libraries, filterText]);
const handleLibraryInsert = (library: ILibraryItem) => {
const { name = '', id = '', type } = library;
const uuid = nanoid();
const template = templateParser.generateTemplate({
content: name,
data: {
id,
uuid,
type,
...(type === 'plugin' && {
apiId: library.api_id,
}),
},
});
templateParser.insertTemplateByRange(editor, template, insertPosition);
onInsert?.({
from: insertPosition.from,
to: insertPosition.from + template.length,
});
};
const { prev, next, apply } = useOptionsOperations({
rootRef: libraryListRef,
libraries: filterLibraries,
applyCallBack: handleLibraryInsert,
});
useKeyboard(enableKeyboard, {
ArrowUp: prev,
ArrowDown: next,
Enter: apply,
});
const libraryListPanel = (
<LibraryList
ref={libraryListRef}
libraries={filterLibraries}
onInsert={handleLibraryInsert}
searchWords={filterText ? [filterText] : undefined}
/>
);
return {
libraryListPanel,
};
}
@@ -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 MutableRefObject } from 'react';
import styles from '../style.module.less';
import type { ILibraryItem, ILibraryList } from '../../../library-insert';
const SELECTED_OPTION_CLASSNAME =
styles['library-suggestion-keyboard-selected'];
interface OptionsInfo {
elements: Element[];
selectedIndex: number;
selectedElement?: Element;
}
const getOptionInfoFromDOM = (
root: Element | null,
): OptionsInfo | undefined => {
if (!root) {
return;
}
const foundNodes = root.querySelectorAll(`.${styles['library-item']}`);
if (foundNodes.length === 0) {
return;
}
const optionElements = [...foundNodes];
// Find the currently highlighted option
const selectedIndex = optionElements.findIndex(element =>
element.classList.contains(SELECTED_OPTION_CLASSNAME),
);
return {
elements: optionElements,
selectedIndex,
selectedElement: optionElements[selectedIndex],
};
};
const 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 Props {
rootRef: MutableRefObject<HTMLDivElement | null>;
libraries: ILibraryList;
applyCallBack?: (item: ILibraryItem) => void;
}
export default function useOptionsOperations({
rootRef,
libraries,
applyCallBack,
}: Props) {
const prev = () => {
const optionsInfo = getOptionInfoFromDOM(rootRef.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);
};
const next = () => {
const optionsInfo = getOptionInfoFromDOM(rootRef.current);
if (!optionsInfo) {
return;
}
const { elements, selectedIndex } = optionsInfo;
const newIndex =
selectedIndex + 1 >= elements.length ? 0 : selectedIndex + 1;
selectNodeByIndex(elements, newIndex);
};
const apply = () => {
const optionsInfo = getOptionInfoFromDOM(rootRef.current);
if (!optionsInfo) {
return;
}
const selectedLibrary = libraries.reduce<ILibraryItem[]>(
(previousValue, currentValue) => [
...previousValue,
...(currentValue?.items || []),
],
[],
)[optionsInfo.selectedIndex];
if (!selectedLibrary) {
return;
}
applyCallBack?.(selectedLibrary);
};
return {
prev,
next,
apply,
};
}
@@ -0,0 +1,35 @@
/* stylelint-disable selector-class-pattern */
.library-variable-insert-tree {
:global {
.semi-tree-option-empty {
background: transparent;
}
.semi-tree-option-highlight {
color: var(--coz-fg-hglt-yellow, #FF7300);
}
}
}
.tab-bar-wrapper {
width: 100%;
padding: 8px 8px 0;
:global {
.semi-radio-buttonRadioGroup {
padding: 4px 5px;
}
.semi-radio-addon-buttonRadio {
width: 158px;
}
}
}
.library-item {
background: transparent;
}
.library-suggestion-keyboard-selected {
background: var(--semi-color-fill-0);
}
@@ -0,0 +1,135 @@
/*
* 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/naming-convention */
import React, { useCallback, useRef } from 'react';
import { useEditor } from '@coze-editor/editor/react';
import type { EditorAPI } from '@coze-editor/editor/preset-prompt';
import type { TreeNodeData } from '@coze-arch/bot-semi/Tree';
import { Tree } from '@coze-arch/bot-semi';
import styles from '../style.module.less';
import { useDeepEqualMemo } from '../../../expression/shared';
import {
applyNode,
getOptionInfoFromDOM,
selectNodeByIndex,
} from '../../../expression/popover/shared';
import type { CompletionContext } from '../../../expression/popover/hooks/types';
import {
useDrillVariableTree,
useFilteredVariableTree,
useKeyboard,
useOptionsOperations,
useSelectedValue,
useTreeRefresh,
useTreeSearch,
} from '../../../expression/popover/hooks';
import type { ExpressionEditorTreeNode } from '../../../expression/core';
interface Props {
variableTree: ExpressionEditorTreeNode[];
enableKeyboard: boolean;
completionContext?: CompletionContext;
}
export default function useVariablesTree({
variableTree: variableTreeFromProps,
enableKeyboard,
completionContext,
}: Props) {
const editor = useEditor<EditorAPI>();
const variableTree = useDeepEqualMemo(variableTreeFromProps);
const treeRef = useRef<Tree | null>(null);
const treeContainerRef = useRef<HTMLDivElement | null>(null);
const drilledVariableTree = useDrillVariableTree(
editor,
variableTree,
completionContext,
);
const selected = useSelectedValue(completionContext?.text, variableTree);
// Replace content in {{}} based on user selection
const handleSelect = useCallback(
(_: string, __: boolean, node: TreeNodeData) => {
if (!editor || !completionContext) {
return;
}
applyNode(editor, node as ExpressionEditorTreeNode, completionContext);
editor.$view.focus();
},
[editor, completionContext],
);
const filteredVariableTree = useFilteredVariableTree(
completionContext,
drilledVariableTree,
);
const treeRefreshKey = useTreeRefresh(filteredVariableTree);
useTreeSearch(treeRefreshKey, treeRef, completionContext, () => {
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
if (!optionsInfo) {
return;
}
const { elements } = optionsInfo;
selectNodeByIndex(elements, 0);
});
const { prev, next, apply } = useOptionsOperations(
editor,
completionContext,
treeContainerRef,
treeRef,
);
// Press the up and down keys to switch the recommended items, and press Enter to fill in.
useKeyboard(enableKeyboard, {
ArrowUp: prev,
ArrowDown: next,
Enter: () => {
apply();
editor.$view.focus();
},
});
const variablesTreePanel = (
<div
className={styles['library-variable-insert-tree']}
ref={treeContainerRef}
>
<Tree
ref={treeRef}
showFilteredOnly
filterTreeNode
onChangeWithObject
treeData={drilledVariableTree}
searchRender={false}
value={selected}
onSelect={handleSelect}
/>
</div>
);
return {
variablesTreePanel,
};
}
@@ -0,0 +1,19 @@
/*
* 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 { ContentSearchPopover } from './content-search-popover';
export type { ContentSearchPopoverProps } from './types';
@@ -0,0 +1,30 @@
/*
* 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 ComponentProps } from 'react';
import { type Popover } from '@coze-arch/coze-design';
import type { ILibraryList } from '../library-insert';
import type { ExpressionEditorTreeNode } from '../expression/core';
export interface ContentSearchPopoverProps {
libraries: ILibraryList;
direction?: ComponentProps<typeof Popover>['position'];
readonly?: boolean;
onInsert?: (insertPosition: { from: number; to: number }) => void;
variableTree?: ExpressionEditorTreeNode[];
}
@@ -0,0 +1,41 @@
/*
* 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 } from 'react';
import { useEditor } from '@coze-editor/editor/react';
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import { type ViewUpdate } from '@codemirror/view';
export const useReadonly = () => {
const editor = useEditor<EditorAPI>();
const [isReadOnly, setIsReadOnly] = useState(false);
useEffect(() => {
if (!editor) {
return;
}
setIsReadOnly(editor.$view.state.readOnly);
const handleViewUpdate = (update: ViewUpdate) => {
if (update.startState.readOnly !== update.state.readOnly) {
setIsReadOnly(update.state.readOnly);
}
};
editor.$on('viewUpdate', handleViewUpdate);
return () => {
editor.$off('viewUpdate', handleViewUpdate);
};
}, [editor]);
return isReadOnly;
};
@@ -0,0 +1,362 @@
/*
* 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 SyntaxNode } from '@lezer/common';
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import { type SelectionEnlargerSpec } from '@coze-editor/editor';
import { StateField, type EditorState } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
export interface MarkRange {
from: number;
to: number;
}
export interface MarkRangeInfo {
from: number;
to: number;
open: MarkRange;
close: MarkRange;
}
// Parse template string: {#slot name = "slot_name" #} xxx {#/slot #}
export class TemplateParser {
public mark!: 'LibraryBlock' | 'InputSlot';
private openReg!: RegExp;
private closeReg!: RegExp;
public markInfoField!: StateField<{
specs: SelectionEnlargerSpec[];
contents: MarkRange[];
marks: MarkRangeInfo[];
}>;
static instances = new Map<string, TemplateParser>();
constructor(props: { mark: 'LibraryBlock' | 'InputSlot' }) {
if (TemplateParser.instances.has(props.mark)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return TemplateParser.instances.get(props.mark)!;
}
const { mark } = props;
this.mark = mark;
this.openReg = new RegExp(`^\\{#\\s*${mark}`);
this.closeReg = new RegExp(`^\\{#\\s*\/${mark}`);
this.markInfoField = this.getMarkInfoField();
TemplateParser.instances.set(mark, this);
}
getMarkInfoField() {
return StateField.define({
create: state => this.getMarkSpecs(state),
update: (value, tr) => {
if (tr.docChanged) {
return this.getMarkSpecs(tr.state);
}
return value;
},
});
}
isOpenNode(node: SyntaxNode, state: EditorState) {
if (!node || node.name !== 'JinjaComment') {
return false;
}
const text = state.sliceDoc(node.from, node.to);
return this.openReg.test(text);
}
isCloseNode(node: SyntaxNode, state: EditorState) {
if (!node || node.name !== 'JinjaComment') {
return false;
}
const text = state.sliceDoc(node.from, node.to);
return this.closeReg.test(text);
}
getCursorInMarkNodeRange(state: EditorState): MarkRangeInfo | null {
const cursor = state.selection.main.head;
return this.getPostionInMarkNodeRange(cursor, state);
}
getSelectionInMarkNodeRange(
range: { from: number; to: number },
state: EditorState,
): MarkRangeInfo | null {
return (
this.getPostionInMarkNodeRange(range.from, state) &&
this.getPostionInMarkNodeRange(range.to, state)
);
}
getPostionInMarkNodeRange(postion: number, state: EditorState) {
const markRangeInfo = state
.field(this.markInfoField)
.marks.find(
rangeInfo => rangeInfo.from < postion && postion < rangeInfo.to,
);
if (markRangeInfo) {
return markRangeInfo;
}
return null;
}
findCloseNode(node: SyntaxNode, state: EditorState) {
let next = node.nextSibling;
let close = null;
while (next) {
if (this.isCloseNode(next, state)) {
close = next;
break;
}
next = next.nextSibling;
}
return close;
}
// Parse template string: {#slot id = "slot_id" value = "slot_value" #}, get all properties
getData(templateString: string): { [key: string]: string } | null {
// Constructs regular expressions based on the type passed in, such as slots or blocks
const regex = new RegExp(`\\{#${this.mark}\\s+([^#]+)#\\}`, 'g');
const match = regex.exec(templateString);
if (match !== null) {
const attributes = match[1].trim(); // Matched attribute part
const attrRegex = /(\w+)\s*=\s*"([^"]*)"/g;
const obj: { [key: string]: string } = {}; // initial object
let attrMatch: RegExpExecArray | null;
while (true) {
attrMatch = attrRegex.exec(attributes);
if (attrMatch === null) {
break;
}
obj[attrMatch[1]] = attrMatch[2]; // Adds a matching key-value pair to the object
}
return obj; // Return the parsing result
}
return null; // Returns null if there is no match
}
getCursorTemplateData(state: EditorState) {
const markRangeInfo = this.getCursorInMarkNodeRange(state);
if (!markRangeInfo) {
return;
}
const { from, to } = markRangeInfo.open;
const text = state.sliceDoc(from, to);
return this.getData(text);
}
getAllMarksByState(state: EditorState): MarkRangeInfo[] {
const marks: MarkRangeInfo[] = [];
const tree = syntaxTree(state);
const cursor = tree.cursor();
do {
if (this.isOpenNode(cursor.node, state)) {
const open = cursor.node;
const close = this.findCloseNode(cursor.node, state);
if (close) {
marks.push({
from: open.from,
to: close.to,
open: { from: open.from, to: open.to },
close: { from: close.from, to: close.to },
});
}
}
} while (cursor.next());
return marks;
}
getMarkSpecs(state: EditorState): {
specs: SelectionEnlargerSpec[];
contents: MarkRange[];
marks: MarkRangeInfo[];
} {
const marks = this.getAllMarksByState(state);
const specs: SelectionEnlargerSpec[] = [];
const contents: MarkRange[] = [];
marks.forEach((markRangeInfo: MarkRangeInfo) => {
specs.push({
source: {
from: markRangeInfo.open.from,
to: markRangeInfo.open.to,
},
target: {
from: markRangeInfo.from,
to: markRangeInfo.to,
},
});
specs.push({
source: {
from: markRangeInfo.close.from,
to: markRangeInfo.close.to,
},
target: {
from: markRangeInfo.from,
to: markRangeInfo.to,
},
});
contents.push({
from: markRangeInfo.open.to,
to: markRangeInfo.close.from,
});
});
return { specs, contents, marks };
}
/**
* Modify the template data for the current cursor position: {#slot placeholder = "default_placeholder" #} Modify to {#slot placeholder = "new_placeholder" #}
* Added: {#slot placeholder = "default_placeholder" #} Added {#slot value = "new_value" placeholder = "new_placeholder" #}
*/
updateCursorTemplateData(editor: EditorAPI, data: { [key: string]: string }) {
const { state } = editor.$view;
const markRangeInfo = this.getCursorInMarkNodeRange(state);
if (!markRangeInfo) {
return;
}
const { from, to } = markRangeInfo.open;
const text = state.sliceDoc(from, to);
const preData = this.getData(text);
const newData = { ...preData, ...data };
if (!preData) {
this.addCursorTemplateData(editor, newData);
return;
}
const newText = this.generateOpenTemplateByData(newData);
editor.$view.dispatch({
changes: {
from,
to,
insert: newText,
},
});
}
addCursorTemplateData(editor: EditorAPI, data: { [key: string]: string }) {
const { state } = editor.$view;
const markRangeInfo = this.getCursorInMarkNodeRange(state);
if (!markRangeInfo) {
return;
}
const { from, to } = markRangeInfo.open;
const text = state.sliceDoc(from, to);
const preData = this.getData(text);
const newData = { ...preData, ...data };
const newText = this.generateOpenTemplateByData(newData);
editor.replaceTextByRange({
from,
to,
text: newText,
});
}
generateOpenTemplateByData(data: { [key: string]: string }) {
const keys = Object.keys(data);
const values = Object.values(data);
const text = keys
.map((key, index) => `${key}="${values[index]}"`)
.join(' ');
return `{#${this.mark} ${text}#}`;
}
generateTemplate({
content,
data,
}: {
content: string;
data: { [key: string]: string };
}) {
const openTemplate = this.generateOpenTemplateByData(data);
const closeTemplate = `{#/${this.mark}#}`;
return `${openTemplate}${content}${closeTemplate}`;
}
generateTemplateJson({
content,
data,
}: {
content: string;
data: { [key: string]: string };
}) {
const openTemplate = this.generateOpenTemplateByData(data);
const closeTemplate = `{#/${this.mark}#}`;
const textContent = this.extractTemplateContent(content);
return {
open: openTemplate,
close: closeTemplate,
textContent,
template: `${openTemplate}${content}${closeTemplate}`,
};
}
updateCursorTemplateContent(editor: EditorAPI, content: string) {
const { state } = editor.$view;
const markRangeInfo = this.getCursorInMarkNodeRange(state);
if (!markRangeInfo) {
return;
}
editor.replaceTextByRange({
from: markRangeInfo.open.to,
to: markRangeInfo.close.from,
text: content,
});
}
getCursorTemplateContent(editor: EditorAPI) {
const { state } = editor.$view;
const markRangeInfo = this.getCursorInMarkNodeRange(state);
if (!markRangeInfo) {
return;
}
return state.sliceDoc(markRangeInfo.open.to, markRangeInfo.close.from);
}
insertTemplateByCursor(editor: EditorAPI, template: string) {
const { state } = editor.$view;
const cursorPosition = state.selection.main.head;
if (!cursorPosition) {
return;
}
editor.$view.dispatch({
changes: {
from: cursorPosition,
to: cursorPosition,
insert: template,
},
});
}
insertTemplateByRange(
editor: EditorAPI,
template: string,
range: { from: number; to: number },
) {
const { from, to } = range;
editor.$view.dispatch({
changes: {
from,
to,
insert: template,
},
});
}
// Extract the content in the template {#InputSlot placeholder = "placeholder" #} 123 {#/InputSlot #} xxx, all the internal content under the nested template
extractTemplateContent(template: string) {
// Use regular expressions to match parts of {#InputSlot... #}
const regex = new RegExp(
`\\{#${this.mark}\\s+[^#]+#\\}|\\{#\\/${this.mark}#\\}`,
'g',
);
// Use the replace method to replace the matching part
const result = template.replace(regex, '');
console.log('extractTemplateContent', result);
return result;
}
}
@@ -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.
*/
import { Markdown } from './markdown';
import { Jinja } from './jinja';
// eslint-disable-next-line @typescript-eslint/naming-convention
const SyntaxHighlight = {
Markdown,
Jinja,
};
export { SyntaxHighlight };
@@ -0,0 +1,106 @@
/*
* 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 { useLayoutEffect } from 'react';
import { useInjector } from '@coze-editor/editor/react';
import { astDecorator } from '@coze-editor/editor';
import { EditorView } from '@codemirror/view';
const prec = 'lowest';
function Jinja() {
const injector = useInjector();
useLayoutEffect(
() =>
injector.inject([
astDecorator.whole.of(cursor => {
if (
cursor.name === 'JinjaStatement' ||
cursor.name === 'JinjaRawOpenStatement' ||
cursor.name === 'JinjaRawCloseStatement'
) {
return {
type: 'className',
className: 'jinja-statement',
prec,
};
}
if (cursor.name === 'JinjaStringLiteral') {
return {
type: 'className',
className: 'jinja-string',
prec,
};
}
if (cursor.name === 'JinjaComment') {
return {
type: 'className',
className: 'jinja-comment',
prec,
};
}
if (cursor.name === 'JinjaExpression') {
return {
type: 'className',
className: 'jinja-expression',
prec,
};
}
if (
cursor.name === 'JinjaFilterName' ||
cursor.name === 'JinjaStatementStart' ||
cursor.name === 'JinjaStatementEnd' ||
cursor.name === 'JinjaKeyword' ||
cursor.name === 'JinjaFilterName'
) {
return {
type: 'className',
className: 'jinja-statement-keyword',
prec,
};
}
}),
EditorView.theme({
'.jinja-statement': {
color: '#060709CC',
},
'.jinja-statement-keyword': {
color: '#D1009D',
},
'.jinja-string': {
color: '#060709CC',
},
'.jinja-comment': {
color: '#0607094D',
},
'.jinja-expression': {
color: '#4E40E5',
},
}),
]),
[injector],
);
return null;
}
export { Jinja };
@@ -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 { useLayoutEffect } from 'react';
import { useInjector } from '@coze-editor/editor/react';
import { astDecorator } from '@coze-editor/editor';
import { EditorView } from '@codemirror/view';
const prec = 'lowest';
function Markdown() {
const injector = useInjector();
useLayoutEffect(
() =>
injector.inject([
astDecorator.whole.of(cursor => {
// # heading
if (cursor.name.startsWith('ATXHeading')) {
return {
type: 'className',
className: 'markdown-heading',
prec,
};
}
// *italic*
if (cursor.name === 'Emphasis') {
return {
type: 'className',
className: 'markdown-emphasis',
prec,
};
}
// **bold**
if (cursor.name === 'StrongEmphasis') {
return {
type: 'className',
className: 'markdown-strong-emphasis',
prec,
};
}
// -
// 1.
// >
if (cursor.name === 'ListMark' || cursor.name === 'QuoteMark') {
return {
type: 'className',
className: 'markdown-mark',
prec,
};
}
}),
EditorView.theme({
'.markdown-heading': {
color: '#00818C',
fontWeight: '500',
},
'.markdown-emphasis': {
fontStyle: 'italic',
},
'.markdown-strong-emphasis': {
fontWeight: 'bold',
},
'.markdown-mark': {
color: '#4E40E5',
},
}),
]),
[injector],
);
return null;
}
export { Markdown };
@@ -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 { ThemeExtension } from './theme-extension';
@@ -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 { useLayoutEffect } from 'react';
import { useInjector } from '@coze-editor/editor/react';
import { type Extension } from '../types';
export const ThemeExtension: React.FC<{
themes: Extension[];
}> = ({ themes }) => {
const injector = useInjector();
useLayoutEffect(() => injector.inject(themes), [injector, themes]);
return null;
};
@@ -0,0 +1,28 @@
/*
* 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 type Extension =
| {
extension: Extension;
}
| readonly Extension[];
export interface SelectionInfo {
from: number;
to: number;
anchor: number;
head: number;
}
@@ -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' />