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,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();
}
},
);
};
};