Commit 579f5270 by DigHuang Committed by GitHub

feat(app): persist skill debug sessions and optimize playground UI (#7180)

parent 8f322a05
......@@ -512,6 +512,9 @@
"use_agent_sandbox": "Computer",
"enable_agent_sandbox": "Enable virtual machine",
"sandbox_config": "Virtual machine configuration",
"sandbox_config_tooltip": "Configure virtual machine",
"sandbox_enable": "Enable",
"sandbox_disable": "Disable",
"use_computer_desc": "After being turned on, AI will get a virtual machine environment where it can execute commands, operate files, and run code. \nEach session shares a virtual machine environment.",
"sandbox_entrypoint": "Startup script (sh)",
"sandbox_entrypoint_desc": "Script execution timing:\nRuns this script after virtual machine files and skill packages are injected.\n\nExecution rules:\n1. The script runs only once by default.\n2. If the script content changes, it runs again.\n3. Script failures do not block virtual machine execution.\n\nUse cases:\n1. Install dependency packages\n2. Initialize special files",
......
......@@ -512,6 +512,9 @@
"use_agent_sandbox": "虚拟机",
"enable_agent_sandbox": "启用虚拟机",
"sandbox_config": "虚拟机配置",
"sandbox_config_tooltip": "配置虚拟机",
"sandbox_enable": "开启",
"sandbox_disable": "关闭",
"use_computer_desc": "开启后,AI 将获得一个虚拟机环境,可执行命令、操作文件、运行代码。每个会话共享一个虚拟机环境。",
"sandbox_entrypoint": "启动脚本(sh)",
"sandbox_entrypoint_desc": "脚本执行时机:\n虚拟机文件和技能包注入后,执行该脚本\n\n执行规则:\n1. 脚本默认仅会执行一次。\n2. 若脚本发生内容变更,会重新执行。\n3. 脚本执行失败不会阻断虚拟机运行。\n\n适用场景:\n1. 安装依赖包\n2. 初始化特殊文件",
......
......@@ -499,6 +499,9 @@
"use_agent_sandbox": "虛擬機",
"enable_agent_sandbox": "啟用虛擬機",
"sandbox_config": "虛擬機配置",
"sandbox_config_tooltip": "配置虛擬機",
"sandbox_enable": "開啟",
"sandbox_disable": "關閉",
"use_computer_desc": "開啟後,AI 將獲得一個虛擬機器環境,執行指令、操作檔案、執行程式碼。\n每個會話共享一個虛擬機器環境。",
"sandbox_entrypoint": "啟動腳本(sh)",
"sandbox_entrypoint_desc": "腳本執行時機:\n虛擬機檔案和技能包注入後,執行該腳本\n\n執行規則:\n1. 腳本預設僅會執行一次。\n2. 若腳本發生內容變更,會重新執行。\n3. 腳本執行失敗不會阻斷虛擬機運行。\n\n適用場景:\n1. 安裝依賴包\n2. 初始化特殊文件",
......
import React from 'react';
import { Box, Button, Flex, HStack, Switch, useDisclosure, type FlexProps } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import MyModal from '@fastgpt/web/components/v2/common/MyModal';
import MyTag from '@fastgpt/web/components/common/Tag/index';
......@@ -70,10 +69,9 @@ function SandboxConfigButton({
<SandboxNotSupportTip type={showSandbox ? 'freeDisable' : 'systemDisable'} />
)}
<MyTooltip label={t('common:Config')}>
<MyTooltip label={t('app:sandbox_config_tooltip')}>
<Button
variant={'transparentBase'}
leftIcon={<MyIcon name={'common/settingLight'} w={'14px'} />}
iconSpacing={1}
size={'sm'}
mr={'-5px'}
......@@ -81,7 +79,7 @@ function SandboxConfigButton({
isDisabled={!canOpenConfig}
onClick={openConfig}
>
{t('common:Config')}
{isEnabled ? t('app:sandbox_enable') : t('app:sandbox_disable')}
</Button>
</MyTooltip>
......
import React, { useState } from 'react';
import { Box, Flex, IconButton, Center } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import Editor from '@monaco-editor/react';
import { useTranslation } from 'next-i18next';
import { useLatest } from 'ahooks';
......@@ -181,7 +182,7 @@ const EditorContent = ({
overviewRulerBorder: false,
fontSize: 13,
lineNumbers: 'on',
lineNumbersMinChars: 4,
lineNumbersMinChars: 5,
scrollBeyondLastLine: false,
automaticLayout: true,
lineHeight: 20,
......@@ -260,14 +261,16 @@ const EditorContent = ({
<Flex align="center" gap={2}>
{/* HTML Preview Icon */}
{activeFile?.language === 'html' && (
<IconButton
size="sm"
icon={<MyIcon name="common/htmlPreview" w="16px" />}
aria-label={t('chat:sandbox_html_preview')}
isLoading={generatingLink}
onClick={handleHtmlPreview}
variant="whiteBase"
/>
<MyTooltip label={t('chat:sandbox_html_preview')}>
<IconButton
size="sm"
icon={<MyIcon name="common/htmlPreview" w="16px" />}
aria-label={t('chat:sandbox_html_preview')}
isLoading={generatingLink}
onClick={handleHtmlPreview}
variant="whiteBase"
/>
</MyTooltip>
)}
{/* Source/Preview Toggle Switch */}
{supportsPreviewToggle && (
......@@ -300,14 +303,16 @@ const EditorContent = ({
/>
)}
{showDownload && activeFilePath && (
<IconButton
size="sm"
icon={<MyIcon name="common/downloadLine" w="16px" />}
aria-label={t('chat:sandbox_download')}
onClick={downloadCurrentFile}
isLoading={downloadingFile}
variant="whiteBase"
/>
<MyTooltip label={t('chat:sandbox_download')}>
<IconButton
size="sm"
icon={<MyIcon name="common/downloadLine" w="16px" />}
aria-label={t('chat:sandbox_download')}
onClick={downloadCurrentFile}
isLoading={downloadingFile}
variant="whiteBase"
/>
</MyTooltip>
)}
</Flex>
</Flex>
......
......@@ -11,7 +11,7 @@ import {
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { getSkillDetail, streamCreateEditDebugSandbox } from '@/web/core/skill/api';
import { SkillPermission } from '@fastgpt/global/support/permission/skill/controller';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { useSkillDebugChatStore } from './useSkillDebugChatStore';
export enum TabEnum {
config = 'config',
......@@ -71,12 +71,16 @@ const formatTimestamp = () => {
.join(':');
};
const CHAT_ID_STORAGE_KEY = (skillId: string) => `skill_debug_chatId_${skillId}`;
const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => {
const router = useRouter();
const { t } = useTranslation();
const { skillId = '' } = router.query as { skillId: string };
const { skillId: querySkillId } = router.query;
const skillId = (Array.isArray(querySkillId) ? querySkillId[0] : querySkillId) ?? '';
const activeSkillId = useSkillDebugChatStore((state) => state.skillId);
const activeChatId = useSkillDebugChatStore((state) => state.chatId);
const setSkillId = useSkillDebugChatStore((state) => state.setSkillId);
const setChatId = useSkillDebugChatStore((state) => state.setChatId);
const chatId = activeSkillId === skillId ? activeChatId : '';
const [showHistories, setShowHistories] = useState(false);
......@@ -88,31 +92,15 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => {
const hasStartedRef = useRef(false);
const saveAllRef = useRef<() => Promise<void>>();
// 调试会话管理:当 skillId 发生改变时,在渲染期同步调整状态(React 官方标准最佳实践),避免 useEffect 二次级联渲染
const getInitialChatId = useCallback((id: string) => {
if (!id || typeof window === 'undefined') return '';
const stored = localStorage.getItem(CHAT_ID_STORAGE_KEY(id));
if (stored) return stored;
const newId = getNanoid(24);
localStorage.setItem(CHAT_ID_STORAGE_KEY(id), newId);
return newId;
}, []);
const [prevSkillId, setPrevSkillId] = useState(skillId);
const [chatId, setChatId] = useState(() => getInitialChatId(skillId));
// 路由就绪、skillId 改变时,在渲染期同步调整状态(React 官方标准推荐方案)
if (skillId !== prevSkillId) {
setPrevSkillId(skillId);
setChatId(getInitialChatId(skillId));
}
useEffect(() => {
if (skillId && activeSkillId !== skillId) {
setSkillId(skillId);
}
}, [skillId, activeSkillId, setSkillId]);
const restartChat = useCallback(() => {
if (!skillId) return;
const newId = getNanoid(24);
localStorage.setItem(CHAT_ID_STORAGE_KEY(skillId), newId);
setChatId(newId);
}, [skillId]);
setChatId();
}, [setChatId]);
const phaseToMessage = useCallback(
(status: SandboxStatusItemType): string => {
......
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Flex } from '@chakra-ui/react';
import { useContextSelector } from 'use-context-selector';
import { SkillDetailContext } from '../context';
......@@ -22,6 +22,7 @@ import type { AppFileSelectConfigType } from '@fastgpt/global/core/app/type/conf
import type { StartChatFnProps } from '@/components/core/chat/ChatContainer/type';
import { useMemoizedFn } from 'ahooks';
import ProModal from '@/components/ProTip/ProModal';
import { useSkillDebugChatStore } from '../useSkillDebugChatStore';
const fileSelectConfig: AppFileSelectConfigType = {
maxFiles: 10,
......@@ -45,14 +46,20 @@ const SkillPreview = () => {
const { llmModelList, defaultModels, feConfigs } = useSystemStore();
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
const defaultModel = defaultModels.llm?.model || llmModelList[0]?.model || '';
const [selectedModel, setSelectedModel] = useState('');
const [proModalOpen, setProModalOpen] = useState(false);
const userSelectedModelRef = useRef(false);
const selectedModel = useSkillDebugChatStore((state) => state.selectedModel);
const setSelectedModel = useSkillDebugChatStore((state) => state.setSelectedModel);
const modelSelectList = useMemo(
() => llmModelList.map((item) => ({ label: item.name, value: item.model })),
[llmModelList]
);
const fallbackModel = useMemo(() => {
const modelSet = new Set(llmModelList.map((item) => item.model));
if (selectedModel && modelSet.has(selectedModel)) return selectedModel;
if (defaultModel && modelSet.has(defaultModel)) return defaultModel;
return llmModelList[0]?.model || '';
}, [defaultModel, llmModelList, selectedModel]);
const isReady = sandboxState === 'ready';
......@@ -78,12 +85,6 @@ const SkillPreview = () => {
});
}, [skillId, chatId, setChatBoxData]);
useEffect(() => {
if (!userSelectedModelRef.current && defaultModel && selectedModel !== defaultModel) {
setSelectedModel(defaultModel);
}
}, [defaultModel, selectedModel]);
const ModelSelectorInput = useMemo(() => {
return (
<ChatAIModelSelector
......@@ -92,15 +93,12 @@ const SkillPreview = () => {
size={'sm'}
bg={'myGray.50'}
rounded={'10px'}
value={selectedModel}
value={fallbackModel}
list={modelSelectList}
onChange={(val) => {
userSelectedModelRef.current = true;
setSelectedModel(val);
}}
onChange={setSelectedModel}
/>
);
}, [selectedModel, modelSelectList]);
}, [fallbackModel, modelSelectList, setSelectedModel]);
const onStartChat = useMemoizedFn(
async ({ messages, responseChatItemId, controller, generatingMessage }: StartChatFnProps) => {
......@@ -111,7 +109,7 @@ const SkillPreview = () => {
skillId,
chatId,
messages: histories,
model: selectedModel,
model: fallbackModel,
responseChatItemId
},
onMessage: generatingMessage,
......
import { create, createJSONStorage, devtools, persist, immer } from '@fastgpt/web/common/zustand';
import { getNanoid } from '@fastgpt/global/common/string/tools';
type State = {
skillId: string;
chatId: string;
selectedModel: string;
/** 每个 Skill 最近一次打开的调试 chatId,用于切换 Skill 时恢复会话 */
skillChatIdMap: Record<string, string>;
/** 每个 Skill 最近一次使用的调试模型,用于恢复调试偏好 */
skillModelMap: Record<string, string>;
setSkillId: (skillId: string) => void;
setChatId: (chatId?: string) => void;
setSelectedModel: (model: string) => void;
resetSkillDebugCache: () => void;
};
export const createSkillDebugChatStorage = () => {
// 当前 tab 的 active 状态放 sessionStorage;跨 tab 恢复用的 map 放 localStorage。
const sessionKeys = ['skillId', 'chatId', 'selectedModel'];
return {
getItem: (name: string) => {
const sessionData = JSON.parse(sessionStorage.getItem(name) || '{}');
const localData = JSON.parse(localStorage.getItem(name) || '{}');
return JSON.stringify({
version: 0,
state: {
...localData.state,
...sessionData.state
}
});
},
setItem: (name: string, value: string) => {
const data = JSON.parse(value);
const sessionData = Object.fromEntries(
Object.entries(data.state).filter(([key]) => sessionKeys.includes(key))
);
const localData = Object.fromEntries(
Object.entries(data.state).filter(([key]) => !sessionKeys.includes(key))
);
if (Object.keys(sessionData).length > 0) {
sessionStorage.setItem(name, JSON.stringify({ state: sessionData, version: 0 }));
}
if (Object.keys(localData).length > 0) {
localStorage.setItem(name, JSON.stringify({ state: localData, version: 0 }));
}
},
removeItem: (name: string) => {
sessionStorage.removeItem(name);
localStorage.removeItem(name);
}
};
};
export const useSkillDebugChatStore = create<State>()(
devtools(
persist(
immer((set) => ({
skillId: '',
chatId: '',
selectedModel: '',
skillChatIdMap: {},
skillModelMap: {},
setSkillId(skillId) {
if (!skillId) return;
set((state) => {
const isSameSkill = state.skillId === skillId;
if (state.skillId && !isSameSkill) {
if (state.chatId) {
state.skillChatIdMap[state.skillId] = state.chatId;
}
if (state.selectedModel) {
state.skillModelMap[state.skillId] = state.selectedModel;
}
}
const nextChatId =
(isSameSkill ? state.chatId : undefined) ||
state.skillChatIdMap[skillId] ||
getNanoid(24);
const nextModel =
(isSameSkill ? state.selectedModel : undefined) || state.skillModelMap[skillId] || '';
state.skillId = skillId;
state.chatId = nextChatId;
state.selectedModel = nextModel;
state.skillChatIdMap[skillId] = nextChatId;
if (nextModel) {
state.skillModelMap[skillId] = nextModel;
}
});
},
setChatId(chatId) {
const nextChatId = chatId || getNanoid(24);
set((state) => {
if (!state.skillId) return;
state.chatId = nextChatId;
state.skillChatIdMap[state.skillId] = nextChatId;
});
},
setSelectedModel(model) {
set((state) => {
state.selectedModel = model;
if (state.skillId && model) {
state.skillModelMap[state.skillId] = model;
}
});
},
resetSkillDebugCache() {
set((state) => {
state.skillId = '';
state.chatId = '';
state.selectedModel = '';
state.skillChatIdMap = {};
state.skillModelMap = {};
});
}
})),
{
name: 'skillDebugChatStore',
storage: createJSONStorage(createSkillDebugChatStorage),
partialize: (state) => ({
skillId: state.skillId,
chatId: state.chatId,
selectedModel: state.selectedModel,
skillChatIdMap: state.skillChatIdMap,
skillModelMap: state.skillModelMap
})
}
)
)
);
......@@ -11,18 +11,6 @@ import { getSandboxClient } from '@fastgpt/service/core/ai/sandbox/service/runti
import { getSandboxFileContent } from '@/service/core/sandbox/fileService';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
// 在 <head> 中注入 CSP,禁止外部脚本加载,仅允许 inline(沙箱预览场景)
function injectCspMetaTag(html: string): string {
const cspMeta =
"<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'self' data: blob:; script-src 'none'; style-src 'unsafe-inline' 'self' data:;\">";
if (/<head[^>]*>/i.test(html)) {
return html.replace(/(<head[^>]*>)/i, `$1${cspMeta}`);
}
// 没有 <head> 标签时,直接前置
return cspMeta + html;
}
async function handler(req: ApiRequestProps, res: NextApiResponse): Promise<void> {
const { appId, chatId, filePath, outLinkAuthData } = parseApiInput({
req,
......@@ -45,8 +33,7 @@ async function handler(req: ApiRequestProps, res: NextApiResponse): Promise<void
return jsonRes(res, { code: 400, message: 'File is not an HTML file' });
}
// 3. 注入 CSP meta tag 后上传到 S3
const safeHtml = injectCspMetaTag(content.toString('utf-8'));
// 3. 保持 HTML 原始内容上传到 S3,避免预览时移除或禁用脚本逻辑。
const bucket = new S3PrivateBucket();
const { fileKey } = getFileS3Key.temp({ teamId, filename: 'preview.html' });
const expiredTime = addMinutes(new Date(), 30);
......@@ -55,7 +42,7 @@ async function handler(req: ApiRequestProps, res: NextApiResponse): Promise<void
accessUrl: { url }
} = await bucket.uploadFileByBody({
key: fileKey,
body: Buffer.from(safeHtml, 'utf-8'),
body: content,
filename: 'preview.html',
contentType: 'text/html; charset=utf-8',
expiredTime
......
......@@ -22,10 +22,10 @@ const MainLayout = () => {
return (
<HeaderProvider>
<Flex h={'100%'} bg={'myGray.25'} overflow={'hidden'}>
{/* 左栏: 488px 预览对话区域 */}
{/* 左栏: 预览对话区域,默认占 1/3,最小 488px */}
<Flex
w={'488px'}
flexShrink={0}
flex={1}
minW={'488px'}
direction={'column'}
h={'100%'}
sx={{
......@@ -44,7 +44,7 @@ const MainLayout = () => {
</Flex>
{/* 右栏: 文件树 + 编辑器区域 */}
<Box flex={1} h={'100%'} overflow={'hidden'}>
<Box flex={2} minW={0} h={'100%'} overflow={'hidden'}>
<Content />
</Box>
</Flex>
......
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createSkillDebugChatStorage,
useSkillDebugChatStore
} from '@/pageComponents/dashboard/skill/detail/useSkillDebugChatStore';
vi.mock('@fastgpt/global/common/string/tools', () => ({
getNanoid: () => 'test-generated-id'
}));
const sessionStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn()
};
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn()
};
vi.stubGlobal('sessionStorage', sessionStorageMock);
vi.stubGlobal('localStorage', localStorageMock);
const resetStore = () => {
useSkillDebugChatStore.setState({
skillId: '',
chatId: '',
selectedModel: '',
skillChatIdMap: {},
skillModelMap: {}
});
};
describe('useSkillDebugChatStore', () => {
beforeEach(() => {
resetStore();
vi.clearAllMocks();
});
it('restores chatId and model by skill', () => {
const store = useSkillDebugChatStore.getState();
store.setSkillId('skill-a');
store.setChatId('chat-a');
store.setSelectedModel('model-a');
store.setSkillId('skill-b');
store.setChatId('chat-b');
store.setSelectedModel('model-b');
store.setSkillId('skill-a');
expect(useSkillDebugChatStore.getState()).toMatchObject({
chatId: 'chat-a',
selectedModel: 'model-a'
});
store.setSkillId('skill-b');
expect(useSkillDebugChatStore.getState()).toMatchObject({
chatId: 'chat-b',
selectedModel: 'model-b'
});
});
it('writes generated chatId into skill cache when entering a skill', () => {
useSkillDebugChatStore.getState().setSkillId('skill-a');
expect(useSkillDebugChatStore.getState()).toMatchObject({
chatId: 'test-generated-id',
skillChatIdMap: {
'skill-a': 'test-generated-id'
}
});
});
});
describe('createSkillDebugChatStorage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('splits active tab state and cross-tab cache state', () => {
const storage = createSkillDebugChatStorage();
storage.setItem(
'skillDebugChatStore',
JSON.stringify({
state: {
skillId: 'skill-a',
chatId: 'chat-a',
selectedModel: 'model-a',
skillChatIdMap: { 'skill-a': 'chat-a' },
skillModelMap: { 'skill-a': 'model-a' }
},
version: 0
})
);
expect(sessionStorageMock.setItem).toHaveBeenCalledWith(
'skillDebugChatStore',
JSON.stringify({
state: {
skillId: 'skill-a',
chatId: 'chat-a',
selectedModel: 'model-a'
},
version: 0
})
);
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'skillDebugChatStore',
JSON.stringify({
state: {
skillChatIdMap: { 'skill-a': 'chat-a' },
skillModelMap: { 'skill-a': 'model-a' }
},
version: 0
})
);
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment