From 517f42ec3f79568a49b72edce484d65d7311dfef Mon Sep 17 00:00:00 2001 From: yyh Date: Fri, 27 Mar 2026 13:59:07 +0800 Subject: [PATCH] refactor(skill): split file content panel architecture --- .../skills/use-skill-code-collaboration.ts | 34 +- .../use-skill-markdown-collaboration.ts | 34 +- .../skill-body/panels/file-content-panel.tsx | 378 ------------------ .../__tests__/index.spec.tsx} | 158 +++++++- .../file-editor-renderer.tsx | 63 +++ .../file-preview-renderer.tsx | 55 +++ .../panels/file-content-panel/index.tsx | 83 ++++ .../panels/file-content-panel/types.ts | 62 +++ .../use-file-content-controller.ts | 274 +++++++++++++ .../use-file-fallback-lifecycle.ts | 69 ++++ .../use-file-metadata-sync.ts | 28 ++ .../panels/file-content-panel/utils.ts | 35 ++ 12 files changed, 857 insertions(+), 416 deletions(-) delete mode 100644 web/app/components/workflow/skill/skill-body/panels/file-content-panel.tsx rename web/app/components/workflow/skill/skill-body/panels/{file-content-panel.spec.tsx => file-content-panel/__tests__/index.spec.tsx} (81%) create mode 100644 web/app/components/workflow/skill/skill-body/panels/file-content-panel/file-editor-renderer.tsx create mode 100644 web/app/components/workflow/skill/skill-body/panels/file-content-panel/file-preview-renderer.tsx create mode 100644 web/app/components/workflow/skill/skill-body/panels/file-content-panel/index.tsx create mode 100644 web/app/components/workflow/skill/skill-body/panels/file-content-panel/types.ts create mode 100644 web/app/components/workflow/skill/skill-body/panels/file-content-panel/use-file-content-controller.ts create mode 100644 web/app/components/workflow/skill/skill-body/panels/file-content-panel/use-file-fallback-lifecycle.ts create mode 100644 web/app/components/workflow/skill/skill-body/panels/file-content-panel/use-file-metadata-sync.ts create mode 100644 web/app/components/workflow/skill/skill-body/panels/file-content-panel/utils.ts diff --git a/web/app/components/workflow/collaboration/skills/use-skill-code-collaboration.ts b/web/app/components/workflow/collaboration/skills/use-skill-code-collaboration.ts index 792e210e30..db5e77c219 100644 --- a/web/app/components/workflow/collaboration/skills/use-skill-code-collaboration.ts +++ b/web/app/components/workflow/collaboration/skills/use-skill-code-collaboration.ts @@ -9,6 +9,7 @@ type UseSkillCodeCollaborationProps = { initialContent: string baselineContent: string onLocalChange: (value: string) => void + onRemoteChange?: (value: string) => void onLeaderSync: () => void } @@ -19,12 +20,14 @@ export const useSkillCodeCollaboration = ({ initialContent, baselineContent, onLocalChange, + onRemoteChange, onLeaderSync, }: UseSkillCodeCollaborationProps) => { const storeApi = useWorkflowStore() const suppressNextChangeRef = useRef(null) - // Keep the latest server baseline to avoid marking the editor dirty on initial sync. const baselineContentRef = useRef(baselineContent) + const onRemoteChangeRef = useRef(onRemoteChange) + const onLeaderSyncRef = useRef(onLeaderSync) useEffect(() => { suppressNextChangeRef.current = null @@ -34,6 +37,14 @@ export const useSkillCodeCollaboration = ({ baselineContentRef.current = baselineContent }, [baselineContent]) + useEffect(() => { + onRemoteChangeRef.current = onRemoteChange + }, [onRemoteChange]) + + useEffect(() => { + onLeaderSyncRef.current = onLeaderSync + }, [onLeaderSync]) + useEffect(() => { if (!enabled || !fileId) return @@ -43,17 +54,24 @@ export const useSkillCodeCollaboration = ({ const unsubscribe = skillCollaborationManager.subscribe(fileId, (nextText) => { suppressNextChangeRef.current = nextText - const state = storeApi.getState() - if (nextText === baselineContentRef.current) { - state.clearDraftContent(fileId) + if (onRemoteChangeRef.current) { + onRemoteChangeRef.current(nextText) } else { - state.setDraftContent(fileId, nextText) - state.pinTab(fileId) + const state = storeApi.getState() + if (nextText === baselineContentRef.current) { + state.clearDraftContent(fileId) + } + else { + state.setDraftContent(fileId, nextText) + state.pinTab(fileId) + } } }) - const unsubscribeSync = skillCollaborationManager.onSyncRequest(fileId, onLeaderSync) + const unsubscribeSync = skillCollaborationManager.onSyncRequest(fileId, () => { + onLeaderSyncRef.current() + }) return () => { unsubscribe() @@ -61,7 +79,7 @@ export const useSkillCodeCollaboration = ({ skillCollaborationManager.setActiveFile(appId, fileId, false) skillCollaborationManager.closeFile(fileId) } - }, [appId, enabled, fileId, initialContent, onLeaderSync, storeApi]) + }, [appId, enabled, fileId, initialContent, storeApi]) const handleCollaborativeChange = useCallback((value: string | undefined) => { const nextValue = value ?? '' diff --git a/web/app/components/workflow/collaboration/skills/use-skill-markdown-collaboration.ts b/web/app/components/workflow/collaboration/skills/use-skill-markdown-collaboration.ts index f1427f3e46..0ceb5fe920 100644 --- a/web/app/components/workflow/collaboration/skills/use-skill-markdown-collaboration.ts +++ b/web/app/components/workflow/collaboration/skills/use-skill-markdown-collaboration.ts @@ -11,6 +11,7 @@ type UseSkillMarkdownCollaborationProps = { initialContent: string baselineContent: string onLocalChange: (value: string) => void + onRemoteChange?: (value: string) => void onLeaderSync: () => void } @@ -21,13 +22,15 @@ export const useSkillMarkdownCollaboration = ({ initialContent, baselineContent, onLocalChange, + onRemoteChange, onLeaderSync, }: UseSkillMarkdownCollaborationProps) => { const storeApi = useWorkflowStore() const { eventEmitter } = useEventEmitterContextContext() const suppressNextChangeRef = useRef(null) - // Keep the latest server baseline to avoid marking the editor dirty on initial sync. const baselineContentRef = useRef(baselineContent) + const onRemoteChangeRef = useRef(onRemoteChange) + const onLeaderSyncRef = useRef(onLeaderSync) useEffect(() => { suppressNextChangeRef.current = null @@ -37,6 +40,14 @@ export const useSkillMarkdownCollaboration = ({ baselineContentRef.current = baselineContent }, [baselineContent]) + useEffect(() => { + onRemoteChangeRef.current = onRemoteChange + }, [onRemoteChange]) + + useEffect(() => { + onLeaderSyncRef.current = onLeaderSync + }, [onLeaderSync]) + useEffect(() => { if (!enabled || !fileId) return @@ -46,13 +57,18 @@ export const useSkillMarkdownCollaboration = ({ const unsubscribe = skillCollaborationManager.subscribe(fileId, (nextText) => { suppressNextChangeRef.current = nextText - const state = storeApi.getState() - if (nextText === baselineContentRef.current) { - state.clearDraftContent(fileId) + if (onRemoteChangeRef.current) { + onRemoteChangeRef.current(nextText) } else { - state.setDraftContent(fileId, nextText) - state.pinTab(fileId) + const state = storeApi.getState() + if (nextText === baselineContentRef.current) { + state.clearDraftContent(fileId) + } + else { + state.setDraftContent(fileId, nextText) + state.pinTab(fileId) + } } eventEmitter?.emit({ type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER, @@ -61,7 +77,9 @@ export const useSkillMarkdownCollaboration = ({ } as unknown as string) }) - const unsubscribeSync = skillCollaborationManager.onSyncRequest(fileId, onLeaderSync) + const unsubscribeSync = skillCollaborationManager.onSyncRequest(fileId, () => { + onLeaderSyncRef.current() + }) return () => { unsubscribe() @@ -69,7 +87,7 @@ export const useSkillMarkdownCollaboration = ({ skillCollaborationManager.setActiveFile(appId, fileId, false) skillCollaborationManager.closeFile(fileId) } - }, [appId, enabled, eventEmitter, fileId, initialContent, onLeaderSync, storeApi]) + }, [appId, enabled, eventEmitter, fileId, initialContent, storeApi]) const handleCollaborativeChange = useCallback((value: string | undefined) => { const nextValue = value ?? '' diff --git a/web/app/components/workflow/skill/skill-body/panels/file-content-panel.tsx b/web/app/components/workflow/skill/skill-body/panels/file-content-panel.tsx deleted file mode 100644 index ad655fd98b..0000000000 --- a/web/app/components/workflow/skill/skill-body/panels/file-content-panel.tsx +++ /dev/null @@ -1,378 +0,0 @@ -'use client' - -import type { OnMount } from '@monaco-editor/react' -import type { SkillFileDataMode } from '../../hooks/use-skill-file-data' -import type { AppAssetTreeView } from '@/types/app-asset' -import { loader } from '@monaco-editor/react' -import isDeepEqual from 'fast-deep-equal' -import * as React from 'react' -import { useCallback, useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { useStore as useAppStore } from '@/app/components/app/store' -import Loading from '@/app/components/base/loading' -import { useStore, useWorkflowStore } from '@/app/components/workflow/store' -import useTheme from '@/hooks/use-theme' -import dynamic from '@/next/dynamic' -import { Theme } from '@/types/app' -import { basePath } from '@/utils/var' -import { useSkillCodeCollaboration } from '../../../collaboration/skills/use-skill-code-collaboration' -import { useSkillMarkdownCollaboration } from '../../../collaboration/skills/use-skill-markdown-collaboration' -import { START_TAB_ID } from '../../constants' -import CodeFileEditor from '../../editor/code-file-editor' -import MarkdownFileEditor from '../../editor/markdown-file-editor' -import { useSkillAssetNodeMap } from '../../hooks/file-tree/data/use-skill-asset-tree' -import { useSkillSaveManager } from '../../hooks/skill-save-context' -import { useFileNodeViewState } from '../../hooks/use-file-node-view-state' -import { useFileTypeInfo } from '../../hooks/use-file-type-info' -import { useSkillFileData } from '../../hooks/use-skill-file-data' -import StartTabContent from '../../start-tab' -import { getFileLanguage } from '../../utils/file-utils' -import MediaFilePreview from '../../viewer/media-file-preview' -import UnsupportedFileDownload from '../../viewer/unsupported-file-download' - -type SkillFileMetadata = { - files?: Record -} - -const extractFileReferenceIds = (content: string) => { - const ids = new Set() - const regex = /§\[file\]\.\[app\]\.\[([a-fA-F0-9-]{36})\]§/g - let match: RegExpExecArray | null - match = regex.exec(content) - while (match !== null) { - if (match[1]) - ids.add(match[1]) - match = regex.exec(content) - } - return ids -} - -const SQLiteFilePreview = dynamic( - () => import('../../viewer/sqlite-file-preview'), - { ssr: false, loading: () => }, -) - -const PdfFilePreview = dynamic( - () => import('../../viewer/pdf-file-preview'), - { ssr: false, loading: () => }, -) - -if (typeof window !== 'undefined') - loader.config({ paths: { vs: `${window.location.origin}${basePath}/vs` } }) - -const FileContentPanel = () => { - const { t } = useTranslation('workflow') - const { theme: appTheme } = useTheme() - const [isMounted, setIsMounted] = useState(false) - - const appDetail = useAppStore(s => s.appDetail) - const appId = appDetail?.id || '' - - const activeTabId = useStore(s => s.activeTabId) - const editorAutoFocusFileId = useStore(s => s.editorAutoFocusFileId) - const storeApi = useWorkflowStore() - const { - data: nodeMap, - isLoading: isNodeMapLoading, - isFetching: isNodeMapFetching, - isFetched: isNodeMapFetched, - } = useSkillAssetNodeMap() - - const isStartTab = activeTabId === START_TAB_ID - const fileTabId = isStartTab ? null : activeTabId - - const draftContent = useStore(s => fileTabId ? s.dirtyContents.get(fileTabId) : undefined) - const currentMetadata = useStore(s => fileTabId ? s.fileMetadata.get(fileTabId) : undefined) - const isMetadataDirty = useStore(s => fileTabId ? s.dirtyMetadataIds.has(fileTabId) : false) - - const currentFileNode = fileTabId ? nodeMap?.get(fileTabId) : undefined - const shouldAutoFocusEditor = Boolean(fileTabId && editorAutoFocusFileId === fileTabId) - const fileNodeViewState = useFileNodeViewState({ - fileTabId, - hasCurrentFileNode: Boolean(currentFileNode), - isNodeMapLoading, - isNodeMapFetching, - isNodeMapFetched, - }) - const isNodeReady = fileNodeViewState === 'ready' - - const { isMarkdown, isCodeOrText, isImage, isVideo, isPdf, isSQLite, isEditable, isPreviewable } = useFileTypeInfo(isNodeReady ? currentFileNode : undefined) - const fileDataMode: SkillFileDataMode = !fileTabId || !isNodeReady - ? 'none' - : isEditable - ? 'content' - : 'download' - - const { fileContent, downloadUrlData, isLoading, error } = useSkillFileData(appId, fileTabId, fileDataMode) - - const originalContent = fileContent?.content ?? '' - const currentContent = draftContent !== undefined ? draftContent : originalContent - const initialContentRegistryRef = useRef>(new Map()) - const canInitCollaboration = Boolean(appId && fileTabId && isEditable && !isLoading && !error) - - if (canInitCollaboration && fileTabId && !initialContentRegistryRef.current.has(fileTabId)) - initialContentRegistryRef.current.set(fileTabId, currentContent) - - const initialCollaborativeContent = fileTabId - ? (initialContentRegistryRef.current.get(fileTabId) ?? currentContent) - : '' - - useEffect(() => { - if (!fileTabId || !fileContent) - return - if (isMetadataDirty) - return - let nextMetadata: Record = {} - if (fileContent.metadata) { - if (typeof fileContent.metadata === 'string') { - try { - nextMetadata = JSON.parse(fileContent.metadata) - } - catch { - nextMetadata = {} - } - } - else { - nextMetadata = fileContent.metadata - } - } - const { setFileMetadata, clearDraftMetadata } = storeApi.getState() - setFileMetadata(fileTabId, nextMetadata) - clearDraftMetadata(fileTabId) - }, [fileTabId, isMetadataDirty, fileContent, storeApi]) - - const updateFileReferenceMetadata = useCallback((content: string) => { - if (!fileTabId) - return - - const referenceIds = extractFileReferenceIds(content) - const metadata = (currentMetadata || {}) as SkillFileMetadata - const existingFiles = metadata.files || {} - const nextFiles: Record = {} - - referenceIds.forEach((id) => { - const node = nodeMap?.get(id) - if (node) - nextFiles[id] = node - else if (existingFiles[id]) - nextFiles[id] = existingFiles[id] - }) - - const nextMetadata: SkillFileMetadata = { ...metadata } - if (Object.keys(nextFiles).length > 0) - nextMetadata.files = nextFiles - else if ('files' in nextMetadata) - delete nextMetadata.files - - if (isDeepEqual(metadata, nextMetadata)) - return - storeApi.getState().setDraftMetadata(fileTabId, nextMetadata) - }, [currentMetadata, fileTabId, nodeMap, storeApi]) - - const handleEditorChange = useCallback((value: string | undefined) => { - if (!fileTabId || !isEditable) - return - const newValue = value ?? '' - - if (newValue === originalContent) - storeApi.getState().clearDraftContent(fileTabId) - else - storeApi.getState().setDraftContent(fileTabId, newValue) - updateFileReferenceMetadata(newValue) - storeApi.getState().pinTab(fileTabId) - }, [fileTabId, isEditable, originalContent, storeApi, updateFileReferenceMetadata]) - - const { saveFile, registerFallback, unregisterFallback } = useSkillSaveManager() - const handleLeaderSync = useCallback(() => { - if (!fileTabId || !isEditable) - return - void saveFile(fileTabId) - }, [fileTabId, isEditable, saveFile]) - - const saveFileRef = useRef(saveFile) - saveFileRef.current = saveFile - - const fallbackRef = useRef({ content: originalContent, metadata: currentMetadata }) - - useEffect(() => { - if (!fileTabId || fileContent?.content === undefined) - return - - const fallback = { content: originalContent, metadata: currentMetadata } - fallbackRef.current = fallback - registerFallback(fileTabId, fallback) - - return () => { - unregisterFallback(fileTabId) - } - }, [fileTabId, fileContent?.content, originalContent, currentMetadata, registerFallback, unregisterFallback]) - - useEffect(() => { - if (!fileTabId || !isEditable) - return - - return () => { - const { content: fallbackContent, metadata: fallbackMetadata } = fallbackRef.current - void saveFileRef.current(fileTabId, { - fallbackContent, - fallbackMetadata, - }) - } - }, [fileTabId, isEditable]) - - const handleEditorAutoFocus = useCallback(() => { - if (!fileTabId) - return - storeApi.getState().clearEditorAutoFocus(fileTabId) - }, [fileTabId, storeApi]) - - const handleEditorDidMount: OnMount = useCallback((_editor, monaco) => { - monaco.editor.setTheme(appTheme === Theme.light ? 'light' : 'vs-dark') - setIsMounted(true) - }, [appTheme]) - - const language = currentFileNode ? getFileLanguage(currentFileNode.name) : 'plaintext' - const theme = appTheme === Theme.light ? 'light' : 'vs-dark' - - const { handleCollaborativeChange: handleMarkdownCollaborativeChange } = useSkillMarkdownCollaboration({ - appId, - fileId: fileTabId, - enabled: canInitCollaboration && isMarkdown, - initialContent: initialCollaborativeContent, - baselineContent: originalContent, - onLocalChange: handleEditorChange, - onLeaderSync: handleLeaderSync, - }) - const { handleCollaborativeChange: handleCodeCollaborativeChange } = useSkillCodeCollaboration({ - appId, - fileId: fileTabId, - enabled: canInitCollaboration && isCodeOrText, - initialContent: initialCollaborativeContent, - baselineContent: originalContent, - onLocalChange: handleEditorChange, - onLeaderSync: handleLeaderSync, - }) - - if (isStartTab) - return - - if (!fileTabId) { - return ( -
- - {t('skillSidebar.empty')} - -
- ) - } - - if (fileNodeViewState === 'resolving') { - return ( -
- -
- ) - } - - if (fileNodeViewState === 'missing') { - return ( -
- - {t('skillSidebar.loadError')} - -
- ) - } - - if (isLoading) { - return ( -
- -
- ) - } - - if (error) { - return ( -
- - {t('skillSidebar.loadError')} - -
- ) - } - - // For non-editable files (media, sqlite, unsupported), use download URL - const downloadUrl = downloadUrlData?.download_url || '' - const fileName = currentFileNode?.name || '' - const fileSize = currentFileNode?.size - const isUnsupportedFile = !isPreviewable - - return ( -
- {isMarkdown - ? ( - - ) - : null} - {isCodeOrText - ? ( - - ) - : null} - {isImage || isVideo - ? ( - - ) - : null} - {isSQLite - ? ( - - ) - : null} - {isPdf - ? ( - - ) - : null} - {isUnsupportedFile - ? ( - - ) - : null} -
- ) -} - -export default React.memo(FileContentPanel) diff --git a/web/app/components/workflow/skill/skill-body/panels/file-content-panel.spec.tsx b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/index.spec.tsx similarity index 81% rename from web/app/components/workflow/skill/skill-body/panels/file-content-panel.spec.tsx rename to web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/index.spec.tsx index 98c54dff75..4efc8ff107 100644 --- a/web/app/components/workflow/skill/skill-body/panels/file-content-panel.spec.tsx +++ b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/index.spec.tsx @@ -1,9 +1,10 @@ import type { OnMount } from '@monaco-editor/react' import type { AppAssetTreeView } from '@/types/app-asset' import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' import { Theme } from '@/types/app' -import { START_TAB_ID } from '../../constants' -import FileContentPanel from './file-content-panel' +import FileContentPanel from '..' +import { START_TAB_ID } from '../../../../constants' type AppStoreState = { appDetail: { @@ -62,10 +63,14 @@ type UseSkillFileDataMode = 'none' | 'content' | 'download' type UseSkillMarkdownCollaborationArgs = { onLocalChange: (value: string) => void + onRemoteChange?: (value: string) => void + onLeaderSync: () => void } type UseSkillCodeCollaborationArgs = { - onLocalChange: (value: string) => void + onLocalChange: (value: string | undefined) => void + onRemoteChange?: (value: string) => void + onLeaderSync: () => void } const FILE_REFERENCE_ID = '123e4567-e89b-12d3-a456-426614174000' @@ -191,9 +196,23 @@ vi.mock('@monaco-editor/react', () => ({ })) vi.mock('next/dynamic', () => ({ - default: () => { - return ({ downloadUrl }: { downloadUrl: string }) => ( -
{downloadUrl}
+ default: ( + loader: () => Promise, + options?: { loading?: () => React.ReactNode }, + ) => { + const LazyComponent = React.lazy(async (): Promise<{ default: React.ComponentType> }> => { + const mod = await loader() + if (typeof mod === 'function') + return { default: mod as React.ComponentType> } + if (mod && typeof mod === 'object' && 'default' in mod) + return mod as { default: React.ComponentType> } + return { default: () => null } + }) + + return (props: Record) => ( + + + ) }, })) @@ -213,7 +232,7 @@ vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: mocks.appTheme }), })) -vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({ +vi.mock('../../../../hooks/file-tree/data/use-skill-asset-tree', () => ({ useSkillAssetNodeMap: () => ({ data: mocks.nodeMapData, isLoading: mocks.nodeMapStatus.isLoading, @@ -222,22 +241,22 @@ vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({ }), })) -vi.mock('../../hooks/use-file-node-view-state', () => ({ +vi.mock('../../../../hooks/use-file-node-view-state', () => ({ useFileNodeViewState: () => mocks.fileNodeViewState, })) -vi.mock('../../hooks/use-file-type-info', () => ({ +vi.mock('../../../../hooks/use-file-type-info', () => ({ useFileTypeInfo: () => mocks.fileTypeInfo, })) -vi.mock('../../hooks/use-skill-file-data', () => ({ +vi.mock('../../../../hooks/use-skill-file-data', () => ({ useSkillFileData: (appId: string, fileId: string | null, mode: UseSkillFileDataMode) => { mocks.useSkillFileData(appId, fileId, mode) return mocks.fileData }, })) -vi.mock('../../hooks/skill-save-context', () => ({ +vi.mock('../../../../hooks/skill-save-context', () => ({ useSkillSaveManager: () => ({ saveFile: mocks.saveFile, registerFallback: mocks.registerFallback, @@ -245,7 +264,7 @@ vi.mock('../../hooks/skill-save-context', () => ({ }), })) -vi.mock('../../../collaboration/skills/use-skill-markdown-collaboration', () => ({ +vi.mock('../../../../../collaboration/skills/use-skill-markdown-collaboration', () => ({ useSkillMarkdownCollaboration: (args: UseSkillMarkdownCollaborationArgs) => { mocks.useSkillMarkdownCollaboration(args) return { @@ -254,7 +273,7 @@ vi.mock('../../../collaboration/skills/use-skill-markdown-collaboration', () => }, })) -vi.mock('../../../collaboration/skills/use-skill-code-collaboration', () => ({ +vi.mock('../../../../../collaboration/skills/use-skill-code-collaboration', () => ({ useSkillCodeCollaboration: (args: UseSkillCodeCollaborationArgs) => { mocks.useSkillCodeCollaboration(args) return { @@ -263,11 +282,11 @@ vi.mock('../../../collaboration/skills/use-skill-code-collaboration', () => ({ }, })) -vi.mock('../../start-tab', () => ({ +vi.mock('../../../../start-tab', () => ({ default: () =>
, })) -vi.mock('../../editor/markdown-file-editor', () => ({ +vi.mock('../../../../editor/markdown-file-editor', () => ({ default: ({ value, onChange, @@ -298,7 +317,7 @@ vi.mock('../../editor/markdown-file-editor', () => ({ ), })) -vi.mock('../../editor/code-file-editor', () => ({ +vi.mock('../../../../editor/code-file-editor', () => ({ default: ({ value, onChange, @@ -344,20 +363,20 @@ vi.mock('../../editor/code-file-editor', () => ({ ), })) -vi.mock('../../viewer/media-file-preview', () => ({ +vi.mock('../../../../viewer/media-file-preview', () => ({ default: ({ type, src }: { type: 'image' | 'video', src: string }) => (
{`${type}|${src}`}
), })) -vi.mock('../../viewer/unsupported-file-download', () => ({ +vi.mock('../../../../viewer/unsupported-file-download', () => ({ default: ({ name, size, downloadUrl }: { name: string, size?: number, downloadUrl: string }) => (
{`${name}|${String(size)}|${downloadUrl}`}
), })) -vi.mock('../../utils/file-utils', async () => { - const actual = await vi.importActual('../../utils/file-utils') +vi.mock('../../../../utils/file-utils', async () => { + const actual = await vi.importActual('../../../../utils/file-utils') return { ...actual, getFileLanguage: (name: string) => mocks.getFileLanguage(name), @@ -479,6 +498,7 @@ describe('FileContentPanel', () => { // Act render() + await screen.findByTestId('markdown-editor') fireEvent.click(screen.getByRole('button', { name: 'markdown-change' })) fireEvent.click(screen.getByRole('button', { name: 'markdown-autofocus' })) @@ -497,7 +517,7 @@ describe('FileContentPanel', () => { expect(mocks.workflowActions.clearEditorAutoFocus).toHaveBeenCalledWith('file-1') }) - it('should clear draft content when code editor value matches original content', () => { + it('should clear draft content when code editor value matches original content', async () => { // Arrange mocks.fileData.fileContent = { content: '', @@ -506,6 +526,7 @@ describe('FileContentPanel', () => { // Act render() + await screen.findByTestId('code-editor') fireEvent.click(screen.getByRole('button', { name: 'code-clear' })) // Assert @@ -520,6 +541,7 @@ describe('FileContentPanel', () => { // Act render() + await screen.findByTestId('code-editor') fireEvent.click(screen.getByRole('button', { name: 'code-mount' })) // Assert @@ -542,7 +564,7 @@ describe('FileContentPanel', () => { expect(mocks.saveFile).toHaveBeenCalledWith('file-1') }) - it('should ignore editor content updates when file is not editable', () => { + it('should ignore editor content updates when file is not editable', async () => { // Arrange mocks.fileTypeInfo = { isMarkdown: false, @@ -557,6 +579,7 @@ describe('FileContentPanel', () => { // Act render() + await screen.findByTestId('code-editor') fireEvent.click(screen.getByRole('button', { name: 'code-change' })) // Assert @@ -587,6 +610,78 @@ describe('FileContentPanel', () => { // Assert expect(mocks.saveFile).not.toHaveBeenCalled() }) + + it('should sync draft content and metadata when collaboration receives remote markdown changes', async () => { + // Arrange + mocks.fileTypeInfo = { + isMarkdown: true, + isCodeOrText: false, + isImage: false, + isVideo: false, + isPdf: false, + isSQLite: false, + isEditable: true, + isPreviewable: true, + } + mocks.workflowState.fileMetadata = new Map>([ + ['file-1', {}], + ]) + mocks.nodeMapData = new Map([ + ['file-1', createNode({ name: 'prompt.md', extension: 'md' })], + [FILE_REFERENCE_ID, createNode({ id: FILE_REFERENCE_ID, name: 'kb.txt', extension: 'txt' })], + ]) + + // Act + render() + await screen.findByTestId('markdown-editor') + const firstCall = mocks.useSkillMarkdownCollaboration.mock.calls[0] + const args = firstCall?.[0] as UseSkillMarkdownCollaborationArgs | undefined + args?.onRemoteChange?.(`linked §[file].[app].[${FILE_REFERENCE_ID}]§`) + + // Assert + expect(mocks.workflowActions.setDraftContent).toHaveBeenCalledWith( + 'file-1', + `linked §[file].[app].[${FILE_REFERENCE_ID}]§`, + ) + expect(mocks.workflowActions.setDraftMetadata).toHaveBeenCalledWith( + 'file-1', + expect.objectContaining({ + files: expect.objectContaining({ + [FILE_REFERENCE_ID]: expect.objectContaining({ id: FILE_REFERENCE_ID }), + }), + }), + ) + expect(mocks.workflowActions.pinTab).toHaveBeenCalledWith('file-1') + }) + + it('should not pin the tab when remote collaboration sync matches the original content', async () => { + // Arrange + mocks.fileTypeInfo = { + isMarkdown: true, + isCodeOrText: false, + isImage: false, + isVideo: false, + isPdf: false, + isSQLite: false, + isEditable: true, + isPreviewable: true, + } + mocks.fileData.fileContent = { + content: 'server-content', + metadata: {}, + } + + // Act + render() + await screen.findByTestId('markdown-editor') + const firstCall = mocks.useSkillMarkdownCollaboration.mock.calls[0] + const args = firstCall?.[0] as UseSkillMarkdownCollaborationArgs | undefined + args?.onRemoteChange?.('server-content') + + // Assert + expect(mocks.workflowActions.clearDraftContent).toHaveBeenCalledWith('file-1') + expect(mocks.workflowActions.pinTab).not.toHaveBeenCalled() + }) }) describe('Preview modes', () => { @@ -678,6 +773,25 @@ describe('FileContentPanel', () => { expect(mocks.workflowActions.clearDraftMetadata).toHaveBeenCalledWith('file-1') }) + it('should clear stale metadata when loaded file content has no metadata field', async () => { + // Arrange + mocks.workflowState.fileMetadata = new Map>([ + ['file-1', { source: 'stale' }], + ]) + mocks.fileData.fileContent = { + content: 'markdown', + } + + // Act + render() + + // Assert + await waitFor(() => { + expect(mocks.workflowActions.setFileMetadata).toHaveBeenCalledWith('file-1', {}) + }) + expect(mocks.workflowActions.clearDraftMetadata).toHaveBeenCalledWith('file-1') + }) + it('should skip metadata sync when current metadata is marked dirty', async () => { // Arrange mocks.workflowState.dirtyMetadataIds = new Set(['file-1']) diff --git a/web/app/components/workflow/skill/skill-body/panels/file-content-panel/file-editor-renderer.tsx b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/file-editor-renderer.tsx new file mode 100644 index 0000000000..eb9746cedd --- /dev/null +++ b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/file-editor-renderer.tsx @@ -0,0 +1,63 @@ +'use client' + +import type { OnMount } from '@monaco-editor/react' +import type { FileEditorState } from './types' +import { loader } from '@monaco-editor/react' +import * as React from 'react' +import { useCallback, useState } from 'react' +import useTheme from '@/hooks/use-theme' +import { Theme } from '@/types/app' +import { basePath } from '@/utils/var' +import CodeFileEditor from '../../../editor/code-file-editor' +import MarkdownFileEditor from '../../../editor/markdown-file-editor' +import { getFileLanguage } from '../../../utils/file-utils' + +if (typeof window !== 'undefined') + loader.config({ paths: { vs: `${window.location.origin}${basePath}/vs` } }) + +type FileEditorRendererProps = { + state: FileEditorState +} + +const FileEditorRenderer = ({ state }: FileEditorRendererProps) => { + const { theme: appTheme } = useTheme() + const [isMonacoMounted, setIsMonacoMounted] = useState(false) + + const handleEditorDidMount: OnMount = useCallback((_editor, monaco) => { + monaco.editor.setTheme(appTheme === Theme.light ? 'light' : 'vs-dark') + setIsMonacoMounted(true) + }, [appTheme]) + + if (state.editor === 'markdown') { + return ( + + ) + } + + return ( + + ) +} + +export default React.memo(FileEditorRenderer) diff --git a/web/app/components/workflow/skill/skill-body/panels/file-content-panel/file-preview-renderer.tsx b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/file-preview-renderer.tsx new file mode 100644 index 0000000000..345058f138 --- /dev/null +++ b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/file-preview-renderer.tsx @@ -0,0 +1,55 @@ +'use client' + +import type { FilePreviewState } from './types' +import * as React from 'react' +import Loading from '@/app/components/base/loading' +import dynamic from '@/next/dynamic' +import MediaFilePreview from '../../../viewer/media-file-preview' +import UnsupportedFileDownload from '../../../viewer/unsupported-file-download' + +const SQLiteFilePreview = dynamic( + () => import('../../../viewer/sqlite-file-preview'), + { ssr: false, loading: () => }, +) + +const PdfFilePreview = dynamic( + () => import('../../../viewer/pdf-file-preview'), + { ssr: false, loading: () => }, +) + +type FilePreviewRendererProps = { + state: FilePreviewState +} + +const FilePreviewRenderer = ({ state }: FilePreviewRendererProps) => { + if (state.preview === 'media') { + return ( + + ) + } + + if (state.preview === 'sqlite') { + return ( + + ) + } + + if (state.preview === 'pdf') + return + + return ( + + ) +} + +export default React.memo(FilePreviewRenderer) diff --git a/web/app/components/workflow/skill/skill-body/panels/file-content-panel/index.tsx b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/index.tsx new file mode 100644 index 0000000000..8675b2659b --- /dev/null +++ b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/index.tsx @@ -0,0 +1,83 @@ +'use client' + +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import Loading from '@/app/components/base/loading' +import dynamic from '@/next/dynamic' +import StartTabContent from '../../../start-tab' +import FilePreviewRenderer from './file-preview-renderer' +import { useFileContentController } from './use-file-content-controller' + +const FileEditorRenderer = dynamic( + () => import('./file-editor-renderer'), + { ssr: false, loading: () => }, +) + +type CenteredPanelProps = { + children: React.ReactNode + muted?: boolean +} + +const CenteredPanel = ({ children, muted = false }: CenteredPanelProps) => ( +
+ {children} +
+) + +const FileContentPanel = () => { + const { t } = useTranslation('workflow') + const state = useFileContentController() + + if (state.kind === 'start') + return + + if (state.kind === 'empty') { + return ( + + + {t('skillSidebar.empty')} + + + ) + } + + if (state.kind === 'resolving' || state.kind === 'loading') { + return ( + + + + ) + } + + if (state.kind === 'missing' || state.kind === 'error') { + return ( + + + {t('skillSidebar.loadError')} + + + ) + } + + if (state.kind === 'editor') { + return ( +
+ +
+ ) + } + + if (state.kind !== 'preview') + return null + + return ( +
+ +
+ ) +} + +export default React.memo(FileContentPanel) diff --git a/web/app/components/workflow/skill/skill-body/panels/file-content-panel/types.ts b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/types.ts new file mode 100644 index 0000000000..e467389256 --- /dev/null +++ b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/types.ts @@ -0,0 +1,62 @@ +type FileContentStatusState = { + kind: 'start' | 'empty' | 'resolving' | 'missing' | 'loading' | 'error' +} + +type EditorStateBase = { + kind: 'editor' + fileTabId: string + fileName: string + content: string + autoFocus: boolean + collaborationEnabled: boolean + onAutoFocus: () => void +} + +export type MarkdownEditorState = EditorStateBase & { + editor: 'markdown' + onChange: (value: string) => void +} + +export type CodeEditorState = EditorStateBase & { + editor: 'code' + onChange: (value: string | undefined) => void +} + +export type FileEditorState = MarkdownEditorState | CodeEditorState + +type MediaPreviewState = { + kind: 'preview' + preview: 'media' + mediaType: 'image' | 'video' + downloadUrl: string +} + +type SQLitePreviewState = { + kind: 'preview' + preview: 'sqlite' + fileTabId: string + downloadUrl: string +} + +type PdfPreviewState = { + kind: 'preview' + preview: 'pdf' + downloadUrl: string +} + +type UnsupportedPreviewState = { + kind: 'preview' + preview: 'unsupported' + fileName: string + fileSize?: number + downloadUrl: string +} + +export type FilePreviewState = MediaPreviewState + | SQLitePreviewState + | PdfPreviewState + | UnsupportedPreviewState + +export type FileContentControllerState = FileContentStatusState + | FileEditorState + | FilePreviewState diff --git a/web/app/components/workflow/skill/skill-body/panels/file-content-panel/use-file-content-controller.ts b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/use-file-content-controller.ts new file mode 100644 index 0000000000..4b06b8196c --- /dev/null +++ b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/use-file-content-controller.ts @@ -0,0 +1,274 @@ +import type { SkillFileDataMode } from '../../../hooks/use-skill-file-data' +import type { FileContentControllerState } from './types' +import type { SkillFileMetadata } from './utils' +import type { AppAssetTreeView } from '@/types/app-asset' +import isDeepEqual from 'fast-deep-equal' +import { useCallback, useRef } from 'react' +import { useStore as useAppStore } from '@/app/components/app/store' +import { useStore, useWorkflowStore } from '@/app/components/workflow/store' +import { useSkillCodeCollaboration } from '../../../../collaboration/skills/use-skill-code-collaboration' +import { useSkillMarkdownCollaboration } from '../../../../collaboration/skills/use-skill-markdown-collaboration' +import { START_TAB_ID } from '../../../constants' +import { useSkillAssetNodeMap } from '../../../hooks/file-tree/data/use-skill-asset-tree' +import { useSkillSaveManager } from '../../../hooks/skill-save-context' +import { useFileNodeViewState as useFileNodeStatus } from '../../../hooks/use-file-node-view-state' +import { useFileTypeInfo } from '../../../hooks/use-file-type-info' +import { useSkillFileData } from '../../../hooks/use-skill-file-data' +import { useFileFallbackLifecycle } from './use-file-fallback-lifecycle' +import { useFileMetadataSync } from './use-file-metadata-sync' +import { extractFileReferenceIds } from './utils' + +export const useFileContentController = (): FileContentControllerState => { + const appDetail = useAppStore(s => s.appDetail) + const appId = appDetail?.id || '' + const activeTabId = useStore(s => s.activeTabId) + const editorAutoFocusFileId = useStore(s => s.editorAutoFocusFileId) + const storeApi = useWorkflowStore() + const { + data: nodeMap, + isLoading: isNodeMapLoading, + isFetching: isNodeMapFetching, + isFetched: isNodeMapFetched, + } = useSkillAssetNodeMap() + + const isStartTab = activeTabId === START_TAB_ID + const fileTabId = isStartTab ? null : activeTabId + + const draftContent = useStore(s => fileTabId ? s.dirtyContents.get(fileTabId) : undefined) + const currentMetadata = useStore(s => fileTabId ? s.fileMetadata.get(fileTabId) : undefined) + const isMetadataDirty = useStore(s => fileTabId ? s.dirtyMetadataIds.has(fileTabId) : false) + + const currentFileNode = fileTabId ? nodeMap?.get(fileTabId) : undefined + const shouldAutoFocusEditor = Boolean(fileTabId && editorAutoFocusFileId === fileTabId) + const nodeViewStatus = useFileNodeStatus({ + fileTabId, + hasCurrentFileNode: Boolean(currentFileNode), + isNodeMapLoading, + isNodeMapFetching, + isNodeMapFetched, + }) + const isNodeReady = nodeViewStatus === 'ready' + const { + isMarkdown, + isCodeOrText, + isImage, + isVideo, + isPdf, + isSQLite, + isEditable, + } = useFileTypeInfo(isNodeReady ? currentFileNode : undefined) + const fileDataMode: SkillFileDataMode = !fileTabId || !isNodeReady + ? 'none' + : isEditable + ? 'content' + : 'download' + + const { fileContent, downloadUrlData, isLoading, error } = useSkillFileData(appId, fileTabId, fileDataMode) + const originalContent = fileContent?.content ?? '' + const currentContent = draftContent !== undefined ? draftContent : originalContent + const initialContentRegistryRef = useRef>(new Map()) + const canInitCollaboration = Boolean(appId && fileTabId && isEditable && !isLoading && !error) + + if (canInitCollaboration && fileTabId && !initialContentRegistryRef.current.has(fileTabId)) + initialContentRegistryRef.current.set(fileTabId, currentContent) + + const initialCollaborativeContent = fileTabId + ? (initialContentRegistryRef.current.get(fileTabId) ?? currentContent) + : '' + + useFileMetadataSync({ + fileTabId, + hasLoadedContent: fileContent !== undefined, + metadataSource: fileContent?.metadata, + isMetadataDirty, + storeApi, + }) + + const updateFileReferenceMetadata = useCallback((content: string) => { + if (!fileTabId) + return + + const referenceIds = extractFileReferenceIds(content) + const metadata = (currentMetadata || {}) as SkillFileMetadata + const existingFiles = metadata.files || {} + const nextFiles: Record = {} + + referenceIds.forEach((id) => { + const node = nodeMap?.get(id) + if (node) + nextFiles[id] = node + else if (existingFiles[id]) + nextFiles[id] = existingFiles[id] + }) + + const nextMetadata: SkillFileMetadata = { ...metadata } + if (Object.keys(nextFiles).length > 0) + nextMetadata.files = nextFiles + else if ('files' in nextMetadata) + delete nextMetadata.files + + if (isDeepEqual(metadata, nextMetadata)) + return + + storeApi.getState().setDraftMetadata(fileTabId, nextMetadata) + }, [currentMetadata, fileTabId, nodeMap, storeApi]) + + const applyContentChange = useCallback(( + value: string | undefined, + options?: { + pinWhenContentMatchesOriginal?: boolean + }, + ) => { + if (!fileTabId || !isEditable) + return + + const nextValue = value ?? '' + const state = storeApi.getState() + + if (nextValue === originalContent) + state.clearDraftContent(fileTabId) + else + state.setDraftContent(fileTabId, nextValue) + + updateFileReferenceMetadata(nextValue) + if (nextValue !== originalContent || options?.pinWhenContentMatchesOriginal) + state.pinTab(fileTabId) + }, [fileTabId, isEditable, originalContent, storeApi, updateFileReferenceMetadata]) + + const handleLocalContentChange = useCallback((value: string | undefined) => { + applyContentChange(value, { pinWhenContentMatchesOriginal: true }) + }, [applyContentChange]) + + const handleRemoteContentChange = useCallback((value: string) => { + applyContentChange(value) + }, [applyContentChange]) + + const { saveFile, registerFallback, unregisterFallback } = useSkillSaveManager() + const handleLeaderSync = useCallback(() => { + if (!fileTabId || !isEditable) + return + void saveFile(fileTabId) + }, [fileTabId, isEditable, saveFile]) + + useFileFallbackLifecycle({ + fileTabId, + isEditable, + hasLoadedContent: fileContent?.content !== undefined, + originalContent, + currentMetadata, + saveFile, + registerFallback, + unregisterFallback, + }) + + const handleEditorAutoFocus = useCallback(() => { + if (!fileTabId) + return + storeApi.getState().clearEditorAutoFocus(fileTabId) + }, [fileTabId, storeApi]) + + const { handleCollaborativeChange: handleMarkdownCollaborativeChange } = useSkillMarkdownCollaboration({ + appId, + fileId: fileTabId, + enabled: canInitCollaboration && isMarkdown, + initialContent: initialCollaborativeContent, + baselineContent: originalContent, + onLocalChange: handleLocalContentChange, + onRemoteChange: handleRemoteContentChange, + onLeaderSync: handleLeaderSync, + }) + const { handleCollaborativeChange: handleCodeCollaborativeChange } = useSkillCodeCollaboration({ + appId, + fileId: fileTabId, + enabled: canInitCollaboration && isCodeOrText, + initialContent: initialCollaborativeContent, + baselineContent: originalContent, + onLocalChange: handleLocalContentChange, + onRemoteChange: handleRemoteContentChange, + onLeaderSync: handleLeaderSync, + }) + + if (isStartTab) + return { kind: 'start' } + + if (!fileTabId) + return { kind: 'empty' } + + if (nodeViewStatus === 'resolving') + return { kind: 'resolving' } + + if (nodeViewStatus === 'missing') + return { kind: 'missing' } + + if (isLoading) + return { kind: 'loading' } + + if (error) + return { kind: 'error' } + + const downloadUrl = downloadUrlData?.download_url || '' + const fileName = currentFileNode?.name || '' + const fileSize = currentFileNode?.size + + if (isMarkdown) { + return { + kind: 'editor', + editor: 'markdown', + fileTabId, + fileName, + content: currentContent, + onChange: handleMarkdownCollaborativeChange, + autoFocus: shouldAutoFocusEditor, + onAutoFocus: handleEditorAutoFocus, + collaborationEnabled: canInitCollaboration, + } + } + + if (isCodeOrText) { + return { + kind: 'editor', + editor: 'code', + fileTabId, + fileName, + content: currentContent, + onChange: handleCodeCollaborativeChange, + autoFocus: shouldAutoFocusEditor, + onAutoFocus: handleEditorAutoFocus, + collaborationEnabled: canInitCollaboration, + } + } + + if (isImage || isVideo) { + return { + kind: 'preview', + preview: 'media', + mediaType: isImage ? 'image' : 'video', + downloadUrl, + } + } + + if (isSQLite) { + return { + kind: 'preview', + preview: 'sqlite', + fileTabId, + downloadUrl, + } + } + + if (isPdf) { + return { + kind: 'preview', + preview: 'pdf', + downloadUrl, + } + } + + return { + kind: 'preview', + preview: 'unsupported', + fileName, + fileSize, + downloadUrl, + } +} diff --git a/web/app/components/workflow/skill/skill-body/panels/file-content-panel/use-file-fallback-lifecycle.ts b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/use-file-fallback-lifecycle.ts new file mode 100644 index 0000000000..7480efdb9a --- /dev/null +++ b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/use-file-fallback-lifecycle.ts @@ -0,0 +1,69 @@ +import type { SaveFileOptions, SaveResult } from '../../../hooks/use-skill-save-manager' +import { useEffect, useRef } from 'react' + +type UseFileFallbackLifecycleProps = { + fileTabId: string | null + isEditable: boolean + hasLoadedContent: boolean + originalContent: string + currentMetadata: Record | undefined + saveFile: (fileId: string, options?: SaveFileOptions) => Promise + registerFallback: (fileId: string, entry: { content: string, metadata?: Record }) => void + unregisterFallback: (fileId: string) => void +} + +export const useFileFallbackLifecycle = ({ + fileTabId, + isEditable, + hasLoadedContent, + originalContent, + currentMetadata, + saveFile, + registerFallback, + unregisterFallback, +}: UseFileFallbackLifecycleProps) => { + const saveFileRef = useRef(saveFile) + saveFileRef.current = saveFile + + const fallbackRef = useRef({ + content: originalContent, + metadata: currentMetadata, + }) + + useEffect(() => { + if (!fileTabId || !hasLoadedContent) + return + + const fallback = { + content: originalContent, + metadata: currentMetadata, + } + + fallbackRef.current = fallback + registerFallback(fileTabId, fallback) + + return () => { + unregisterFallback(fileTabId) + } + }, [ + currentMetadata, + fileTabId, + hasLoadedContent, + originalContent, + registerFallback, + unregisterFallback, + ]) + + useEffect(() => { + if (!fileTabId || !isEditable) + return + + return () => { + const { content: fallbackContent, metadata: fallbackMetadata } = fallbackRef.current + void saveFileRef.current(fileTabId, { + fallbackContent, + fallbackMetadata, + }) + } + }, [fileTabId, isEditable]) +} diff --git a/web/app/components/workflow/skill/skill-body/panels/file-content-panel/use-file-metadata-sync.ts b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/use-file-metadata-sync.ts new file mode 100644 index 0000000000..4b57d9325e --- /dev/null +++ b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/use-file-metadata-sync.ts @@ -0,0 +1,28 @@ +import type { useWorkflowStore } from '@/app/components/workflow/store' +import { useEffect } from 'react' +import { parseSkillFileMetadata } from './utils' + +type UseFileMetadataSyncProps = { + fileTabId: string | null + hasLoadedContent: boolean + metadataSource: Record | string | undefined + isMetadataDirty: boolean + storeApi: ReturnType +} + +export const useFileMetadataSync = ({ + fileTabId, + hasLoadedContent, + metadataSource, + isMetadataDirty, + storeApi, +}: UseFileMetadataSyncProps) => { + useEffect(() => { + if (!fileTabId || !hasLoadedContent || isMetadataDirty) + return + + const { setFileMetadata, clearDraftMetadata } = storeApi.getState() + setFileMetadata(fileTabId, parseSkillFileMetadata(metadataSource)) + clearDraftMetadata(fileTabId) + }, [fileTabId, hasLoadedContent, isMetadataDirty, metadataSource, storeApi]) +} diff --git a/web/app/components/workflow/skill/skill-body/panels/file-content-panel/utils.ts b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/utils.ts new file mode 100644 index 0000000000..700a09e141 --- /dev/null +++ b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/utils.ts @@ -0,0 +1,35 @@ +import type { AppAssetTreeView } from '@/types/app-asset' + +export type SkillFileMetadata = { + files?: Record +} + +export const extractFileReferenceIds = (content: string) => { + const ids = new Set() + const regex = /§\[file\]\.\[app\]\.\[([a-fA-F0-9-]{36})\]§/g + let match: RegExpExecArray | null + match = regex.exec(content) + while (match !== null) { + if (match[1]) + ids.add(match[1]) + match = regex.exec(content) + } + return ids +} + +export const parseSkillFileMetadata = (metadata: unknown): Record => { + if (!metadata) + return {} + + if (typeof metadata === 'string') { + try { + const parsed = JSON.parse(metadata) as unknown + return typeof parsed === 'object' && parsed ? parsed as Record : {} + } + catch { + return {} + } + } + + return typeof metadata === 'object' ? metadata as Record : {} +}