feat: manually mirror opencoze's code from bytedance

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

View File

@@ -0,0 +1,31 @@
import { mergeConfig } from 'vite';
import svgr from 'vite-plugin-svgr';
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.tsx'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-onboarding',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
viteFinal: config =>
mergeConfig(config, {
plugins: [
svgr({
svgrOptions: {
native: false,
},
}),
],
}),
};
export default config;

View File

@@ -0,0 +1,14 @@
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

View File

@@ -0,0 +1,5 @@
const { defineConfig } = require('@coze-arch/stylelint-config');
module.exports = defineConfig({
extends: [],
});

View File

@@ -0,0 +1,16 @@
# @coze-common/chat-area-plugins-chat-shortcuts
chat-area插件用于配置bot相关的快捷指令
## Features
- [x] eslint & ts
- [x] esm bundle
- [x] umd bundle
- [x] storybook
## Commands
- init: `rush update`
- dev: `npm run dev`
- build: `npm run build`

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderHook, act } from '@testing-library/react-hooks';
import { type ShortCutCommand } from '@coze-agent-ide/tool-config';
import {
useLoadMore,
getNextActiveItem,
getPreviousItem,
} from '../../../src/hooks/shortcut-bar/use-load-more';
describe('useLoadMore', () => {
it('should initialize with default values', async () => {
const { result } = renderHook(() =>
useLoadMore<ShortCutCommand>({
getId: item => item.command_id,
listRef: { current: null },
getMoreListService: async () =>
Promise.resolve({ list: [], hasMore: false }),
}),
);
expect(result.current.activeId).toBe('');
expect(result.current.loadingMore).toBe(false);
expect(result.current.data).toEqual({ list: [], hasMore: false });
expect(result.current.loading).toBe(true);
await act(() => {});
expect(result.current.loading).toBe(false);
});
it('should load more when reaching limit', async () => {
const getMoreListService = vi
.fn()
.mockResolvedValue({ list: [{ id: '2' }], hasMore: false });
const { result, waitForNextUpdate } = renderHook(() =>
useLoadMore({
getId: item => item.id,
listRef: { current: null },
getMoreListService,
defaultList: [{ id: '1' }],
}),
);
act(() => {
result.current.goNext();
});
await waitForNextUpdate();
expect(getMoreListService).toHaveBeenCalled();
expect(result.current.data.list).toEqual([{ id: '1' }, { id: '2' }]);
});
});
describe('getNextActiveItem', () => {
it('should return next item and reach limit flag', () => {
const result = getNextActiveItem({
curItem: { id: '1' },
list: [{ id: '1' }, { id: '2' }, { id: '3' }],
getId: item => item.id,
});
expect(result).toEqual({ reachLimit: true, item: { id: '2' } });
});
});
describe('getPreviousItem', () => {
it('should return previous item and reach limit flag', () => {
const result = getPreviousItem({
curItem: { id: '2' },
list: [{ id: '1' }, { id: '2' }, { id: '3' }],
getId: item => item.id,
});
expect(result).toEqual({ reachLimit: false, item: { id: '1' } });
});
});

View File

@@ -0,0 +1,318 @@
/*
* 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 { ContentType, useSendTextMessage } from '@coze-common/chat-area';
import { sendTeaEvent, EVENT_NAMES } from '@coze-arch/bot-tea';
import { type ShortCutCommand } from '@coze-agent-ide/tool-config';
import {
useSendTextQueryMessage,
useSendUseToolMessage,
getTemplateQuery,
getImageAndFileList,
} from '../../src/hooks/shortcut';
const sendTextMessageMock = vi.fn();
const sendMultimodalMessage = vi.fn();
vi.mock('@coze-arch/bot-tea', () => ({
sendTeaEvent: vi.fn(),
EVENT_NAMES: {
page_view: 'page_view',
},
}));
vi.mock('../../src/shortcut-tool/shortcut-edit/method', () => ({
enableSendTypePanelHideTemplate: vi.fn(),
}));
vi.mock('@coze-common/chat-area', () => ({
useSendTextMessage: () => sendTextMessageMock,
useSendMultimodalMessage: () => sendMultimodalMessage,
ContentType: {
Image: 'image',
File: 'file',
},
}));
vi.mock('@coze-common/chat-core', () => ({
default: () => vi.fn(),
getFileInfo: vi.fn().mockImplementation(file => {
if (file.type === 'image/png') {
return {
fileType: 'image',
};
}
return {
fileType: 'file',
};
}),
}));
const mockShortcut: ShortCutCommand = {
command_id: '7374755905893793836',
command_name: 'muti',
components_list: [
{
name: 'news',
description: 'Keywords to search for news, must in English',
input_type: 0,
parameter: 'q',
options: [],
},
],
description: '',
object_id: '7374633552917479468',
plugin_api_name: 'getNews',
plugin_id: '7373521805258014764',
send_type: 1,
shortcut_command: '/muti',
template_query: '查询{{news}}',
tool_type: 2,
tool_info: {
tool_name: 'News',
tool_params_list: [
{
default_value: '',
desc: 'Keywords to search for news, must in English',
name: 'q',
refer_component: true,
required: true,
type: 'string',
},
],
// @ts-expect-error -- test ignore
tool_type: 2,
},
work_flow_id: '',
};
describe('useSendTextQueryMessage', () => {
it('should send text message with query template', () => {
const sendTextMessage = useSendTextMessage();
const sendTextQueryMessage = useSendTextQueryMessage();
mockShortcut.tool_type = undefined;
sendTextQueryMessage({
queryTemplate: 'test',
shortcut: mockShortcut,
});
expect(sendTextMessage).toHaveBeenCalledWith(
{ text: 'test', mentionList: [] },
'shortcut',
{
extendFiled: {
device_id: expect.any(String),
},
},
);
expect(sendTeaEvent).toHaveBeenCalledWith(EVENT_NAMES.shortcut_use, {
show_panel: undefined,
tool_type: undefined,
use_components: true,
});
});
it('should send modify message with onBeforeSend', () => {
const sendTextQueryMessage = useSendTextQueryMessage();
const defaultOptions = {
extendFiled: {
device_id: expect.any(String),
},
};
const onBeforeSendMock = vi.fn().mockReturnValue({
message: {
payload: {
text: 'modified query template',
mention_list: [{ id: 123 }],
},
},
options: {
...defaultOptions,
test: '123',
},
});
sendTextQueryMessage({
queryTemplate: 'test',
onBeforeSend: onBeforeSendMock,
shortcut: mockShortcut,
});
});
});
describe('useSendUseToolMessage', () => {
it('should send multimodal message with shortcut command', () => {
const sendUseToolMessage = useSendUseToolMessage();
const shortcut = {
command_id: '7374755905893793836',
command_name: 'muti',
components_list: [
{
name: 'news',
description: 'Keywords to search for news, must in English',
input_type: 0,
parameter: 'q',
options: [],
},
],
description: '',
object_id: '7374633552917479468',
plugin_api_name: 'getNews',
plugin_id: '7373521805258014764',
send_type: 1,
tool_type: 2,
shortcut_command: '/muti',
template_query: '查询{{news}}',
tool_info: {
tool_name: 'News',
tool_params_list: [
{
default_value: '',
desc: 'Keywords to search for news, must in English',
name: 'q',
refer_component: true,
required: true,
type: 'string',
},
],
tool_type: 2,
},
work_flow_id: '',
};
const componentsFormValues = { news: '查询北京news' };
// @ts-expect-error --单测忽略
sendUseToolMessage({ shortcut, componentsFormValues });
expect(sendMultimodalMessage).toHaveBeenCalled();
expect(sendTeaEvent).toHaveBeenCalledWith(EVENT_NAMES.shortcut_use, {
show_panel: true,
tool_type: 2,
use_components: true,
});
});
});
describe('getTemplateQuery', () => {
it('should return query from template', () => {
const shortcut = {
command_id: '7374755905893793836',
command_name: 'muti',
components_list: [
{
name: 'news',
description: 'Keywords to search for news, must in English',
input_type: 0,
parameter: 'q',
options: [],
},
],
description: '',
object_id: '7374633552917479468',
plugin_api_name: 'getNews',
plugin_id: '7373521805258014764',
send_type: 1,
shortcut_command: '/muti',
template_query: '查询{{news}}',
tool_info: {
tool_name: 'News',
tool_params_list: [
{
default_value: '',
desc: 'Keywords to search for news, must in English',
name: 'q',
refer_component: true,
required: true,
type: 'string',
},
],
tool_type: 2,
},
work_flow_id: '',
};
const componentsFormValues = { news: '北京新闻' };
// @ts-expect-error --单测忽略
const result = getTemplateQuery(shortcut, componentsFormValues);
expect(result).toBe('查询北京新闻');
});
it('should throw error when template_query is not defined', () => {
const shortcut = {
command_id: '7374755905893793836',
command_name: 'muti',
components_list: [
{
name: 'news',
description: 'Keywords to search for news, must in English',
input_type: 0,
parameter: 'q',
options: [],
},
],
description: '',
object_id: '7374633552917479468',
plugin_api_name: 'getNews',
plugin_id: '7373521805258014764',
send_type: 1,
shortcut_command: '/muti',
tool_info: {
tool_name: 'News',
tool_params_list: [
{
default_value: '',
desc: 'Keywords to search for news, must in English',
name: 'q',
refer_component: true,
required: true,
type: 'string',
},
],
tool_type: 2,
},
work_flow_id: '',
};
const componentsFormValues = { news: '北京新闻' };
// @ts-expect-error --单测忽略
expect(() => getTemplateQuery(shortcut, componentsFormValues)).toThrowError(
'template_query is not defined',
);
});
});
describe('getImageAndFileList', () => {
it('should return list of images and files', () => {
const componentsFormValues = {
image: {
fileInstance: new File([''], 'filename', { type: 'image/png' }),
url: 'http://example.com/image.png',
width: 100,
height: 100,
},
file: {
fileInstance: new File([''], 'filename', { type: 'text/plain' }),
url: 'http://example.com/file.txt',
},
};
const result = getImageAndFileList(componentsFormValues);
expect(result).toEqual([
{
type: ContentType.Image,
file: componentsFormValues.image.fileInstance,
uri: componentsFormValues.image.url,
width: componentsFormValues.image.width,
height: componentsFormValues.image.height,
},
{
type: ContentType.File,
file: componentsFormValues.file.fileInstance,
uri: componentsFormValues.file.url,
},
]);
});
});

View File

@@ -0,0 +1,111 @@
/*
* 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 WorkFlowItemType } from '@coze-studio/bot-detail-store';
import { type PluginApi, ToolType } from '@coze-arch/bot-api/playground_api';
import {
initToolInfoByToolApi,
initToolInfoByWorkFlow,
initToolInfoByPlugin,
MAX_TOOL_PARAMS_COUNT,
} from '../../../../../../src/shortcut-tool/shortcut-edit/action-switch-area/skill-switch/method';
describe('initToolInfoByToolApi', () => {
it('returns null when no toolApi is provided', () => {
expect(initToolInfoByToolApi()).toBeNull();
});
it('initializes tool info by workflow when workflow_id is present', () => {
// @ts-expect-error -- workflow_id is not required
const workflow: WorkFlowItemType = {
workflow_id: '1',
name: 'Test Workflow',
parameters: [],
};
const result = initToolInfoByToolApi(workflow);
expect(result?.tool_type).toBe(ToolType.ToolTypeWorkFlow);
});
it('initializes tool info by plugin when workflow_id is not present', () => {
const plugin: PluginApi = {
name: 'Test Plugin',
plugin_name: 'Test Plugin',
parameters: [],
};
const result = initToolInfoByToolApi(plugin);
expect(result?.tool_type).toBe(ToolType.ToolTypePlugin);
});
it('sorts parameters by required field and limits to MAX_TOOL_PARAMS_COUNT', () => {
const parameters = Array(MAX_TOOL_PARAMS_COUNT + 2)
.fill(null)
.map((_, index) => ({
name: `param${index}`,
desc: `desc${index}`,
required: index < MAX_TOOL_PARAMS_COUNT,
type: 'string',
}));
const plugin: PluginApi = {
name: 'Test Plugin',
plugin_name: 'Test Plugin',
parameters,
};
const result = initToolInfoByToolApi(plugin);
expect(result?.tool_params_list.length).toBe(MAX_TOOL_PARAMS_COUNT + 2);
// 前10个是required=true的参数
expect(
result?.tool_params_list
.slice(0, MAX_TOOL_PARAMS_COUNT)
.every(param => param.required),
).toBeTruthy();
});
});
describe('initToolInfoByWorkFlow', () => {
it('initializes tool info from a workflow item', () => {
// @ts-expect-error -- workflow_id is not required
const workflow: WorkFlowItemType = {
workflow_id: '1',
name: 'Test Workflow',
parameters: [],
};
const result = initToolInfoByWorkFlow(workflow);
expect(result.tool_type).toBe(ToolType.ToolTypeWorkFlow);
expect(result.tool_name).toBe(workflow.name);
expect(result.work_flow_id).toBe(workflow.workflow_id);
});
});
describe('initToolInfoByPlugin', () => {
it('initializes tool info from a plugin item', () => {
const plugin: PluginApi = {
name: 'Test Plugin',
plugin_name: 'Test Plugin',
parameters: [],
};
const result = initToolInfoByPlugin(plugin);
expect(result.tool_type).toBe(ToolType.ToolTypePlugin);
expect(result.tool_name).toBe(plugin.plugin_name);
expect(result.plugin_api_name).toBe(plugin.name);
});
});

View File

@@ -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.
*/
import { InputType } from '@coze-arch/bot-api/playground_api';
import { type ShortcutEditFormValues } from '../../../../../src/shortcut-tool/types';
import {
initComponentsByToolParams,
getUnusedComponents,
} from '../../../../../src/shortcut-tool/shortcut-edit/action-switch-area/method';
describe('initComponentsByToolParams', () => {
it('should initialize components correctly', () => {
const params = [
{ name: 'param1', desc: 'description1', refer_component: true },
{ name: 'param2', desc: 'description2', refer_component: false },
];
const expected = [
{
name: 'param1',
parameter: 'param1',
description: 'description1',
input_type: InputType.TextInput,
default_value: { value: '' },
hide: false,
},
{
name: 'param2',
parameter: 'param2',
description: 'description2',
input_type: InputType.TextInput,
default_value: { value: '' },
hide: true,
},
];
expect(initComponentsByToolParams(params)).toEqual(expected);
});
it('should handle empty params', () => {
expect(initComponentsByToolParams([])).toEqual([]);
});
});
describe('getUnusedComponents', () => {
it('should return unused components', () => {
// @ts-expect-error -- hide is missing
const shortcut: ShortcutEditFormValues = {
components_list: [
{ name: 'comp1', hide: false },
{ name: 'comp2', hide: false },
],
template_query: '{{comp1}}',
};
const expected = [{ name: 'comp2', hide: false }];
expect(getUnusedComponents(shortcut)).toEqual(expected);
});
it('should handle empty components_list', () => {
// @ts-expect-error -- hide is missing
const shortcut: ShortcutEditFormValues = {
components_list: [],
template_query: '',
};
expect(getUnusedComponents(shortcut)).toEqual([]);
});
it('should handle no unused components', () => {
// @ts-expect-error -- hide is missing
const shortcut: ShortcutEditFormValues = {
components_list: [{ name: 'comp1', hide: false }],
template_query: '{{comp1}}',
};
expect(getUnusedComponents(shortcut)).toEqual([]);
});
});

View File

@@ -0,0 +1,237 @@
/*
* 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 {
InputType,
// eslint-disable-next-line camelcase
type shortcut_command,
} from '@coze-arch/bot-api/playground_api';
import {
type ComponentsWithId,
type ComponentTypeItem,
} from '../../../../../src/shortcut-tool/shortcut-edit/components-table/types';
import {
attachIdToComponents,
checkDuplicateName,
formatSubmitValues,
getComponentTypeFormBySubmitField,
getComponentTypeSelectFormInitValues,
getSubmitFieldFromComponentTypeForm,
isUploadType,
modifyComponentWhenSwitchChange,
type SubmitComponentTypeFields,
} from '../../../../../src/shortcut-tool/shortcut-edit/components-table/method';
describe('attachIdToComponents', () => {
it('should attach unique id to each component', () => {
// eslint-disable-next-line camelcase
const components: shortcut_command.Components[] = [
{ input_type: InputType.TextInput },
{ input_type: InputType.Select },
];
const result = attachIdToComponents(components);
expect(result[0]?.id).toBeDefined();
expect(result[1]?.id).toBeDefined();
expect(result[0]?.id).not.toEqual(result[1]?.id);
});
});
describe('formatSubmitValues', () => {
it('should format values correctly', () => {
const values: ComponentsWithId[] = [
{ id: '1', input_type: InputType.TextInput, options: ['option1'] },
{
id: '2',
input_type: InputType.Select,
options: ['option1', 'option2'],
},
];
const result = formatSubmitValues(values);
expect(result[0]?.options).toEqual([]);
expect(result[1]?.options).toEqual(['option1', 'option2']);
});
});
describe('checkDuplicateName', () => {
it('should return true if duplicate names exist', () => {
const values: ComponentsWithId[] = [
{ id: '1', name: 'component1' },
{ id: '2', name: 'component1' },
];
const formApi = { setError: vi.fn() };
const result = checkDuplicateName(values, formApi as any);
expect(result).toBe(true);
});
it('should return false if no duplicate names exist', () => {
const values: ComponentsWithId[] = [
{ id: '1', name: 'component1' },
{ id: '2', name: 'component2' },
];
const formApi = { setError: vi.fn() };
const result = checkDuplicateName(values, formApi as any);
expect(result).toBe(false);
});
});
describe('getComponentTypeSelectFormInitValues', () => {
it('should return initial values', () => {
const result = getComponentTypeSelectFormInitValues();
expect(result).toEqual({ type: 'text' });
});
});
describe('getSubmitFieldFromComponentTypeForm', () => {
it('returns TextInput type for text', () => {
const values: ComponentTypeItem = { type: 'text' };
const result = getSubmitFieldFromComponentTypeForm(values);
expect(result).toEqual({ input_type: InputType.TextInput });
});
it('returns Select type with options for select', () => {
const values: ComponentTypeItem = {
type: 'select',
options: ['option1', 'option2'],
};
const result = getSubmitFieldFromComponentTypeForm(values);
expect(result).toEqual({
input_type: InputType.Select,
options: ['option1', 'option2'],
});
});
it('returns MixUpload type with upload options for multiple upload types', () => {
const values: ComponentTypeItem = {
type: 'upload',
uploadTypes: [InputType.UploadImage, InputType.UploadDoc],
};
const result = getSubmitFieldFromComponentTypeForm(values);
expect(result).toEqual({
input_type: InputType.MixUpload,
upload_options: [InputType.UploadImage, InputType.UploadDoc],
});
});
it('returns specific Upload type for single upload type', () => {
const values: ComponentTypeItem = {
type: 'upload',
uploadTypes: [InputType.UploadImage],
};
const result = getSubmitFieldFromComponentTypeForm(values);
expect(result).toEqual({ input_type: InputType.UploadImage });
});
it('returns TextInput type for unrecognized type', () => {
// @ts-expect-error -- 无视
const values: ComponentTypeItem = { type: 'unknown' };
const result = getSubmitFieldFromComponentTypeForm(values);
expect(result).toEqual({ input_type: InputType.TextInput });
});
});
describe('isUploadType', () => {
it('should return true for upload types', () => {
const result = isUploadType(InputType.UploadImage);
expect(result).toBe(true);
});
it('should return false for non-upload types', () => {
const result = isUploadType(InputType.TextInput);
expect(result).toBe(false);
});
});
describe('getComponentTypeFormBySubmitField', () => {
it('returns initial values when input_type is not provided', () => {
const values: SubmitComponentTypeFields = {};
const result = getComponentTypeFormBySubmitField(values);
expect(result).toEqual({ type: 'text' });
});
it('returns correct form for TextInput type', () => {
const values: SubmitComponentTypeFields = {
input_type: InputType.TextInput,
};
const result = getComponentTypeFormBySubmitField(values);
expect(result).toEqual({ type: 'text' });
});
it('returns correct form for Select type with options', () => {
const values: SubmitComponentTypeFields = {
input_type: InputType.Select,
options: ['option1', 'option2'],
};
const result = getComponentTypeFormBySubmitField(values);
expect(result).toEqual({ type: 'select', options: ['option1', 'option2'] });
});
it('returns correct form for Upload type with upload options', () => {
const values: SubmitComponentTypeFields = {
input_type: InputType.UploadImage,
upload_options: [InputType.UploadAudio, InputType.VIDEO],
};
const result = getComponentTypeFormBySubmitField(values);
expect(result).toEqual({
type: 'upload',
uploadTypes: [InputType.UploadAudio, InputType.VIDEO],
});
});
it('returns initial values when input_type is not recognized', () => {
const values: SubmitComponentTypeFields = {
input_type: 'unknown' as unknown as InputType,
};
const result = getComponentTypeFormBySubmitField(values);
expect(result).toEqual({ type: 'text' });
});
});
describe('isUploadType', () => {
it('returns true for upload types', () => {
expect(isUploadType(InputType.UploadImage)).toBeTruthy();
expect(isUploadType(InputType.UploadDoc)).toBeTruthy();
expect(isUploadType(InputType.UploadTable)).toBeTruthy();
expect(isUploadType(InputType.UploadAudio)).toBeTruthy();
expect(isUploadType(InputType.CODE)).toBeTruthy();
expect(isUploadType(InputType.ARCHIVE)).toBeTruthy();
expect(isUploadType(InputType.PPT)).toBeTruthy();
expect(isUploadType(InputType.VIDEO)).toBeTruthy();
expect(isUploadType(InputType.TXT)).toBeTruthy();
expect(isUploadType(InputType.MixUpload)).toBeTruthy();
});
it('returns false for non-upload types', () => {
expect(isUploadType(InputType.TextInput)).toBeFalsy();
expect(isUploadType(InputType.Select)).toBeFalsy();
});
});
describe('modifyComponentWhenSwitchChange', () => {
it('should modify component hide property correctly', () => {
const components: ComponentsWithId[] = [
{ id: '1', hide: false },
{ id: '2', hide: false },
];
const record: ComponentsWithId = { id: '1', hide: false };
const result = modifyComponentWhenSwitchChange({
components,
record,
checked: false,
});
expect(result[0]?.hide).toBe(true);
expect(result[1]?.hide).toBe(false);
});
});

View File

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

View File

@@ -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 { type ItemType } from '../../src/utils/data-helper';
describe('ItemType', () => {
it('returns array item type for array input', () => {
type Result = ItemType<string[]>;
const result: Result = 'test';
expect(typeof result).to.equal('string');
});
it('returns same type for non-array input', () => {
type Result = ItemType<number>;
const result: Result = 123;
expect(typeof result).to.equal('number');
});
});

View File

@@ -0,0 +1,40 @@
/*
* 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 { getUIModeByBizScene } from '../../src/utils/get-ui-mode-by-biz-scene';
describe('ItemType', () => {
it('returns UIMode correctly', () => {
const res1 = getUIModeByBizScene({
bizScene: 'agentApp',
showBackground: false,
});
const res2 = getUIModeByBizScene({
bizScene: 'home',
showBackground: false,
});
const res3 = getUIModeByBizScene({
bizScene: 'agentApp',
showBackground: false,
});
expect(res1).toBe('grey');
expect(res2).toBe('white');
expect(res3).toBe('grey');
});
});

View File

@@ -0,0 +1,42 @@
/*
* 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 { isApiError } from '../../src/utils/handle-error';
describe('isApiError', () => {
it('identifies ApiError correctly', () => {
const error = { name: 'ApiError' };
const result = isApiError(error);
expect(result).to.be.true;
});
it('returns false for non-ApiError', () => {
const error = { name: 'OtherError' };
const result = isApiError(error);
expect(result).to.be.false;
});
it('returns false for error without name', () => {
const error = { message: 'An error occurred' };
const result = isApiError(error);
expect(result).to.be.false;
});
it('handles null and undefined', () => {
expect(isApiError(null)).to.be.false;
expect(isApiError(undefined)).to.be.false;
});
});

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getQueryFromTemplate } from '../../src/utils/shortcut-query';
describe('getQueryFromTemplate', () => {
it('should replace placeholders with corresponding values', () => {
const templateQuery = 'Hello, {{name}}!';
const values = { name: 'John' };
const result = getQueryFromTemplate(templateQuery, values);
expect(result).to.equal('Hello, John!');
});
it('should handle multiple placeholders', () => {
const templateQuery = '{{greeting}}, {{name}}!';
const values = { greeting: 'Hi', name: 'John' };
const result = getQueryFromTemplate(templateQuery, values);
expect(result).to.equal('Hi, John!');
});
it('should leave unreplaced placeholders intact', () => {
const templateQuery = 'Hello, {{name}}!';
const values = { greeting: 'Hi' };
const result = getQueryFromTemplate(templateQuery, values);
expect(result).to.equal('Hello, {{name}}!');
});
it('should handle empty values object', () => {
const templateQuery = 'Hello, {{name}}!';
const values = {};
const result = getQueryFromTemplate(templateQuery, values);
expect(result).to.equal('Hello, {{name}}!');
});
it('should handle empty template string', () => {
const templateQuery = '';
const values = { name: 'John' };
const result = getQueryFromTemplate(templateQuery, values);
expect(result).to.equal('');
});
});

View File

@@ -0,0 +1,12 @@
{
"operationSettings": [
{
"operationName": "test:cov",
"outputFolderNames": ["coverage"]
},
{
"operationName": "ts-check",
"outputFolderNames": ["./dist"]
}
]
}

View File

@@ -0,0 +1,9 @@
const { defineConfig } = require('@coze-arch/eslint-config');
module.exports = defineConfig({
packageRoot: __dirname,
preset: 'web',
rules: {
'@coze-arch/no-deep-relative-import': 'off',
},
});

View File

@@ -0,0 +1,88 @@
{
"name": "@coze-common/chat-area-plugins-chat-shortcuts",
"version": "0.0.1",
"description": "作为chat-input的插件slot默认实现维护快捷键相关功能",
"license": "Apache-2.0",
"author": "haozhenfei@bytedance.com",
"maintainers": [],
"exports": {
".": "./src/index.tsx",
"./shortcut-tool": "./src/shortcut-tool/index.tsx"
},
"main": "src/index.tsx",
"typesVersions": {
"*": {
"shortcut-tool": [
"./src/shortcut-tool/index.tsx"
]
}
},
"scripts": {
"build": "exit 0",
"lint": "eslint ./ --cache",
"test": "vitest --run --passWithNoTests",
"test:cov": "npm run test -- --coverage"
},
"dependencies": {
"@coze-agent-ide/tool": "workspace:*",
"@coze-arch/bot-api": "workspace:*",
"@coze-arch/bot-error": "workspace:*",
"@coze-arch/bot-flags": "workspace:*",
"@coze-arch/bot-icons": "workspace:*",
"@coze-arch/bot-semi": "workspace:*",
"@coze-arch/bot-space-api": "workspace:*",
"@coze-arch/bot-studio-store": "workspace:*",
"@coze-arch/bot-tea": "workspace:*",
"@coze-arch/coze-design": "0.0.6-alpha.346d77",
"@coze-arch/i18n": "workspace:*",
"@coze-arch/logger": "workspace:*",
"@coze-common/chat-area": "workspace:*",
"@coze-common/chat-area-utils": "workspace:*",
"@coze-common/chat-core": "workspace:*",
"@coze-common/chat-uikit": "workspace:*",
"@coze-common/chat-uikit-shared": "workspace:*",
"@coze-common/websocket-manager-adapter": "workspace:*",
"@coze-studio/bot-detail-store": "workspace:*",
"@coze-studio/components": "workspace:*",
"@coze-workflow/sdk": "workspace:*",
"@douyinfe/semi-icons": "^2.36.0",
"@douyinfe/semi-ui": "~2.72.3",
"ahooks": "^3.7.8",
"class-variance-authority": "^0.7.0",
"classnames": "^2.3.2",
"immer": "^10.0.3",
"lodash-es": "^4.17.21",
"nanoid": "^4.0.2",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"zustand": "^4.4.7"
},
"devDependencies": {
"@coze-agent-ide/tool-config": "workspace:*",
"@coze-arch/bot-typings": "workspace:*",
"@coze-arch/eslint-config": "workspace:*",
"@coze-arch/stylelint-config": "workspace:*",
"@coze-arch/ts-config": "workspace:*",
"@coze-arch/vitest-config": "workspace:*",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/react-hooks": "^8.0.1",
"@types/lodash-es": "^4.17.10",
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"@vitest/coverage-v8": "~3.0.5",
"react": "~18.2.0",
"react-dom": "~18.2.0",
"stylelint": "^15.11.0",
"tailwindcss": "~3.3.3",
"utility-types": "^3.10.0",
"vite-plugin-svgr": "~3.3.0",
"vitest": "~3.0.5"
},
"peerDependencies": {
"react": ">=18.2.0",
"react-dom": ">=18.2.0"
},
"// deps": "immer@^10.0.3 为脚本自动补齐,请勿改动"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,5 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon_cross_fill">
<path id="Union" d="M1.75753 2.11084C1.56227 2.3061 1.56227 2.62268 1.75753 2.81795L4.93951 5.99993L1.75753 9.18192C1.56227 9.37718 1.56227 9.69376 1.75753 9.88902L2.11108 10.2426C2.30635 10.4378 2.62293 10.4378 2.81819 10.2426L6.00017 7.06059L9.18215 10.2426C9.37741 10.4378 9.694 10.4378 9.88926 10.2426L10.2428 9.88901C10.4381 9.69375 10.4381 9.37717 10.2428 9.18191L7.06084 5.99993L10.2428 2.81795C10.4381 2.62269 10.4381 2.30611 10.2428 2.11085L9.88926 1.75729C9.694 1.56203 9.37741 1.56203 9.18215 1.75729L6.00017 4.93927L2.81819 1.75729C2.62293 1.56202 2.30635 1.56202 2.11108 1.75729L1.75753 2.11084Z" fill="#060709" fill-opacity="0.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 779 B

View File

@@ -0,0 +1,73 @@
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5257_104690)">
<g filter="url(#filter0_d_5257_104690)">
<path
d="M3.6 2.68c0-1.008 0-1.512.196-1.897a1.8 1.8 0 0 1 .786-.787C4.967-.2 5.472-.2 6.48-.2H16.8l6 7.2v13.92c0 1.008 0 1.512-.196 1.897a1.8 1.8 0 0 1-.787.787c-.384.196-.889.196-1.897.196H6.48c-1.008 0-1.512 0-1.897-.196a1.8 1.8 0 0 1-.786-.787c-.197-.385-.197-.89-.197-1.897V2.68z"
fill="#FF54C5" />
<path
d="M3.66 2.68c0-.505 0-.88.024-1.178.024-.296.072-.51.165-.692a1.74 1.74 0 0 1 .76-.76c.183-.094.397-.142.693-.166.297-.024.672-.024 1.177-.024h10.292l5.968 7.162V20.92c0 .505 0 .88-.024 1.177-.024.297-.072.51-.165.693a1.74 1.74 0 0 1-.76.76c-.183.093-.397.141-.693.165-.297.025-.673.025-1.178.025H6.48c-.505 0-.88 0-1.177-.024-.296-.025-.51-.073-.693-.166a1.74 1.74 0 0 1-.76-.76c-.093-.183-.141-.396-.165-.693a15.907 15.907 0 0 1-.025-1.177V2.68z"
stroke="#000" stroke-opacity=".08" stroke-width=".12" />
</g>
<path d="M3.6 1.6A1.8 1.8 0 0 1 5.4-.2h11.4l6 7.2v15a1.8 1.8 0 0 1-1.8 1.8H5.4A1.8 1.8 0 0 1 3.6 22V1.6z"
fill="url(#paint0_radial_5257_104690)" fill-opacity=".6" />
<g filter="url(#filter1_dd_5257_104690)" shape-rendering="crispEdges">
<path
d="M16.8-.2l6 7.2h-2.16c-1.345 0-2.017 0-2.53-.262A2.4 2.4 0 0 1 17.06 5.69c-.262-.513-.262-1.185-.262-2.53V-.2z"
fill="#fff" />
<path
d="M16.846-.239l-.107-.127v3.529c0 .67 0 1.175.033 1.578.033.404.1.71.236.976.235.463.612.839 1.075 1.075.266.136.571.202.975.235.403.033.909.033 1.579.033h2.29l-.081-.099-6-7.2z"
stroke="#000" stroke-opacity=".08" stroke-width=".12" />
</g>
<path
d="M16.8-.2l6 7.2h-2.16c-1.345 0-2.017 0-2.53-.262A2.4 2.4 0 0 1 17.06 5.69c-.262-.513-.262-1.185-.262-2.53V-.2z"
fill="#FF7BD2" />
<path d="M16.8-.2l6 7.2h-3.6a2.4 2.4 0 0 1-2.4-2.4V-.2z" fill="url(#paint1_linear_5257_104690)" fill-opacity=".6" />
</g>
<defs>
<filter id="filter0_d_5257_104690" x="-1.543" y="-3.629" width="29.486" height="34.286" filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dy="1.714" />
<feGaussianBlur stdDeviation="2.571" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.16 0" />
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow_5257_104690" />
<feBlend in="SourceGraphic" in2="effect1_dropShadow_5257_104690" result="shape" />
</filter>
<filter id="filter1_dd_5257_104690" x="9.479" y="-5.331" width="20.776" height="22.051" filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dy="2.4" />
<feGaussianBlur stdDeviation="3.6" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0" />
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow_5257_104690" />
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dy="1.2" />
<feGaussianBlur stdDeviation="1.2" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0" />
<feBlend in2="effect1_dropShadow_5257_104690" result="effect2_dropShadow_5257_104690" />
<feBlend in="SourceGraphic" in2="effect2_dropShadow_5257_104690" result="shape" />
</filter>
<radialGradient id="paint0_radial_5257_104690" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(23.09998 27.59995 -27.8692 23.32533 4.2 .4)">
<stop stop-color="#FCF3CE" />
<stop offset=".357" stop-color="#FCF3CD" stop-opacity="0" />
<stop offset=".783" stop-color="#FCF3CD" stop-opacity=".095" />
<stop offset="1" stop-color="#FCF3CD" />
</radialGradient>
<linearGradient id="paint1_linear_5257_104690" x1="15.299" y1="4.6" x2="25.75" y2="-.606"
gradientUnits="userSpaceOnUse">
<stop stop-color="#FCF3CE" />
<stop offset=".5" stop-color="#FCF3CD" stop-opacity="0" />
<stop offset=".723" stop-color="#FCF3CD" stop-opacity=".095" />
<stop offset="1" stop-color="#FCF3CD" />
</linearGradient>
<clipPath id="clip0_5257_104690">
<path fill="#fff" transform="translate(.9)" d="M0 0h24v24H0z" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1,80 @@
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5257_104663)">
<g filter="url(#filter0_d_5257_104663)">
<path
d="M3.3 2.88c0-1.008 0-1.512.197-1.897a1.8 1.8 0 0 1 .786-.787C4.668 0 5.173 0 6.18 0H16.5l6 7.2v13.92c0 1.008 0 1.512-.196 1.897a1.8 1.8 0 0 1-.786.787c-.385.196-.89.196-1.898.196H6.18c-1.008 0-1.512 0-1.897-.196a1.8 1.8 0 0 1-.786-.787c-.197-.385-.197-.889-.197-1.897V2.88z"
fill="#00A8F3" />
<path
d="M3.36 2.88c0-.505 0-.88.025-1.177.024-.297.072-.51.165-.693a1.74 1.74 0 0 1 .76-.76c.183-.093.397-.141.693-.166C5.3.06 5.675.06 6.18.06h10.292l5.968 7.162V21.12c0 .505 0 .88-.024 1.177-.024.297-.072.51-.165.693a1.741 1.741 0 0 1-.76.76c-.183.093-.397.142-.693.166-.297.024-.672.024-1.178.024H6.18c-.505 0-.88 0-1.177-.024-.296-.024-.51-.073-.693-.166a1.74 1.74 0 0 1-.76-.76c-.093-.183-.141-.396-.165-.693a15.907 15.907 0 0 1-.025-1.177V2.88z"
stroke="#000" stroke-opacity=".08" stroke-width=".12" />
</g>
<path d="M3.3 1.8A1.8 1.8 0 0 1 5.1 0h11.4l6 7.2v15a1.8 1.8 0 0 1-1.8 1.8H5.1a1.8 1.8 0 0 1-1.8-1.8V1.8z"
fill="url(#paint0_radial_5257_104663)" fill-opacity=".6" />
<g clip-path="url(#clip1_5257_104663)" fill="#fff">
<path
d="M14.1 20.7h-1.8a.601.601 0 0 1-.6-.6v-3a.6.6 0 0 1 .6-.6h1.8v.6h-1.8v3h1.8v.6zM10.5 20.7H9.3a.601.601 0 0 1-.6-.6v-3a.6.6 0 0 1 .6-.6h1.2a.6.6 0 0 1 .6.6v3a.6.6 0 0 1-.6.6zm-1.2-3.6v3h1.2v-3H9.3zM6.9 20.7H5.7v-4.2h1.2a1.201 1.201 0 0 1 1.2 1.2v1.8a1.201 1.201 0 0 1-1.2 1.2zm-.6-.6h.6a.6.6 0 0 0 .6-.6v-1.8a.6.6 0 0 0-.6-.6h-.6v3z" />
</g>
<g filter="url(#filter1_dd_5257_104663)" shape-rendering="crispEdges">
<path
d="M16.5 0l6 7.2h-2.16c-1.344 0-2.016 0-2.53-.262a2.4 2.4 0 0 1-1.048-1.048c-.262-.514-.262-1.186-.262-2.53V0z"
fill="#fff" />
<path
d="M16.547-.038l-.107-.128v3.529c0 .67 0 1.176.033 1.578.033.404.1.71.236.976.236.463.612.839 1.075 1.075.266.136.571.202.975.235.403.033.909.033 1.579.033h2.291l-.082-.098-6-7.2z"
stroke="#000" stroke-opacity=".08" stroke-width=".12" />
</g>
<path
d="M16.5 0l6 7.2h-2.16c-1.344 0-2.016 0-2.53-.262a2.4 2.4 0 0 1-1.048-1.048c-.262-.514-.262-1.186-.262-2.53V0z"
fill="#00A8F3" />
<path d="M16.5 0l6 7.2h-3.6a2.4 2.4 0 0 1-2.4-2.4V0z" fill="url(#paint1_linear_5257_104663)" fill-opacity=".6" />
</g>
<defs>
<filter id="filter0_d_5257_104663" x="-1.842" y="-3.429" width="29.486" height="34.286" filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dy="1.714" />
<feGaussianBlur stdDeviation="2.571" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.16 0" />
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow_5257_104663" />
<feBlend in="SourceGraphic" in2="effect1_dropShadow_5257_104663" result="shape" />
</filter>
<filter id="filter1_dd_5257_104663" x="9.18" y="-5.131" width="20.776" height="22.051" filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dy="2.4" />
<feGaussianBlur stdDeviation="3.6" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0" />
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow_5257_104663" />
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dy="1.2" />
<feGaussianBlur stdDeviation="1.2" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0" />
<feBlend in2="effect1_dropShadow_5257_104663" result="effect2_dropShadow_5257_104663" />
<feBlend in="SourceGraphic" in2="effect2_dropShadow_5257_104663" result="shape" />
</filter>
<radialGradient id="paint0_radial_5257_104663" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(23.09998 27.59995 -27.8692 23.32533 3.9 .6)">
<stop stop-color="#FCF3CE" />
<stop offset=".357" stop-color="#FCF3CD" stop-opacity="0" />
<stop offset=".783" stop-color="#FCF3CD" stop-opacity=".095" />
<stop offset="1" stop-color="#FCF3CD" />
</radialGradient>
<linearGradient id="paint1_linear_5257_104663" x1="15.001" y1="4.8" x2="25.451" y2="-.405"
gradientUnits="userSpaceOnUse">
<stop stop-color="#FCF3CE" />
<stop offset=".5" stop-color="#FCF3CD" stop-opacity="0" />
<stop offset=".723" stop-color="#FCF3CD" stop-opacity=".095" />
<stop offset="1" stop-color="#FCF3CD" />
</linearGradient>
<clipPath id="clip0_5257_104663">
<path fill="#fff" transform="translate(.9)" d="M0 0h24v24H0z" />
</clipPath>
<clipPath id="clip1_5257_104663">
<path fill="#fff" transform="translate(5.1 13.8)" d="M0 0h9.6v9.6H0z" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -0,0 +1,80 @@
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5257_104664)">
<g filter="url(#filter0_d_5257_104664)">
<path
d="M3.4 2.88c0-1.008 0-1.512.196-1.897a1.8 1.8 0 0 1 .787-.787C4.768 0 5.273 0 6.28 0H16.6l6 7.2v13.92c0 1.008 0 1.512-.196 1.897a1.8 1.8 0 0 1-.787.787c-.385.196-.889.196-1.897.196H6.28c-1.008 0-1.512 0-1.897-.196a1.8 1.8 0 0 1-.787-.787c-.196-.385-.196-.889-.196-1.897V2.88z"
fill="#00C979" />
<path
d="M3.46 2.88c0-.505 0-.88.024-1.177.025-.297.073-.51.166-.693a1.74 1.74 0 0 1 .76-.76c.183-.093.396-.141.693-.166C5.4.06 5.775.06 6.28.06h10.292l5.968 7.162V21.12c0 .505 0 .88-.024 1.177-.024.297-.072.51-.165.693a1.74 1.74 0 0 1-.76.76c-.184.093-.397.142-.694.166-.297.024-.672.024-1.177.024H6.28c-.505 0-.88 0-1.177-.024-.297-.024-.51-.073-.693-.166a1.74 1.74 0 0 1-.76-.76c-.093-.183-.141-.396-.166-.693a15.907 15.907 0 0 1-.024-1.177V2.88z"
stroke="#000" stroke-opacity=".08" stroke-width=".12" />
</g>
<path d="M3.4 1.8A1.8 1.8 0 0 1 5.2 0h11.4l6 7.2v15a1.8 1.8 0 0 1-1.8 1.8H5.2a1.8 1.8 0 0 1-1.8-1.8V1.8z"
fill="url(#paint0_radial_5257_104664)" fill-opacity=".6" />
<g clip-path="url(#clip1_5257_104664)" fill="#fff">
<path
d="M13.6 20.7h-1.8v-.6h1.8v-1.2h-1.2a.6.6 0 0 1-.6-.6v-1.2a.6.6 0 0 1 .6-.6h1.8v.6h-1.8v1.2h1.2a.6.6 0 0 1 .6.6v1.2a.6.6 0 0 1-.6.6zM9.4 20.1v-3.6h-.6v4.2h2.4v-.6H9.4zM8.2 16.5h-.6L7 18.3l-.6-1.8h-.6l.826 2.1-.826 2.1h.6l.6-1.8.6 1.8h.6l-.826-2.1.826-2.1z" />
</g>
<g filter="url(#filter1_dd_5257_104664)" shape-rendering="crispEdges">
<path
d="M16.6 0l6 7.2h-2.16c-1.344 0-2.016 0-2.53-.262a2.4 2.4 0 0 1-1.048-1.048c-.262-.514-.262-1.186-.262-2.53V0z"
fill="#fff" />
<path
d="M16.646-.038l-.106-.128v3.529c0 .67 0 1.176.033 1.578.033.404.1.71.235.976.236.463.613.839 1.075 1.075.267.136.572.202.976.235.403.033.909.033 1.578.033H22.728l-.082-.098-6-7.2z"
stroke="#000" stroke-opacity=".08" stroke-width=".12" />
</g>
<path
d="M16.6 0l6 7.2h-2.16c-1.344 0-2.016 0-2.53-.262a2.4 2.4 0 0 1-1.048-1.048c-.262-.514-.262-1.186-.262-2.53V0z"
fill="#00C979" />
<path d="M16.6 0l6 7.2H19a2.4 2.4 0 0 1-2.4-2.4V0z" fill="url(#paint1_linear_5257_104664)" fill-opacity=".6" />
</g>
<defs>
<filter id="filter0_d_5257_104664" x="-1.743" y="-3.429" width="29.486" height="34.286" filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dy="1.714" />
<feGaussianBlur stdDeviation="2.571" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.16 0" />
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow_5257_104664" />
<feBlend in="SourceGraphic" in2="effect1_dropShadow_5257_104664" result="shape" />
</filter>
<filter id="filter1_dd_5257_104664" x="9.28" y="-5.131" width="20.776" height="22.051" filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dy="2.4" />
<feGaussianBlur stdDeviation="3.6" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0" />
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow_5257_104664" />
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dy="1.2" />
<feGaussianBlur stdDeviation="1.2" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0" />
<feBlend in2="effect1_dropShadow_5257_104664" result="effect2_dropShadow_5257_104664" />
<feBlend in="SourceGraphic" in2="effect2_dropShadow_5257_104664" result="shape" />
</filter>
<radialGradient id="paint0_radial_5257_104664" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(23.09998 27.59995 -27.8692 23.32533 4 .6)">
<stop stop-color="#FCF3CE" />
<stop offset=".357" stop-color="#FCF3CD" stop-opacity="0" />
<stop offset=".783" stop-color="#FCF3CD" stop-opacity=".095" />
<stop offset="1" stop-color="#FCF3CD" />
</radialGradient>
<linearGradient id="paint1_linear_5257_104664" x1="15.1" y1="4.8" x2="25.551" y2="-.405"
gradientUnits="userSpaceOnUse">
<stop stop-color="#FCF3CE" />
<stop offset=".5" stop-color="#FCF3CD" stop-opacity="0" />
<stop offset=".723" stop-color="#FCF3CD" stop-opacity=".095" />
<stop offset="1" stop-color="#FCF3CD" />
</linearGradient>
<clipPath id="clip0_5257_104664">
<path fill="#fff" transform="translate(.9)" d="M0 0h24v24H0z" />
</clipPath>
<clipPath id="clip1_5257_104664">
<path fill="#fff" transform="translate(5.2 13.8)" d="M0 0h9.6v9.6H0z" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -0,0 +1,5 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="coz_arrow_down_fill">
<path id="Union" d="M6.00241 7.17466L9.53794 3.63912C9.7332 3.44386 10.0498 3.44386 10.245 3.63912L10.5986 3.99268C10.7939 4.18794 10.7939 4.50452 10.5986 4.69978L6.70951 8.58887C6.31899 8.97939 5.68582 8.97939 5.2953 8.58887L1.40621 4.69978C1.21095 4.50452 1.21095 4.18794 1.40621 3.99268L1.75977 3.63912C1.95503 3.44386 2.27161 3.44386 2.46687 3.63912L6.00241 7.17466Z" fill="currentColor" fill-opacity="0.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 550 B

View File

@@ -0,0 +1,5 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="coz_arrow_down_fill">
<path id="Union" d="M6.00241 4.82534L9.53794 8.36088C9.7332 8.55614 10.0498 8.55614 10.245 8.36088L10.5986 8.00732C10.7939 7.81206 10.7939 7.49548 10.5986 7.30022L6.70951 3.41113C6.31899 3.02061 5.68582 3.02061 5.2953 3.41113L1.40621 7.30022C1.21095 7.49548 1.21095 7.81206 1.40621 8.00732L1.75977 8.36088C1.95503 8.55614 2.27161 8.55614 2.46687 8.36088L6.00241 4.82534Z" fill="currentColor" fill-opacity="0.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

View File

@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="coz_ban">
<path id="Union" d="M0.666992 8.00008C0.666992 12.0502 3.95024 15.3334 8.00033 15.3334C12.0504 15.3334 15.3337 12.0502 15.3337 8.00008C15.3337 3.94999 12.0504 0.666748 8.00033 0.666748C3.95024 0.666748 0.666992 3.94999 0.666992 8.00008ZM11.7453 12.6881C10.7189 13.5091 9.41694 14.0001 8.00033 14.0001C4.68662 14.0001 2.00033 11.3138 2.00033 8.00008C2.00033 6.58346 2.49127 5.28151 3.31229 4.25508L11.7453 12.6881ZM12.6882 11.7453L4.25508 3.31225C5.28154 2.4911 6.58359 2.00008 8.00033 2.00008C11.314 2.00008 14.0003 4.68637 14.0003 8.00008C14.0003 9.41681 13.5093 10.7189 12.6882 11.7453Z" fill="#060709" fill-opacity="0.3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 751 B

View File

@@ -0,0 +1,9 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="coz_warning_circle_fill_palette">
<path id="Union" d="M15.9997 30.6666C24.0998 30.6666 30.6663 24.1001 30.6663 15.9999C30.6663 7.89974 24.0998 1.33325 15.9997 1.33325C7.8995 1.33325 1.33301 7.89974 1.33301 15.9999C1.33301 24.1001 7.8995 30.6666 15.9997 30.6666Z" fill="#F22435"/>
<g id="Union_2">
<path d="M16.0003 9.33325C15.2639 9.33325 14.667 9.93021 14.667 10.6666V17.3333C14.667 18.0696 15.2639 18.6666 16.0003 18.6666C16.7367 18.6666 17.3337 18.0696 17.3337 17.3333V10.6666C17.3337 9.93021 16.7367 9.33325 16.0003 9.33325Z" fill="white"/>
<path d="M16.0003 19.9999C15.2639 19.9999 14.667 20.5969 14.667 21.3333C14.667 22.0696 15.2639 22.6666 16.0003 22.6666C16.7367 22.6666 17.3337 22.0696 17.3337 21.3333C17.3337 20.5969 16.7367 19.9999 16.0003 19.9999Z" fill="white"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 897 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,3 @@
<svg width="2" height="12" viewBox="0 0 2 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector 2594" d="M1 0V12" stroke="#1D1C23" stroke-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 177 B

View File

@@ -0,0 +1,158 @@
/*
* 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, useRef } from 'react';
import { useUpdateEffect } from 'ahooks';
import { IconSpin } from '@douyinfe/semi-icons';
import { useLoadMore } from '../../hooks/shortcut-bar/use-load-more';
const TIME_TO_CANCEL_MOUSE_MOVE = 50;
export interface LoadMoreListData<TData extends object> {
list: TData[];
hasMore: boolean;
}
export type LoadMoreListProps<TData extends object> = {
className?: string;
getId: (data: TData) => string;
defaultId?: string;
itemRender: (data: TData) => React.ReactNode;
defaultList?: TData[];
listTopSlot?: React.ReactNode;
getMoreListService: (
currentData: LoadMoreListData<TData> | undefined,
) => Promise<LoadMoreListData<TData>>;
onSelect?: (data: TData) => void;
onActiveId?: (id: string) => void;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'className' | 'onSelect'>;
export const LoadMoreList = <TData extends object>(
props: LoadMoreListProps<TData>,
) => {
const {
className,
onSelect,
getId,
itemRender,
onActiveId,
getMoreListService,
defaultId,
listTopSlot,
defaultList,
...restProps
} = props;
const mouseMovingCancelIdRef = useRef<ReturnType<typeof setTimeout>>();
const mouseMovingRef = useRef(false);
const listRef = useRef<HTMLDivElement>(null);
const activeItemRef = useRef<HTMLDivElement | null>(null);
const {
data,
scrollIntoView,
activeId,
focusTo,
goNext,
goPrev,
loadingMore,
loading,
} = useLoadMore<TData>({
getMoreListService,
getId: (item: TData) => getId(item),
listRef,
defaultList,
});
const list = data?.list ?? [];
useEffect(() => {
onActiveId?.(activeId);
}, [activeId]);
useUpdateEffect(() => {
if (loading) {
return;
}
const defaultItem = list.find(item => getId(item) === defaultId);
if (defaultItem) {
focusTo(defaultItem);
scrollIntoView(defaultItem);
onActiveId?.(defaultId || getId(defaultItem));
}
}, [loading]);
return (
<div
ref={listRef}
tabIndex={1}
className={className}
onMouseLeave={() => {
focusTo(null);
}}
onMouseMove={() => {
clearTimeout(mouseMovingCancelIdRef.current);
mouseMovingRef.current = true;
mouseMovingCancelIdRef.current = setTimeout(() => {
mouseMovingRef.current = false;
}, TIME_TO_CANCEL_MOUSE_MOVE);
}}
onKeyDown={event => {
if (event.key === 'ArrowDown') {
event.preventDefault();
goNext();
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
goPrev();
return;
}
if (event.key === 'Enter') {
const selectItem = list.find(item => getId(item) === activeId);
selectItem && onSelect?.(selectItem);
}
}}
{...restProps}
>
{listTopSlot}
{list.map(item => (
<div
key={getId(item)}
data-id={getId(item)}
ref={getId(item) === activeId ? activeItemRef : null}
onClick={() => {
onSelect?.(item);
}}
onMouseEnter={() => {
// 鼠标位于滚动条中,会触发该事件,设置仅在移动鼠标过程中进行更新
if (mouseMovingRef.current) {
focusTo(item);
listRef.current?.focus();
}
}}
>
{itemRender(item)}
</div>
))}
{loadingMore || loading ? (
<div className="flex justify-center items-center">
<IconSpin style={{ color: '#4D53E8' }} spin />
</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,74 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC } from 'react';
import { useIsSendMessageLock } from '@coze-common/chat-area';
import { type DSL } from '../../types';
import { ChatAreaStateContext } from '../../context/chat-area-state/context';
import { type DSLContext } from './widgets/types';
import { DSLWidgetsMap } from './widgets';
const getChildrenIds = (item: DSL['elements'][string]): string[] =>
item.children ??
((item.props?.Columns ?? []) as { children: string[] }[])?.reduce<string[]>(
(res, column) => {
if (column.children) {
res.push(...column.children);
}
return res;
},
[],
);
const DSLRender: FC<
{
elementId: string;
} & IShortCutPanelProps
> = ({ elementId, ...context }) => {
const { dsl } = context;
const item = dsl?.elements[elementId];
const itemType = item?.type || '';
const Component = itemType in DSLWidgetsMap ? DSLWidgetsMap[itemType] : null;
const childrenIds = item && getChildrenIds(item);
if (!Component) {
// TODO slardar report
return null;
}
return (
<Component context={context} props={item?.props}>
{childrenIds?.map(childrenId => (
<div className="flex-1 overflow-hidden">
<DSLRender key={childrenId} elementId={childrenId} {...context} />
</div>
))}
</Component>
);
};
export type IShortCutPanelProps = DSLContext;
export const ShortCutPanel: FC<IShortCutPanelProps> = ({ dsl, ...context }) => {
const isSendMessageLock = useIsSendMessageLock();
return (
<ChatAreaStateContext.Provider value={{ isSendMessageLock }}>
<DSLRender elementId={dsl.rootID} dsl={dsl} {...context} />
</ChatAreaStateContext.Provider>
);
};

View File

@@ -0,0 +1,81 @@
/*
* 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 { useRef } from 'react';
import { type FormApi } from '@coze-arch/bot-semi/Form';
import { Form } from '@coze-arch/bot-semi';
import { type DSLComponent, type TValue } from '../types';
import { findInputElementsWithDefault } from '../../../../utils/dsl-template';
type FormValue = Record<string, TValue>;
export const DSLForm: DSLComponent = ({
context: { onChange, onSubmit, dsl },
children,
}) => {
const formRef = useRef<FormApi>();
/**
* text类型组件交互 支持 placeholder 表示默认值
* @param formValues
*/
const onSubmitWrap = (formValues: FormValue) => {
if (!onSubmit) {
return;
}
const inputElementsWithDefault = findInputElementsWithDefault(dsl);
const newValues = Object.entries(formValues).reduce(
(prev: Record<string, TValue>, curr) => {
const [field, value] = curr;
const input = inputElementsWithDefault.find(i => i.id === field);
if (input && !value) {
prev[field] = input.defaultValue;
} else {
prev[field] = value;
}
return prev;
},
{},
);
inputElementsWithDefault.forEach(input => {
const { id, defaultValue } = input;
if (id && !(id in newValues)) {
newValues[id] = defaultValue;
}
});
onSubmit(newValues);
};
return (
<Form<FormValue>
className="w-full"
autoComplete="off"
getFormApi={api => (formRef.current = api)}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
onChange={formState => onChange?.(formState.values!)}
onSubmit={onSubmitWrap}
>
{children}
</Form>
);
};

