feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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属性 value:values中对应的值 | 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属性 value:values中对应的值 | 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属性 value:values中对应的值 | 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;
|
||||
}, {});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user