Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/global/core/chat/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export type AIChatItemType = {
citeCollectionIds?: string[];

/**
* @deprecated 不再存储在 chatItemSchema 里,分别存储到 chatItemResponseSchema
* 不再存储在 chatItemSchema 里,分别存储到 chatItemResponseSchema
*/
[DispatchNodeResponseKeyEnum.nodeResponse]?: ChatHistoryItemResType[];
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type { HelperBotRefType } from '@/components/core/chat/HelperBot/context'
import { HelperBotTypeEnum } from '@fastgpt/global/core/chat/helperBot/type';
import { loadGeneratedTools } from './utils';
import { systemSubInfo } from '@fastgpt/global/core/workflow/node/agent/constants';
import { useSandboxEditor } from '@/pageComponents/chat/SandboxEditor/hook';
import { useSandboxEditor, useSandboxStatus } from '@/pageComponents/chat/SandboxEditor/hook';

type Props = {
appForm: AppFormEditFormType;
Expand All @@ -50,8 +50,12 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props
edges: appDetail.edges || []
});

// Sandbox state
const { SandboxEditorModal, SandboxEntryIcon } = useSandboxEditor({
// Sandbox: Status Hook 负责网络同步,UI Hook 负责弹窗渲染
const { SandboxEntryIcon } = useSandboxStatus({
appId: appDetail._id,
chatId
});
const { SandboxEditorModal, onOpenSandboxModal } = useSandboxEditor({
appId: appDetail._id,
chatId
});
Expand Down Expand Up @@ -126,7 +130,7 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props
)}

<Box flex={1} />
<SandboxEntryIcon size={'smSquare'} mr={2} />
<SandboxEntryIcon size={'smSquare'} mr={2} onOpen={onOpenSandboxModal} />
<MyTooltip label={t('common:core.chat.Restart')}>
<IconButton
className="chat"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
import VariablePopover from '@/components/core/chat/ChatContainer/components/VariablePopover';
import { ChatTypeEnum } from '@/components/core/chat/ChatContainer/ChatBox/constants';
import type { Form2WorkflowFnType } from '../FormComponent/type';
import { useSandboxEditor } from '@/pageComponents/chat/SandboxEditor/hook';
import { useSandboxEditor, useSandboxStatus } from '@/pageComponents/chat/SandboxEditor/hook';

type Props = {
appForm: AppFormEditFormType;
Expand All @@ -40,8 +40,12 @@ const ChatTest = ({ appForm, setRenderEdit, form2WorkflowFn }: Props) => {
edges: appDetail.edges || []
});