View File

@@ -0,0 +1,38 @@
/*
* 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 { DSLFormUpload } from './upload';
import { type DSLComponent } from './types';
import { DSLFormInput } from './text-input';
import { DSLSubmitButton } from './submit-button';
import { DSLFormSelect } from './select';
import { DSLRoot } from './root';
import { DSLPlaceholer } from './placeholder';
import { DSLColumnLayout } from './layout';
import { DSLForm } from './form';
// 组件参数是在运行时决定,无法具体做类型约束
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const DSLWidgetsMap: Record<string, DSLComponent<any>> = {
'@flowpd/cici-components/Input': DSLFormInput,
'@flowpd/cici-components/Select': DSLFormSelect,
'@flowpd/cici-components/Upload': DSLFormUpload,
'@flowpd/cici-components/Placeholder': DSLPlaceholer,
'@flowpd/cici-components/ColumnLayout': DSLColumnLayout,
'@flowpd/cici-components/Form': DSLForm,
'@flowpd/cici-components/PageContainer': DSLRoot,
'@flowpd/cici-components/Button': DSLSubmitButton,
} as const;

View File

@@ -0,0 +1,31 @@
.label {
overflow-x: hidden;
width: fit-content;
max-width: calc(100 - 16px);
margin-bottom: 0;
padding-right: 0;
:global(.semi-form-field-label-text) {
display: flex;
flex-wrap: nowrap;
align-items: center;
width: 100%;
}
}
.text {
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
.icon {
margin-left: 2px;
padding: 2px;
svg {
width: 12px;
height: 12px;
}
}

View File

@@ -0,0 +1,48 @@
/*
* 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 } from 'react';
import { Form, Tooltip, Typography } from '@coze-arch/bot-semi';
import { IconInfo } from '@coze-arch/bot-icons';
import style from './index.module.less';
export const LabelWithDescription: FC<{
name: string;
description?: string;
required?: boolean;
}> = ({ name, description, required = true }) => (
<div className="w-full flex items-center px-2 mb-[2px]">
<Form.Label
text={
<Typography.Text
ellipsis={{ showTooltip: true }}
className={style.text}
>
{name}
</Typography.Text>
}
required={required}
className={style.label}
/>
{!!description && (
<Tooltip content={description}>
<IconInfo className={style.icon} />
</Tooltip>
)}
</div>
);

View File

@@ -0,0 +1,24 @@
/*
* 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, PropsWithChildren } from 'react';
// 本期不需要不支持复布局解析
export const DSLColumnLayout: FC<PropsWithChildren> = ({ children }) => (
<div className="flex items-center justify-between w-full mb-3 gap-2">
{children}
</div>
);

View File

@@ -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.
*/
import type { FC } from 'react';
import { I18n } from '@coze-arch/i18n';
export const DSLPlaceholer: FC = () => (
<div
className="flex items-center justify-center rounded-lg coz-bg-plus text-center text-xs font-medium coz-fg-secondary "
style={{ height: 58 }}
>
{I18n.t('shortcut_modal_components')}
</div>
);

View File

@@ -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.
*/
import type { FC, PropsWithChildren } from 'react';
export const DSLRoot: FC<PropsWithChildren> = ({ children }) => <>{children}</>;

View File

@@ -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 { type SelectProps } from '@coze-arch/bot-semi/Select';
import { UIFormSelect } from '@coze-arch/bot-semi';
import { type DSLFormFieldCommonProps, type DSLComponent } from '../types';
import { LabelWithDescription } from '../label-with-desc';
export const DSLFormSelect: DSLComponent<
DSLFormFieldCommonProps & Pick<SelectProps, 'optionList'>
> = ({
context: { readonly },
props: { name, description, defaultValue, ...props },
}) => {
const required = !defaultValue?.value;
return (
<div>
<LabelWithDescription
name={name}
description={description}
required={required}
/>
<UIFormSelect
disabled={readonly}
fieldStyle={{ padding: 0 }}
className="w-full"
field={name}
initValue={defaultValue?.value}
noLabel
{...props}
/>
</div>
);
};

View File

@@ -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 { IconButton, useFormState } from '@coze-arch/bot-semi';
import { IconSend } from '@coze-arch/bot-icons';
import {
type DSLContext,
type DSLComponent,
type DSLFormFieldCommonProps,
} from '../types';
import { findInputElementById } from '../../../../utils/dsl-template';
import { useChatAreaState } from '../../../../context/chat-area-state';
import styles from './index.module.less';
interface DSLSubmitButtonProps {
formFields?: string[];
}
const useIsSubmitButtonDisable = ({
context: { readonly, dsl },
props: { formFields = [] },
}: {
context: DSLContext;
props: Pick<DSLSubmitButtonProps, 'formFields'>;
}): boolean => {
const formState = useFormState();
const disabled = formFields.some(field => {
const isEmpty = !formState.values[field];
const isError = !!formState.errors?.[field];
const inputDefaultValue = findInputElementById(dsl, field)?.props
?.defaultValue as DSLFormFieldCommonProps['defaultValue'];
if (inputDefaultValue?.value) {
return isError;
}
return isError || isEmpty;
});
const { isSendMessageLock } = useChatAreaState();
return readonly || disabled || isSendMessageLock;
};
export const DSLSubmitButton: DSLComponent<DSLSubmitButtonProps> = ({
context,
props,
}) => {
const isDisabled = useIsSubmitButtonDisable({ context, props });
return (
<div className="flex justify-end">
<IconButton
theme="borderless"
className={styles.button}
htmlType="submit"
size="small"
disabled={isDisabled}
icon={<IconSend />}
/>
</div>
);
};

View File

@@ -0,0 +1,60 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type RuleItem } from '@coze-arch/bot-semi/Form';
import { Form } from '@coze-arch/bot-semi';
import { type DSLFormFieldCommonProps, type DSLComponent } from '../types';
import { LabelWithDescription } from '../label-with-desc';
const parseRules = (rules: RuleItem[]): RuleItem[] =>
rules.map(rule => {
if (rule.required) {
return {
...rule,
// required 情况下,禁止输入空格
validator: (r, v) => !!v?.trim(),
};
}
return rule;
});
export const DSLFormInput: DSLComponent<DSLFormFieldCommonProps> = ({
context: { readonly },
props: { name, description, rules, defaultValue, ...props },
}) => {
const required = !defaultValue?.value;
return (
<div>
<LabelWithDescription
required={required}
name={name}
description={description}
/>
<Form.Input
disabled={readonly}
fieldStyle={{ padding: 0 }}
placeholder={defaultValue?.value}
className="w-full"
field={name}
noLabel
rules={parseRules(rules)}
{...props}
/>
</div>
);
};

View File

@@ -0,0 +1,60 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type PropsWithChildren, type FC } from 'react';
import { type RuleItem } from '@coze-arch/bot-semi/Form';
import { type InputType } from '@coze-arch/bot-api/playground_api';
import { type DSL } from '../../../types';
export interface FileValue {
fileInstance?: File;
url?: string;
width?: number;
height?: number;
}
export type TValue = string | FileValue | undefined;
export type TCustomUpload = (uploadParams: {
file: File;
onProgress?: (percent: number) => void;
onSuccess?: (url: string, width?: number, height?: number) => void;
onError?: (e: { status?: number }) => void;
}) => void;
export interface DSLContext {
dsl: DSL;
uploadFile?: TCustomUpload;
onChange?: (value: Record<string, TValue>) => void; // 需要兼容 file
onSubmit?: (value: Record<string, TValue>) => void;
readonly?: boolean; // 支持搭建时的预览模式
}
export interface DSLFormFieldCommonProps {
name: string;
description?: string;
rules: RuleItem[];
defaultValue?: {
type: InputType;
value: string;
};
}
export type DSLComponent<TProps = unknown> = FC<
PropsWithChildren<{ context: DSLContext; props: TProps }>
>;

View File

@@ -0,0 +1,85 @@
.upload-button {
min-width: 0;
padding: 8px;
border-style: dashed
}
button.delete-btn {
height: 20px;
line-height: 1;
border-radius: 6px;
}
.delete-icon {
svg {
width: 12px;
height: 12px;
}
}
.file {
position: relative;
padding: 3px;
* {
z-index: 1;
}
&:focus {
border-color: #4E40E5;
}
}
.file-uploading::after {
content: '';
position: absolute;
top: 0;
left: 0;
display: block;
width: var(--var-percent);
min-width: 15%;
height: 100%;
background-color: #e6e8ff;
}
.container.container-error {
.upload-button {
border-color: #F22435;
}
.input {
border-color: #F22435;
}
.file {
border-color: #F22435;
}
}
.retry {
cursor: pointer;
display: flex;
flex-shrink: 0;
flex-wrap: nowrap;
gap: 8px;
align-items: center;
justify-content: flex-start;
margin-right: 12px;
font-size: 12px;
font-weight: 500;
line-height: 16px;
color: #4E40E5;
svg {
width: 12px;
height: 12px;
}
}

View File

@@ -0,0 +1,365 @@
/*
* 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, useState, useRef, useEffect } from 'react';
import classnames from 'classnames';
import { getFileInfo } from '@coze-common/chat-core';
import { I18n } from '@coze-arch/i18n';
import { type FileItem } from '@coze-arch/bot-semi/Upload';
import {
IconButton,
Toast,
Typography,
UIButton,
UIInput,
Upload,
useFieldApi,
withField,
} from '@coze-arch/bot-semi';
import {
IconAdd,
IconClose,
IconCloseNoCycle,
IconCopyLink,
} from '@coze-arch/bot-icons';
import { type shortcut_command } from '@coze-arch/bot-api/playground_api';
import {
type DSLFormFieldCommonProps,
type DSLComponent,
type TValue,
type TCustomUpload,
} from '../types';
import { LabelWithDescription } from '../label-with-desc';
import { getFileInfoByFileType } from '../../../../utils/file-const';
import style from './index.module.less';
const UploadContent: FC<{
file: FileItem;
disabled?: boolean;
inputType: shortcut_command.InputType;
onRemove: () => void;
onRetry: () => void;
}> = ({ file, disabled, inputType, onRemove, onRetry }) => {
const isFailed = file.status === 'uploadFail';
const isUploading = file.status === 'uploading';
const fileType =
file.fileInstance && getFileInfo(file.fileInstance)?.fileType;
const fileIcon = fileType && getFileInfoByFileType(fileType)?.icon;
return (
<div
className={classnames(
style.file,
'flex border border-solid rounded-lg items-center w-full coz-stroke-primary',
{
[style['file-uploading'] || '']: isUploading,
},
)}
style={{
// @ts-expect-error ts 无法识别自定义变量
'--var-percent': `${file.percent}%`,
}}
>
<img
src={fileIcon ?? file.url}
className={classnames(
'w-6 h-6',
fileType === 'image' &&
'rounded border border-solid coz-stroke-primary',
)}
/>
<Typography.Text ellipsis className="mx-2 flex-1 text-sm">
{file.name}
</Typography.Text>
{isFailed ? (
<div
onClick={e => {
e.stopPropagation();
if (!disabled) {
onRetry();
}
}}
className={style.retry}
>
<IconClose className="coz-fg-hglt-red" />
<div>{I18n.t('Retry')}</div>
</div>
) : null}
<IconButton
className={classnames('close-btn w-5 h-5', style['delete-btn'])}
disabled={disabled}
onClick={e => {
e.stopPropagation();
onRemove();
}}
theme="borderless"
size="small"
icon={<IconCloseNoCycle className={style['delete-icon']} />}
/>
</div>
);
};
interface UploadProps {
value?: unknown;
name: string;
onChange?: (value: TValue) => void;
uploadFile?: TCustomUpload;
maxSize?: number;
accept?: string;
disabled?: boolean;
validateStatus?: 'error' | 'success';
inputType: shortcut_command.InputType;
}
const FileUpload: FC<
UploadProps & {
toggle: () => void;
}
> = ({
value,
name,
uploadFile,
onChange,
inputType,
disabled,
toggle,
...props
}) => {
const [file, setFile] = useState<FileItem | undefined>();
const fieldApi = useFieldApi(name);
const uidRef = useRef<string | undefined>(file?.uid);
const onUpload = (newFile: FileItem) => {
if (newFile.fileInstance) {
setFile({
...newFile,
percent: 0,
status: 'uploading',
});
// 立即清理错误状态
fieldApi.setError(true);
uidRef.current = newFile?.uid;
uploadFile?.({
file: newFile.fileInstance,
onProgress: percent => {
if (uidRef.current !== newFile.uid) {
return;
}
setFile({
...newFile,
percent,
status: 'uploading',
});
},
onSuccess: (url, width = 0, height = 0) => {
if (uidRef.current !== newFile.uid) {
return;
}
onChange?.({
fileInstance: newFile.fileInstance,
url,
width,
height,
});
setFile({
...newFile,
response: url,
percent: 100,
status: 'success',
});
},
onError: () => {
if (uidRef.current !== newFile.uid) {
return;
}
// 上传失败,触发错误状态
fieldApi.setError(false);
setFile({
...newFile,
status: 'uploadFail',
});
},
});
}
};
return (
<>
<Upload
action=""
className="w-full"
draggable
limit={1}
{...props}
disabled={disabled}
onAcceptInvalid={() => {
Toast.error(I18n.t('shortcut_Illegal_file_format'));
}}
onSizeError={() => {
if (props.maxSize) {
Toast.error(
I18n.t('file_too_large', {
max_size: `${props.maxSize / 1024}MB`,
}),
);
}
}}
customRequest={({ onSuccess }) => {
// 即使 action="" ,在不传 customRequest 仍然会触发一次向当前 URL 上传文件的请求
// 这里传一个 mock customRequest 来阻止 semi 默认的上传行为
onSuccess('');
}}
showUploadList={false}
onChange={({ currentFile }) => {
// semi 同一个文件会触发多次 onChange这里只响应首个
if (
uidRef.current !== currentFile.uid &&
(!props.maxSize ||
(currentFile.fileInstance?.size &&
currentFile.fileInstance.size <= props.maxSize * 1024))
) {
onUpload(currentFile);
}
}}
>
{file ? (
<UploadContent
file={file}
inputType={inputType}
onRemove={() => {
uidRef.current = undefined;
setFile(undefined);
onChange?.('');
setTimeout(() => {
// 删除文件,清理错误状态避免立刻飘红
fieldApi.setError(true);
});
}}
onRetry={() => {
if (file) {
onUpload(file);
}
}}
/>
) : (
<UIButton
icon={<IconAdd />}
disabled={disabled}
className={classnames(style['upload-button'], 'w-full')}
>
<span className={style['upload-button-text-short']}>
{I18n.t('shortcut_component_upload_component_placeholder')}
</span>
</UIButton>
)}
</Upload>
{!file && (
<IconButton
disabled={disabled}
icon={<IconCopyLink />}
onClick={toggle}
/>
)}
</>
);
};
const FileInput: FC<
UploadProps & {
toggle: () => void;
}
> = ({ disabled, onChange, toggle }) => (
<>
<UIInput disabled={disabled} onChange={onChange} className={style.input} />
<IconButton
disabled={disabled}
icon={<IconCloseNoCycle />}
onClick={toggle}
/>
</>
);
// 为了方便控制向外传递的 value
const UploadInner = withField((props: UploadProps) => {
const [showInput, setShowInput] = useState(false);
const hasError = props.validateStatus === 'error';
const fieldApi = useFieldApi(props.name);
// 避免清空输入导致的飘红
useEffect(() => {
setTimeout(() => {
props.onChange?.('');
// 避免 onchange 触发校验导致立刻飘红
setTimeout(() => {
fieldApi.setError(true);
});
});
}, [showInput]);
return (
<div
className={classnames(
'flex items-center justify-start gap-2',
style.container,
hasError && style['container-error'],
)}
>
{showInput ? (
<FileInput
toggle={() => {
setShowInput(false);
}}
{...props}
/>
) : (
<FileUpload
toggle={() => {
setShowInput(true);
}}
{...props}
/>
)}
</div>
);
});
export const DSLFormUpload: DSLComponent<
DSLFormFieldCommonProps & {
maxSize?: number;
accept?: string;
inputType: shortcut_command.InputType;
}
> = ({
context: { uploadFile, readonly },
props: { name, description, rules, ...props },
}) => (
<div>
<LabelWithDescription name={name} description={description} />
<UploadInner
field={name}
noLabel
name={name}
fieldStyle={{ padding: 0 }}
uploadFile={uploadFile}
disabled={readonly}
rules={readonly ? undefined : rules}
{...props}
/>
</div>
);

View File

@@ -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.
*/
import { createContext } from 'react';
export const ChatAreaStateContext = createContext<{
isSendMessageLock: boolean;
}>({ isSendMessageLock: false });

