add skill markdown file collaboration

This commit is contained in:
hjlarry
2026-01-27 14:08:44 +08:00
parent 61608e0423
commit a9e1394011
9 changed files with 577 additions and 3 deletions

View File

@ -0,0 +1,298 @@
import type { Socket } from 'socket.io-client'
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
import { LoroDoc } from 'loro-crdt'
import { emitWithAuthGuard, webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
type SkillUpdatePayload = {
file_id: string
update: Uint8Array
is_snapshot?: boolean
}
type SkillStatusPayload = {
file_id: string
isLeader: boolean
}
type SkillDocEntry = {
doc: LoroDoc
text: ReturnType<LoroDoc['getText']>
subscribers: Set<(text: string, source: 'remote') => void>
suppressBroadcast: boolean
}
class SkillCollaborationManager {
private appId: string | null = null
private socket: Socket | null = null
private docs = new Map<string, SkillDocEntry>()
private leaderByFile = new Map<string, boolean>()
private syncHandlers = new Map<string, Set<() => void>>()
private activeFileId: string | null = null
private pendingResync = new Set<string>()
private handleSkillUpdate = (payload: SkillUpdatePayload) => {
if (!payload || !payload.file_id || !payload.update)
return
const entry = this.docs.get(payload.file_id)
if (!entry)
return
try {
entry.doc.import(new Uint8Array(payload.update))
}
catch (error) {
console.error('Failed to import skill update:', error)
}
}
private handleSkillStatus = (payload: SkillStatusPayload) => {
if (!payload || !payload.file_id)
return
this.leaderByFile.set(payload.file_id, !!payload.isLeader)
}
private handleCollaborationUpdate = (update: CollaborationUpdate) => {
if (!update || !update.type)
return
if (update.type === 'skill_resync_request') {
const fileId = (update.data as { file_id?: string } | undefined)?.file_id
if (!fileId || !this.isLeader(fileId))
return
this.emitSnapshot(fileId)
return
}
if (update.type === 'skill_sync_request') {
const fileId = (update.data as { file_id?: string } | undefined)?.file_id
if (!fileId || !this.isLeader(fileId))
return
const handlers = this.syncHandlers.get(fileId)
handlers?.forEach(handler => handler())
}
}
private handleConnect = () => {
if (this.activeFileId)
this.emitSkillFileActive(this.activeFileId, true)
if (this.pendingResync.size > 0) {
Array.from(this.pendingResync).forEach(fileId => this.emitResyncRequest(fileId))
this.pendingResync.clear()
}
}
private ensureSocket(appId: string): Socket {
if (this.appId && this.appId !== appId) {
this.teardownSocket()
this.docs.clear()
this.leaderByFile.clear()
this.syncHandlers.clear()
this.activeFileId = null
this.pendingResync.clear()
}
this.appId = appId
const socket = webSocketClient.connect(appId)
if (this.socket !== socket) {
this.teardownSocket()
this.socket = socket
this.bindSocketListeners(socket)
}
return socket
}
private bindSocketListeners(socket: Socket) {
socket.on('skill_update', this.handleSkillUpdate)
socket.on('skill_status', this.handleSkillStatus)
socket.on('collaboration_update', this.handleCollaborationUpdate)
socket.on('connect', this.handleConnect)
}
private teardownSocket() {
if (!this.socket)
return
this.socket.off('skill_update', this.handleSkillUpdate)
this.socket.off('skill_status', this.handleSkillStatus)
this.socket.off('collaboration_update', this.handleCollaborationUpdate)
this.socket.off('connect', this.handleConnect)
this.socket = null
}
openFile(appId: string, fileId: string, initialContent: string): void {
if (!appId || !fileId)
return
const socket = this.ensureSocket(appId)
if (!this.docs.has(fileId)) {
const doc = new LoroDoc()
const text = doc.getText('content')
const entry: SkillDocEntry = {
doc,
text,
subscribers: new Set(),
suppressBroadcast: true,
}
if (initialContent)
text.update(initialContent)
doc.commit()
entry.suppressBroadcast = false
doc.subscribe((event: { by?: string }) => {
if (event.by === 'local') {
if (entry.suppressBroadcast)
return
const update = doc.export({ mode: 'update' })
this.emitUpdate(fileId, update)
return
}
const nextText = text.toString()
entry.subscribers.forEach(callback => callback(nextText, 'remote'))
})
this.docs.set(fileId, entry)
}
if (socket.connected)
this.emitResyncRequest(fileId)
else
this.pendingResync.add(fileId)
}
closeFile(fileId: string): void {
if (!fileId)
return
if (this.activeFileId === fileId)
this.activeFileId = null
}
updateText(fileId: string, text: string): void {
const entry = this.docs.get(fileId)
if (!entry)
return
if (entry.text.toString() === text)
return
entry.text.update(text)
entry.doc.commit()
}
getText(fileId: string): string | null {
const entry = this.docs.get(fileId)
return entry ? entry.text.toString() : null
}
subscribe(fileId: string, callback: (text: string, source: 'remote') => void): () => void {
const entry = this.docs.get(fileId)
if (!entry)
return () => {}
entry.subscribers.add(callback)
return () => {
entry.subscribers.delete(callback)
}
}
onSyncRequest(fileId: string, callback: () => void): () => void {
const handlers = this.syncHandlers.get(fileId) || new Set()
handlers.add(callback)
this.syncHandlers.set(fileId, handlers)
return () => {
const current = this.syncHandlers.get(fileId)
if (!current)
return
current.delete(callback)
if (current.size === 0)
this.syncHandlers.delete(fileId)
}
}
isLeader(fileId: string): boolean {
return this.leaderByFile.get(fileId) || false
}
isFileCollaborative(fileId: string): boolean {
return this.docs.has(fileId)
}
requestSync(fileId: string): void {
this.emitSyncRequest(fileId)
}
setActiveFile(appId: string, fileId: string, active: boolean): void {
if (!appId || !fileId)
return
this.ensureSocket(appId)
if (active)
this.activeFileId = fileId
else if (this.activeFileId === fileId)
this.activeFileId = null
if (this.socket?.connected)
this.emitSkillFileActive(fileId, active)
}
private emitUpdate(fileId: string, update: Uint8Array): void {
if (!this.socket || !this.socket.connected || !this.appId)
return
const payload: SkillUpdatePayload = { file_id: fileId, update }
emitWithAuthGuard(this.socket, 'skill_event', payload)
}
private emitSnapshot(fileId: string): void {
const entry = this.docs.get(fileId)
if (!entry || !this.socket || !this.socket.connected)
return
const snapshot = entry.doc.export({ mode: 'snapshot' })
const payload: SkillUpdatePayload = { file_id: fileId, update: snapshot, is_snapshot: true }
emitWithAuthGuard(this.socket, 'skill_event', payload)
}
private emitResyncRequest(fileId: string): void {
if (!this.socket || !this.socket.connected)
return
emitWithAuthGuard(this.socket, 'collaboration_event', {
type: 'skill_resync_request',
data: { file_id: fileId },
timestamp: Date.now(),
})
}
private emitSyncRequest(fileId: string): void {
if (!this.socket || !this.socket.connected)
return
emitWithAuthGuard(this.socket, 'collaboration_event', {
type: 'skill_sync_request',
data: { file_id: fileId },
timestamp: Date.now(),
})
}
private emitSkillFileActive(fileId: string, active: boolean): void {
if (!this.socket || !this.socket.connected)
return
emitWithAuthGuard(this.socket, 'collaboration_event', {
type: 'skill_file_active',
data: { file_id: fileId, active },
timestamp: Date.now(),
})
}
}
export const skillCollaborationManager = new SkillCollaborationManager()

View File

@ -0,0 +1,85 @@
import { useCallback, useEffect, useRef } from 'react'
import { PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER } from '@/app/components/base/prompt-editor/plugins/update-block'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { skillCollaborationManager } from './skill-collaboration-manager'
type UseSkillMarkdownCollaborationProps = {
appId: string
fileId: string | null
enabled: boolean
initialContent: string
onLocalChange: (value: string) => void
onLeaderSync: () => void
}
export const useSkillMarkdownCollaboration = ({
appId,
fileId,
enabled,
initialContent,
onLocalChange,
onLeaderSync,
}: UseSkillMarkdownCollaborationProps) => {
const storeApi = useWorkflowStore()
const { eventEmitter } = useEventEmitterContextContext()
const suppressNextChangeRef = useRef<string | null>(null)
useEffect(() => {
suppressNextChangeRef.current = null
}, [fileId])
useEffect(() => {
if (!enabled || !fileId)
return
skillCollaborationManager.openFile(appId, fileId, initialContent)
skillCollaborationManager.setActiveFile(appId, fileId, true)
const unsubscribe = skillCollaborationManager.subscribe(fileId, (nextText) => {
suppressNextChangeRef.current = nextText
const state = storeApi.getState()
state.setDraftContent(fileId, nextText)
state.pinTab(fileId)
eventEmitter?.emit({
type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER,
instanceId: fileId,
payload: nextText,
} as unknown as string)
})
const unsubscribeSync = skillCollaborationManager.onSyncRequest(fileId, onLeaderSync)
return () => {
unsubscribe()
unsubscribeSync()
skillCollaborationManager.setActiveFile(appId, fileId, false)
skillCollaborationManager.closeFile(fileId)
}
}, [appId, enabled, eventEmitter, fileId, initialContent, onLeaderSync, storeApi])
const handleCollaborativeChange = useCallback((value: string | undefined) => {
const nextValue = value ?? ''
if (!fileId) {
onLocalChange(nextValue)
return
}
if (!enabled) {
onLocalChange(nextValue)
return
}
if (suppressNextChangeRef.current === nextValue) {
suppressNextChangeRef.current = null
return
}
skillCollaborationManager.updateText(fileId, nextValue)
onLocalChange(nextValue)
}, [enabled, fileId, onLocalChange])
return {
handleCollaborativeChange,
isLeader: fileId ? skillCollaborationManager.isLeader(fileId) : false,
}
}

View File

@ -63,6 +63,9 @@ export type CollaborationEventType
| 'node_panel_presence'
| 'app_publish_update'
| 'graph_view_active'
| 'skill_file_active'
| 'skill_sync_request'
| 'skill_resync_request'
| 'graph_resync_request'
| 'workflow_restore_request'
| 'workflow_restore_intent'

View File

@ -4,11 +4,12 @@ import { useTranslation } from 'react-i18next'
import SkillEditor from './skill-editor'
type MarkdownFileEditorProps = {
instanceId?: string
value: string
onChange: (value: string) => void
}
const MarkdownFileEditor: FC<MarkdownFileEditorProps> = ({ value, onChange }) => {
const MarkdownFileEditor: FC<MarkdownFileEditorProps> = ({ instanceId, value, onChange }) => {
const { t } = useTranslation()
const handleChange = React.useCallback((val: string) => {
if (val !== value) {
@ -19,6 +20,7 @@ const MarkdownFileEditor: FC<MarkdownFileEditorProps> = ({ value, onChange }) =>
return (
<div className="h-full w-full bg-components-panel-bg">
<SkillEditor
instanceId={instanceId}
value={value}
onChange={handleChange}
showLineNumbers

View File

@ -13,6 +13,7 @@ import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { basePath } from '@/utils/var'
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'
@ -61,6 +62,15 @@ const FileContentPanel: FC = () => {
const originalContent = fileContent?.content ?? ''
const currentContent = draftContent !== undefined ? draftContent : originalContent
const initialContentRegistryRef = useRef<Map<string, string>>(new Map())
const canInitCollaboration = Boolean(appId && fileTabId && isMarkdown && 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)
@ -100,6 +110,11 @@ const FileContentPanel: FC = () => {
}, [fileTabId, isEditable, originalContent, storeApi])
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
@ -141,6 +156,15 @@ const FileContentPanel: FC = () => {
const language = currentFileNode ? getFileLanguage(currentFileNode.name) : 'plaintext'
const theme = appTheme === Theme.light ? 'light' : 'vs-dark'
const { handleCollaborativeChange } = useSkillMarkdownCollaboration({
appId,
fileId: fileTabId,
enabled: canInitCollaboration,
initialContent: initialCollaborativeContent,
onLocalChange: handleEditorChange,
onLeaderSync: handleLeaderSync,
})
if (isStartTab)
return <StartTabContent />
@ -184,8 +208,9 @@ const FileContentPanel: FC = () => {
? (
<MarkdownFileEditor
key={fileTabId}
instanceId={fileTabId || undefined}
value={currentContent}
onChange={handleEditorChange}
onChange={handleCollaborativeChange}
/>
)
: null}

View File

@ -7,8 +7,10 @@ import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { extractToolConfigIds } from '@/app/components/workflow/utils'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { consoleQuery } from '@/service/client'
import { useUpdateAppAssetFileContent } from '@/service/use-app-asset'
import { skillCollaborationManager } from '../../collaboration/skills/skill-collaboration-manager'
import { START_TAB_ID } from '../constants'
type SaveSnapshot = {
@ -87,6 +89,7 @@ export const SkillSaveProvider = ({
const storeApi = useWorkflowStore()
const queryClient = useQueryClient()
const updateContent = useUpdateAppAssetFileContent()
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
const queueRef = useRef<Map<string, Promise<SaveResult>>>(new Map())
const fallbackRegistryRef = useRef<Map<string, FallbackEntry>>(new Map())
@ -172,6 +175,11 @@ export const SkillSaveProvider = ({
if (!appId || !fileId || fileId === START_TAB_ID)
return { saved: false }
if (isCollaborationEnabled && skillCollaborationManager.isFileCollaborative(fileId) && !skillCollaborationManager.isLeader(fileId)) {
skillCollaborationManager.requestSync(fileId)
return { saved: false }
}
const snapshot = buildSnapshot(fileId, options?.fallbackContent, options?.fallbackMetadata)
if (!snapshot)
return { saved: false }
@ -207,7 +215,7 @@ export const SkillSaveProvider = ({
catch (error) {
return { saved: false, error }
}
}, [appId, buildSnapshot, storeApi, updateCachedContent, updateContent])
}, [appId, buildSnapshot, isCollaborationEnabled, storeApi, updateCachedContent, updateContent])
const saveFile = useCallback(async (
fileId: string,