// Sandbox state
const { SandboxEditorModal, SandboxEntryIcon, setSandboxExists } = useSandboxEditor({
// Sandbox: Status Hook 负责网络同步,UI Hook 负责弹窗渲染
const { SandboxEntryIcon, setSandboxExists } = useSandboxStatus({
appId: appDetail._id,
chatId
});
const { SandboxEditorModal, onOpenSandboxModal } = useSandboxEditor({
appId: appDetail._id,
chatId
});
Expand Down Expand Up @@ -81,7 +85,7 @@ const ChatTest = ({ appForm, setRenderEdit, form2WorkflowFn }: Props) => {
{!isVariableVisible && <VariablePopover chatType={ChatTypeEnum.test} />}
<Box flex={1} />

<SandboxEntryIcon size={'smSquare'} mr={2} />
<SandboxEntryIcon size={'smSquare'} mr={2} onOpen={onOpenSandboxModal} />
<MyTooltip label={t('common:core.chat.Restart')}>
<IconButton
className="chat"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { useContextSelector } from 'use-context-selector';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
import { ChatTypeEnum } from '@/components/core/chat/ChatContainer/ChatBox/constants';
import { DetailLogsModalFeedbackTypeFilter } from './FeedbackTypeFilter';
import { useSandboxEditor } from '@/pageComponents/chat/SandboxEditor/hook';
import { useSandboxEditor, useSandboxStatus } from '@/pageComponents/chat/SandboxEditor/hook';
import MyIcon from '@fastgpt/web/components/common/Icon';

const PluginRunBox = dynamic(() => import('@/components/core/chat/ChatContainer/PluginRunBox'));
Expand Down Expand Up @@ -87,11 +87,9 @@ const DetailLogsModal = ({
const chatModels = chat?.app?.chatModels;
const isPlugin = chat?.app.type === AppTypeEnum.workflowTool;

// Sandbox state
const { SandboxEditorModal, SandboxEntryIcon } = useSandboxEditor({
appId,
chatId
});
// Sandbox: Status Hook 负责网络同步,UI Hook 负责弹窗渲染
const { SandboxEntryIcon } = useSandboxStatus({ appId, chatId });
const { SandboxEditorModal, onOpenSandboxModal } = useSandboxEditor({ appId, chatId });

return (
<>
Expand Down Expand Up @@ -176,7 +174,7 @@ const DetailLogsModal = ({
</>
)}

<SandboxEntryIcon size={'smSquare'} mr={2} />
<SandboxEntryIcon size={'smSquare'} mr={2} onOpen={onOpenSandboxModal} />
<IconButton
variant={'whiteBase'}
size={'smSquare'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
import VariablePopover from '@/components/core/chat/ChatContainer/components/VariablePopover';
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
import { ChatTypeEnum } from '@/components/core/chat/ChatContainer/ChatBox/constants';
import { useSandboxEditor } from '@/pageComponents/chat/SandboxEditor/hook';
import { useSandboxEditor, useSandboxStatus } from '@/pageComponents/chat/SandboxEditor/hook';

type Props = {
isOpen: boolean;
Expand Down Expand Up @@ -54,8 +54,12 @@ const ChatTest = ({ isOpen, nodes = [], edges = [], onClose, chatId }: Props) =>
const isVariableVisible = useContextSelector(ChatItemContext, (v) => v.isVariableVisible);
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);

// Sandbox state
const { SandboxEditorModal, SandboxEntryIcon } = useSandboxEditor({
// Sandbox: Status Hook 负责网络同步,UI Hook 负责弹窗渲染
const { SandboxEntryIcon } = useSandboxStatus({
appId: appDetail._id,
chatId
});
const { SandboxEditorModal, onOpenSandboxModal } = useSandboxEditor({
appId: appDetail._id,
chatId
});
Expand Down Expand Up @@ -143,7 +147,7 @@ const ChatTest = ({ isOpen, nodes = [], edges = [], onClose, chatId }: Props) =>
{!isVariableVisible && <VariablePopover chatType={ChatTypeEnum.test} />}
<Box flex={1} />

<SandboxEntryIcon mr={2} />
<SandboxEntryIcon mr={2} onOpen={onOpenSandboxModal} />
<MyTooltip label={t('common:core.chat.Restart')}>
<IconButton
mr={2}
Expand Down
122 changes: 87 additions & 35 deletions projects/app/src/pageComponents/chat/SandboxEditor/hook.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,44 @@
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import SandboxEditorModal from '@/pageComponents/chat/SandboxEditor/modal';
import type { IconButtonProps } from '@chakra-ui/react';
import { IconButton } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { checkSandboxExist } from './api';
import { useInterval } from 'ahooks';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useTranslation } from 'next-i18next';
import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import { useContextSelector } from 'use-context-selector';
import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext';
import { addStatisticalDataToHistoryItem } from '@/global/core/chat/utils';

/**
* useSandboxEditor —— UI Hook
*
* 职责:仅负责渲染 SandboxEditorModal 弹窗及其开关逻辑。
*/
export const useSandboxEditor = ({
appId,
chatId,
outLinkAuthData
outLinkAuthData,
afterClose
}: {
appId: string;
chatId: string;
outLinkAuthData?: OutLinkChatAuthProps;
afterClose?: () => void;
}) => {
const { t } = useTranslation();
// Sandbox state
const [sandboxModalOpen, setSandboxModalOpen] = useState(false);
const [sandboxExists, setSandboxExists] = useState(false);

// 检查沙盒是否存在
const checkSandboxStatus = useCallback(async () => {
try {
const result = await checkSandboxExist({ appId, chatId, outLinkAuthData });
setSandboxExists(result.exists);
} catch (error) {
console.error('Failed to check sandbox status:', error);
}
}, [appId, chatId, outLinkAuthData]);

// 组件挂载时检查
useInterval(checkSandboxStatus, 10000, {
immediate: true
});

const onOpenSandboxModal = useCallback(() => {
setSandboxModalOpen(true);
}, []);

const onCloseSandboxModal = useCallback(() => {
setSandboxModalOpen(false);
// 关闭后重新检查状态
checkSandboxStatus();
}, [checkSandboxStatus]);
afterClose?.();
}, [afterClose]);

const Dom = useCallback(() => {
const SandboxEditorModalDom = useCallback(() => {
return sandboxModalOpen ? (
<SandboxEditorModal
onClose={onCloseSandboxModal}
Expand All @@ -59,9 +49,75 @@ export const useSandboxEditor = ({
) : null;
}, [sandboxModalOpen, onCloseSandboxModal, appId, chatId, outLinkAuthData]);

return {
SandboxEditorModal: SandboxEditorModalDom,
onOpenSandboxModal,
onCloseSandboxModal
};
};

/**
* useSandboxStatus —— Status Hook
*
* 职责:负责 checkSandboxExist 的网络同步及 SandboxEntryIcon 的显示控制。
* 同步模式:
* 1. 历史记录(ChatRecordContext):useMemo 派生,无副作用。
* 2. chatId 切换:渲染周期利用 useRef 确认 ID 变化并同步重置状态,防止 UI 闪烁。
* 3. 网络请求:单一 useEffect,在参数变化时触发 1 次。
*/
export const useSandboxStatus = ({
appId,
chatId,
outLinkAuthData
}: {
appId: string;
chatId: string;
outLinkAuthData?: OutLinkChatAuthProps;
}) => {
const { t } = useTranslation();
const [apiSandboxExists, setApiSandboxExists] = useState(false);
const lastChatIdRef = useRef(chatId);

if (lastChatIdRef.current !== chatId) {
lastChatIdRef.current = chatId;
setApiSandboxExists(false);
}

const chatRecords = useContextSelector(ChatRecordContext, (v) => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 优化建议 — 网络请求从轮询改为单次的取舍

原来的实现使用 useInterval(checkSandboxStatus, 10000) 每 10 秒轮询一次,现在改为仅在 chatId 变化时请求一次。

这个改动减少了不必要的网络请求,但也意味着:如果用户在聊天过程中 AI 创建了新的 sandbox(流式响应结束后),状态不会自动更新,用户需要切换聊天再切回来才能看到 sandbox 图标。

hasSandboxInHistory 通过 chatRecordsuseMemo 派生可以部分弥补这个问题——但前提是 chatRecords 会在新消息完成后及时更新。请确认这个场景是否有覆盖。

return v.chatRecords;
});
const isChatRecordsLoaded = useContextSelector(ChatRecordContext, (v) => v.isChatRecordsLoaded);

const hasSandboxInHistory = useMemo(() => {
if (!isChatRecordsLoaded) return false;
return chatRecords.some((record) => {
const enriched = addStatisticalDataToHistoryItem(record);
return enriched.useAgentSandbox === true;
});
}, [chatRecords, isChatRecordsLoaded]);

useEffect(() => {
if (!chatId) return;
let cancelled = false;
checkSandboxExist({ appId, chatId, outLinkAuthData })
.then((result) => {
if (!cancelled) setApiSandboxExists(result.exists);
})
.catch((error) => {
console.error('Failed to check sandbox status:', error);
});
return () => {
cancelled = true;
};
}, [appId, chatId]);

const sandboxExists = hasSandboxInHistory || apiSandboxExists;

const SandboxEntryIcon = useCallback(
(props: Omit<IconButtonProps, 'name' | 'onClick' | 'aria-label'>) => {
// 只有沙盒存在时才显示图标
({
onOpen,
...props
}: Omit<IconButtonProps, 'name' | 'onClick' | 'aria-label'> & { onOpen: () => void }) => {
if (!sandboxExists) return null;

return (
Expand All @@ -70,23 +126,19 @@ export const useSandboxEditor = ({
variant={'whiteBase'}
size={'smSquare'}
icon={<MyIcon name={'core/app/sandbox/file'} w={'16px'} />}
onClick={onOpenSandboxModal}
onClick={onOpen}
{...props}
aria-label="Sandbox Entry"
/>
</MyTooltip>
);
},
[sandboxExists, t, onOpenSandboxModal]
[sandboxExists, t]
);

return {
sandboxExists,
setSandboxExists,
checkSandboxStatus,
SandboxEntryIcon,
SandboxEditorModal: Dom,
onOpenSandboxModal,
onCloseSandboxModal
setSandboxExists: setApiSandboxExists,
SandboxEntryIcon
};
};
23 changes: 12 additions & 11 deletions projects/app/src/pageComponents/chat/ToolMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import MyMenu from '@fastgpt/web/components/common/MyMenu';
import { useContextSelector } from 'use-context-selector';
import { ChatContext } from '@/web/core/chat/context/chatContext';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import { useSandboxEditor } from './SandboxEditor/hook';
import { useSandboxEditor, useSandboxStatus } from './SandboxEditor/hook';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import { useSystem } from '@fastgpt/web/hooks/useSystem';

Expand All @@ -25,24 +25,25 @@ const ToolMenu = ({

const onChangeChatId = useContextSelector(ChatContext, (v) => v.onChangeChatId);
const chatData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
const { chatId, appId, setChatId, outLinkAuthData } = useChatStore();
const { chatId, outLinkAuthData } = useChatStore();

// Sandbox state
const {
SandboxEditorModal,
SandboxEntryIcon,
setSandboxExists,
sandboxExists,
onOpenSandboxModal
} = useSandboxEditor({
// Status Hook: 顶层单例,负责网络同步与入口图标显示
const { sandboxExists, setSandboxExists, SandboxEntryIcon } = useSandboxStatus({
appId: chatData.appId,
chatId,
outLinkAuthData
});

// UI Hook: 负责弹窗渲染
const { SandboxEditorModal, onOpenSandboxModal } = useSandboxEditor({
appId: chatData.appId,
chatId,
outLinkAuthData
});

return (
<>
{isPc && <SandboxEntryIcon />}
{isPc && <SandboxEntryIcon onOpen={onOpenSandboxModal} />}
<MyMenu
Button={
<Box transform={reserveSpace ? 'translateX(-32px)' : 'none'}>
Expand Down
Loading