View File

@@ -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.
*/
import { useContext } from 'react';
import { ChatAreaStateContext } from './context';
export const useChatAreaState = () => useContext(ChatAreaStateContext);

View File

@@ -0,0 +1,235 @@
/*
* 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, useMemo, useState } from 'react';
import { useInfiniteScroll } from 'ahooks';
import { useImperativeLayoutEffect } from '../use-imperative-layout-effect';
import { type LoadMoreListData } from '../../components/load-more-list';
export interface LoadMoreHookProps<TData extends object> {
getId: (item: TData) => string;
listRef: RefObject<HTMLDivElement>;
defaultList?: TData[];
getMoreListService: (
currentData: LoadMoreListData<TData> | undefined,
) => Promise<LoadMoreListData<TData>>;
}
export const useLoadMore = <TData extends object>(
props: LoadMoreHookProps<TData>,
) => {
const { getId, listRef, getMoreListService, defaultList } = props;
const [activeId, setActiveId] = useState('');
const { data, loadingMore, loading, loadMore } = useInfiniteScroll<
LoadMoreListData<TData>
>(currentData => getMoreListService(currentData), {
target: listRef,
isNoMore: d => !d?.hasMore,
});
const resultData = useMemo(() => {
if (defaultList) {
return {
list: defaultList.concat(data?.list ?? []),
hasMore: !!data?.hasMore,
};
}
return {
list: data?.list ?? [],
hasMore: !!data?.hasMore,
};
}, [data]);
const { list } = resultData;
const focusTo = (toItem: TData | null) => {
if (!toItem) {
setActiveId('');
return;
}
if (!listRef.current) {
return;
}
const findItem = list.find(item => getId(toItem) === getId(item));
if (!findItem) {
return;
}
const itemId = getId(findItem);
setActiveId(itemId);
};
const focusFirst = () => {
const firstItem = list[0];
firstItem && focusTo(firstItem);
};
const scrollToFirst = () => {
if (!listRef.current) {
return;
}
listRef.current.scrollTop = 0;
};
const scrollIntoView = useImperativeLayoutEffect((toItem: TData) => {
const itemId = getId(toItem);
const itemRef = listRef.current?.querySelector(`[data-id="${itemId}"]`);
if (!itemRef) {
return;
}
itemRef.scrollIntoView({
behavior: 'instant' as ScrollBehavior,
block: 'nearest',
});
});
const goNext = () => {
const curItem = list.find(item => getId(item) === activeId);
if (!curItem) {
return;
}
const { item: nextItem, reachLimit } = getNextActiveItem<TData>({
getId,
list,
curItem,
});
if (reachLimit) {
loadMore();
}
if (!loadingMore) {
focusTo(nextItem);
scrollIntoView(nextItem);
}
};
const goPrev = () => {
const curItem = list.find(item => getId(item) === activeId);
if (!curItem) {
return;
}
const { item: prevItem, reachLimit } = getPreviousItem<TData>({
getId,
list,
curItem,
});
if (reachLimit) {
loadMore();
}
focusTo(prevItem);
scrollIntoView(prevItem);
};
return {
activeId,
focusFirst,
focusTo,
scrollToFirst,
scrollIntoView,
goNext,
goPrev,
loadingMore,
data: resultData,
loading,
};
};
const getTargetItemAndIndex = <TData extends object>({
getId,
list,
target,
}: {
getId: (item: TData) => string;
list: TData[];
target: TData;
}) => {
let targetIndex = -1;
const targetItem = list.find((item, index) => {
if (getId(item) === getId(target)) {
targetIndex = index;
return true;
}
return false;
});
return {
targetItem,
targetIndex,
};
};
export const getNextActiveItem = <TData extends object>({
curItem,
list,
getId,
}: {
curItem: TData;
list: TData[];
getId: (item: TData) => string;
}): {
reachLimit: boolean;
item: TData;
} => {
const { targetIndex } = getTargetItemAndIndex({
getId,
list,
target: curItem,
});
if (targetIndex < 0) {
return {
reachLimit: false,
item: curItem,
};
}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const reachLimit = targetIndex >= list.length - 3;
const nextIndex = (targetIndex + 1) % list.length;
const item = list.at(nextIndex) || curItem;
return {
reachLimit,
item,
};
};
export const getPreviousItem = <TData extends object>({
curItem,
list,
getId,
}: {
curItem: TData;
list: TData[];
getId: (item: TData) => string;
}): {
reachLimit: boolean;
item: TData;
} => {
const { targetIndex } = getTargetItemAndIndex({
getId,
list,
target: curItem,
});
if (targetIndex < 0) {
return {
reachLimit: false,
item: curItem,
};
}
const reachLimit = targetIndex === 0;
const nextIdx = (targetIndex - 1) % list.length;
const item = list.at(nextIdx) || curItem;
return {
reachLimit,
item,
};
};

View File

@@ -0,0 +1,418 @@
/*
* 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 { cloneDeep, merge } from 'lodash-es';
import websocketManager from '@coze-common/websocket-manager-adapter';
import {
getFileInfo,
type TextAndFileMixMessageProps,
} from '@coze-common/chat-core';
import {
ContentType,
type SendMessageOptions,
useSendMultimodalMessage,
useSendTextMessage,
} from '@coze-common/chat-area';
import { type PartialRequired } from '@coze-arch/bot-typings/common';
import { EVENT_NAMES, sendTeaEvent } from '@coze-arch/bot-tea';
import { ToolType } from '@coze-arch/bot-api/playground_api';
import type { ShortCutCommand } from '@coze-agent-ide/tool-config';
import { getQueryFromTemplate } from '../utils/shortcut-query';
import { enableSendTypePanelHideTemplate } from '../shortcut-tool/shortcut-edit/method';
import {
type OnBeforeSendQueryShortcutParams,
type OnBeforeSendTemplateShortcutParams,
} from '../shortcut-bar/types';
import {
type FileValue,
type TValue,
} from '../components/short-cut-panel/widgets/types';
export const useSendTextQueryMessage = () => {
const sendTextMessage = useSendTextMessage();
return (params: {
queryTemplate: string;
options?: SendMessageOptions;
onBeforeSend?: (
sendParams: OnBeforeSendQueryShortcutParams,
) => OnBeforeSendQueryShortcutParams;
shortcut: ShortCutCommand;
}) => {
const {
queryTemplate,
onBeforeSend,
options: inputOptions,
shortcut,
} = params;
const { tool_type } = shortcut;
const useTool =
tool_type !== undefined &&
[ToolType.ToolTypeWorkFlow, ToolType.ToolTypePlugin].includes(tool_type);
const message = {
payload: {
text: queryTemplate,
mention_list: [],
},
};
const pluginParams = useTool ? getPluginDefaultParams(shortcut) : {};
const options = merge(
{
extendFiled: {
...pluginParams,
device_id: String(websocketManager.deviceId),
},
},
inputOptions,
);
const { message: newMessage, options: newOptions } = onBeforeSend?.({
message,
options,
}) || {
message,
options,
};
sendTextMessage(
{
text: newMessage.payload.text,
mentionList: newMessage.payload.mention_list,
},
'shortcut',
newOptions,
);
sendTeaEvent(EVENT_NAMES.shortcut_use, {
tool_type,
use_components: !!shortcut.components_list?.length,
show_panel: enableSendTypePanelHideTemplate(shortcut),
});
};
};
export const useSendUseToolMessage = () => {
const sendMultimodalMessage = useSendMultimodalMessage();
return ({
shortcut,
options: inputOptions,
componentsFormValues,
onBeforeSendTemplateShortcut,
withoutComponentsList = false,
}: {
shortcut: ShortCutCommand;
componentsFormValues: Record<string, TValue>;
options?: SendMessageOptions;
onBeforeSendTemplateShortcut?: (
params: OnBeforeSendTemplateShortcutParams,
) => OnBeforeSendTemplateShortcutParams;
withoutComponentsList?: boolean;
}) => {
const { tool_type } = shortcut;
const sendQuery = getTemplateQuery(
shortcut,
componentsFormValues,
/**
* 无参数调用 store 场景下没有 componentList
*/
withoutComponentsList,
);
const useTool =
tool_type !== undefined &&
[ToolType.ToolTypeWorkFlow, ToolType.ToolTypePlugin].includes(tool_type);
const pluginParams = useTool
? getPluginParams(shortcut, componentsFormValues)
: {};
const imageAndFileList = getImageAndFileList(componentsFormValues);
const message: TextAndFileMixMessageProps = {
payload: {
mixList: [
{
type: ContentType.Text,
// TODO 需要看下是否能够优化
/**
* 防止发送空消息(没有对话的气泡框) => 使用空格占位
*/
text: sendQuery || ' ',
},
...imageAndFileList,
],
mention_list: [],
},
};
const options = merge(
{
extendFiled: {
...pluginParams,
device_id: String(websocketManager.deviceId),
},
},
inputOptions,
);
const handledParams = onBeforeSendTemplateShortcut?.({
message: cloneDeep(message),
options: cloneDeep(options),
}) || {
message,
options,
};
sendMultimodalMessage(
handledParams.message ?? message,
'shortcut',
handledParams.options,
);
sendTeaEvent(EVENT_NAMES.shortcut_use, {
tool_type,
use_components: !!shortcut.components_list?.length,
show_panel: !enableSendTypePanelHideTemplate(shortcut),
});
};
};
interface ToolParamValue {
value: string;
resource_type: 'uri' | '';
}
const getPluginParams = (
shortcut: ShortCutCommand,
componentsFormValues: Record<string, TValue>,
) => {
const {
plugin_id,
plugin_api_name,
components_list,
tool_info: { tool_params_list } = {},
} = shortcut;
const filterImagesValues = filterComponentFormValues(
componentsFormValues,
value => {
const { fileInstance, url } = value;
const resourceType = fileInstance ? 'uri' : '';
return {
value: url,
resource_type: resourceType,
};
},
value => ({
value,
resource_type: '',
}),
);
// key: components_list中的parameter属性 valuevalues中对应的值 | default_value
const runPluginVariables = (tool_params_list ?? []).reduce<
Record<string, ToolParamValue>
>((acc, cur) => {
const { default_value, name, refer_component } = cur;
if (!name) {
return acc;
}
if (!refer_component) {
acc[name] = {
value: default_value ?? '',
resource_type: '',
};
return acc;
}
const targetComponentName = components_list?.find(
com => com.parameter === name,
)?.name;
const componentValue =
targetComponentName && filterImagesValues[targetComponentName];
if (componentValue) {
acc[name] = componentValue as ToolParamValue;
}
return acc;
}, {});
if (!Object.keys(runPluginVariables).length) {
return {
shortcut_cmd_id: shortcut.command_id,
toolList: [],
};
}
return {
shortcut_cmd_id: shortcut.command_id,
toolList: [
{
plugin_id,
api_name: plugin_api_name ?? '',
parameters: runPluginVariables,
},
],
};
};
const getPluginDefaultParams = (shortcut: ShortCutCommand) => {
const {
plugin_id,
plugin_api_name,
tool_info: { tool_params_list } = {},
} = shortcut;
// key: components_list中的parameter属性 valuevalues中对应的值 | default_value
const runPluginVariables = (tool_params_list ?? []).reduce<
Record<string, ToolParamValue>
>((acc, cur) => {
const { default_value, name } = cur;
if (!name) {
return acc;
}
acc[name] = {
value: default_value ?? '',
resource_type: '',
};
return acc;
}, {});
return {
shortcut_cmd_id: shortcut.command_id,
toolList: [
{
plugin_id,
api_name: plugin_api_name ?? '',
parameters: runPluginVariables,
},
],
};
};
export const getTemplateQuery = (
shortcut: ShortCutCommand,
componentsFormValues: Record<string, TValue>,
withoutComponentsList = false,
) => {
const { template_query, components_list } = shortcut;
if (!template_query) {
throw new Error('template_query is not defined');
}
// 处理图片文件
const componentListValue = getComponentListValue(
components_list,
componentsFormValues,
);
if (withoutComponentsList) {
return getQueryFromTemplate(template_query, componentsFormValues ?? {});
}
return getQueryFromTemplate(template_query, componentListValue);
};
const filterComponentFormValues = (
componentsFormValues: Record<string, TValue>,
setImageAndFileValue: (value: FileValue) => unknown,
setTextValue: (value: string) => unknown,
) =>
Object.keys(componentsFormValues).reduce<Record<string, unknown>>(
(acc, cur) => {
const value = componentsFormValues[cur];
// 文件类型
if (typeof value === 'object' && value.fileInstance) {
acc[cur] = setImageAndFileValue(value);
return acc;
}
// 普通文本类型
acc[cur] = setTextValue(value as string);
return acc;
},
{},
);
export const getImageAndFileList = (
componentsFormValues: Record<string, TValue>,
): TextAndFileMixMessageProps['payload']['mixList'] =>
Object.keys(componentsFormValues).reduce<
TextAndFileMixMessageProps['payload']['mixList']
>((acc, cur) => {
const value = componentsFormValues[cur];
if (isComponentFile(value)) {
acc.push({
type: ContentType.File,
file: value.fileInstance,
uri: value.url,
});
return acc;
}
if (isComponentImage(value)) {
acc.push({
type: ContentType.Image,
file: value.fileInstance,
uri: value.url,
width: value.width || 0,
height: value.height || 0,
});
return acc;
}
return acc;
}, []);
const isComponentFile = (
value: TValue,
): value is PartialRequired<FileValue, 'fileInstance' | 'url'> =>
Boolean(
typeof value === 'object' &&
value.fileInstance &&
getFileInfo(value.fileInstance)?.fileType !== 'image',
);
const isComponentImage = (
value: TValue,
): value is PartialRequired<FileValue, 'fileInstance' | 'url'> =>
Boolean(
typeof value === 'object' &&
value.fileInstance &&
getFileInfo(value.fileInstance)?.fileType === 'image',
);
// 获取component_list的value, 带上默认值
export const getComponentListValue = (
componentsList: ShortCutCommand['components_list'],
componentsFormValues: Record<string, TValue>,
): Record<string, string> => {
const filterValues = filterComponentFormValues(
componentsFormValues,
value => value?.fileInstance?.name,
value => value,
);
// key: components_list中的parameter属性 valuevalues中对应的值 | default_value
return (componentsList ?? []).reduce<Record<string, string>>((acc, cur) => {
const { default_value, name, hide } = cur;
if (!name) {
return acc;
}
if (hide) {
acc[name] = default_value?.value ?? '';
return acc;
}
const componentValue = filterValues[name];
if (componentValue) {
acc[name] = componentValue as string;
}
return acc;
}, {});
};

View File

@@ -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 { useState, useRef, useLayoutEffect } from 'react';
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- x
type Destructor = (() => void) | void;
type Fn<ARGS extends unknown[]> = (...args: ARGS) => Destructor;
export const useImperativeLayoutEffect = <Params extends unknown[]>(
effect: Fn<Params>,
deps: unknown[] = [],
) => {
const [effectValue, setEffectValue] = useState(0);
const paramRef = useRef<Params>();
const effectRef = useRef<Fn<Params>>(() => undefined);
effectRef.current = effect;
useLayoutEffect(() => {
if (!effectValue) {
return;
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- 体操不动, 凑活用吧
// @ts-expect-error
const params = paramRef.current || ([] as Params);
return effectRef.current(...params);
}, [effectValue, ...deps]);
return (...args: Params) => {
paramRef.current = args;
setEffectValue(pre => pre + 1);
};
};

View File

@@ -0,0 +1,108 @@
/*
* 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 {
getFileInfo,
type UploadPluginConstructor,
} from '@coze-common/chat-core';
import { useGetRegisteredPlugin } from '@coze-common/chat-area';
// 延迟1.5s后开始模拟上传进度
const FAKE_PROGRESS_START_DELAY = 1500;
// fake progress 初始进度
const FAKE_PROGRESS_START = 50;
// 最大进度
const FAKE_PROGRESS_MAX = 85;
// 每次步进值
const FAKE_PROGRESS_STEP = 5;
// 循环间隔
const FAKE_PROGRESS_INTERVAL = 100;
export const useGetUploadPluginInstance = () => {
const getRegisteredPlugin = useGetRegisteredPlugin();
return ({
file,
onProgress,
onError,
onSuccess,
}: {
file: File;
onProgress?: (percent: number) => void;
onError?: (error: { status: number | undefined }) => void;
onSuccess?: (url: string, width: number, height: number) => void;
}) => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const UploadPlugin: UploadPluginConstructor | null | undefined =
getRegisteredPlugin('upload-plugin');
if (!UploadPlugin) {
return;
}
const uploader = new UploadPlugin({
file,
type: getFileInfo(file)?.fileType === 'image' ? 'image' : 'object',
});
// 如果1s内上传进度没有变化主动触发fake progress, 500ms内从50%上升到80%,忽略后续的真实进度
let isStartFakeProgress = false;
let fakeProgressTimer: number | undefined;
let fakeProgress = FAKE_PROGRESS_START;
const fakeProgressHandler = () => {
if (fakeProgress < FAKE_PROGRESS_MAX) {
fakeProgress += FAKE_PROGRESS_STEP;
onProgress?.(fakeProgress);
}
};
const startFakeProgressTimer = setTimeout(() => {
isStartFakeProgress = true;
fakeProgressTimer = window.setInterval(
fakeProgressHandler,
FAKE_PROGRESS_INTERVAL,
);
}, FAKE_PROGRESS_START_DELAY);
function clearFakeProgress() {
clearTimeout(startFakeProgressTimer);
clearInterval(fakeProgressTimer);
fakeProgressTimer = undefined;
fakeProgressTimer = undefined;
isStartFakeProgress = false;
}
uploader.on('progress', ({ percent }) => {
// 有假进度,忽略后续的真实进度
if (isStartFakeProgress) {
return;
}
startFakeProgressTimer && clearFakeProgress();
onProgress?.(percent);
});
uploader.on('error', e => {
onError?.({ status: e.extra.errorCode });
clearFakeProgress();
});
uploader.on(
'complete',
// eslint-disable-next-line @typescript-eslint/naming-convention
({ uploadResult: { Url, Uri, ImageHeight = 0, ImageWidth = 0 } }) => {
{
onSuccess?.(Url || Uri || '', ImageWidth, ImageHeight);
clearFakeProgress();
}
},
);
};
};

View File

@@ -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.
*/
// !Notice 禁止直接导出 shortcut-tool会导致下游依赖不需要的 knowledge-upload
// export { ShortcutToolConfig } from './shortcut-tool';
export { ShortcutBar } from './shortcut-bar';
export { ComponentsTable } from './shortcut-tool/shortcut-edit/components-table';
export {
ShortCutCommand,
getStrictShortcuts,
} from '@coze-agent-ide/tool-config';
export type {
OnBeforeSendTemplateShortcutParams,
OnBeforeSendQueryShortcutParams,
} from './shortcut-bar/types';
export { getUIModeByBizScene } from './utils/get-ui-mode-by-biz-scene';

View File

@@ -0,0 +1,13 @@
.shortcut-bar {
position: relative;
display: flex;
justify-content: center;
:global {
.semi-portal-inner {
width: 100%;
min-width: 100%;
max-width: 100%;
}
}
}

View File

@@ -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.
*/
// 快捷指令操作bar
import { type CSSProperties, type FC, useRef, useState } from 'react';
import cls from 'classnames';
import { type ShortCutCommand } from '@coze-agent-ide/tool-config';
import { useMessageWidth } from '@coze-common/chat-area';
import { OverflowList, Popover } from '@coze-arch/bot-semi';
import { SendType } from '@coze-arch/bot-api/playground_api';
import {
enableSendTypePanelHideTemplate,
getFormValueFromShortcut,
} from '../shortcut-tool/shortcut-edit/method';
import { ShortcutTemplate } from '../shortcut-template';
import { ShortcutsLoadMoreList } from '../shortcut/load-more/shortcuts-load-more-list';
import { TemplateShortcut, LoadMore, QueryShortcut } from '../shortcut';
import { useSendUseToolMessage } from '../hooks/shortcut';
import { type TValue } from '../components/short-cut-panel/widgets/types';
import {
type OnBeforeSendQueryShortcutParams,
type OnBeforeSendTemplateShortcutParams,
type UIMode,
} from './types';
import style from './index.module.less';
interface ChatShortCutBarProps {
shortcuts: ShortCutCommand[];
onActiveShortcutChange?: (
shortcutInfo?: ShortCutCommand,
isTemplateShortcutActive?: boolean,
) => void;
className?: string;
wrapperClassName?: string;
uiMode?: UIMode; // 默认为白色,有背景的时候为模糊
defaultId?: string;
wrapperStyle?: CSSProperties;
toolTipFooterSlot?: React.ReactNode;
onBeforeSendTemplateShortcut?: (
params: OnBeforeSendTemplateShortcutParams,
) => OnBeforeSendTemplateShortcutParams;
onBeforeSendTextMessage?: (
params: OnBeforeSendQueryShortcutParams,
) => OnBeforeSendQueryShortcutParams;
popoverTipShowBotInfo?: boolean;
}
// eslint-disable-next-line @coze-arch/max-line-per-function
export const ShortcutBar: FC<ChatShortCutBarProps> = props => {
const {
shortcuts,
onActiveShortcutChange,
className,
wrapperClassName,
defaultId,
uiMode = 'white',
wrapperStyle,
toolTipFooterSlot,
onBeforeSendTemplateShortcut,
onBeforeSendTextMessage,
popoverTipShowBotInfo = false,
} = props;
const overflowListRef = useRef<HTMLDivElement>(null);
const [isShowLoadMoreList, setIsShowLoadMoreList] = useState(false);
const [activeShortcut, setActiveShortcut] = useState<
ShortCutCommand | undefined
>(undefined);
const [shortcutTemplateVisible, setShortcutTemplateVisible] = useState(false);
const sendUseToolMessage = useSendUseToolMessage();
const messageWidth = useMessageWidth();
const handleActiveShortcutChange = (
shortcut: ShortCutCommand | undefined,
hideTemplate = false,
) => {
setActiveShortcut(shortcut);
const isTemplateShortcutActive =
shortcut?.send_type === SendType.SendTypePanel && !hideTemplate;
onActiveShortcutChange?.(shortcut, isTemplateShortcutActive);
setShortcutTemplateVisible(isTemplateShortcutActive);
};
const shortcutClick = (shortcut: ShortCutCommand) => {
/**
* send_type=SendTypePanel 且 components_list hide均为true
* 直接发送
*/
const hideTemplate = enableSendTypePanelHideTemplate(shortcut);
if (hideTemplate) {
onShortcutTemplateNoParamsSubmit(
getFormValueFromShortcut(shortcut),
shortcut,
);
}
handleActiveShortcutChange(shortcut, hideTemplate);
setIsShowLoadMoreList(false);
};
const closeShortcutTemplate = () => {
setShortcutTemplateVisible(false);
handleActiveShortcutChange(undefined);
};
const renderShortcut = (shortcut: ShortCutCommand) => (
<>
{shortcut.send_type === SendType.SendTypeQuery && (
<QueryShortcut
uiMode={uiMode}
key={shortcut.command_id}
shortcut={shortcut}
onBeforeSend={onBeforeSendTextMessage}
toolTipFooterSlot={toolTipFooterSlot}
popoverTipShowBotInfo={popoverTipShowBotInfo}
onClick={() => shortcutClick(shortcut)}
/>
)}
{shortcut.send_type === SendType.SendTypePanel && (
<TemplateShortcut
uiMode={uiMode}
key={shortcut.command_id}
shortcut={shortcut}
toolTipFooterSlot={toolTipFooterSlot}
popoverTipShowBotInfo={popoverTipShowBotInfo}
onClick={() => shortcutClick(shortcut)}
/>
)}
</>
);
const onShortcutTemplateSubmit = (
componentsFormValues: Record<string, TValue>,
) => {
if (!activeShortcut) {
return;
}
const { agent_id, object_id } = activeShortcut;
sendUseToolMessage({
shortcut: activeShortcut,
options: {
extendFiled: {
extra: {
bot_state: JSON.stringify({
agent_id,
bot_id: object_id,
}),
},
},
},
componentsFormValues,
onBeforeSendTemplateShortcut,
});
closeShortcutTemplate();
};
/**
* sendType=panel 支持不展示组件直接发送
*/
const onShortcutTemplateNoParamsSubmit = (
componentsFormValues: Record<string, TValue>,
shortcut?: ShortCutCommand,
) => {
if (!shortcut) {
return;
}
const { agent_id, object_id, components_list, tool_info } = shortcut;
/**
* sendType=panel,useTool=true 无参数直接发送
*/
const withoutComponentsList =
!!tool_info?.tool_name && !components_list?.length;
sendUseToolMessage({
shortcut,
options: {
extendFiled: {
extra: {
bot_state: JSON.stringify({
agent_id,
bot_id: object_id,
}),
},
},
},
componentsFormValues,
onBeforeSendTemplateShortcut,
withoutComponentsList,
});
closeShortcutTemplate();
};
if (!shortcuts?.length) {
return null;
}
if (shortcutTemplateVisible && activeShortcut) {
return (
<ShortcutTemplate
shortcut={activeShortcut}
onSubmit={onShortcutTemplateSubmit}
visible={shortcutTemplateVisible}
onClose={() => {
handleActiveShortcutChange(undefined);
}}
/>
);
}
return (
<div
className={cls(
style['shortcut-bar'],
className,
'flex justify-center items-center w-full',
)}
>
<Popover
content={
<ShortcutsLoadMoreList
defaultId={defaultId}
shortcuts={shortcuts}
onBeforeSendTextMessage={onBeforeSendTextMessage}
onSelect={shortcutClick}
/>
}
onVisibleChange={setIsShowLoadMoreList}
position={'topLeft'}
trigger="custom"
visible={isShowLoadMoreList}
spacing={{
x: 0,
y: 9,
}}
getPopupContainer={() => overflowListRef.current || document.body}
onClickOutSide={() => setIsShowLoadMoreList(false)}
onEscKeyDown={() => setIsShowLoadMoreList(false)}
>
<div
ref={overflowListRef}
className={cls(wrapperClassName, 'relative flex justify-start pb-4')}
style={{
maxWidth: messageWidth,
...wrapperStyle,
}}
>
<OverflowList
style={{
width: '100%',
}}
minVisibleItems={1}
items={shortcuts}
// @ts-expect-error visibleItemRenderer 有问题
visibleItemRenderer={renderShortcut}
overflowRenderer={overflowItems => {
if (!overflowItems.length) {
return null;
}
return (
<LoadMore
uiMode={uiMode}
isLoadMoreActive={isShowLoadMoreList}
shortcuts={shortcuts}
onOpen={() => setIsShowLoadMoreList(true)}
onClose={() => setIsShowLoadMoreList(false)}
getPopupContainer={() => overflowListRef.current}
/>
);
}}
/>
</div>
</Popover>
</div>
);
};

View File

@@ -0,0 +1,39 @@
/*
* 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 ShortCutCommand } from '@coze-agent-ide/tool-config';
import {
type SendMessageOptions,
type TextAndFileMixMessageProps,
type TextMessageProps,
} from '@coze-common/chat-core';
export interface ChatShortCutBarProps {
shortcuts: ShortCutCommand[]; // 目前支持两种快捷键
onClickShortCut: (shortcutInfo: ShortCutCommand) => void;
}
// 更新后 home 为 white 调试区、商店为 grey
export type UIMode = 'grey' | 'white' | 'blur'; // 默认为白色,有背景的时候为模糊
export interface OnBeforeSendTemplateShortcutParams {
message: TextAndFileMixMessageProps;
options?: SendMessageOptions;
}
export interface OnBeforeSendQueryShortcutParams {
message: TextMessageProps;
options?: SendMessageOptions;
}

View File

@@ -0,0 +1,10 @@
.shortcut-template {
margin: 0 24px 36px;
background: #FFF;
border: 1px solid #4E40E5;
border-radius: 24px;
}
.template-icon {
margin-right: 4px;
}

View File

@@ -0,0 +1,103 @@
/*
* 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, useMemo, useRef } from 'react';
import { type ShortCutCommand } from '@coze-agent-ide/tool-config';
import { useMessageWidth } from '@coze-common/chat-area';
import { UIIconButton } from '@coze-arch/bot-semi';
import { IconShortcutTemplateClose } from '@coze-arch/bot-icons';
import { getDSLFromComponents } from '../utils/dsl-template';
import { useGetUploadPluginInstance } from '../hooks/use-upload-plugin';
import { type TValue } from '../components/short-cut-panel/widgets/types';
import { ShortCutPanel } from '../components/short-cut-panel';
import style from './index.module.less';
interface ShortcutTemplateProps {
shortcut: Partial<ShortCutCommand>;
visible?: boolean;
readonly?: boolean;
onClose?: () => void;
onSubmit?: (componentsFormValues: Record<string, TValue>) => void;
}
export const ShortcutTemplate: FC<ShortcutTemplateProps> = props => {
const { shortcut, onClose, visible, readonly, onSubmit } = props;
const shortcutTemplateRef = useRef<HTMLDivElement>(null);
const getRegisteredPluginInstance = useGetUploadPluginInstance();
const messageWidth = useMessageWidth();
const dsl = useMemo(() => {
const showComponents =
shortcut.components_list?.filter(com => !com.hide) ?? [];
return getDSLFromComponents(showComponents);
}, [shortcut.components_list]);
const onShortcutPanelSubmit = (values: Record<string, TValue>) => {
onSubmit?.(values);
};
if (!visible) {
return null;
}
return (
<>
<div
ref={shortcutTemplateRef}
className={style['shortcut-template']}
style={{
width: `calc(${messageWidth} - 48px)`,
}}
>
{/*header*/}
<div className="flex items-center text-sm coz-fg-primary px-4 py-[6px] coz-bg-primary rounded-t-3xl h-8">
{shortcut.shortcut_icon?.url ? (
<img
src={shortcut.shortcut_icon.url}
alt="icon"
className="mr-1 h-[14px]"
/>
) : null}
<div>{shortcut.command_name}</div>
<UIIconButton
icon={<IconShortcutTemplateClose />}
onClick={onClose}
wrapperClass="ml-auto"
/>
</div>
{/*content*/}
<div className="p-3">
<ShortCutPanel
uploadFile={({ file, onError, onProgress, onSuccess }) => {
getRegisteredPluginInstance?.({
file,
onProgress,
onError,
onSuccess,
});
}}
readonly={readonly}
onSubmit={onShortcutPanelSubmit}
dsl={dsl}
/>
</div>
</div>
</>
);
};

