mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +08:00
feat: sync the markdown file dirty status
This commit is contained in:
@ -21,6 +21,12 @@ type SkillCursorPayload = {
|
||||
end?: number | null
|
||||
}
|
||||
|
||||
type SkillFileSavedPayload = {
|
||||
file_id: string
|
||||
content?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type SkillDocEntry = {
|
||||
doc: LoroDoc
|
||||
text: ReturnType<LoroDoc['getText']>
|
||||
@ -47,6 +53,8 @@ class SkillCollaborationManager {
|
||||
private pendingResync = new Set<string>()
|
||||
private cursorByFile = new Map<string, SkillCursorMap>()
|
||||
private cursorEmitter = new EventEmitter()
|
||||
private fileEmitter = new EventEmitter()
|
||||
private fileSavedGlobalKey = 'skill_file_saved:all'
|
||||
|
||||
private handleSkillUpdate = (payload: SkillUpdatePayload) => {
|
||||
if (!payload || !payload.file_id || !payload.update)
|
||||
@ -80,6 +88,15 @@ class SkillCollaborationManager {
|
||||
if (!update || !update.type)
|
||||
return
|
||||
|
||||
if (update.type === 'skill_file_saved') {
|
||||
const data = update.data as SkillFileSavedPayload | undefined
|
||||
const fileId = data?.file_id
|
||||
if (!fileId)
|
||||
return
|
||||
this.fileEmitter.emit(this.fileSavedGlobalKey, data)
|
||||
return
|
||||
}
|
||||
|
||||
if (update.type === 'skill_cursor') {
|
||||
const data = update.data as SkillCursorPayload | undefined
|
||||
const fileId = data?.file_id
|
||||
@ -139,6 +156,7 @@ class SkillCollaborationManager {
|
||||
this.pendingResync.clear()
|
||||
this.cursorByFile.clear()
|
||||
this.cursorEmitter.removeAllListeners()
|
||||
this.fileEmitter.removeAllListeners()
|
||||
}
|
||||
|
||||
this.appId = appId
|
||||
@ -256,6 +274,10 @@ class SkillCollaborationManager {
|
||||
return off
|
||||
}
|
||||
|
||||
onAnyFileSaved(callback: (payload: SkillFileSavedPayload) => void): () => void {
|
||||
return this.fileEmitter.on(this.fileSavedGlobalKey, callback)
|
||||
}
|
||||
|
||||
isLeader(fileId: string): boolean {
|
||||
return this.leaderByFile.get(fileId) || false
|
||||
}
|
||||
@ -285,6 +307,18 @@ class SkillCollaborationManager {
|
||||
})
|
||||
}
|
||||
|
||||
emitFileSaved(fileId: string, content: string, metadata?: Record<string, unknown>): void {
|
||||
if (!fileId || !this.socket || !this.socket.connected) {
|
||||
return
|
||||
}
|
||||
|
||||
emitWithAuthGuard(this.socket, 'collaboration_event', {
|
||||
type: 'skill_file_saved',
|
||||
data: { file_id: fileId, content, metadata },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
setActiveFile(appId: string, fileId: string, active: boolean): void {
|
||||
if (!appId || !fileId)
|
||||
return
|
||||
|
||||
@ -9,6 +9,7 @@ type UseSkillMarkdownCollaborationProps = {
|
||||
fileId: string | null
|
||||
enabled: boolean
|
||||
initialContent: string
|
||||
baselineContent: string
|
||||
onLocalChange: (value: string) => void
|
||||
onLeaderSync: () => void
|
||||
}
|
||||
@ -18,17 +19,24 @@ export const useSkillMarkdownCollaboration = ({
|
||||
fileId,
|
||||
enabled,
|
||||
initialContent,
|
||||
baselineContent,
|
||||
onLocalChange,
|
||||
onLeaderSync,
|
||||
}: UseSkillMarkdownCollaborationProps) => {
|
||||
const storeApi = useWorkflowStore()
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const suppressNextChangeRef = useRef<string | null>(null)
|
||||
// Keep the latest server baseline to avoid marking the editor dirty on initial sync.
|
||||
const baselineContentRef = useRef(baselineContent)
|
||||
|
||||
useEffect(() => {
|
||||
suppressNextChangeRef.current = null
|
||||
}, [fileId])
|
||||
|
||||
useEffect(() => {
|
||||
baselineContentRef.current = baselineContent
|
||||
}, [baselineContent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !fileId)
|
||||
return
|
||||
@ -39,8 +47,13 @@ export const useSkillMarkdownCollaboration = ({
|
||||
const unsubscribe = skillCollaborationManager.subscribe(fileId, (nextText) => {
|
||||
suppressNextChangeRef.current = nextText
|
||||
const state = storeApi.getState()
|
||||
state.setDraftContent(fileId, nextText)
|
||||
state.pinTab(fileId)
|
||||
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,
|
||||
instanceId: fileId,
|
||||
|
||||
@ -64,6 +64,7 @@ export type CollaborationEventType
|
||||
| 'app_publish_update'
|
||||
| 'graph_view_active'
|
||||
| 'skill_file_active'
|
||||
| 'skill_file_saved'
|
||||
| 'skill_cursor'
|
||||
| 'skill_sync_request'
|
||||
| 'skill_resync_request'
|
||||
|
||||
@ -161,6 +161,7 @@ const FileContentPanel: FC = () => {
|
||||
fileId: fileTabId,
|
||||
enabled: canInitCollaboration,
|
||||
initialContent: initialCollaborativeContent,
|
||||
baselineContent: originalContent,
|
||||
onLocalChange: handleEditorChange,
|
||||
onLeaderSync: handleLeaderSync,
|
||||
})
|
||||
|
||||
@ -2,7 +2,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEventListener } from 'ahooks'
|
||||
import isDeepEqual from 'fast-deep-equal'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
@ -181,8 +181,9 @@ export const SkillSaveProvider = ({
|
||||
}
|
||||
|
||||
const snapshot = buildSnapshot(fileId, options?.fallbackContent, options?.fallbackMetadata)
|
||||
if (!snapshot)
|
||||
if (!snapshot) {
|
||||
return { saved: false }
|
||||
}
|
||||
|
||||
try {
|
||||
await updateContent.mutateAsync({
|
||||
@ -210,6 +211,10 @@ export const SkillSaveProvider = ({
|
||||
latestState.clearDraftMetadata(fileId)
|
||||
}
|
||||
|
||||
if (isCollaborationEnabled && skillCollaborationManager.isFileCollaborative(fileId)) {
|
||||
skillCollaborationManager.emitFileSaved(fileId, snapshot.content, snapshot.metadata)
|
||||
}
|
||||
|
||||
return { saved: true }
|
||||
}
|
||||
catch (error) {
|
||||
@ -299,6 +304,38 @@ export const SkillSaveProvider = ({
|
||||
unregisterFallback,
|
||||
}), [saveAllDirty, saveFile, registerFallback, unregisterFallback])
|
||||
|
||||
useEffect(() => {
|
||||
if (!appId || !isCollaborationEnabled)
|
||||
return
|
||||
|
||||
return skillCollaborationManager.onAnyFileSaved((payload) => {
|
||||
if (!payload?.file_id || typeof payload.content !== 'string')
|
||||
return
|
||||
|
||||
const fileId = payload.file_id
|
||||
const queryKey = consoleQuery.appAsset.getFileContent.queryKey({
|
||||
input: { params: { appId, nodeId: fileId } },
|
||||
})
|
||||
const serialized = JSON.stringify({
|
||||
content: payload.content,
|
||||
...(payload.metadata ? { metadata: payload.metadata } : {}),
|
||||
})
|
||||
const existing = queryClient.getQueryData<CachedFileContent>(queryKey)
|
||||
queryClient.setQueryData(queryKey, {
|
||||
...(existing && typeof existing === 'object' ? existing : {}),
|
||||
content: serialized,
|
||||
})
|
||||
|
||||
const state = storeApi.getState()
|
||||
state.clearDraftContent(fileId)
|
||||
|
||||
const latestMetadata = state.fileMetadata.get(fileId)
|
||||
const normalizedLatest = normalizeMetadata(latestMetadata, payload.content)
|
||||
if (payload.metadata === undefined || isDeepEqual(normalizedLatest, payload.metadata))
|
||||
state.clearDraftMetadata(fileId)
|
||||
})
|
||||
}, [appId, isCollaborationEnabled, queryClient, storeApi])
|
||||
|
||||
return (
|
||||
<SkillSaveContext.Provider value={value}>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user