View File

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

View File

@@ -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 React from 'react';
import { I18n } from '@coze-arch/i18n';
import { Image } from '@coze-arch/bot-semi';
import style from '../index.module.less';
import shortcutTipEn from '../../../assets/shortcut-tip_en.png';
import shortcutTipCn from '../../../assets/shortcut-tip_cn.png';
export const ShortcutTips = () => (
<div className={style['tip-content']}>
<div style={{ marginBottom: '8px' }}>
{I18n.t('bot_ide_shortcut_intro')}
</div>
<Image
preview={false}
width={416}
src={IS_OVERSEA ? shortcutTipEn : shortcutTipCn}
/>
</div>
);

View File

@@ -0,0 +1,197 @@
@ide-tool-prefix: chat-studio-tool-content-block;
.shortcut-tool-config {
padding-right: 0;
padding-left: 0;
:global {
.@{ide-tool-prefix}-content {
/* stylelint-disable declaration-no-important */
padding-right: 0 !important;
padding-left: 0 !important;
}
}
}
.shortcut-list {
display: flex;
flex-direction: column;
}
.shortcut-item {
display: flex;
place-content: center space-between;
height: 52px;
margin-bottom: 4px;
padding: 8px;
background: rgba(6, 7, 9, 4%);
border-radius: 8px;
&.shortcut-item-mouse_hover {
background: rgba(6, 7, 9, 12%);
border: 1px solid rgba(6, 7, 9, 10%);
}
&.shortcut-item_hovered {
background: rgba(6, 7, 9, 4%);
}
}
.shortcut-item_title {
overflow: hidden;
display: flex;
align-items: center;
font-size: 12px;
font-weight: 500;
font-style: normal;
line-height: 16px;
color: rgba(6, 7, 9, 80%);
text-overflow: ellipsis;
}
.shortcut-item_header {
display: flex;
flex-direction: column;
justify-content: center;
width: calc(100% - 80px);
}
.operation {
display: flex;
align-items: center;
justify-content: space-between;
}
.operation-item-icon {
cursor: pointer;
:global {
.semi-icon {
svg {
width: 14px;
height: 14px;
}
}
}
}
.operation-item-icon_drag {
cursor: grab;
background: unset !important;
&.operation-dragging {
cursor: grabbing;
}
}
.operation-item-icon_hover {
&:hover {
background: rgba(6, 7, 9, 16%);
border-radius: 6px;
}
}
.delete-modal {
:global {
.semi-modal {
border-radius: 8px;
.semi-modal-content {
padding: 16px;
border-radius: 8px;
.semi-modal-header {
margin: 0;
}
.semi-modal-footer {
margin: 24px 0 0;
}
}
}
}
}
.delete-common-modal-button-style {
min-width: 56px;
height: 32px;
padding: 6px 16px;
font-size: 14px;
font-weight: 500;
font-style: normal;
line-height: 20px;
background: rgba(6, 7, 9, 8%);
border-radius: 8px;
}
.delete-modal-cancel-button {
.delete-common-modal-button-style;
color: rgba(6, 7, 9, 80%);
}
.delete-modal-ok-button {
.delete-common-modal-button-style;
color: #fff;
background-color: #f22435;
border-radius: 8px;
&:hover {
background-color: #ba0010 !important;
}
&:active {
background-color: #b0000f !important;
}
}
.icon-button-16 {
cursor: pointer;
&:hover {
border-radius: 4px;
}
:global {
.semi-button {
&.semi-button-size-small {
height: 16px;
padding: 1px !important;
svg {
@apply text-foreground-2;
}
}
}
}
}
.tip-content {
display: flex;
flex-direction: column;
width: 416px;
font-size: 14px;
font-weight: 500;
font-style: normal;
line-height: 20px;
}
.hidden {
visibility: hidden;
}
.shortcut-config-empty {
font-size: 14px;
font-weight: 400;
font-style: normal;
line-height: 20px;
color: var(--coz-fg-secondary);
}

View File

@@ -0,0 +1,319 @@
/*
* 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.
*/
// 快捷指令在IDE中的配置tool
import React, { type FC, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useDebounceFn } from 'ahooks';
import {
type ShortCutStruct,
getStrictShortcuts,
type ShortCutCommand,
ToolKey,
} from '@coze-agent-ide/tool-config';
import type { IToggleContentBlockEventParams } from '@coze-agent-ide/tool';
import {
AddButton,
EventCenterEventName,
ToolContentBlock,
useEvent,
useToolContentBlockDefaultExpand,
useToolDispatch,
useToolValidData,
} from '@coze-agent-ide/tool';
import { useBotSkillStore } from '@coze-studio/bot-detail-store/bot-skill';
import { useBotInfoStore } from '@coze-studio/bot-detail-store/bot-info';
import {
getBotDetailIsReadonly,
updateShortcutSort,
} from '@coze-studio/bot-detail-store';
import { logger } from '@coze-arch/logger';
import { I18n } from '@coze-arch/i18n';
import { useSpaceStore } from '@coze-arch/bot-studio-store';
import { Toast } from '@coze-arch/bot-semi';
import { BotMode } from '@coze-arch/bot-api/playground_api';
import { PlaygroundApi } from '@coze-arch/bot-api';
import { type SkillsModalProps } from '../types';
import { useShortcutEditModal } from '../shortcut-edit';
import { isApiError } from '../../utils/handle-error';
import { EmptyShortcuts } from './shortcut-list/empty-shortcuts';
import { ShortcutList } from './shortcut-list';
import { ShortcutTips } from './config-action';
import style from './index.module.less';
const MAX_SHORTCUTS = 10;
export interface ShortcutToolConfigProps {
title: string;
toolKey: 'shortcut';
skillModal: FC<SkillsModalProps>;
botMode: BotMode;
}
// eslint-disable-next-line @coze-arch/max-line-per-function
export const ShortcutToolConfig: FC<ShortcutToolConfigProps> = props => {
const [apiErrorMessage, setApiErrorMessage] = useState('');
const { title, skillModal: SkillModal, botMode } = props;
const { isReadonly, botId } = useBotInfoStore(
useShallow(state => ({
isReadonly: getBotDetailIsReadonly(),
botId: state.botId,
})),
);
const { shortcuts: initShortcuts = [] } = useBotSkillStore(
useShallow(state => ({
shortcuts: state.shortcut.shortcut_list,
})),
);
const getSpaceId = useSpaceStore(state => state.getSpaceId);
const setHasValidData = useToolValidData();
// single不展示指定agent的快捷指令
const singleShortcuts = initShortcuts?.filter(shortcut => !shortcut.agent_id);
const shortcuts =
botMode === BotMode.SingleMode ? singleShortcuts : initShortcuts;
const hasConfiguredShortcuts = Boolean(shortcuts && shortcuts.length > 0);
setHasValidData(hasConfiguredShortcuts);
const isReachLimit = shortcuts.length >= MAX_SHORTCUTS;
const defaultExpand = useToolContentBlockDefaultExpand({
configured: hasConfiguredShortcuts,
});
const dispatch = useToolDispatch<ShortCutStruct>();
const { emit } = useEvent();
const [selectedShortcut, setSelectedShortcut] = useState<
ShortCutCommand | undefined
>(undefined);
const { run: updateShortcutSortDebounce } = useDebounceFn(
async (newShortcuts: string[]) => {
await updateShortcutSort(newShortcuts);
},
{
wait: 500,
},
);
const onDisorder = async (orderList: ShortCutCommand[]) => {
try {
const newSortList = orderList.map(item => item.command_id);
dispatch({ shortcut_list: orderList, shortcut_sort: newSortList });
await updateShortcutSortDebounce(newSortList);
} catch (e) {
logger.error({
error: e as Error,
eventName: 'shortcut-disorder-service-fail',
});
}
};
const onEditClick = (shortcut: ShortCutCommand) => {
setSelectedShortcut(shortcut);
openShortcutModal();
};
const onRemoveClick = async (shortcut: ShortCutCommand) => {
try {
const newSorts = shortcuts
?.filter(item => item.command_id !== shortcut.command_id)
.map(item => item.command_id);
await updateShortcutSort(newSorts);
const newShortcuts = shortcuts?.filter(
item => item.command_id !== shortcut.command_id,
);
newShortcuts && dispatch({ shortcut_list: newShortcuts });
} catch (error) {
if (!isApiError(error)) {
Toast.error(I18n.t('shortcut_modal_fail_to_delete_shortcut_error'));
}
logger.error({
error: error as Error,
eventName: 'shortcut-removeShortcut-fail',
});
}
};
const closeModal = () => {
closeShortcutModal();
setApiErrorMessage('');
};
const editShortcut = async (
shortcut: ShortCutCommand,
onFail: () => void,
) => {
try {
await PlaygroundApi.CreateUpdateShortcutCommand(
{
object_id: botId,
space_id: getSpaceId(),
shortcuts: shortcut,
},
{ __disableErrorToast: true },
);
// TODO: hzf 得加上
// if (res && res.data?.check_not_pass) {
// Toast.error(I18n.t('shortcut_modal_illegal_keyword_detected_error'));
// onFail();
// return;
// }
const newShortcuts = shortcuts?.map(item =>
item.command_id === shortcut.command_id ? shortcut : item,
);
newShortcuts && dispatch({ shortcut_list: newShortcuts });
closeModal();
onFail();
} catch (e) {
onFail();
if (!isApiError(e)) {
Toast.error(I18n.t('shortcut_modal_fail_to_update_shortcut_error'));
}
if (isApiError(e)) {
const error = e as { message?: string; msg?: string };
setApiErrorMessage(error.message || error.msg || '');
}
logger.error({
error: e as Error,
eventName: 'shortcut-editShortcut-fail',
});
}
};
const addShortcut = async (shortcut: ShortCutCommand, onFail: () => void) => {
try {
const { shortcuts: newShortcut } =
await PlaygroundApi.CreateUpdateShortcutCommand(
{
object_id: botId,
space_id: getSpaceId(),
shortcuts: shortcut,
},
{ __disableErrorToast: true },
);
const strictShortcuts = newShortcut && getStrictShortcuts([newShortcut]);
// 一次只能添加一个快捷指令
const strictShortcut = strictShortcuts?.[0];
if (!strictShortcut) {
Toast.error('Please fill in the required fields');
return;
}
const newShortcuts = [
...(shortcuts?.map(item => item.command_id) || []),
strictShortcut.command_id,
];
await updateShortcutSort(newShortcuts);
dispatch({ shortcut_list: [...(shortcuts || []), ...strictShortcuts] });
emit<IToggleContentBlockEventParams>(
EventCenterEventName.ToggleContentBlock,
{
abilityKey: ToolKey.SHORTCUT,
isExpand: true,
},
);
closeModal();
} catch (error) {
onFail();
if (!isApiError(error)) {
Toast.error(I18n.t('shortcut_modal_fail_to_add_shortcut_error'));
}
if (isApiError(error)) {
const e = error as { message?: string; msg?: string };
setApiErrorMessage(e.message || e.msg || '');
}
logger.error({
error: error as Error,
eventName: 'shortcut-addShortcut-fail',
});
}
};
const {
node: ShortcutModal,
open: openShortcutModal,
close: closeShortcutModal,
} = useShortcutEditModal({
skillModal: SkillModal,
shortcut: selectedShortcut,
errorMessage: apiErrorMessage,
setErrorMessage: setApiErrorMessage,
onAdd: addShortcut,
onEdit: editShortcut,
botMode,
});
const renderShortcutConfig = () => {
if (!hasConfiguredShortcuts) {
return <EmptyShortcuts />;
}
return (
<ShortcutList
shortcuts={shortcuts}
isReadonly={isReadonly}
onDisorder={onDisorder}
onRemove={onRemoveClick}
onEdit={onEditClick}
/>
);
};
return (
<>
{ShortcutModal}
<ToolContentBlock
className={style['shortcut-tool-config']}
showBottomBorder={!hasConfiguredShortcuts}
header={title}
defaultExpand={defaultExpand}
tooltip={<ShortcutTips />}
actionButton={
!isReadonly && (
<>
<AddButton
tooltips={
isReachLimit
? I18n.t('bot_ide_shortcut_max_limit', {
maxCount: MAX_SHORTCUTS,
})
: I18n.t('bot_ide_shortcut_add_button')
}
onClick={() => {
if (isReachLimit) {
return;
}
setSelectedShortcut(undefined);
openShortcutModal();
}}
enableAutoHidden={true}
data-testid="bot.editor.tool.shortcut.add-button"
/>
</>
)
}
>
{renderShortcutConfig()}
</ToolContentBlock>
</>
);
};

View File

@@ -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.
*/
import { type FC } from 'react';
import { I18n } from '@coze-arch/i18n';
import style from '../index.module.less';
export const EmptyShortcuts: FC = () => (
<div className={style['shortcut-config-empty']}>
{I18n.t('bot_ide_shortcut_intro')}
</div>
);

View File

@@ -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 { type FC } from 'react';
import cls from 'classnames';
import { type ShortCutCommand } from '@coze-agent-ide/tool-config';
import { ToolItemList } from '@coze-agent-ide/tool';
import { SortableList } from '@coze-studio/components/sortable-list';
import style from '../index.module.less';
import { ShortcutItem } from './shortcut-item';
interface ShortcutsListProps {
shortcuts: ShortCutCommand[];
isReadonly: boolean;
onRemove?: (shortcut: ShortCutCommand) => void;
onDisorder?: (orderList: ShortCutCommand[]) => void;
onEdit?: (shortcut: ShortCutCommand) => void;
}
const SortableListSymbol = Symbol('Shortcut-config-list-sortlist');
export const ShortcutList: FC<ShortcutsListProps> = props => {
const { shortcuts, onDisorder, onEdit, onRemove, isReadonly } = props;
const handleRemove = (shortcut: ShortCutCommand) => {
onRemove?.(shortcut);
};
const handleDisorder = (orderList: ShortCutCommand[]) => {
onDisorder?.(orderList);
};
const handleEdit = (shortcut: ShortCutCommand) => {
onEdit?.(shortcut);
};
return (
<>
<div className={cls(style['shortcut-list'])}>
<ToolItemList>
<SortableList
type={SortableListSymbol}
list={shortcuts}
getId={shortcut => shortcut.command_id}
enabled={shortcuts.length > 1 && !isReadonly}
onChange={handleDisorder}
itemRender={({ data: shortcut, connect, isDragging }) => (
<ShortcutItem
isDragging={Boolean(isDragging)}
connect={connect}
key={shortcut.command_id}
shortcut={shortcut}
isReadonly={isReadonly}
onRemove={() => handleRemove(shortcut)}
onEdit={() => handleEdit(shortcut)}
/>
)}
/>
</ToolItemList>
</div>
</>
);
};

View File

@@ -0,0 +1,111 @@
/*
* 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, useEffect, useRef } from 'react';
import type { ShortCutCommand } from '@coze-agent-ide/tool-config';
import {
ToolItem,
ToolItemActionEdit,
ToolItemActionDelete,
ToolItemActionDrag,
} from '@coze-agent-ide/tool';
import { type ConnectDnd } from '@coze-studio/components';
import { I18n } from '@coze-arch/i18n';
import { UIModal } from '@coze-arch/bot-semi';
import style from '../index.module.less';
import DefaultShortcutIcon from '../../../assets/shortcut-icon-default.svg';
interface ShortcutItemProps {
shortcut: ShortCutCommand;
isReadonly: boolean;
connect: ConnectDnd;
isDragging: boolean;
onRemove?: (shortcut: ShortCutCommand) => void;
onEdit?: (shortcut: ShortCutCommand) => void;
onDisorder?: (order: number) => void;
}
export const ShortcutItem: FC<ShortcutItemProps> = ({
shortcut,
onEdit,
onRemove,
connect,
isReadonly,
isDragging,
}) => {
const dropRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<HTMLDivElement>(null);
connect(dropRef, dragRef);
useEffect(() => {
connect(dropRef, dragRef);
}, [dragRef, dropRef]);
// 点击删除,弹出二次确认弹窗
const openConfirmRemoveModal = () => {
UIModal.info({
title: I18n.t('bot_ide_shortcut_removal_confirm'),
width: 320,
icon: null,
closeIcon: <></>,
className: style['delete-modal'],
cancelText: I18n.t('Cancel'),
okText: I18n.t('Remove'),
cancelButtonProps: { className: style['delete-modal-cancel-button'] },
okButtonProps: {
className: style['delete-modal-ok-button'],
},
onOk: () => onRemove?.(shortcut),
});
};
return (
<div ref={dropRef}>
<ToolItem
title={shortcut.command_name ?? ''}
description={shortcut.description ?? ''}
avatar={shortcut.shortcut_icon?.url || DefaultShortcutIcon}
avatarStyle={{
padding: '10px',
background: '#fff',
}}
actions={
<>
<div ref={dragRef}>
<ToolItemActionDrag
data-testid="chat-area.shortcut.drag-button"
isDragging={isDragging}
disabled={isReadonly}
/>
</div>
<ToolItemActionEdit
tooltips={I18n.t('bot_ide_shortcut_item_edit')}
onClick={() => onEdit?.(shortcut)}
data-testid="chat-area.shortcut.edit-button"
disabled={isReadonly}
/>
<ToolItemActionDelete
tooltips={I18n.t('bot_ide_shortcut_item_trash')}
onClick={() => openConfirmRemoveModal()}
disabled={isReadonly}
data-testid="chat-area.shortcut.delete-button"
/>
</>
}
/>
</div>
);
};

View File

@@ -0,0 +1,109 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC, useRef, useState } from 'react';
import { type ShortCutCommand } from '@coze-agent-ide/tool-config';
import { Deferred } from '@coze-common/chat-area-utils';
import { I18n } from '@coze-arch/i18n';
import { Button } from '@coze-arch/coze-design';
import { UIModal } from '@coze-arch/bot-semi';
export interface HasUnusedComponentsConfirmModalProps {
onConfirm?: () => void;
onCancel?: () => void;
components: ShortCutCommand['components_list'];
}
export const HasUnusedComponentsConfirmModal: FC<
HasUnusedComponentsConfirmModalProps
> = ({ onConfirm, components, onCancel }) => {
const unUsedComponentsNames = components
?.map(component => component.name)
.join(', ');
return (
<UIModal
visible
footer={null}
onCancel={onCancel}
bodyStyle={{
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
padding: '0 0 16px 0',
}}
title={I18n.t(
'shortcut_modal_save_shortcut_with_components_unused_modal_title',
)}
>
<div className="pb-6">
{I18n.t(
'shortcut_modal_save_shortcut_with_components_unused_modal_desc',
{
unUsedComponentsNames,
},
)}
</div>
<div className="flex gap-2 justify-end">
<Button
onClick={onCancel}
color="highlight"
className="!coz-mg-hglt !coz-fg-hglt"
>
{I18n.t('Cancel')}
</Button>
<Button onClick={onConfirm}>{I18n.t('Confirm')}</Button>
</div>
</UIModal>
);
};
export const useHasUnusedComponentsConfirmModal = () => {
const [visible, setVisible] = useState(false);
const [components, setComponents] = useState<
ShortCutCommand['components_list']
>([]);
const openDeferred = useRef<Deferred<boolean> | null>(null);
const close = () => {
openDeferred.current?.resolve(false);
setVisible(false);
};
const onConfirm = () => {
openDeferred.current?.resolve(true);
setVisible(false);
};
const open = (unUsedComponents: ShortCutCommand['components_list']) => {
openDeferred.current = new Deferred<boolean>();
setComponents(unUsedComponents);
setVisible(true);
return openDeferred.current.promise;
};
return {
node: visible ? (
<HasUnusedComponentsConfirmModal
components={components}
onCancel={close}
onConfirm={onConfirm}
/>
) : null,
close,
open,
};
};

View File

@@ -0,0 +1,185 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, {
type FC,
forwardRef,
type RefObject,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { type Form } from '@coze-arch/bot-semi';
import {
type shortcut_command,
ToolType,
} from '@coze-arch/bot-api/playground_api';
import VarQueryTextareaWrapperWithField from '../var-query-textarea/field';
import { ComponentsTable } from '../components-table';
import type { ComponentsTableActions } from '../components-table';
import type {
ShortcutEditFormValues,
SkillsModalProps,
ToolInfo,
} from '../../types';
import { getDSLFromComponents } from '../../../utils/dsl-template';
import { SkillSwitch } from './skill-switch';
import { getUnusedComponents, initComponentsByToolParams } from './method';
import { useHasUnusedComponentsConfirmModal } from './confirm-modal';
export interface IActionSwitchAreaProps {
skillModal: FC<SkillsModalProps>;
editedShortcut: ShortcutEditFormValues;
formRef: RefObject<Form>;
modalRef?: RefObject<HTMLDivElement>;
isBanned: boolean;
}
export interface IActionSwitchAreaRef {
getValues: () => ShortcutEditFormValues;
validate: () => Promise<boolean>;
}
export const ActionSwitchArea = forwardRef<
IActionSwitchAreaRef,
IActionSwitchAreaProps
>((props, ref) => {
const {
editedShortcut,
skillModal: SkillModal,
formRef,
isBanned,
modalRef,
} = props;
const useTool = editedShortcut?.use_tool ?? false;
const initialComponents = editedShortcut?.components_list?.length
? editedShortcut.components_list
: [];
const [components, setComponents] =
useState<shortcut_command.Components[]>(initialComponents);
const componentsRef = useRef<{
formApi?: ComponentsTableActions;
}>(null);
const { open: openConfirmModal, node: ConfirmModal } =
useHasUnusedComponentsConfirmModal();
useImperativeHandle(ref, () => ({
getValues: () => {
const values = formRef.current?.formApi.getValues();
return {
...values,
components_list: components,
use_tool: useTool,
card_schema: getDSLFromComponents(components),
};
},
validate: async () => {
if (!formRef.current) {
return false;
}
return await checkComponentsValid();
},
}));
const onToolParamsChange = (toolInfo: ToolInfo | null) => {
const {
tool_type,
plugin_id,
plugin_api_name,
api_id,
tool_name,
work_flow_id,
tool_params_list = [],
} = toolInfo || {};
const newComponents = initComponentsByToolParams(tool_params_list);
// TODO: hzf, 有点复杂,看看可以initValue么
formRef.current?.formApi.setValue('components_list', newComponents);
setComponents(newComponents);
// 只有这种情况需要手动更新数据
componentsRef.current?.formApi?.setValues(newComponents);
formRef.current?.formApi.setValue('tool_type', tool_type);
formRef.current?.formApi.setValue('plugin_id', plugin_id);
tool_type === ToolType.ToolTypeWorkFlow &&
formRef.current?.formApi.setValue('work_flow_id', work_flow_id);
formRef.current?.formApi.setValue('plugin_api_name', plugin_api_name);
formRef.current?.formApi.setValue('plugin_api_id', api_id);
formRef.current?.formApi.setValue('tool_info', {
tool_name,
tool_params_list,
});
};
const checkComponentsValid = async (): Promise<boolean> => {
if (!formRef.current) {
return false;
}
try {
await componentsRef.current?.formApi?.validate();
// eslint-disable-next-line @coze-arch/use-error-in-catch -- form validate
} catch (err) {
return false;
}
const componentNotUsed = getUnusedComponents(editedShortcut);
if (componentNotUsed.length) {
return await openConfirmModal(componentNotUsed);
}
return true;
};
useEffect(() => {
formRef.current?.formApi.setValue('components_list', components);
}, [components]);
return (
<>
<SkillSwitch
skillModal={SkillModal}
isBanned={isBanned}
onToolChange={onToolParamsChange}
editedShortcut={editedShortcut}
/>
<ComponentsTable
toolType={useTool ? ToolType.ToolTypePlugin : undefined}
toolInfo={editedShortcut?.tool_info ?? {}}
ref={componentsRef}
disabled={isBanned}
components={components}
onChange={newComponents => {
setComponents(newComponents);
}}
/>
<VarQueryTextareaWrapperWithField
field="template_query"
value={editedShortcut?.template_query || ''}
components={components}
modalRef={modalRef}
/>
{ConfirmModal}
</>
);
});

View File

@@ -0,0 +1,52 @@
/*
* 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 {
InputType,
type shortcut_command,
type ToolParams,
} from '@coze-arch/bot-api/playground_api';
import { type ShortcutEditFormValues } from '../../types';
export const initComponentsByToolParams = (
params: ToolParams[],
): shortcut_command.Components[] =>
params?.map(param => {
const { name, desc, refer_component } = param;
return {
name,
parameter: name,
description: desc,
input_type: InputType.TextInput,
default_value: {
value: '',
},
hide: !refer_component,
};
});
// 获取没有被使用的组件
export const getUnusedComponents = (
shortcut: ShortcutEditFormValues,
): shortcut_command.Components[] => {
const { components_list, template_query } = shortcut;
return (
components_list?.filter(
component => !template_query?.includes(`{{${component.name}}}`),
) ?? []
);
};

View File

@@ -0,0 +1,8 @@
.icon {
margin-right: 4px;
> svg {
width: 18px;
height: 18px;
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { type FC } from 'react';
import cls from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Form } from '@coze-arch/bot-semi';
import style from '../../index.module.less';
import FieldLabel from '../../components/field-label';
import {
type ShortcutEditFormValues,
type SkillsModalProps,
type ToolInfo,
} from '../../../types';
import { getToolInfoByShortcut } from '../../../../utils/tool-params';
import { useToolAction } from './tool-action';
export interface ChooseSendTypeRadioProps {
editedShortcut?: ShortcutEditFormValues;
skillModal: FC<SkillsModalProps>;
isBanned: boolean;
onToolChange?: (tooInfo: ToolInfo | null) => void;
}
const { Checkbox } = Form;
export const SkillSwitch: FC<ChooseSendTypeRadioProps> = props => {
const { editedShortcut, skillModal, isBanned, onToolChange } = props;
const { action, open, cancel } = useToolAction({
initTool: getToolInfoByShortcut(editedShortcut),
onSelect: onToolChange,
skillModal,
isBanned,
});
return (
<div
className={cls(
style['form-item'],
style['shortcut-action-item'],
'pb-[16px]',
)}
>
<FieldLabel>{I18n.t('shortcut_modal_skill')}</FieldLabel>
<div className="flex items-center justify-between h-[32px]">
<Checkbox
field="use_tool"
onChange={e => {
const { checked } = e.target;
checked ? open() : cancel();
}}
noLabel
fieldClassName="!pb-0"
>
{I18n.t('shortcut_modal_shortcut_action_use_plugin_wf')}
</Checkbox>
<div className="flex items-center">
{editedShortcut?.use_tool ? action : null}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,95 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// format选择的插件参数列表
import { type WorkFlowItemType } from '@coze-studio/bot-detail-store';
import { type PluginApi, ToolType } from '@coze-arch/bot-api/playground_api';
import { type ToolInfo } from '../../../types';
// 最多勾选10个如果入参数量超过10个仅勾选其中10个优先勾选required参数勾满10个时其他checkbox置灰不可继续勾选。
export const MAX_TOOL_PARAMS_COUNT = 10;
// 初始化工具列表参数
export const initToolInfoByToolApi = (
toolApi?: WorkFlowItemType | PluginApi,
): ToolInfo | null => {
if (!toolApi) {
return null;
}
const isWorkflow = 'workflow_id' in toolApi;
const workflowPluginProcessedToolInfo = isWorkflow
? initToolInfoByWorkFlow(toolApi as WorkFlowItemType)
: initToolInfoByPlugin(toolApi);
const { tool_params_list } = workflowPluginProcessedToolInfo;
// 对params进行排序将required=true的字段排在前面
const sortedParams = tool_params_list?.sort(
(a, b) => (b.required ? 1 : -1) - (a.required ? 1 : -1),
);
return {
...workflowPluginProcessedToolInfo,
tool_params_list:
sortedParams?.map((param, index) => {
const { name, desc, required, type } = param;
return {
name,
type,
desc,
required,
default_value: '',
refer_component: index < MAX_TOOL_PARAMS_COUNT,
};
}) || [],
};
};
// workflow参数转化为toolParams
export const initToolInfoByWorkFlow = (
workFlow: WorkFlowItemType,
): ToolInfo => {
const { name, parameters, workflow_id, ...rest } = workFlow;
return {
...rest,
tool_type: ToolType.ToolTypeWorkFlow,
tool_name: name,
plugin_api_name: name,
tool_params_list: parameters || [],
work_flow_id: workflow_id,
};
};
export const initToolInfoByPlugin = (plugin: PluginApi): ToolInfo => {
const { name, plugin_name, parameters, ...rest } = plugin;
return {
...rest,
tool_type: ToolType.ToolTypePlugin,
tool_name: plugin_name ?? '',
plugin_api_name: name,
tool_params_list: parameters || [],
};
};
// 获取skillModal开启的tab
export const getSkillModalTab = (): (
| 'plugin'
| 'workflow'
| 'imageFlow'
| 'datasets'
)[] => ['plugin', 'workflow'];

View File

@@ -0,0 +1,206 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { type FC, useState } from 'react';
import cs from 'classnames';
import { type WorkFlowItemType } from '@coze-studio/bot-detail-store';
import { I18n } from '@coze-arch/i18n';
import {
Typography,
Toast,
Popover,
UIButton,
Tooltip,
} from '@coze-arch/bot-semi';
import {
IconAdd,
IconInfo,
IconPluginsSelected,
IconWorkflowsSelected,
} from '@coze-arch/bot-icons';
import {
type PluginApi,
type PluginParameter,
ToolType,
} from '@coze-arch/bot-api/developer_api';
import style from '../../index.module.less';
import ActionButton from '../../components/action-button';
import {
OpenModeType,
type SkillsModalProps,
type ToolInfo,
} from '../../../types';
import { validatePluginAndWorkflowParams } from '../../../../utils/tool-params';
import CloseToolIcon from '../../../../assets/close-tool.svg';
import { getSkillModalTab, initToolInfoByToolApi } from './method';
import styles from './index.module.less';
interface ToolActionProps {
initTool?: ToolInfo;
skillModal: FC<SkillsModalProps>;
onSelect?: (tooInfo: ToolInfo | null) => void;
isBanned: boolean;
}
const ToolButton = (props: {
toolInfo: ToolInfo;
onCancel: () => void;
isBanned: boolean;
}) => {
const {
toolInfo: { tool_type, tool_name },
onCancel,
isBanned,
} = props;
const [removePopoverVisible, setRemovePopoverVisible] = useState(false);
const removePopoverContent = (
<div className={style['remove-popover-content']}>
<Typography.Text className={style.title}>
{I18n.t('shortcut_modal_remove_plugin_wf_double_confirm')}
</Typography.Text>
<Typography.Text className={style.desc}>
{I18n.t('shortcut_modal_remove_plugin_wf_double_tip')}
</Typography.Text>
<UIButton className={style['delete-btn']} onClick={() => onCancel()}>
{I18n.t('shortcut_modal_remove_plugin_wf_button')}
</UIButton>
</div>
);
return (
<Popover
trigger="custom"
position="bottomRight"
content={removePopoverContent}
onClickOutSide={() => setRemovePopoverVisible(false)}
visible={removePopoverVisible}
>
<div
className={cs(
'flex ml-2 rounded-[6px] coz-mg-primary items-center px-[10px] py-[3px] text-xs coz-fg-primary',
)}
>
{tool_type === ToolType.ToolTypePlugin && (
<IconPluginsSelected className={styles.icon} />
)}
{tool_type === ToolType.ToolTypeWorkFlow && (
<IconWorkflowsSelected className={styles.icon} />
)}
<Typography.Text
ellipsis={{
showTooltip: {
opts: { content: tool_name },
},
}}
size="small"
>
{tool_name}
</Typography.Text>
{isBanned ? (
<Tooltip content={I18n.t('Plugin_delisted')}>
<IconInfo className="ml-1" />
</Tooltip>
) : null}
<img
className="ml-[8px] cursor-pointer"
alt="close"
src={CloseToolIcon}
onClick={() => {
setRemovePopoverVisible(true);
}}
/>
</div>
</Popover>
);
};
export const useToolAction = (props: ToolActionProps) => {
const { skillModal: SkillModal, onSelect, initTool, isBanned } = props;
const [skillModalVisible, setSkillModalVisible] = useState(false);
const [selectedTool, setSelectedTool] = useState<ToolInfo | null>(
initTool || null,
);
const onToolChange = (toolApi: WorkFlowItemType | PluginApi | undefined) => {
const tooInfo = initToolInfoByToolApi(toolApi);
const { tool_params_list } = tooInfo || {};
if (!checkParams(tool_params_list ?? [])) {
return;
}
onSelect?.(tooInfo);
setSelectedTool(tooInfo);
setSkillModalVisible(false);
};
const checkParams = (parameters: Array<PluginParameter>) => {
const { isSuccess, inValidType } = validatePluginAndWorkflowParams(
parameters ?? [],
true,
);
if (isSuccess) {
return true;
}
if (inValidType === 'empty') {
Toast.error(I18n.t('shortcut_modal_add_plugin_wf_no_input_error'));
} else {
Toast.error(I18n.t('shortcut_modal_add_plugin_wf_complex_input_error'));
}
return false;
};
const open = () => {
onSelect?.(null);
setSkillModalVisible(true);
};
const cancel = () => {
onSelect?.(null);
setSelectedTool(null);
};
const action = (
<div className="mr-2 mt-[-2px]">
{selectedTool?.tool_type ? (
<ToolButton
toolInfo={selectedTool}
onCancel={cancel}
isBanned={isBanned}
/>
) : (
<ActionButton icon={<IconAdd />} onClick={open}>
{I18n.t('shortcut_modal_use_tool_select_button')}
</ActionButton>
)}
{skillModalVisible ? (
<SkillModal
tabs={getSkillModalTab()}
onCancel={() => setSkillModalVisible(false)}
openMode={OpenModeType.OnlyOnceAdd}
openModeCallback={onToolChange}
/>
) : null}
</div>
);
return {
action,
open,
cancel,
};
};

View File

@@ -0,0 +1,90 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { type FC, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useToolStore } from '@coze-agent-ide/tool';
import { I18n } from '@coze-arch/i18n';
import { type ShortcutFileInfo } from '@coze-arch/bot-api/playground_api';
import { FormInputWithMaxCount } from '../components';
import { type ShortcutEditFormValues } from '../../types';
import { validateButtonNameRepeat } from '../../../utils/tool-params';
import { ShortcutIconField } from './shortcut-icon';
export interface ButtonNameProps {
editedShortcut: ShortcutEditFormValues;
}
export const ButtonName: FC<ButtonNameProps> = props => {
const { existedShortcuts } = useToolStore(
useShallow(state => ({
existedShortcuts: state.shortcut.shortcut_list,
})),
);
const { editedShortcut } = props;
const [selectIcon, setSelectIcon] = useState<ShortcutFileInfo | undefined>(
editedShortcut.shortcut_icon,
);
return (
<FormInputWithMaxCount
className="p-1"
field="command_name"
placeholder={I18n.t('shortcut_modal_button_name_input_placeholder')}
prefix={
<ShortcutIconField
iconInfo={selectIcon}
field="shortcut_icon"
noLabel
fieldClassName="!pb-0"
onLoadList={list => {
// 如果是编辑状态不设置默认icon, 新增下默认选中列表第一个icon
const isEdit = !!editedShortcut.command_id;
if (isEdit) {
return;
}
const defaultIcon = list.at(0);
defaultIcon && setSelectIcon(defaultIcon);
}}
/>
}
suffix={<></>}
maxCount={20}
maxLength={20}
rules={[
{
required: true,
message: I18n.t('shortcut_modal_button_name_is_required'),
},
{
validator: (rule, value) =>
validateButtonNameRepeat(
{
...editedShortcut,
command_name: value,
},
existedShortcuts ?? [],
),
message: I18n.t('shortcut_modal_button_name_conflict_error'),
},
]}
noLabel
required
/>
);
};

View File

@@ -0,0 +1,114 @@
/*
* 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, useState } from 'react';
import cls from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozWarningCircle } from '@coze-arch/coze-design/icons';
import { Skeleton } from '@coze-arch/bot-semi';
import { type FileInfo } from '@coze-arch/bot-api/playground_api';
import { Icon } from './icon';
const SINGLE_LINE_LOADING_COUNT = 10;
export interface IconListProps {
list: FileInfo[];
initValue?: FileInfo;
onSelect: (item: FileInfo) => void;
onClear: (item: FileInfo) => void;
}
export const IconList: FC<IconListProps> = props => {
const { list, onSelect, onClear, initValue } = props;
const [selectIcon, setSelectIcon] = useState<FileInfo | undefined>(initValue);
const onIconClick = (item: FileInfo) => {
const { url } = item;
if (!url) {
return;
}
if (url === selectIcon?.url) {
setSelectIcon(undefined);
onClear(item);
return;
}
setSelectIcon(item);
onSelect(item);
};
return (
<div className="flex flex-wrap gap-1 p-4">
{list.map((item, index) => (
<div onClick={() => onIconClick?.(item)}>
<Icon
key={index}
icon={item}
className={cls({
'coz-mg-secondary-pressed': item.uri === selectIcon?.uri,
})}
/>
</div>
))}
</div>
);
};
export const AnimateLoading = () => (
<>
<SingleLoading />
<SingleLoading />
<SingleLoading />
</>
);
const SingleLoading = () => (
<div>
<Skeleton
active
loading
placeholder={
<div
style={{
display: 'flex',
gap: 12,
padding: 8,
}}
>
{Array.from({ length: SINGLE_LINE_LOADING_COUNT }).map((_, index) => (
<Skeleton.Image
key={index}
style={{
height: 28,
width: 28,
borderRadius: 6,
}}
/>
))}
</div>
}
/>
</div>
);
export const IconListField = () => (
<div className="flex justify-center items-center flex-col w-[420px] h-[148px]">
<IconCozWarningCircle className="mb-4 w-8 h-8 coz-fg-hglt-red" />
<div className="coz-fg-secondary text-xs">
{/*@ts-expect-error --替换*/}
{I18n.t('Connection failed')}
</div>
</div>
);

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC } from 'react';
import cls from 'classnames';
import { type FileInfo } from '@coze-arch/bot-api/playground_api';
import DefaultIcon from '../../../../assets/shortcut-icon-default.svg';
export interface ShortcutIconProps {
icon?: FileInfo;
className?: string;
width?: number;
height?: number;
}
const DEFAULT_ICON_SIZE = 28;
const DefaultIconInfo = {
url: DefaultIcon,
};
export const Icon: FC<ShortcutIconProps> = props => {
const { icon, width, height, className } = props;
return (
<div className="flex items-center">
<img
className={cls(
'rounded-[6px] p-1 coz-mg-primary hover:coz-mg-secondary-hovered mr-1 cursor-pointer',
className,
)}
style={{
width: width ?? DEFAULT_ICON_SIZE,
height: height ?? DEFAULT_ICON_SIZE,
}}
alt="icon"
src={icon?.url || DefaultIconInfo.url}
/>
</div>
);
};

View File

@@ -0,0 +1,119 @@
/*
* 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, useEffect, useState } from 'react';
import cls from 'classnames';
import { type CommonFieldProps } from '@coze-arch/bot-semi/Form';
import { Popover, withField } from '@coze-arch/bot-semi';
import { type FileInfo } from '@coze-arch/bot-api/playground_api';
import DefaultIcon from '../../../../assets/shortcut-icon-default.svg';
import { useGetIconList } from './use-get-icon-list';
import { IconList, AnimateLoading, IconListField } from './icon-list';
import { Icon } from './icon';
export interface ShortcutIconProps {
iconInfo?: FileInfo;
onChange?: (iconInfo: FileInfo | undefined) => void;
onLoadList?: (list: FileInfo[]) => void;
}
const DefaultIconInfo = {
url: DefaultIcon,
};
export const ShortcutIcon: FC<ShortcutIconProps> = props => {
const { iconInfo: initIconInfo, onChange, onLoadList } = props;
const [iconListVisible, setIconListVisible] = useState(false);
const { iconList, loading, error } = useGetIconList();
const [selectIcon, setSelectIcon] = useState(
initIconInfo?.url ? initIconInfo : DefaultIconInfo,
);
const onSelectIcon = (item: FileInfo) => {
const { url } = item;
if (!url) {
return;
}
setSelectIcon(item);
setIconListVisible(false);
onChange?.(item);
};
const onClearIcon = () => {
setSelectIcon(DefaultIcon);
setIconListVisible(false);
onChange?.(undefined);
};
const IconListRender = () => {
if (error) {
return <IconListField />;
}
if (loading) {
return <AnimateLoading />;
}
return (
<IconList
initValue={selectIcon}
list={iconList}
onSelect={onSelectIcon}
onClear={onClearIcon}
/>
);
};
useEffect(() => {
if (loading) {
return;
}
onLoadList?.(iconList);
}, [loading]);
useEffect(() => {
initIconInfo && onSelectIcon(initIconInfo);
}, [initIconInfo]);
return (
<Popover
trigger="custom"
visible={iconListVisible}
onClickOutSide={() => setIconListVisible(false)}
position="bottomLeft"
spacing={{
x: 0,
y: 10,
}}
content={IconListRender()}
>
<div
className="flex items-center"
onClick={() => setIconListVisible(true)}
>
<Icon
icon={selectIcon}
width={22}
height={24}
className={cls({
'coz-mg-secondary-pressed': iconListVisible,
})}
/>
</div>
</Popover>
);
};
export const ShortcutIconField: FC<ShortcutIconProps & CommonFieldProps> =
withField(ShortcutIcon);

View File

@@ -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.
*/
import { useRequest } from 'ahooks';
import { GetFileUrlsScene } from '@coze-arch/bot-api/playground_api';
import { PlaygroundApi } from '@coze-arch/bot-api';
export const useGetIconList = () => {
const { data, loading, error } = useRequest(
async () =>
await PlaygroundApi.GetFileUrls({
scene: GetFileUrlsScene.shorcutIcon,
}),
);
return {
iconList: data?.file_list ?? [],
loading,
error,
};
};

View File

@@ -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.
*/
import { type FC } from 'react';
import { I18n } from '@coze-arch/i18n';
import { InputType } from '@coze-arch/bot-api/playground_api';
import {
type SelectComponentTypeItem,
type TextComponentTypeItem,
type UploadComponentTypeItem,
} from '../types';
import { type UploadItemType } from '../../../../utils/file-const';
import { UploadField } from './upload-field';
import { SelectWithInputTypeField } from './select-field';
import { InputWithInputTypeField } from './input-field';
export interface ComponentDefaultChangeValue {
type: InputType.TextInput | UploadItemType;
value: string;
}
export interface ComponentDefaultValueProps {
field: string;
componentType:
| TextComponentTypeItem
| SelectComponentTypeItem
| UploadComponentTypeItem;
disabled?: boolean;
}
export const ComponentDefaultValue: FC<ComponentDefaultValueProps> = props => {
const { componentType, field, disabled = false } = props;
const { type } = componentType;
if (type === 'text') {
return (
<InputWithInputTypeField
noLabel
value={{
type: InputType.TextInput,
value: '',
}}
field={field}
noErrorMessage
placeholder={I18n.t(
'shortcut_modal_use_tool_parameter_default_value_placeholder',
)}
disabled={disabled}
/>
);
}
if (type === 'select') {
return (
<SelectWithInputTypeField
value={{
type: InputType.TextInput,
value: '',
}}
noLabel
style={{
width: '100%',
}}
field={field}
noErrorMessage
optionList={componentType.options.map(option => ({
label: option,
value: option,
}))}
disabled={disabled}
/>
);
}
if (type === 'upload') {
// 先置灰,后续放开上传默认值
return <UploadField />;
// return (
// <UploadDefaultValue
// noLabel
// field={field}
// acceptUploadItemTypes={componentType.uploadTypes}
// uploadItemConfig={{
// [InputType.UploadImage]: {
// maxSize: IMAGE_MAX_SIZE,
// },
// [InputType.UploadDoc]: {
// maxSize: FILE_MAX_SIZE,
// },
// [InputType.UploadTable]: {
// maxSize: FILE_MAX_SIZE,
// },
// [InputType.UploadAudio]: {
// maxSize: FILE_MAX_SIZE,
// },
// }}
// onChange={res => {
// const { default_value, default_value_type } = res
// ? convertComponentDefaultValueToFormValues(res)
// : {
// default_value: '',
// default_value_type: undefined,
// };
// return {
// value: default_value,
// type: default_value_type,
// };
// }}
// uploadFile={({ file, onError, onProgress, onSuccess }) => {
// getRegisteredPluginInstance?.({
// file,
// onProgress,
// onError,
// onSuccess,
// });
// }}
// />
// );
}
return <></>;
};

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { type FC } from 'react';
import type { InputProps } from '@coze-arch/bot-semi/Input';
import { type CommonFieldProps } from '@coze-arch/bot-semi/Form';
import { UIInput, withField } from '@coze-arch/bot-semi';
import { InputType } from '@coze-arch/bot-api/playground_api';
type InputWithInputTypeProps = {
value?: { type: InputType; value: string };
onChange?: (value: { type: InputType; value: string }) => void;
} & Omit<InputProps, 'value'>;
const MaxLength = 100;
const InputWithInputType: FC<InputWithInputTypeProps> = props => {
const { value, onChange, ...rest } = props;
return (
<UIInput
value={value?.value}
{...rest}
maxLength={MaxLength}
onChange={inputValue => {
const newValue = {
type: value?.type || InputType.TextInput,
value: inputValue,
};
onChange?.(newValue);
return newValue;
}}
/>
);
};
export const InputWithInputTypeField: FC<
InputWithInputTypeProps & CommonFieldProps
> = withField(InputWithInputType, {
valueKey: 'value',
onKeyChangeFnName: 'onChange',
});

View File

@@ -0,0 +1,123 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FileTypeEnum, getFileInfo } from '@coze-common/chat-core';
import { shortcut_command } from '@coze-arch/bot-api/playground_api';
import { type UploadItemConfig } from '../types';
import { acceptMap, type UploadItemType } from '../../../../utils/file-const';
type FileTypeEnumWithoutDefault = Exclude<
FileTypeEnum,
FileTypeEnum.DEFAULT_UNKNOWN
>;
const fileTypeToInputTypeMap: {
[key in FileTypeEnumWithoutDefault]: UploadItemType;
} = {
[FileTypeEnum.IMAGE]: shortcut_command.InputType.UploadImage,
[FileTypeEnum.AUDIO]: shortcut_command.InputType.UploadAudio,
[FileTypeEnum.PDF]: shortcut_command.InputType.UploadDoc,
[FileTypeEnum.DOCX]: shortcut_command.InputType.UploadDoc,
[FileTypeEnum.EXCEL]: shortcut_command.InputType.UploadTable,
[FileTypeEnum.CSV]: shortcut_command.InputType.UploadTable,
[FileTypeEnum.VIDEO]: shortcut_command.InputType.VIDEO,
[FileTypeEnum.PPT]: shortcut_command.InputType.PPT,
[FileTypeEnum.TXT]: shortcut_command.InputType.TXT,
[FileTypeEnum.ARCHIVE]: shortcut_command.InputType.ARCHIVE,
[FileTypeEnum.CODE]: shortcut_command.InputType.CODE,
};
export const getFileTypeFromInputType = (
inputType: shortcut_command.InputType,
) => {
for (const [fileType, type] of Object.entries(fileTypeToInputTypeMap)) {
if (type === inputType) {
return fileType;
}
}
return null;
};
export const getInputTypeFromFileType = (
fileType: FileTypeEnumWithoutDefault,
) => fileTypeToInputTypeMap[fileType];
export const getInputTypeFromFile = (file: File): UploadItemType | '' => {
const fileInfo = getFileInfo(file);
const fileType = fileInfo?.fileType;
if (!fileInfo) {
return '';
}
if (!fileType || fileType === FileTypeEnum.DEFAULT_UNKNOWN) {
return '';
}
return getInputTypeFromFileType(fileType);
};
// 判断文件是否超过最大限制
export const isOverMaxSizeByUploadItemConfig = (
file: File | undefined,
config: UploadItemConfig | undefined,
): {
isOverSize: boolean;
// 单位 MB
maxSize?: number;
} => {
if (!file) {
return {
isOverSize: false,
};
}
if (!config) {
return {
isOverSize: false,
};
}
const inputType = getInputTypeFromFile(file);
if (!inputType) {
return {
isOverSize: false,
};
}
const { maxSize } = config[inputType];
if (!maxSize) {
return {
isOverSize: false,
};
}
return {
isOverSize: file.size > maxSize * 1024,
maxSize,
};
};
// 根据acceptUploadItemTypes获取accept
export const getAcceptByUploadItemTypes = (
acceptUploadItemTypes: UploadItemType[],
) => {
const accept: string[] = [];
for (const type of acceptUploadItemTypes) {
if (!type) {
continue;
}
const acceptStr = acceptMap[type];
if (!acceptStr) {
continue;
}
accept.push(...acceptStr.split(','));
}
return accept.join(',');
};

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { type FC } from 'react';
import { type CommonFieldProps } from '@coze-arch/bot-semi/Form';
import { Select, withField } from '@coze-arch/bot-semi';
import { InputType } from '@coze-arch/bot-api/playground_api';
type InputWithInputTypeProps = {
value?: { type: InputType; value: string };
onSelect?: (value: { type: InputType; value: string }) => void;
} & Omit<React.ComponentProps<typeof Select>, 'value' | 'onSelect'>;
const SelectWithInputType: FC<InputWithInputTypeProps> = props => {
const { value, onSelect, ...rest } = props;
return (
<Select
{...rest}
showClear={!!value?.value}
onClear={() => {
onSelect?.({ type: InputType.TextInput, value: '' });
}}
value={value?.value}
onSelect={selectValue => {
const newValue = {
type: value?.type || InputType.TextInput,
value: selectValue as string,
};
onSelect?.(newValue);
return newValue;
}}
/>
);
};
export const SelectWithInputTypeField: FC<
InputWithInputTypeProps & CommonFieldProps
> = withField(SelectWithInputType, {
valueKey: 'value',
onKeyChangeFnName: 'onSelect',
});

View File

@@ -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.
*/
import { UIInput } from '@coze-arch/bot-semi';
export const UploadField = () => <UIInput disabled />;

View File

@@ -0,0 +1,81 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { type FC } from 'react';
import { I18n } from '@coze-arch/i18n';
import { Tooltip } from '@coze-arch/coze-design';
import { Typography } from '@coze-arch/bot-semi';
import { IconInfo } from '@coze-arch/bot-icons';
import { type ToolInfo } from '@coze-arch/bot-api/playground_api';
const { Text } = Typography;
export interface ComponentParameterProps {
toolInfo: ToolInfo;
parameter: string;
}
export const ComponentParameter: FC<ComponentParameterProps> = ({
toolInfo,
parameter,
}) => {
const { tool_params_list = [] } = toolInfo;
const { name, type, required, desc } =
tool_params_list.find(item => item.name === parameter) || {};
return (
<div className="px-2 flex items-center justify-center coz-fg-secondary max-w-[86px]">
<Text className="mr-1" ellipsis>
{name}
</Text>
<Tooltip
className="max-w-[226px]"
content={
<div className="flex flex-col justify-center" key={name}>
<div className="flex items-center">
<Text
ellipsis={{
showTooltip: {
opts: {
content: name || '',
position: 'top',
},
},
}}
>
<span className="text-sm font-medium mr-[9px]">
{name || '-'}
</span>
</Text>
<span className="rounded coz-mg-primary px-[6px] py-[1px] mr-[3px]">
{type}
</span>
{Boolean(required) && (
<span className="rounded coz-mg-primary px-[6px] py-[1px]">
{I18n.t('workflow_add_parameter_required')}
</span>
)}
</div>
<span className="mt-[3px] coze-fg-primary text-sm">
{desc || '-'}
</span>
</div>
}
>
<IconInfo />
</Tooltip>
</div>
);
};

View File

@@ -0,0 +1,12 @@
.upload-content {
:global {
.semi-checkbox-inner {
.semi-checkbox-inner-display {
}
}
.semi-checkbox-content {
flex: 0 auto;
}
}
}

View File

@@ -0,0 +1,255 @@
/*
* 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,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import cls from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Button } from '@coze-arch/coze-design';
import { Toast, UIInput, Popover, Form } from '@coze-arch/bot-semi';
import { IconChevronDown } from '@douyinfe/semi-icons';
import {
type ComponentTypeItem,
type ComponentTypeSelectContentRadioValueType,
type SelectComponentTypeItem,
type UploadComponentTypeItem,
} from '../types';
import { UploadContent } from './upload-contnet';
import { SelectContentField } from './select-content';
import { formatComponentTypeForm } from './method';
const { RadioGroup, Radio } = Form;
const SelectTypeAndLableMap: Record<
ComponentTypeSelectContentRadioValueType,
string
> = {
text: I18n.t('shortcut_component_type_text'),
select: I18n.t('shortcut_component_type_selector'),
upload: I18n.t('shortcut_modal_components_modal_upload_component'),
};
export const ComponentTypeSelectRecordItem = (props: {
value: ComponentTypeItem;
onSubmit?: (value: ComponentTypeItem) => void;
disabled?: boolean;
}) => {
const { value: defaultValue, onSubmit, disabled = false } = props;
const [submitValue, setSubmitValue] =
useState<ComponentTypeItem>(defaultValue);
const [componentType, setComponentType] =
useState<ComponentTypeItem>(defaultValue);
const [selectPopoverVisible, setSelectPopoverVisible] = useState(false);
const componentTypeSelectFormRef = useRef<{
formApi: ComponentTypeSelectFormMethods;
} | null>(null);
const onComponentTypeSelectFormSubmit = async () => {
if (await componentTypeSelectFormRef.current?.formApi.validate()) {
if (!componentType) {
return;
}
onSubmit?.(componentType);
setSubmitValue(componentType);
setSelectPopoverVisible(false);
}
};
useEffect(() => {
setSubmitValue(defaultValue);
}, [defaultValue]);
return (
<div className="flex items-center">
<div className="w-full">
<>
<Popover
trigger="custom"
footer={null}
visible={selectPopoverVisible}
position="topRight"
onClickOutSide={() => setSelectPopoverVisible(false)}
content={() => (
<div className="p-6 w-[288px]">
<ComponentTypeSelectForm
ref={componentTypeSelectFormRef}
value={submitValue}
onChange={setComponentType}
/>
<div className="flex justify-end gap-2">
<Button
color="highlight"
onClick={() => setSelectPopoverVisible(false)}
>
{I18n.t('cancel')}
</Button>
<Button onClick={onComponentTypeSelectFormSubmit}>
{I18n.t('Confirm')}
</Button>
</div>
</div>
)}
>
<UIInput
className={cls('w-full', disabled && '!pointer-events-auto')}
suffix={
<IconChevronDown
onClick={() => setSelectPopoverVisible(!selectPopoverVisible)}
/>
}
placeholder={I18n.t(
'shortcut_modal_selector_component_default_text',
)}
value={SelectTypeAndLableMap[submitValue?.type]}
onClick={() => setSelectPopoverVisible(!selectPopoverVisible)}
disabled={disabled}
readonly
/>
</Popover>
</>
</div>
</div>
);
};
export interface ComponentTypeSelectFormProps {
value: ComponentTypeItem;
onChange?: (values: ComponentTypeItem) => void;
}
export interface ComponentTypeSelectFormMethods {
validate: () => Promise<boolean>;
}
export const ComponentTypeSelectForm = forwardRef<
{ formApi?: ComponentTypeSelectFormMethods },
ComponentTypeSelectFormProps
>((props, ref) => {
const { value, onChange } = props;
const [selectOption, setSelectOption] =
useState<ComponentTypeSelectContentRadioValueType>(value.type);
const optionsMap = getComponentTypeOptionMap(value);
const formRef = useRef<Form>(null);
useImperativeHandle(ref, () => ({
formApi: {
validate: async () => {
try {
if (selectOption === 'select') {
return Boolean(
await formRef.current?.formApi.validate(['values.options']),
);
}
if (selectOption === 'upload') {
return Boolean(
await formRef.current?.formApi.validate(['values.uploadTypes']),
);
}
return true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (errors: any) {
if (selectOption === 'select') {
const message = errors?.values?.options;
message && Toast.error(message);
}
if (selectOption === 'upload') {
const message = errors?.values?.uploadTypes;
message && Toast.error(message);
}
return false;
}
},
},
}));
return (
<Form<{ values: ComponentTypeItem }>
autoComplete="off"
ref={formRef}
initValues={{ values: value }}
className="flex flex-col gap-6"
onValueChange={({ values }) => {
onChange?.(formatComponentTypeForm(values));
}}
>
<div className="coz-fg-plus text-[16px] font-medium">
{I18n.t('shortcut_modal_components_modal_component_type')}
</div>
<RadioGroup
fieldStyle={{
padding: 0,
}}
className="flex flex-col !p-0 gap-3"
defaultValue={selectOption}
field="values.type"
noLabel
onChange={e => {
setSelectOption(e.target.value);
}}
>
{Object.entries(optionsMap).map(([key, { label }]) => (
<Radio value={key}>{label}</Radio>
))}
</RadioGroup>
{Object.entries(optionsMap).map(([key, { render }]) => (
<div
key={key}
className={cls({
hidden: key !== selectOption,
})}
>
{render?.()}
</div>
))}
</Form>
);
});
const getComponentTypeOptionMap = (
initValue?: ComponentTypeItem,
): {
[key in ComponentTypeSelectContentRadioValueType]: {
label: string;
render?: () => React.ReactNode;
};
} => ({
text: {
label: I18n.t('shortcut_component_type_text'),
},
select: {
label: I18n.t('shortcut_component_type_selector'),
render: () => (
<SelectContentField
field="values.options"
value={(initValue as SelectComponentTypeItem)?.options}
/>
),
},
upload: {
label: I18n.t('shortcut_modal_components_modal_upload_component'),
render: () => (
<UploadContent
value={(initValue as UploadComponentTypeItem)?.uploadTypes}
/>
),
},
});

View File

@@ -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 { type ComponentTypeItem } from '../types';
export const formatComponentTypeForm = (
values: ComponentTypeItem,
): ComponentTypeItem => {
const { type } = values;
if (type === 'text') {
return { type };
}
if (type === 'select') {
return { type, options: values.options };
}
if (type === 'upload') {
return { type, uploadTypes: values.uploadTypes };
}
return values;
};

View File

@@ -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.
*/
import {
type Dispatch,
type SetStateAction,
useEffect,
useMemo,
useRef,
useState,
type FC,
} from 'react';
import { SortableList } from '@coze-studio/components/sortable-list';
import { type TItemRender } from '@coze-studio/components';
import { I18n } from '@coze-arch/i18n';
import { type CommonFieldProps } from '@coze-arch/bot-semi/Form';
import {
IconButton,
Tooltip,
UIButton,
UIInput,
useFieldApi,
useFieldState,
withField,
} from '@coze-arch/bot-semi';
import {
IconAdd,
IconShortcutDisorder,
IconShortcutTrash,
} from '@coze-arch/bot-icons';
import { shortid } from '../../../../utils/uuid';
export interface OptionData {
value?: string;
id: string;
}
export interface OptionListProps {
options: OptionData[];
onChange: Dispatch<SetStateAction<OptionData[]>>;
}
const dndType = Symbol.for(
'chat-area-plugins-chat-shortcuts-component-options-dnd-list',
);
export const OptionsList: FC<OptionListProps> = ({ options, onChange }) => {
const sortable = options.length > 1;
const itemRender = useMemo<TItemRender<OptionData>>(
() =>
({ data, connect }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const dropRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line react-hooks/rules-of-hooks
const handleRef = useRef<HTMLDivElement>(null);
connect(dropRef, handleRef);
return (
<div ref={dropRef} className="flex items-center mb-6 last:mb-0">
<UIInput
className="flex-1"
value={data.value}
maxLength={20}
onChange={value => {
onChange(_options => {
const index = _options.findIndex(item => item.id === data.id);
_options.splice(index, 1, {
value,
id: data.id,
});
return [..._options];
});
}}
/>
<div className="ml-2" ref={handleRef}>
<IconButton
size="small"
className={sortable ? 'cursor-grab' : ''}
icon={<IconShortcutDisorder />}
disabled={!sortable}
theme="borderless"
type="tertiary"
/>
</div>
<div className="ml-2">
<Tooltip content={I18n.t('Remove')}>
<IconButton
size="small"
icon={<IconShortcutTrash />}
type="tertiary"
theme="borderless"
disabled={options.length <= 1}
onClick={() => {
onChange(_options =>
_options.filter(item => item.id !== data.id),
);
}}
/>
</Tooltip>
</div>
</div>
);
},
[sortable],
);
return (
<SortableList
type={dndType}
list={options}
itemRender={itemRender}
onChange={onChange}
enabled={sortable}
/>
);
};
const MAX_OPTIONS = 20;
export interface SelectContentProps {
value?: string[];
onChange?: (newOptions: string[]) => void;
hasError?: boolean;
}
export const SelectContent: FC<SelectContentProps> = ({
value: initialValue,
onChange,
hasError,
}) => {
const [options, setOptions] = useState<OptionData[]>([]);
useEffect(() => {
setOptions(
(initialValue?.length ? initialValue : ['']).map<OptionData>(item => ({
value: item,
id: shortid(),
})),
);
}, []);
useEffect(() => {
const values = options
.map(option => option.value?.trim())
.filter(value => !!value);
onChange?.(values as string[]);
}, [options]);
return (
<div className="flex flex-col items-start">
<div className="coz-fg-plus mb-[14px] font-medium">
{I18n.t('shortcut_modal_selector_component_options')}
</div>
<div className="flex justify-between">
<UIButton
size="small"
type="tertiary"
theme="borderless"
disabled={options.length >= MAX_OPTIONS}
icon={<IconAdd />}
className="!coz-fg-hglt text-sm font-medium"
onClick={() => {
setOptions([
...options,
{
value: '',
id: shortid(),
},
]);
}}
>
{I18n.t('shortcut_modal_selector_component_options')}
</UIButton>
</div>
<div className="max-h-40 my-6 overflow-y-auto">
<OptionsList options={options} onChange={setOptions} />
</div>
</div>
);
};
const SelectContentFieldInner = withField(SelectContent);
export const SelectContentField: FC<
CommonFieldProps & SelectContentProps
> = props => {
const state = useFieldState(props.field);
const api = useFieldApi(props.field);
return (
<div onMouseEnter={() => api.setError('')}>
<SelectContentFieldInner
{...props}
pure
hasError={!!state.error?.length}
trigger="custom"
rules={[
{
validator: (rules, value) => !!value?.length,
message: I18n.t(
'shortcut_modal_selector_component_no_options_error',
),
},
]}
/>
</div>
);
};

View File

@@ -0,0 +1,89 @@
/*
* 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 { I18n } from '@coze-arch/i18n';
import { Form } from '@coze-arch/bot-semi';
import { InputType } from '@coze-arch/bot-api/playground_api';
import { type UploadComponentTypeItem } from '../types';
import { ACCEPT_UPLOAD_TYPES } from '../../../../utils/file-const';
import styles from './index.module.less';
export interface UploadContentProps {
value: UploadComponentTypeItem['uploadTypes'] | undefined;
onChange?: (value: UploadComponentTypeItem['uploadTypes']) => void;
}
const { Checkbox, CheckboxGroup } = Form;
const DefaultValue = [
InputType.UploadImage,
InputType.UploadAudio,
InputType.UploadDoc,
InputType.UploadTable,
InputType.CODE,
InputType.ARCHIVE,
InputType.PPT,
InputType.VIDEO,
InputType.TXT,
];
export const UploadContent = (props: UploadContentProps) => {
const { value = DefaultValue, onChange } = props;
return (
<>
<div className="coz-fg-plus text-[16px] font-medium">
{I18n.t('shortcut_modal_upload_component_supported_file_formats')}
</div>
<CheckboxGroup
field="values.uploadTypes"
onChange={checkedValues => {
onChange?.(checkedValues);
}}
initValue={value}
className={cls('flex flex-wrap flex-row', styles['upload-content'])}
noLabel
noErrorMessage
rules={[
{
validator: (rules, newValue) => !!newValue?.length,
message: I18n.t(
'shortcut_modal_please_select_file_formats_for_upload_component_tip',
),
},
]}
>
{ACCEPT_UPLOAD_TYPES.map(({ type, label, icon }) => (
<div key={type} className="flex-1 basis-1/2">
<Checkbox
className="flex-row-reverse justify-end"
noLabel
defaultChecked={value?.includes(type)}
value={type}
>
<div className="flex gap-1">
<img src={icon} alt={label} className="w-5 h-[25px] mr-2" />
{label}
</div>
</Checkbox>
</div>
))}
</CheckboxGroup>
</>
);
};

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { I18n } from '@coze-arch/i18n';
import styles from './index.module.less';
export const tableEmpty = (useTool: boolean, selected: boolean) => (
<div className={styles.empty}>
{useTool
? selected
? I18n.t('shortcut_modal_skill_has_no_param_tip')
: I18n.t('shortcut_modal_skill_select_button')
: I18n.t('shortcut_modal_form_to_be_filled_up_tip')}
</div>
);

View File

@@ -0,0 +1,30 @@
.table {
padding: 0 12px;
border: 1px solid var(--coz-stroke-primary);
border-radius: 8px;
:global {
.semi-table-placeholder {
border-bottom: 0;
}
.semi-form-field {
padding: 4px 0;
}
.semi-table-tbody {
tr:first-child td {
padding-top: 8px;
}
tr:last-child td {
padding-bottom: 12px;
}
}
}
}
.empty {
color: var(--coz-fg-dim);
text-align: left;
}

View File

@@ -0,0 +1,242 @@
/*
* 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 { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import { DndProvider } from '@coze-studio/components/dnd-provider';
import { type OnMove } from '@coze-studio/components';
import { I18n } from '@coze-arch/i18n';
import { type FormApi } from '@coze-arch/bot-semi/Form';
import { Form, Table, Toast, Tooltip } from '@coze-arch/bot-semi';
import { IconAdd } from '@coze-arch/bot-icons';
import {
InputType,
ToolType,
type ToolInfo,
type shortcut_command,
} from '@coze-arch/bot-api/playground_api';
import { compTip } from '../components/tip';
import FieldLabel from '../components/field-label';
import ActionButton from '../components/action-button';
import { shortid } from '../../../utils/uuid';
import { type ComponentsWithId } from './types';
import { getColumns, tableComponents } from './table-components';
import {
attachIdToComponents,
checkDuplicateName,
formatSubmitValues,
} from './method';
import { tableEmpty } from './empty';
import styles from './index.module.less';
export interface ComponentsTableProps {
components: shortcut_command.Components[]; // 变量列表
onChange?: (components: shortcut_command.Components[]) => void;
toolType?: shortcut_command.ToolType;
toolInfo: ToolInfo;
disabled: boolean;
}
export interface ComponentsTableActions {
validate: () => Promise<shortcut_command.Components[]>;
setValues: (values: shortcut_command.Components[]) => void;
}
const MAX_COMPONENTS = 10;
// 半受控组件,使用初始值 + 暴露 API 的方式,方便内部维护本地 id 用于拖拽排序标识数据
export const ComponentsTable = forwardRef<
{ formApi?: ComponentsTableActions },
ComponentsTableProps
// eslint-disable-next-line @coze-arch/max-line-per-function
>(({ components, onChange, toolType, disabled, toolInfo }, ref) => {
const [values, setValues] = useState<ComponentsWithId[]>(
attachIdToComponents(components),
);
const formRef = useRef<FormApi<{ values: ComponentsWithId[] }>>();
const onChangeInner = (newValues: ComponentsWithId[]) => {
setValues(newValues);
formRef.current?.setValues({ values: newValues }, { isOverride: true });
onChange?.(formatSubmitValues(newValues));
};
useImperativeHandle(ref, () => ({
formApi: formRef.current
? {
validate: async (...props) => {
// 在这里统一处理,避免多个相同字段触发多次 toast
if (
values.some(
component =>
component.input_type === InputType.Select &&
!component.options?.length,
)
) {
Toast.error(
I18n.t('shortcut_modal_selector_component_no_options_error'),
);
throw Error('shortcut_modal_selector_component_no_options_error');
}
if (
formRef.current &&
checkDuplicateName(values, formRef.current)
) {
throw Error('duplicated names');
}
const submitValues = await formRef.current?.validate(...props);
return formatSubmitValues(submitValues?.values ?? []);
},
setValues: newComponents => {
const newValues = attachIdToComponents(newComponents);
setValues(newValues);
formRef.current?.setValues(
{ values: newValues },
{ isOverride: true },
);
formRef.current?.setTouched('values', false);
formRef.current?.setError('values', '');
},
}
: undefined,
}));
const onMove: OnMove<string> = (sourceId, targetId, isBefore) => {
const newValues = [...values];
const sourceIndex = newValues.findIndex(source => source.id === sourceId);
const errors = formRef.current?.getError('values') || [];
const sourceError = errors.splice(sourceIndex, 1)[0];
const sourceItem = newValues.splice(sourceIndex, 1)[0];
const targetIndex =
newValues.findIndex(target => target.id === targetId) +
(isBefore ? 0 : 1);
sourceItem && newValues.splice(targetIndex, 0, sourceItem);
errors.splice(targetIndex, 0, sourceError);
// 前后 index 相同的情况不触发 onChange 避免频繁 rerender
if (sourceIndex !== targetIndex) {
onChangeInner(newValues);
// 只在拖拽排序后,需要手动更新 form value
formRef.current?.setValues(
{
values: newValues,
},
{ isOverride: true },
);
formRef.current?.setError('values', errors);
}
};
const showAdd =
toolType === undefined ||
![ToolType.ToolTypeWorkFlow, ToolType.ToolTypePlugin].includes(toolType);
const selected = !!toolInfo?.tool_name;
const oversize = values.length >= MAX_COMPONENTS;
const addBtn = (
<ActionButton
icon={<IconAdd />}
disabled={oversize || disabled}
onClick={() => {
onChangeInner([
...values,
{
id: shortid(),
input_type: InputType.TextInput,
},
]);
}}
>
{I18n.t('add')}
</ActionButton>
);
const tipBtn = oversize ? (
<Tooltip
content={I18n.t('shortcut_modal_max_component_tip', {
maxCount: MAX_COMPONENTS,
})}
>
{addBtn}
</Tooltip>
) : (
addBtn
);
return (
<div className="pb-6">
<div className="flex items-center justify-between pb-1.5">
<FieldLabel tip={compTip()}>
{I18n.t('shortcut_modal_components')}
</FieldLabel>
{showAdd ? tipBtn : null}
</div>
<DndProvider>
<Form<{ values: ComponentsWithId[] }>
initValues={{ values }}
// 手动触发校验,避免受增删和拖拽排序影响
trigger="custom"
autoComplete="off"
disabled={disabled}
getFormApi={api => (formRef.current = api)}
onValueChange={(newValues, changedValues) => {
const changedKeys = Object.keys(changedValues);
if (
changedKeys.length === 1 &&
// 只在表单修改场景下触发 onChange 避免无限循环
changedKeys[0]?.startsWith('values.[')
) {
onChangeInner([...newValues.values]);
// 只在编辑表单场景下对具体字段触发校验,其它场景(整行的增删排序)不触发校验
setTimeout(() => {
if (formRef.current) {
checkDuplicateName(newValues.values, formRef.current);
}
// @ts-expect-error semi 的类型定义无法支持多段 path
formRef.current?.validate([changedKeys[0]]);
});
}
}}
>
<div className={styles.table}>
<Table<ComponentsWithId>
dataSource={values}
size="small"
columns={getColumns({
components: values,
onChange: onChangeInner,
toolInfo,
toolType,
disabled,
})}
components={tableComponents}
pagination={false}
onRow={item => ({
id: item?.id ?? '',
sortable: (values?.length ?? 0) > 1 && !disabled,
onMove,
})}
empty={tableEmpty(!showAdd, selected)}
/>
</div>
</Form>
</DndProvider>
</div>
);
});

View File

@@ -0,0 +1,250 @@
/*
* 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 FormApi } from '@coze-arch/bot-semi/Form';
import {
InputType,
type shortcut_command,
type ToolInfo,
} from '@coze-arch/bot-api/playground_api';
import { shortid } from '../../../utils/uuid';
import { type UploadItemType } from '../../../utils/file-const';
import { type ComponentsWithId, type ComponentTypeItem } from './types';
const MAX_COMPONENTS = 10;
export const attachIdToComponents = (
components: shortcut_command.Components[],
): ComponentsWithId[] =>
components.map(item => ({
...item,
id: shortid(),
}));
export const formatSubmitValues = (
values: ComponentsWithId[],
): shortcut_command.Components[] =>
values.map(({ id, options, ...value }) => ({
...value,
options: value.input_type === InputType.Select ? options : [],
}));
export const checkDuplicateName = (
values: ComponentsWithId[],
formApi: FormApi,
) => {
const fieldMap: Record<string, number[]> = {};
values.forEach((item, index) => {
if (item.name) {
if (fieldMap[item.name]) {
fieldMap[item.name]?.push(index);
} else {
fieldMap[item.name] = [index];
}
}
});
setTimeout(() => {
// 避免修改后立刻被 field 自己的校验状态覆盖
Object.entries(fieldMap).forEach(([name, indexArray]) => {
const isDuplicated = indexArray.length > 1;
indexArray.forEach(index => {
formApi.setError(`values.${index}.name`, !isDuplicated);
});
});
});
return Object.entries(fieldMap).some(
([name, indexArr]) => indexArr.length > 1,
);
};
export interface SubmitComponentTypeFields {
input_type?: InputType;
options?: string[];
upload_options?: UploadItemType[];
}
export const getComponentTypeSelectFormInitValues = (): ComponentTypeItem => ({
type: 'text',
});
// 定义一个映射对象将ComponentTypeItem的type映射到对应的input_type和其他字段
const componentTypeHandlers = {
text: () => ({ input_type: InputType.TextInput }),
select: (value: ComponentTypeItem) => {
const { type } = value;
if (type !== 'select') {
return;
}
return {
input_type: InputType.Select,
options: value.options,
};
},
upload: (value: ComponentTypeItem) => {
if (value.type !== 'upload') {
return;
}
const { uploadTypes } = value;
if (uploadTypes.length > 1) {
return {
input_type: InputType.MixUpload,
upload_options: uploadTypes,
};
}
return {
input_type: uploadTypes.at(0) as InputType,
upload_options: undefined,
};
},
};
export const getSubmitFieldFromComponentTypeForm = (
values: ComponentTypeItem,
): SubmitComponentTypeFields => {
const { type } = values;
const handler = componentTypeHandlers[type];
const result = handler && handler(values);
if (result) {
return result;
}
// 如果没有找到处理函数,就返回默认值
return { input_type: InputType.TextInput };
};
// 是否是上传类型
export const isUploadType = (
type: InputType,
): type is
| InputType.UploadImage
| InputType.UploadDoc
| InputType.UploadTable
| InputType.UploadAudio
| InputType.CODE
| InputType.ARCHIVE
| InputType.PPT
| InputType.VIDEO
| InputType.TXT
| InputType.MixUpload =>
[
InputType.UploadImage,
InputType.UploadDoc,
InputType.UploadTable,
InputType.UploadAudio,
InputType.CODE,
InputType.ARCHIVE,
InputType.PPT,
InputType.VIDEO,
InputType.TXT,
InputType.MixUpload,
].includes(type);
// 将input_type映射到对应的处理函数
const inputTypeHandlers = {
[InputType.TextInput]: () => ({ type: 'text' }),
[InputType.Select]: (options: string[] = []) => ({
type: 'select' as const,
options,
}),
upload: (uploadTypes: UploadItemType[] = []) => ({
type: 'upload' as const,
uploadTypes,
}),
};
export const getComponentTypeFormBySubmitField = (
values: SubmitComponentTypeFields,
): ComponentTypeItem => {
const { input_type, options, upload_options } = values;
if (!input_type) {
return getComponentTypeSelectFormInitValues();
}
if (isUploadType(input_type)) {
const handler = inputTypeHandlers.upload;
return handler(upload_options);
}
const handler = inputTypeHandlers[input_type];
if (handler) {
return handler(options);
}
return getComponentTypeSelectFormInitValues();
};
/**
* 1. 修改components列表中对应组件的hidetrue
*/
export const modifyComponentWhenSwitchChange = ({
components,
record,
checked,
}: {
components: ComponentsWithId[];
record: ComponentsWithId;
checked: boolean;
}) =>
components.map(item => {
if (item.id === record.id) {
return {
...item,
hide: !checked,
};
}
return item;
});
// components switch是否disable
export const isSwitchDisabled = ({
components,
record,
toolInfo,
}: {
components: ComponentsWithId[];
record: ComponentsWithId;
toolInfo: ToolInfo;
}) => {
const { default_value } = record ?? {};
const isWithDefaultValue = !!default_value?.value;
const isRequired = (() => {
if (!toolInfo?.tool_name) {
return true;
}
/**
* 使用工具&为必填参数
*/
return !!toolInfo?.tool_params_list?.find(t => t.name === record.parameter)
?.required;
})();
// 组件超过最大数量, 不允许开启
const isMaxCount =
record.hide && components.filter(com => !com.hide).length >= MAX_COMPONENTS;
/** 必填且没有默认值不允许关闭 */
const isFinalRequired = isRequired && !isWithDefaultValue;
return isFinalRequired || isMaxCount;
};

View File

@@ -0,0 +1,335 @@
/*
* 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,
useEffect,
useRef,
type FC,
type PropsWithChildren,
} from 'react';
import cs from 'classnames';
import { useDnDSortableItem } from '@coze-studio/components/sortable-list-hooks';
import { type OnMove } from '@coze-studio/components';
import { I18n } from '@coze-arch/i18n';
import { Switch } from '@coze-arch/coze-design';
import { type TooltipProps } from '@coze-arch/bot-semi/Tooltip';
import {
type ColumnProps,
type TableComponents,
} from '@coze-arch/bot-semi/Table';
import { Form, IconButton, Tooltip } from '@coze-arch/bot-semi';
import { IconShortcutTrash, IconSvgShortcutDrag } from '@coze-arch/bot-icons';
import {
shortcut_command,
type ToolInfo,
} from '@coze-arch/bot-api/playground_api';
import { type UploadItemType } from '../../../utils/file-const';
import { type ComponentsWithId } from './types';
import {
getComponentTypeFormBySubmitField,
getSubmitFieldFromComponentTypeForm,
isSwitchDisabled,
modifyComponentWhenSwitchChange,
} from './method';
import { ComponentTypeSelectRecordItem } from './component-type-select';
import { ComponentParameter } from './component-parameter';
import { ComponentDefaultValue } from './component-default-value';
type ColumnPropType = ColumnProps<ComponentsWithId>;
const TooltipWithDisabled: FC<TooltipProps & { disabled?: boolean }> = ({
disabled,
children,
...props
}) => (disabled ? <>{children}</> : <Tooltip {...props}>{children}</Tooltip>);
const getOperationColumns = ({
components,
onChange,
toolType,
disabled,
toolInfo,
}: GetColumnsParams): ColumnPropType => {
const deleteable = !disabled;
const showDelete = toolType === undefined;
return {
key: 'operation',
title: null,
width: showDelete ? '80px' : '40px',
render: (_, record) => (
<div className="flex items-center pl-[12px]">
<Switch
checked={!record.hide}
disabled={isSwitchDisabled({
components,
record,
toolInfo,
})}
size="mini"
onChange={checked =>
onChange?.(
modifyComponentWhenSwitchChange({
components,
record,
checked,
}),
)
}
/>
{showDelete ? (
<div className="px-2">
<TooltipWithDisabled
content={I18n.t('Remove')}
disabled={!deleteable}
>
<IconButton
size="small"
theme="borderless"
type="tertiary"
disabled={!deleteable}
icon={<IconShortcutTrash />}
onClick={() => {
onChange?.(components.filter(item => item.id !== record.id));
}}
/>
</TooltipWithDisabled>
</div>
) : null}
</div>
),
};
};
const getColumnsMap = (params: GetColumnsParams) => {
const { components, disabled } = params;
const sortable = components.length > 1 && !disabled;
return {
name: {
key: 'name',
title: (
<Form.Label
className="leading-5 p-0 m-0"
text={I18n.t('shortcut_modal_component_name')}
required
/>
),
width: 1,
render: (_, record, index) => (
<div className="flex items-center">
<div
id={handleId}
className={cs(
'px-[2px]',
sortable ? 'cursor-grab' : 'cursor-not-allowed',
)}
>
<IconSvgShortcutDrag />
</div>
<Form.Input
noLabel
maxLength={20}
field={`values.[${index}].name`}
noErrorMessage
placeholder={I18n.t('shortcut_modal_component_name')}
rules={[
{
required: true,
},
]}
disabled={disabled || record.hide}
/>
</div>
),
},
description: {
key: 'description',
title: (
<Form.Label
className="leading-5 p-0 m-0"
text={I18n.t('Description')}
/>
),
width: '190px',
render: (_, record, index) => (
<div className="pl-[2px]">
<Form.Input
noLabel
maxLength={100}
field={`values.[${index}].description`}
noErrorMessage
placeholder={I18n.t('Description')}
disabled={disabled || record.hide}
/>
</div>
),
},
inputType: {
key: 'input_type',
title: (
<Form.Label
className="leading-5 p-0 m-0"
text={I18n.t('shortcut_modal_component_type')}
required
/>
),
render: (_, record, index) => (
<div className="pl-[2px]">
<ComponentTypeSelectRecordItem
value={getComponentTypeFormBySubmitField({
input_type: record.input_type,
options: record.options,
upload_options: record.upload_options as UploadItemType[],
})}
disabled={disabled || record.hide}
onSubmit={value => {
const { input_type, options, upload_options } =
getSubmitFieldFromComponentTypeForm(value);
params?.onChange?.(
params.components.map((item, i) =>
i === index
? {
...item,
input_type,
options,
default_value: {
value: '',
},
upload_options,
}
: item,
),
);
}}
/>
</div>
),
},
defaultValue: {
key: 'default_value',
title: (
<Form.Label
className="leading-5 p-0 m-0"
text={I18n.t('shortcut_modal_use_tool_parameter_default_value')}
/>
),
render: (_, record, index) => (
<div className="pl-[2px] max-w-[136px]">
<ComponentDefaultValue
componentType={getComponentTypeFormBySubmitField({
input_type: record.input_type,
options: record.options,
upload_options: record.upload_options as UploadItemType[],
})}
field={`values.[${index}].default_value`}
disabled={disabled || record.hide}
/>
</div>
),
},
parameter: {
key: 'parameter',
title: (
<Form.Label
className="leading-5 p-0 m-0"
text={I18n.t('shortcut_modal_component_plugin_wf_parameter')}
/>
),
dataIndex: 'parameter',
render: text => (
<ComponentParameter toolInfo={params.toolInfo} parameter={text} />
),
},
operations: getOperationColumns(params),
} satisfies Record<string, ColumnPropType>;
};
interface GetColumnsParams {
components: ComponentsWithId[];
onChange?: (values: ComponentsWithId[]) => void;
toolType?: shortcut_command.ToolType;
disabled: boolean;
toolInfo: ToolInfo;
}
const assignWidth = (base: ColumnPropType, width: string | number) =>
Object.assign({}, base, { width });
export const getColumns = (params: GetColumnsParams): ColumnPropType[] => {
const { toolType } = params;
const columnsMap = getColumnsMap(params);
if (
toolType === shortcut_command.ToolType.ToolTypePlugin ||
toolType === shortcut_command.ToolType.ToolTypeWorkFlow
) {
return [
assignWidth(columnsMap.name, '103px'),
assignWidth(columnsMap.description, '103px'),
assignWidth(columnsMap.inputType, '103px'),
assignWidth(columnsMap.defaultValue, '126px'),
assignWidth(columnsMap.parameter, '86px'),
columnsMap.operations,
];
}
return [
assignWidth(columnsMap.name, '125px'),
assignWidth(columnsMap.description, '125px'),
assignWidth(columnsMap.inputType, '125px'),
assignWidth(columnsMap.defaultValue, '136px'),
columnsMap.operations,
];
};
const type = Symbol.for(
'chat-area-plugins-chat-shortcuts-components-table-item',
);
const handleId = 'chat-area-plugins-chat-shortcuts-components-drag-handle';
const DraggableBodyRow: FC<
PropsWithChildren<{
id: string;
sortable: boolean;
onMove: OnMove<string>;
}>
> = ({ id, onMove, children, sortable }) => {
// 因为 name 可能为空,这里拿 shortid 做一个兜底
const dropRef = useRef<HTMLElement>(null);
const { connect } = useDnDSortableItem<string>({
type,
id,
onMove,
enabled: sortable,
});
useEffect(() => {
// 为了避免复杂的跨组件传值,这里稍微直接操作一下 DOM ,非常抱歉
const handleRef = {
current: (dropRef.current?.querySelector(`#${handleId}`) ??
null) as HTMLElement | null,
};
connect(dropRef, handleRef);
}, []);
return <tr ref={dropRef as RefObject<HTMLTableRowElement>}>{children}</tr>;
};
export const tableComponents = {
body: {
// semi-ui 导出的类型定义非常不负责任
row: DraggableBodyRow,
},
} as unknown as TableComponents;

View File

@@ -0,0 +1,65 @@
/*
* 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 shortcut_command } from '@coze-arch/bot-api/playground_api';
import { type UploadItemType } from '../../../utils/file-const';
import { type FileValue } from '../../../components/short-cut-panel/widgets/types';
export type ComponentsWithId = shortcut_command.Components & { id: string };
export type ComponentTypeSelectContentRadioValueType =
| 'text'
| 'select'
| 'upload';
export interface BaseComponentTypeItem {
type: ComponentTypeSelectContentRadioValueType;
}
export interface TextComponentTypeItem extends BaseComponentTypeItem {
type: 'text';
}
export interface SelectComponentTypeItem extends BaseComponentTypeItem {
type: 'select';
options: string[];
}
export interface UploadComponentTypeItem extends BaseComponentTypeItem {
type: 'upload';
uploadTypes: UploadItemType[];
}
export type ComponentTypeItem =
| TextComponentTypeItem
| SelectComponentTypeItem
| UploadComponentTypeItem;
export type TValue = string | FileValue | undefined;
export type TCustomUpload = (uploadParams: {
file: File;
onProgress?: (percent: number) => void;
onSuccess?: (url: string, width?: number, height?: number) => void;
onError?: (e: { status?: number }) => void;
}) => void;
export type UploadItemConfig = {
[key in UploadItemType]: {
maxSize?: number;
};
};

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Form } from '@coze-arch/bot-semi';
import style from './index.module.less';
// TODO: hzf, 取名component有点奇怪
export type FormInputWithMaxCountProps = {
maxCount: number;
} & React.ComponentProps<typeof Form.Input>;
// input后带上suffix表示能够输入的最大字数
export const FormInputWithMaxCount = (props: FormInputWithMaxCountProps) => {
const [count, setCount] = React.useState(0);
const handleChange = (v: string) => {
setCount(v.length);
};
const countSuffix = (
<div
className={style['form-input-with-count']}
>{`${count}/${props.maxCount}`}</div>
);
return (
<Form.Input
{...props}
onChange={value => handleChange(value)}
suffix={countSuffix}
/>
);
};

View File

@@ -0,0 +1,17 @@
.btn {
:global {
.semi-icon {
color: var(--coz-fg-secondary);
}
button {
padding: 2px 8px;
}
.semi-button-content-right {
margin-left: 4px;
font-weight: 500;
color: var(--coz-fg-secondary);
}
}
}

View File

@@ -0,0 +1,40 @@
/*
* 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, type PropsWithChildren } from 'react';
import { UIIconButton } from '@coze-arch/bot-semi';
import styles from './index.module.less';
const ActionButton: FC<
PropsWithChildren<{
icon: ReactNode;
onClick?: () => void;
disabled?: boolean;
}>
> = ({ onClick, icon, children, disabled }) => (
<UIIconButton
icon={icon}
wrapperClass={styles.btn}
onClick={onClick}
disabled={disabled}
>
{children}
</UIIconButton>
);
export default ActionButton;

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