fix web style

This commit is contained in:
hjlarry 2026-01-18 13:40:12 +08:00
parent 682c93f262
commit 511df81201
40 changed files with 452 additions and 331 deletions

View File

@ -91,13 +91,15 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
? buildTriggerModeMessage(t('mcp.server.title', { ns: 'tools' }))
: null
const updateAppDetail = async () => {
const updateAppDetail = useCallback(async () => {
try {
const res = await fetchAppDetail({ url: '/apps', id: appId })
setAppDetail({ ...res })
}
catch (error) { console.error(error) }
}
catch (error) {
console.error(error)
}
}, [appId, setAppDetail])
const handleCallbackResult = (err: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => {
const type = err ? 'error' : 'success'
@ -129,9 +131,8 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
if (!appId)
return
const unsubscribe = collaborationManager.onAppStateUpdate(async (update: any) => {
const unsubscribe = collaborationManager.onAppStateUpdate(async () => {
try {
console.log('Received app state update from collaboration:', update)
// Update app detail when other clients modify app state
await updateAppDetail()
}
@ -141,7 +142,7 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
})
return unsubscribe
}, [appId])
}, [appId, updateAppDetail])
const onChangeSiteStatus = async (value: boolean) => {
const [err] = await asyncRunSafe<App>(

View File

@ -90,7 +90,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
timestamp: Date.now(),
})
}
}, [appDetail?.id])
}, [appDetail])
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,

View File

@ -1,6 +1,7 @@
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
import type { FileUpload } from '@/app/components/base/features/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useState } from 'react'
@ -13,7 +14,7 @@ import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { Resolution } from '@/types/app'
type Props = Omit<AppPublisherProps, 'onPublish'> & {
onPublish?: (modelAndParameter?: ModelAndParameter, features?: any) => Promise<any> | any
onPublish?: (params?: ModelAndParameter | PublishWorkflowParams, features?: any) => Promise<any> | any
publishedConfig?: any
resetAppConfig?: () => void
}
@ -62,8 +63,8 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
setRestoreConfirmOpen(false)
}, [featuresStore, props])
const handlePublish = useCallback((modelAndParameter?: ModelAndParameter) => {
return props.onPublish?.(modelAndParameter, features)
const handlePublish = useCallback((params?: ModelAndParameter | PublishWorkflowParams) => {
return props.onPublish?.(params, features)
}, [features, props])
return (

View File

@ -1,5 +1,7 @@
import type { ModelAndParameter } from '../configuration/debug/types'
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { InstalledApp } from '@/models/explore'
import type { I18nKeysByPrefix } from '@/types/i18n'
import type { PublishWorkflowParams } from '@/types/workflow'
import {
@ -59,6 +61,10 @@ import SuggestedAction from './suggested-action'
type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'>
type InstalledAppsResponse = {
installed_apps?: InstalledApp[]
}
const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: React.ElementType }> = {
[AccessMode.ORGANIZATION]: {
label: 'organization',
@ -105,8 +111,8 @@ export type AppPublisherProps = {
debugWithMultipleModel?: boolean
multipleModelConfigs?: ModelAndParameter[]
/** modelAndParameter is passed when debugWithMultipleModel is true */
onPublish?: (params?: any) => Promise<any> | any
onRestore?: () => Promise<any> | any
onPublish?: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void> | void
onRestore?: () => Promise<void> | void
onToggle?: (state: boolean) => void
crossAxisOffset?: number
toolPublished?: boolean
@ -248,9 +254,10 @@ const AppPublisher = ({
await openAsyncWindow(async () => {
if (!appDetail?.id)
throw new Error('App not found')
const { installed_apps }: any = await fetchInstalledAppList(appDetail?.id) || {}
if (installed_apps?.length > 0)
return `${basePath}/explore/installed/${installed_apps[0].id}`
const response = (await fetchInstalledAppList(appDetail?.id)) as InstalledAppsResponse
const installedApps = response?.installed_apps
if (installedApps?.length)
return `${basePath}/explore/installed/${installedApps[0].id}`
throw new Error('No app found in Explore')
}, {
onError: (err) => {
@ -283,8 +290,9 @@ const AppPublisher = ({
if (!appId)
return
const unsubscribe = collaborationManager.onAppPublishUpdate((update: any) => {
if (update?.data?.action === 'published')
const unsubscribe = collaborationManager.onAppPublishUpdate((update: CollaborationUpdate) => {
const action = typeof update.data.action === 'string' ? update.data.action : undefined
if (action === 'published')
invalidateAppWorkflow(appId)
})

View File

@ -18,6 +18,7 @@ import type {
TextToSpeechConfig,
} from '@/models/debug'
import type { ModelConfig as BackendModelConfig, UserInputFormItem, VisionSettings } from '@/types/app'
import type { PublishWorkflowParams } from '@/types/workflow'
import { CodeBracketIcon } from '@heroicons/react/20/solid'
import { useBoolean, useGetState } from 'ahooks'
import { clone } from 'es-toolkit/object'
@ -760,7 +761,8 @@ const Configuration: FC = () => {
else { return promptEmpty }
})()
const contextVarEmpty = mode === AppModeEnum.COMPLETION && dataSets.length > 0 && !hasSetContextVar
const onPublish = async (modelAndParameter?: ModelAndParameter, features?: FeaturesData) => {
const onPublish = async (params?: ModelAndParameter | PublishWorkflowParams, features?: FeaturesData) => {
const modelAndParameter = params && 'model' in params ? params : undefined
const modelId = modelAndParameter?.model || modelConfig.model_id
const promptTemplate = modelConfig.configs.prompt_template
const promptVariables = modelConfig.configs.prompt_variables

View File

@ -23,7 +23,7 @@ export const useAvailableNodesMetaData = () => {
},
knowledgeBaseDefault,
dataSourceEmptyDefault,
], [])
] as AvailableNodesMetaData['nodes'], [])
const helpLinkUri = useMemo(() => {
if (language === 'zh_Hans')
@ -52,7 +52,7 @@ export const useAvailableNodesMetaData = () => {
title,
},
}
}), [mergedNodesMetaData, t])
}) as AvailableNodesMetaData['nodes'], [mergedNodesMetaData, t])
const availableNodesMetaDataMap = useMemo(() => availableNodesMetaData.reduce((acc, node) => {
acc![node.metaData.type] = node

View File

@ -3,8 +3,14 @@ import { useWorkflowStore } from '@/app/components/workflow/store'
export const useGetRunAndTraceUrl = () => {
const workflowStore = useWorkflowStore()
const getWorkflowRunAndTraceUrl = useCallback((runId: string) => {
const getWorkflowRunAndTraceUrl = useCallback((runId?: string) => {
const { pipelineId } = workflowStore.getState()
if (!pipelineId || !runId) {
return {
runUrl: '',
traceUrl: '',
}
}
return {
runUrl: `/rag/pipelines/${pipelineId}/workflow-runs/${runId}`,

View File

@ -1,6 +1,8 @@
'use client'
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
import type { InputVar } from '@/app/components/workflow/types'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import type { AppSSO, ModelConfig, UserInputFormItem } from '@/types/app'
import { RiEditLine, RiLoopLeftLine } from '@remixicon/react'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
@ -38,6 +40,16 @@ export type IAppCardProps = {
triggerModeMessage?: React.ReactNode // display-only message explaining the trigger restriction
}
type BasicAppConfig = Partial<ModelConfig> & {
updated_at?: number
}
type McpServerParam = {
label: string
variable: string
type: string
}
function MCPServiceCard({
appInfo,
triggerModeDisabled = false,
@ -56,16 +68,16 @@ function MCPServiceCard({
const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW
const isBasicApp = !isAdvancedApp
const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '')
const [basicAppConfig, setBasicAppConfig] = useState<any>({})
const basicAppInputForm = useMemo(() => {
if (!isBasicApp || !basicAppConfig?.user_input_form)
const [basicAppConfig, setBasicAppConfig] = useState<BasicAppConfig>({})
const basicAppInputForm = useMemo<McpServerParam[]>(() => {
if (!isBasicApp || !basicAppConfig.user_input_form)
return []
return basicAppConfig.user_input_form.map((item: any) => {
const type = Object.keys(item)[0]
return {
...item[type],
type: type || 'text-input',
}
return basicAppConfig.user_input_form.map((item: UserInputFormItem) => {
if ('text-input' in item)
return { label: item['text-input'].label, variable: item['text-input'].variable, type: 'text-input' }
if ('select' in item)
return { label: item.select.label, variable: item.select.variable, type: 'select' }
return { label: item.paragraph.label, variable: item.paragraph.variable, type: 'paragraph' }
})
}, [basicAppConfig.user_input_form, isBasicApp])
useEffect(() => {
@ -92,12 +104,22 @@ function MCPServiceCard({
const [activated, setActivated] = useState(serverActivated)
const latestParams = useMemo(() => {
const latestParams = useMemo<McpServerParam[]>(() => {
if (isAdvancedApp) {
if (!currentWorkflow?.graph)
return []
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as any
return startNode?.data.variables as any[] || []
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start)
const variables = (startNode?.data as { variables?: InputVar[] } | undefined)?.variables || []
return variables.map((variable) => {
const label = typeof variable.label === 'string'
? variable.label
: (variable.label.variable || variable.label.nodeName)
return {
label,
variable: variable.variable,
type: variable.type,
}
})
}
return basicAppInputForm
}, [currentWorkflow, basicAppInputForm, isAdvancedApp])
@ -178,9 +200,8 @@ function MCPServiceCard({
if (!appId)
return
const unsubscribe = collaborationManager.onMcpServerUpdate(async (update: any) => {
const unsubscribe = collaborationManager.onMcpServerUpdate(async (_update: CollaborationUpdate) => {
try {
console.log('Received MCP server update from collaboration:', update)
invalidateMCPServerDetail(appId)
}
catch (error) {

View File

@ -108,7 +108,7 @@ vi.mock('@/app/components/app/app-publisher', () => ({
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.()).catch(() => undefined) }}>
publisher-publish
</button>
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ url: '/apps/app-id/workflows/publish', title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
publisher-publish-with-params
</button>
</div>

View File

@ -1,3 +1,4 @@
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import type {
@ -140,7 +141,8 @@ const FeaturesTrigger = () => {
const needWarningNodes = useChecklist(nodes, edges)
const updatePublishedWorkflow = useInvalidateAppWorkflow()
const onPublish = useCallback(async (params?: PublishWorkflowParams) => {
const onPublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
const publishParams = params && 'title' in params ? params : undefined
// First check if there are any items in the checklist
// if (!validateBeforeRun())
// throw new Error('Checklist has unresolved items')
@ -154,8 +156,8 @@ const FeaturesTrigger = () => {
if (await handleCheckBeforePublish()) {
const res = await publishWorkflow({
url: `/apps/${appID}/workflows/publish`,
title: params?.title || '',
releaseNotes: params?.releaseNotes || '',
title: publishParams?.title || '',
releaseNotes: publishParams?.releaseNotes || '',
})
if (res) {

View File

@ -1,13 +1,16 @@
import type { Features as FeaturesData } from '@/app/components/base/features/types'
import type { WorkflowProps } from '@/app/components/workflow'
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store/store'
import type { Edge, Node } from '@/app/components/workflow/types'
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useReactFlow, useStoreApi } from 'reactflow'
import { useReactFlow } from 'reactflow'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { WorkflowWithInnerContext } from '@/app/components/workflow'
@ -31,6 +34,7 @@ import {
import WorkflowChildren from './workflow-children'
type WorkflowMainProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'>
type WorkflowDataUpdatePayload = Pick<FetchWorkflowDraftResponse, 'features' | 'conversation_variables' | 'environment_variables'>
const WorkflowMain = ({
nodes,
edges,
@ -42,7 +46,14 @@ const WorkflowMain = ({
const containerRef = useRef<HTMLDivElement>(null)
const reactFlow = useReactFlow()
const store = useStoreApi()
const reactFlowStore = useMemo(() => ({
getState: () => ({
getNodes: () => reactFlow.getNodes(),
setNodes: (nodesToSet: Node[]) => reactFlow.setNodes(nodesToSet),
getEdges: () => reactFlow.getEdges(),
setEdges: (edgesToSet: Edge[]) => reactFlow.setEdges(edgesToSet),
}),
}), [reactFlow])
const {
startCursorTracking,
stopCursorTracking,
@ -50,15 +61,11 @@ const WorkflowMain = ({
cursors,
isConnected,
isEnabled: isCollaborationEnabled,
} = useCollaboration(appId || '', store)
const [myUserId, setMyUserId] = useState<string | null>(null)
useEffect(() => {
if (isCollaborationEnabled && isConnected)
setMyUserId('current-user')
else
setMyUserId(null)
}, [isCollaborationEnabled, isConnected])
} = useCollaboration(appId || '', reactFlowStore)
const myUserId = useMemo(
() => (isCollaborationEnabled && isConnected ? 'current-user' : null),
[isCollaborationEnabled, isConnected],
)
const filteredCursors = Object.fromEntries(
Object.entries(cursors).filter(([userId]) => userId !== myUserId),
@ -76,7 +83,7 @@ const WorkflowMain = ({
}
}, [startCursorTracking, stopCursorTracking, reactFlow, isCollaborationEnabled])
const handleWorkflowDataUpdate = useCallback((payload: any) => {
const handleWorkflowDataUpdate = useCallback((payload: WorkflowDataUpdatePayload) => {
const {
features,
conversation_variables,
@ -141,7 +148,7 @@ const WorkflowMain = ({
if (!appId || !isCollaborationEnabled)
return
const unsubscribe = collaborationManager.onVarsAndFeaturesUpdate(async (update: any) => {
const unsubscribe = collaborationManager.onVarsAndFeaturesUpdate(async (_update: CollaborationUpdate) => {
try {
const response = await fetchWorkflowDraft(`/apps/${appId}/workflows/draft`)
handleWorkflowDataUpdate(response)
@ -160,7 +167,6 @@ const WorkflowMain = ({
return
const unsubscribe = collaborationManager.onWorkflowUpdate(async () => {
console.log('Received workflow update from collaborator, fetching latest workflow data')
try {
const response = await fetchWorkflowDraft(`/apps/${appId}/workflows/draft`)
@ -190,7 +196,6 @@ const WorkflowMain = ({
return
const unsubscribe = collaborationManager.onSyncRequest(() => {
console.log('Leader received sync request, performing sync')
doSyncWorkflowDraft()
})
@ -234,7 +239,7 @@ const WorkflowMain = ({
invalidateConversationVarValues,
} = useInspectVarsCrud()
const hooksStore = useMemo(() => {
const hooksStore = useMemo<Partial<HooksStoreShape>>(() => {
return {
syncWorkflowDraftWhenPageClose,
doSyncWorkflowDraft,
@ -320,7 +325,7 @@ const WorkflowMain = ({
edges={edges}
viewport={viewport}
onWorkflowDataUpdate={handleWorkflowDataUpdate}
hooksStore={hooksStore as any}
hooksStore={hooksStore}
cursors={filteredCursors}
myUserId={myUserId}
onlineUsers={onlineUsers}

View File

@ -38,7 +38,7 @@ export const useAvailableNodesMetaData = () => {
TriggerPluginDefault,
]
),
], [isChatMode, startNodeMetaData])
] as AvailableNodesMetaData['nodes'], [isChatMode, startNodeMetaData])
const availableNodesMetaData = useMemo(() => mergedNodesMetaData.map((node) => {
const { metaData } = node
@ -59,7 +59,7 @@ export const useAvailableNodesMetaData = () => {
title,
},
}
}), [mergedNodesMetaData, t, docLink])
}) as AvailableNodesMetaData['nodes'], [mergedNodesMetaData, t, docLink])
const availableNodesMetaDataMap = useMemo(() => availableNodesMetaData.reduce((acc, node) => {
acc![node.metaData.type] = node

View File

@ -3,8 +3,14 @@ import { useWorkflowStore } from '@/app/components/workflow/store'
export const useGetRunAndTraceUrl = () => {
const workflowStore = useWorkflowStore()
const getWorkflowRunAndTraceUrl = useCallback((runId: string) => {
const getWorkflowRunAndTraceUrl = useCallback((runId?: string) => {
const { appId } = workflowStore.getState()
if (!appId || !runId) {
return {
runUrl: '',
traceUrl: '',
}
}
return {
runUrl: `/apps/${appId}/workflow-runs/${runId}`,

View File

@ -98,15 +98,12 @@ export const useNodesSyncDraft = () => {
const currentIsLeader = isCollaborationEnabled ? collaborationManager.getIsLeader() : true
// Only allow leader to sync data
if (isCollaborationEnabled && !currentIsLeader) {
console.log('Not leader, skipping sync on page close')
if (isCollaborationEnabled && !currentIsLeader)
return
}
const postParams = getPostParams()
if (postParams) {
console.log('Leader syncing workflow draft on page close')
navigator.sendBeacon(
`${API_PREFIX}/apps/${params.appId}/workflows/draft`,
JSON.stringify(postParams.params),
@ -131,14 +128,11 @@ export const useNodesSyncDraft = () => {
// If not leader and not forcing upload, request the leader to sync
if (isCollaborationEnabled && !currentIsLeader && !forceUpload) {
console.log('Not leader, requesting leader to sync workflow draft')
if (isCollaborationEnabled)
collaborationManager.emitSyncRequest()
callback?.onSettled?.()
return
}
console.log(forceUpload ? 'Force uploading workflow draft' : 'Leader performing workflow draft sync')
const postParams = getPostParams()
if (postParams) {

View File

@ -35,7 +35,7 @@ const NodeSelectorWrapper = (props: NodeSelectorProps) => {
return true
})
}, [availableNodesMetaData?.nodes])
}, [availableNodesMetaData?.nodes]) as NodeSelectorProps['blocks']
return (
<NodeSelector

View File

@ -1,3 +1,4 @@
import type { Value } from 'loro-crdt'
import type { Socket } from 'socket.io-client'
import type {
CommonNodeType,
@ -24,18 +25,46 @@ type NodePanelPresenceEventData = {
action: 'open' | 'close'
user: NodePanelPresenceUser
clientId: string
timestamp?: number
timestamp: number
}
type ReactFlowStore = {
getState: () => {
getNodes: () => Node[]
setNodes: (nodes: Node[]) => void
getEdges: () => Edge[]
setEdges: (edges: Edge[]) => void
}
}
type CollaborationEventPayload = {
type: CollaborationUpdate['type']
data: Record<string, unknown>
timestamp: number
userId?: string
}
type LoroSubscribeEvent = {
by?: string
}
type LoroContainer = {
kind?: () => string
getAttached?: () => unknown
}
const toLoroValue = (value: unknown): Value => cloneDeep(value) as Value
const toLoroRecord = (value: unknown): Record<string, Value> => cloneDeep(value) as Record<string, Value>
export class CollaborationManager {
private doc: LoroDoc | null = null
private undoManager: UndoManager | null = null
private provider: CRDTProvider | null = null
private nodesMap: LoroMap | null = null
private edgesMap: LoroMap | null = null
private nodesMap: LoroMap<Record<string, Value>> | null = null
private edgesMap: LoroMap<Record<string, Value>> | null = null
private eventEmitter = new EventEmitter()
private currentAppId: string | null = null
private reactFlowStore: any = null
private reactFlowStore: ReactFlowStore | null = null
private isLeader = false
private leaderId: string | null = null
private cursors: Record<string, CursorPosition> = {}
@ -80,7 +109,7 @@ export class CollaborationManager {
)
}
private sendCollaborationEvent(payload: any): void {
private sendCollaborationEvent(payload: CollaborationEventPayload): void {
const socket = this.getActiveSocket()
if (!socket)
return
@ -88,7 +117,7 @@ export class CollaborationManager {
emitWithAuthGuard(socket, 'collaboration_event', payload, { onUnauthorized: this.handleSessionUnauthorized })
}
private sendGraphEvent(payload: any): void {
private sendGraphEvent(payload: Uint8Array): void {
const socket = this.getActiveSocket()
if (!socket)
return
@ -96,59 +125,67 @@ export class CollaborationManager {
emitWithAuthGuard(socket, 'graph_event', payload, { onUnauthorized: this.handleSessionUnauthorized })
}
private getNodeContainer(nodeId: string): LoroMap<any> {
private getNodeContainer(nodeId: string): LoroMap<Record<string, Value>> {
if (!this.nodesMap)
throw new Error('Nodes map not initialized')
let container = this.nodesMap.get(nodeId) as any
let container = this.nodesMap.get(nodeId) as unknown
if (!container || typeof container.kind !== 'function' || container.kind() !== 'Map') {
const isMapContainer = (value: unknown): value is LoroMap<Record<string, Value>> & LoroContainer => {
return !!value && typeof (value as LoroContainer).kind === 'function' && (value as LoroContainer).kind?.() === 'Map'
}
if (!container || !isMapContainer(container)) {
const previousValue = container
const newContainer = this.nodesMap.setContainer(nodeId, new LoroMap())
container = typeof newContainer.getAttached === 'function' ? newContainer.getAttached() ?? newContainer : newContainer
const attached = (newContainer as LoroContainer).getAttached?.() ?? newContainer
container = attached
if (previousValue && typeof previousValue === 'object')
this.populateNodeContainer(container, previousValue as Node)
this.populateNodeContainer(container as LoroMap<Record<string, Value>>, previousValue as Node)
}
else {
container = typeof container.getAttached === 'function' ? container.getAttached() ?? container : container
const attached = (container as LoroContainer).getAttached?.() ?? container
container = attached
}
return container
return container as LoroMap<Record<string, Value>>
}
private ensureDataContainer(nodeContainer: LoroMap<any>): LoroMap<any> {
let dataContainer = nodeContainer.get('data') as any
private ensureDataContainer(nodeContainer: LoroMap<Record<string, Value>>): LoroMap<Record<string, Value>> {
let dataContainer = nodeContainer.get('data') as unknown
if (!dataContainer || typeof dataContainer.kind !== 'function' || dataContainer.kind() !== 'Map')
if (!dataContainer || typeof (dataContainer as LoroContainer).kind !== 'function' || (dataContainer as LoroContainer).kind?.() !== 'Map')
dataContainer = nodeContainer.setContainer('data', new LoroMap())
return typeof dataContainer.getAttached === 'function' ? dataContainer.getAttached() ?? dataContainer : dataContainer
const attached = (dataContainer as LoroContainer).getAttached?.() ?? dataContainer
return attached as LoroMap<Record<string, Value>>
}
private ensureList(nodeContainer: LoroMap<any>, key: string): LoroList<any> {
private ensureList(nodeContainer: LoroMap<Record<string, Value>>, key: string): LoroList<unknown> {
const dataContainer = this.ensureDataContainer(nodeContainer)
let list = dataContainer.get(key) as any
let list = dataContainer.get(key) as unknown
if (!list || typeof list.kind !== 'function' || list.kind() !== 'List')
if (!list || typeof (list as LoroContainer).kind !== 'function' || (list as LoroContainer).kind?.() !== 'List')
list = dataContainer.setContainer(key, new LoroList())
return typeof list.getAttached === 'function' ? list.getAttached() ?? list : list
const attached = (list as LoroContainer).getAttached?.() ?? list
return attached as LoroList<unknown>
}
private exportNode(nodeId: string): Node {
const container = this.getNodeContainer(nodeId)
const json = container.toJSON() as any
const json = container.toJSON() as Node
return {
...json,
data: json.data || {},
}
}
private populateNodeContainer(container: LoroMap<any>, node: Node): void {
private populateNodeContainer(container: LoroMap<Record<string, Value>>, node: Node): void {
const listFields = new Set(['variables', 'prompt_template', 'parameters'])
container.set('id', node.id)
container.set('type', node.type)
container.set('position', cloneDeep(node.position))
container.set('position', toLoroValue(node.position))
container.set('sourcePosition', node.sourcePosition)
container.set('targetPosition', node.targetPosition)
@ -189,7 +226,7 @@ export class CollaborationManager {
if (value === undefined)
container.delete(prop as string)
else
container.set(prop as string, cloneDeep(value as any))
container.set(prop as string, toLoroValue(value))
})
const dataContainer = this.ensureDataContainer(container)
@ -203,7 +240,7 @@ export class CollaborationManager {
if (listFields.has(key))
this.syncList(container, key, Array.isArray(value) ? value : [])
else
dataContainer.set(key, cloneDeep(value))
dataContainer.set(key, toLoroValue(value))
})
const existingData = dataContainer.toJSON() || {}
@ -222,9 +259,9 @@ export class CollaborationManager {
return (syncDataAllowList.has(key) || !key.startsWith('_')) && key !== 'selected'
}
private syncList(nodeContainer: LoroMap<any>, key: string, desired: any[]): void {
private syncList(nodeContainer: LoroMap<Record<string, Value>>, key: string, desired: Array<unknown>): void {
const list = this.ensureList(nodeContainer, key)
const current = list.toJSON() as any[]
const current = list.toJSON() as Array<unknown>
const target = Array.isArray(desired) ? desired : []
const minLength = Math.min(current.length, target.length)
@ -309,7 +346,7 @@ export class CollaborationManager {
this.eventEmitter.emit('nodePanelPresence', this.getNodePanelPresenceSnapshot())
}
init = (appId: string, reactFlowStore: any): void => {
init = (appId: string, reactFlowStore: ReactFlowStore): void => {
if (!reactFlowStore) {
console.warn('CollaborationManager.init called without reactFlowStore, deferring to connect()')
return
@ -345,7 +382,7 @@ export class CollaborationManager {
this.disconnect()
}
async connect(appId: string, reactFlowStore?: any): Promise<string> {
async connect(appId: string, reactFlowStore?: ReactFlowStore): Promise<string> {
const connectionId = Math.random().toString(36).substring(2, 11)
this.activeConnections.add(connectionId)
@ -373,15 +410,15 @@ export class CollaborationManager {
this.setupSocketEventListeners(socket)
this.doc = new LoroDoc()
this.nodesMap = this.doc.getMap('nodes')
this.edgesMap = this.doc.getMap('edges')
this.nodesMap = this.doc.getMap('nodes') as LoroMap<Record<string, Value>>
this.edgesMap = this.doc.getMap('edges') as LoroMap<Record<string, Value>>
// Initialize UndoManager for collaborative undo/redo
this.undoManager = new UndoManager(this.doc, {
maxUndoSteps: 100,
mergeInterval: 500, // Merge operations within 500ms
excludeOriginPrefixes: [], // Don't exclude anything - let UndoManager track all local operations
onPush: (isUndo, range, event) => {
onPush: (_isUndo, _range, _event) => {
// Store current selection state when an operation is pushed
const selectedNode = this.reactFlowStore?.getState().getNodes().find((n: Node) => n.data?.selected)
@ -401,10 +438,10 @@ export class CollaborationManager {
cursors: [],
}
},
onPop: (isUndo, value, counterRange) => {
onPop: (_isUndo, value, _counterRange) => {
// Restore selection state when undoing/redoing
if (value?.value && typeof value.value === 'object' && 'selectedNodeId' in value.value && this.reactFlowStore) {
const selectedNodeId = (value.value as any).selectedNodeId
const selectedNodeId = (value.value as { selectedNodeId?: string | null }).selectedNodeId
if (selectedNodeId) {
const { setNodes } = this.reactFlowStore.getState()
const nodes = this.reactFlowStore.getState().getNodes()
@ -481,7 +518,7 @@ export class CollaborationManager {
}
getEdges(): Edge[] {
return this.edgesMap ? Array.from(this.edgesMap.values()) : []
return this.edgesMap ? Array.from(this.edgesMap.values()) as Edge[] : []
}
emitCursorMove(position: CursorPosition): void {
@ -567,23 +604,23 @@ export class CollaborationManager {
return this.eventEmitter.on('workflowUpdate', callback)
}
onVarsAndFeaturesUpdate(callback: (update: any) => void): () => void {
onVarsAndFeaturesUpdate(callback: (update: CollaborationUpdate) => void): () => void {
return this.eventEmitter.on('varsAndFeaturesUpdate', callback)
}
onAppStateUpdate(callback: (update: any) => void): () => void {
onAppStateUpdate(callback: (update: CollaborationUpdate) => void): () => void {
return this.eventEmitter.on('appStateUpdate', callback)
}
onAppPublishUpdate(callback: (update: any) => void): () => void {
onAppPublishUpdate(callback: (update: CollaborationUpdate) => void): () => void {
return this.eventEmitter.on('appPublishUpdate', callback)
}
onAppMetaUpdate(callback: (update: any) => void): () => void {
onAppMetaUpdate(callback: (update: CollaborationUpdate) => void): () => void {
return this.eventEmitter.on('appMetaUpdate', callback)
}
onMcpServerUpdate(callback: (update: any) => void): () => void {
onMcpServerUpdate(callback: (update: CollaborationUpdate) => void): () => void {
return this.eventEmitter.on('mcpServerUpdate', callback)
}
@ -635,12 +672,13 @@ export class CollaborationManager {
const result = this.undoManager.undo()
// After undo, manually update React state from CRDT without triggering collaboration
if (result && this.reactFlowStore) {
const reactFlowStore = this.reactFlowStore
if (result && reactFlowStore) {
requestAnimationFrame(() => {
// Get ReactFlow's native setters, not the collaborative ones
const state = this.reactFlowStore.getState()
const updatedNodes = Array.from(this.nodesMap?.values() || [])
const updatedEdges = Array.from(this.edgesMap?.values() || [])
const state = reactFlowStore.getState()
const updatedNodes = Array.from(this.nodesMap?.values() || []) as Node[]
const updatedEdges = Array.from(this.edgesMap?.values() || []) as Edge[]
// Call ReactFlow's native setters directly to avoid triggering collaboration
state.setNodes(updatedNodes)
state.setEdges(updatedEdges)
@ -674,12 +712,13 @@ export class CollaborationManager {
const result = this.undoManager.redo()
// After redo, manually update React state from CRDT without triggering collaboration
if (result && this.reactFlowStore) {
const reactFlowStore = this.reactFlowStore
if (result && reactFlowStore) {
requestAnimationFrame(() => {
// Get ReactFlow's native setters, not the collaborative ones
const state = this.reactFlowStore.getState()
const updatedNodes = Array.from(this.nodesMap?.values() || [])
const updatedEdges = Array.from(this.edgesMap?.values() || [])
const state = reactFlowStore.getState()
const updatedNodes = Array.from(this.nodesMap?.values() || []) as Node[]
const updatedEdges = Array.from(this.edgesMap?.values() || []) as Edge[]
// Call ReactFlow's native setters directly to avoid triggering collaboration
state.setNodes(updatedNodes)
state.setEdges(updatedEdges)
@ -753,21 +792,22 @@ export class CollaborationManager {
newEdges.forEach((newEdge) => {
const oldEdge = oldEdgesMap.get(newEdge.id)
if (!oldEdge || !isEqual(oldEdge, newEdge)) {
const clonedEdge = cloneDeep(newEdge)
const clonedEdge = toLoroRecord(newEdge)
this.edgesMap?.set(newEdge.id, clonedEdge)
}
})
}
private setupSubscriptions(): void {
this.nodesMap?.subscribe((event: any) => {
if (event.by === 'import' && this.reactFlowStore) {
this.nodesMap?.subscribe((event: LoroSubscribeEvent) => {
const reactFlowStore = this.reactFlowStore
if (event.by === 'import' && reactFlowStore) {
// Don't update React nodes during undo/redo to prevent loops
if (this.isUndoRedoInProgress)
return
requestAnimationFrame(() => {
const state = this.reactFlowStore.getState()
const state = reactFlowStore.getState()
const previousNodes: Node[] = state.getNodes()
const previousNodeMap = new Map(previousNodes.map(node => [node.id, node]))
const selectedIds = new Set(
@ -813,16 +853,17 @@ export class CollaborationManager {
}
})
this.edgesMap?.subscribe((event: any) => {
if (event.by === 'import' && this.reactFlowStore) {
this.edgesMap?.subscribe((event: LoroSubscribeEvent) => {
const reactFlowStore = this.reactFlowStore
if (event.by === 'import' && reactFlowStore) {
// Don't update React edges during undo/redo to prevent loops
if (this.isUndoRedoInProgress)
return
requestAnimationFrame(() => {
// Get ReactFlow's native setters, not the collaborative ones
const state = this.reactFlowStore.getState()
const updatedEdges = Array.from(this.edgesMap?.values() || [])
const state = reactFlowStore.getState()
const updatedEdges = Array.from(this.edgesMap?.values() || []) as Edge[]
this.pendingInitialSync = false
@ -926,9 +967,6 @@ export class CollaborationManager {
const wasLeader = this.isLeader
this.isLeader = data.isLeader
if (wasLeader !== this.isLeader)
console.log(`Collaboration: I am now the ${this.isLeader ? 'Leader' : 'Follower'}.`)
if (this.isLeader)
this.pendingInitialSync = false
else
@ -943,13 +981,11 @@ export class CollaborationManager {
})
socket.on('connect', () => {
console.log('WebSocket connected successfully')
this.eventEmitter.emit('stateChange', { isConnected: true })
this.pendingInitialSync = true
})
socket.on('disconnect', (reason: string) => {
console.log('WebSocket disconnected:', reason)
socket.on('disconnect', () => {
this.cursors = {}
this.isLeader = false
this.leaderId = null
@ -958,12 +994,12 @@ export class CollaborationManager {
this.eventEmitter.emit('cursors', {})
})
socket.on('connect_error', (error: any) => {
socket.on('connect_error', (error: Error) => {
console.error('WebSocket connection error:', error)
this.eventEmitter.emit('stateChange', { isConnected: false, error: error.message })
})
socket.on('error', (error: any) => {
socket.on('error', (error: Error) => {
console.error('WebSocket error:', error)
})
}

View File

@ -15,7 +15,7 @@ export class CRDTProvider {
}
private setupEventListeners(): void {
this.doc.subscribe((event: any) => {
this.doc.subscribe((event: { by?: string }) => {
if (event.by === 'local') {
const update = this.doc.export({ mode: 'update' })
emitWithAuthGuard(this.socket, 'graph_event', update, { onUnauthorized: this.onUnauthorized })

View File

@ -1,24 +1,24 @@
export type EventHandler<T = any> = (data: T) => void
export type EventHandler<T = unknown> = (data: T) => void
export class EventEmitter {
private events: Map<string, Set<EventHandler>> = new Map()
private events: Map<string, Set<EventHandler<unknown>>> = new Map()
on<T = any>(event: string, handler: EventHandler<T>): () => void {
on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
if (!this.events.has(event))
this.events.set(event, new Set())
this.events.get(event)!.add(handler)
this.events.get(event)!.add(handler as EventHandler<unknown>)
return () => this.off(event, handler)
}
off<T = any>(event: string, handler?: EventHandler<T>): void {
off<T = unknown>(event: string, handler?: EventHandler<T>): void {
if (!this.events.has(event))
return
const handlers = this.events.get(event)!
if (handler)
handlers.delete(handler)
handlers.delete(handler as EventHandler<unknown>)
else
handlers.clear()
@ -26,7 +26,7 @@ export class EventEmitter {
this.events.delete(event)
}
emit<T = any>(event: string, data: T): void {
emit<T = unknown>(event: string, data: T): void {
if (!this.events.has(event))
return

View File

@ -3,27 +3,31 @@ import type { DebugInfo, WebSocketConfig } from '../types/websocket'
import { io } from 'socket.io-client'
import { ACCESS_TOKEN_LOCAL_STORAGE_NAME } from '@/config'
const isUnauthorizedAck = (...ackArgs: any[]): boolean => {
type AckArgs = unknown[]
const isUnauthorizedAck = (...ackArgs: AckArgs): boolean => {
const [first, second] = ackArgs
if (second === 401 || first === 401)
return true
if (first && typeof first === 'object' && first.msg === 'unauthorized')
return true
if (first && typeof first === 'object' && 'msg' in first) {
const message = (first as { msg?: unknown }).msg
return message === 'unauthorized'
}
return false
}
export type EmitAckOptions = {
onAck?: (...ackArgs: any[]) => void
onUnauthorized?: (...ackArgs: any[]) => void
onAck?: (...ackArgs: AckArgs) => void
onUnauthorized?: (...ackArgs: AckArgs) => void
}
export const emitWithAuthGuard = (
socket: Socket | null | undefined,
event: string,
payload: any,
payload: unknown,
options?: EmitAckOptions,
): void => {
if (!socket)
@ -32,7 +36,7 @@ export const emitWithAuthGuard = (
socket.emit(
event,
payload,
(...ackArgs: any[]) => {
(...ackArgs: AckArgs) => {
options?.onAck?.(...ackArgs)
if (isUnauthorizedAck(...ackArgs))
options?.onUnauthorized?.(...ackArgs)

View File

@ -1,31 +1,44 @@
import type { ReactFlowInstance } from 'reactflow'
import type { CollaborationState } from '../types/collaboration'
import type {
CollaborationState,
CursorPosition,
NodePanelPresenceMap,
OnlineUser,
} from '../types/collaboration'
import { useEffect, useRef, useState } from 'react'
import Toast from '@/app/components/base/toast'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { collaborationManager } from '../core/collaboration-manager'
import { CursorService } from '../services/cursor-service'
export function useCollaboration(appId: string, reactFlowStore?: any) {
const [state, setState] = useState<Partial<CollaborationState & { isLeader: boolean }>>({
isConnected: false,
onlineUsers: [],
cursors: {},
nodePanelPresence: {},
isLeader: false,
})
type CollaborationViewState = {
isConnected: boolean
onlineUsers: OnlineUser[]
cursors: Record<string, CursorPosition>
nodePanelPresence: NodePanelPresenceMap
isLeader: boolean
}
type ReactFlowStore = NonNullable<Parameters<typeof collaborationManager.connect>[1]>
const initialState: CollaborationViewState = {
isConnected: false,
onlineUsers: [],
cursors: {},
nodePanelPresence: {},
isLeader: false,
}
export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore) {
const [state, setState] = useState<CollaborationViewState>(initialState)
const cursorServiceRef = useRef<CursorService | null>(null)
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
useEffect(() => {
if (!appId || !isCollaborationEnabled) {
setState({
isConnected: false,
onlineUsers: [],
cursors: {},
nodePanelPresence: {},
isLeader: false,
Promise.resolve().then(() => {
setState(initialState)
})
return
}
@ -44,7 +57,7 @@ export function useCollaboration(appId: string, reactFlowStore?: any) {
return
}
connectionId = id
setState((prev: any) => ({ ...prev, appId, isConnected: collaborationManager.isConnected() }))
setState(prev => ({ ...prev, isConnected: collaborationManager.isConnected() }))
}
catch (error) {
console.error('Failed to initialize collaboration:', error)
@ -53,27 +66,27 @@ export function useCollaboration(appId: string, reactFlowStore?: any) {
initCollaboration()
const unsubscribeStateChange = collaborationManager.onStateChange((newState: any) => {
console.log('Collaboration state change:', newState)
setState((prev: any) => ({ ...prev, ...newState }))
const unsubscribeStateChange = collaborationManager.onStateChange((newState: Partial<CollaborationState>) => {
if (newState.isConnected === undefined)
return
setState(prev => ({ ...prev, isConnected: newState.isConnected ?? prev.isConnected }))
})
const unsubscribeCursors = collaborationManager.onCursorUpdate((cursors: any) => {
setState((prev: any) => ({ ...prev, cursors }))
const unsubscribeCursors = collaborationManager.onCursorUpdate((cursors: Record<string, CursorPosition>) => {
setState(prev => ({ ...prev, cursors }))
})
const unsubscribeUsers = collaborationManager.onOnlineUsersUpdate((users: any) => {
console.log('Online users update:', users)
setState((prev: any) => ({ ...prev, onlineUsers: users }))
const unsubscribeUsers = collaborationManager.onOnlineUsersUpdate((users: OnlineUser[]) => {
setState(prev => ({ ...prev, onlineUsers: users }))
})
const unsubscribeNodePanelPresence = collaborationManager.onNodePanelPresenceUpdate((presence) => {
setState((prev: any) => ({ ...prev, nodePanelPresence: presence }))
const unsubscribeNodePanelPresence = collaborationManager.onNodePanelPresenceUpdate((presence: NodePanelPresenceMap) => {
setState(prev => ({ ...prev, nodePanelPresence: presence }))
})
const unsubscribeLeaderChange = collaborationManager.onLeaderChange((isLeader: boolean) => {
console.log('Leader status changed:', isLeader)
setState((prev: any) => ({ ...prev, isLeader }))
setState(prev => ({ ...prev, isLeader }))
})
return () => {

View File

@ -1,38 +1,34 @@
export type CollaborationEvent = {
export type CollaborationEvent<TData = unknown> = {
type: string
data: any
data: TData
timestamp: number
}
export type GraphUpdateEvent = {
type: 'graph_update'
data: Uint8Array
} & CollaborationEvent
} & CollaborationEvent<Uint8Array>
export type CursorMoveEvent = {
type: 'cursor_move'
data: {
x: number
y: number
userId: string
}
} & CollaborationEvent
} & CollaborationEvent<{
x: number
y: number
userId: string
}>
export type UserConnectEvent = {
type: 'user_connect'
data: {
workflow_id: string
}
} & CollaborationEvent
} & CollaborationEvent<{
workflow_id: string
}>
export type OnlineUsersEvent = {
type: 'online_users'
data: {
users: Array<{
user_id: string
username: string
avatar: string
sid: string
}>
}
} & CollaborationEvent
} & CollaborationEvent<{
users: Array<{
user_id: string
username: string
avatar: string
sid: string
}>
}>

View File

@ -253,11 +253,15 @@ const MentionInputInner = forwardRef<HTMLTextAreaElement, MentionInputProps>(({
}, [value, syncHighlightScroll])
useLayoutEffect(() => {
evaluateContentLayout()
Promise.resolve().then(() => {
evaluateContentLayout()
})
}, [value, evaluateContentLayout])
useLayoutEffect(() => {
updateLayoutPadding()
Promise.resolve().then(() => {
updateLayoutPadding()
})
}, [updateLayoutPadding, isEditing, shouldReserveButtonGap])
useEffect(() => {
@ -271,9 +275,11 @@ const MentionInputInner = forwardRef<HTMLTextAreaElement, MentionInputProps>(({
}, [evaluateContentLayout, updateLayoutPadding])
useEffect(() => {
baseTextareaHeightRef.current = null
evaluateContentLayout()
setShouldReserveHorizontalSpace(!isEditing)
Promise.resolve().then(() => {
baseTextareaHeightRef.current = null
evaluateContentLayout()
setShouldReserveHorizontalSpace(!isEditing)
})
}, [isEditing, evaluateContentLayout])
const filteredMentionUsers = useMemo(() => {
@ -481,8 +487,11 @@ const MentionInputInner = forwardRef<HTMLTextAreaElement, MentionInputProps>(({
}, [])
useEffect(() => {
if (!value)
resetMentionState()
if (!value) {
Promise.resolve().then(() => {
resetMentionState()
})
}
}, [value, resetMentionState])
useEffect(() => {

View File

@ -190,7 +190,9 @@ export const CommentThread: FC<CommentThreadProps> = memo(({
}, [mentionUsers])
useEffect(() => {
setReplyContent('')
Promise.resolve().then(() => {
setReplyContent('')
})
}, [comment.id])
useEffect(() => () => {

View File

@ -62,8 +62,6 @@ const Features = () => {
file_upload: currentFeatures.file,
}
console.log('Sending features to server:', transformedFeatures)
await updateFeatures({
appId,
features: transformedFeatures,

View File

@ -1,4 +1,5 @@
'use client'
import type { OnlineUser } from '../collaboration/types'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { useEffect, useState } from 'react'
import { useReactFlow } from 'reactflow'
@ -16,7 +17,7 @@ import { useCollaboration } from '../collaboration/hooks/use-collaboration'
import { getUserColor } from '../collaboration/utils/user-color'
import { useStore } from '../store'
const useAvatarUrls = (users: any[]) => {
const useAvatarUrls = (users: OnlineUser[]) => {
const [avatarUrls, setAvatarUrls] = useState<Record<string, string>>({})
useEffect(() => {
@ -59,7 +60,7 @@ const OnlineUsers = () => {
const currentUserId = userProfile?.id
const renderDisplayName = (
user: any,
user: OnlineUser,
baseClassName: string,
suffixClassName: string,
) => {
@ -99,7 +100,7 @@ const OnlineUsers = () => {
const visibleUsers = onlineUsers.slice(0, maxVisible)
const remainingCount = onlineUsers.length - maxVisible
const getAvatarUrl = (user: any) => {
const getAvatarUrl = (user: OnlineUser) => {
return avatarUrls[user.sid] || user.avatar
}

View File

@ -27,7 +27,9 @@ const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
}
// Initial state
updateButtonStates()
Promise.resolve().then(() => {
updateButtonStates()
})
// Listen for undo/redo state changes
const unsubscribe = collaborationManager.onUndoRedoStateChange((state) => {

View File

@ -1,6 +1,8 @@
import type { FileUpload } from '../../base/features/types'
import type { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import type {
BlockEnum,
CommonNodeType,
Node,
NodeDefault,
ToolWithProvider,
@ -9,7 +11,7 @@ import type {
import type { IOtherOptions } from '@/service/base'
import type { SchemaTypeDefinition } from '@/service/use-common'
import type { FlowType } from '@/types/common'
import type { VarInInspect } from '@/types/workflow'
import type { FetchWorkflowDraftResponse, VarInInspect } from '@/types/workflow'
import { noop } from 'es-toolkit/function'
import { useContext } from 'react'
import {
@ -18,9 +20,17 @@ import {
import { createStore } from 'zustand/vanilla'
import { HooksStoreContext } from './provider'
export type AvailableNodeDefault = NodeDefault<CommonNodeType<Record<string, unknown>>>
export type WorkflowRunOptions = {
mode?: TriggerType
scheduleNodeId?: string
webhookNodeId?: string
pluginNodeId?: string
allNodeIds?: string[]
}
export type AvailableNodesMetaData = {
nodes: NodeDefault[]
nodesMap?: Record<BlockEnum, NodeDefault<any>>
nodes: AvailableNodeDefault[]
nodesMap?: Partial<Record<BlockEnum, AvailableNodeDefault>>
}
export type CommonHooksFnMap = {
doSyncWorkflowDraft: (
@ -36,9 +46,9 @@ export type CommonHooksFnMap = {
handleRefreshWorkflowDraft: () => void
handleBackupDraft: () => void
handleLoadBackupDraft: () => void
handleRestoreFromPublishedWorkflow: (...args: any[]) => void
handleRun: (params: any, callback?: IOtherOptions, options?: any) => void
handleStopRun: (...args: any[]) => void
handleRestoreFromPublishedWorkflow: (publishedWorkflow: FetchWorkflowDraftResponse) => void
handleRun: (params: unknown, callback?: IOtherOptions, options?: WorkflowRunOptions) => void | Promise<void>
handleStopRun: (taskId: string) => void
handleStartWorkflowRun: () => void
handleWorkflowStartRunInWorkflow: () => void
handleWorkflowStartRunInChatflow: () => void
@ -54,7 +64,7 @@ export type CommonHooksFnMap = {
hasNodeInspectVars: (nodeId: string) => boolean
hasSetInspectVar: (nodeId: string, name: string, sysVars: VarInInspect[], conversationVars: VarInInspect[]) => boolean
fetchInspectVarValue: (selector: ValueSelector, schemaTypeDefinitions: SchemaTypeDefinition[]) => Promise<void>
editInspectVarValue: (nodeId: string, varId: string, value: any) => Promise<void>
editInspectVarValue: (nodeId: string, varId: string, value: unknown) => Promise<void>
renameInspectVarName: (nodeId: string, oldName: string, newName: string) => Promise<void>
appendNodeInspectVars: (nodeId: string, payload: VarInInspect[], allNodes: Node[]) => void
deleteInspectVar: (nodeId: string, varId: string) => Promise<void>
@ -68,7 +78,7 @@ export type CommonHooksFnMap = {
configsMap?: {
flowId: string
flowType: FlowType
fileSettings: FileUpload
fileSettings?: FileUpload
}
}
@ -82,9 +92,9 @@ export const createHooksStore = ({
handleRefreshWorkflowDraft = noop,
handleBackupDraft = noop,
handleLoadBackupDraft = noop,
handleRestoreFromPublishedWorkflow = noop,
handleRestoreFromPublishedWorkflow = (_publishedWorkflow: FetchWorkflowDraftResponse) => noop(),
handleRun = noop,
handleStopRun = noop,
handleStopRun = (_taskId: string) => noop(),
handleStartWorkflowRun = noop,
handleWorkflowStartRunInWorkflow = noop,
handleWorkflowStartRunInChatflow = noop,

View File

@ -362,7 +362,10 @@ export const useChecklistBeforePublish = () => {
usedVars = getNodeUsedVars(node).filter(v => v.length > 0)
}
const checkData = getCheckData(node.data, datasets)
const { errorMessage } = nodesExtraData![node.data.type as BlockEnum].checkValid(checkData, t, moreDataForCheckValid)
const nodeMetaData = nodesExtraData?.[node.data.type as BlockEnum]
if (!nodeMetaData)
continue
const { errorMessage } = nodeMetaData.checkValid(checkData, t, moreDataForCheckValid)
if (errorMessage) {
notify({ type: 'error', message: `[${node.data.title}] ${errorMessage}` })

View File

@ -1,18 +1,16 @@
import type { SyncCallback } from './use-nodes-sync-draft'
import { produce } from 'immer'
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import { useCollaborativeWorkflow } from './use-collaborative-workflow'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useNodesReadOnly } from './use-workflow'
type NodeDataUpdatePayload = {
id: string
data: Record<string, any>
data: Record<string, unknown>
}
export const useNodeDataUpdate = () => {
const store = useStoreApi()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { getNodesReadOnly } = useNodesReadOnly()
const collaborativeWorkflow = useCollaborativeWorkflow()
@ -26,7 +24,7 @@ export const useNodeDataUpdate = () => {
currentNode.data = { ...currentNode.data, ...data }
})
setNodes(newNodes)
}, [store])
}, [collaborativeWorkflow])
const handleNodeDataUpdateWithSyncDraft = useCallback((
payload: NodeDataUpdatePayload,

View File

@ -815,7 +815,10 @@ export const useNodesInteractions = () => {
const nodesWithSameType = nodes.filter(
node => node.data.type === nodeType,
)
const { defaultValue } = nodesMetaDataMap![nodeType]
const nodeMetaData = nodesMetaDataMap?.[nodeType]
if (!nodeMetaData)
return
const { defaultValue } = nodeMetaData
const { newNode, newIterationStartNode, newLoopStartNode }
= generateNewNode({
type: getNodeCustomTypeByNodeDataType(nodeType),
@ -1376,7 +1379,10 @@ export const useNodesInteractions = () => {
const nodesWithSameType = nodes.filter(
node => node.data.type === nodeType,
)
const { defaultValue } = nodesMetaDataMap![nodeType]
const nodeMetaData = nodesMetaDataMap?.[nodeType]
if (!nodeMetaData)
return
const { defaultValue } = nodeMetaData
const {
newNode: newCurrentNode,
newIterationStartNode,
@ -1537,7 +1543,9 @@ export const useNodesInteractions = () => {
return false
if (node.type === CUSTOM_NOTE_NODE)
return true
const { metaData } = nodesMetaDataMap![node.data.type as BlockEnum]
const metaData = nodesMetaDataMap?.[node.data.type as BlockEnum]?.metaData
if (!metaData)
return false
if (metaData.isSingleton)
return false
return !node.data.isInIteration && !node.data.isInLoop
@ -1553,7 +1561,9 @@ export const useNodesInteractions = () => {
return false
if (node.type === CUSTOM_NOTE_NODE)
return true
const { metaData } = nodesMetaDataMap![node.data.type as BlockEnum]
const metaData = nodesMetaDataMap?.[node.data.type as BlockEnum]?.metaData
if (!metaData)
return false
return !metaData.isSingleton
})
@ -1588,12 +1598,15 @@ export const useNodesInteractions = () => {
const parentChildrenToAppend: { parentId: string, childId: string, childType: BlockEnum }[] = []
clipboardElements.forEach((nodeToPaste, index) => {
const nodeType = nodeToPaste.data.type
const nodeDefaultValue = nodeToPaste.type !== CUSTOM_NOTE_NODE
? nodesMetaDataMap?.[nodeType]?.defaultValue
: undefined
const { newNode, newIterationStartNode, newLoopStartNode }
= generateNewNode({
type: nodeToPaste.type,
data: {
...(nodeToPaste.type !== CUSTOM_NOTE_NODE && nodesMetaDataMap![nodeType].defaultValue),
...(nodeDefaultValue || {}),
...nodeToPaste.data,
selected: false,
_isBundled: false,
@ -1898,16 +1911,7 @@ export const useNodesInteractions = () => {
return
// Use collaborative undo from Loro
const undoResult = collaborationManager.undo()
if (undoResult) {
// The undo operation will automatically trigger subscriptions
// which will update the nodes and edges through setupSubscriptions
console.log('Collaborative undo performed')
}
else {
console.log('Nothing to undo')
}
collaborationManager.undo()
const { edges, nodes } = workflowHistoryStore.getState()
if (edges.length === 0 && nodes.length === 0)
return
@ -1928,16 +1932,7 @@ export const useNodesInteractions = () => {
return
// Use collaborative redo from Loro
const redoResult = collaborationManager.redo()
if (redoResult) {
// The redo operation will automatically trigger subscriptions
// which will update the nodes and edges through setupSubscriptions
console.log('Collaborative redo performed')
}
else {
console.log('Nothing to redo')
}
collaborationManager.redo()
const { edges, nodes } = workflowHistoryStore.getState()
if (edges.length === 0 && nodes.length === 0)
return

View File

@ -10,6 +10,13 @@ import { useStore } from '../store'
import { ControlMode } from '../types'
const EMPTY_USERS: UserProfile[] = []
type CommentDetailResponse = WorkflowCommentDetail | { data: WorkflowCommentDetail }
const getCommentDetail = (response: CommentDetailResponse): WorkflowCommentDetail => {
if ('data' in response)
return response.data
return response
}
export const useWorkflowComment = () => {
const params = useParams()
@ -56,8 +63,8 @@ export const useWorkflowComment = () => {
if (!appId)
return
const detailResponse = await fetchWorkflowComment(appId, commentId)
const detail = (detailResponse as any)?.data ?? detailResponse
const detailResponse = await fetchWorkflowComment(appId, commentId) as CommentDetailResponse
const detail = getCommentDetail(detailResponse)
commentDetailCacheRef.current = {
...commentDetailCacheRef.current,
@ -106,8 +113,6 @@ export const useWorkflowComment = () => {
if (!pendingComment)
return
console.log('Submitting comment:', { appId, pendingComment, content, mentionedUserIds })
if (!appId) {
console.error('AppId is missing')
return
@ -128,9 +133,10 @@ export const useWorkflowComment = () => {
mentioned_user_ids: mentionedUserIds,
})
console.log('Comment created successfully:', newComment)
const createdAt = (newComment as any)?.created_at
const createdAt = Number(newComment.created_at)
const createdAtSeconds = Number.isNaN(createdAt)
? Math.floor(Date.parse(newComment.created_at) / 1000)
: createdAt
const createdByAccount = {
id: userProfile?.id ?? '',
name: userProfile?.name ?? '',
@ -162,8 +168,8 @@ export const useWorkflowComment = () => {
content,
created_by: createdByAccount.id,
created_by_account: createdByAccount,
created_at: createdAt,
updated_at: createdAt,
created_at: createdAtSeconds,
updated_at: createdAtSeconds,
resolved: false,
mention_count: mentionedUserIds.length,
reply_count: 0,
@ -177,8 +183,8 @@ export const useWorkflowComment = () => {
content,
created_by: createdByAccount.id,
created_by_account: createdByAccount,
created_at: createdAt,
updated_at: createdAt,
created_at: createdAtSeconds,
updated_at: createdAtSeconds,
resolved: false,
replies: [],
mentions: mentionedUserIds.map(mentionedId => ({
@ -246,8 +252,8 @@ export const useWorkflowComment = () => {
setActiveCommentLoading(!cachedDetail)
try {
const detailResponse = await fetchWorkflowComment(appId, comment.id)
const detail = (detailResponse as any)?.data ?? detailResponse
const detailResponse = await fetchWorkflowComment(appId, comment.id) as CommentDetailResponse
const detail = getCommentDetail(detailResponse)
commentDetailCacheRef.current = {
...commentDetailCacheRef.current,
@ -499,13 +505,8 @@ export const useWorkflowComment = () => {
elementX: number
elementY: number
}) => {
if (controlMode === ControlMode.Comment) {
console.log('Setting pending comment at screen position:', mousePosition)
if (controlMode === ControlMode.Comment)
setPendingComment(mousePosition)
}
else {
console.log('Control mode is not Comment:', controlMode)
}
}, [controlMode, setPendingComment])
return {

View File

@ -4,10 +4,13 @@ import type { FC } from 'react'
import type {
Viewport,
} from 'reactflow'
import type { CursorPosition, OnlineUser } from './collaboration/types'
import type { Shape as HooksStoreShape } from './hooks-store'
import type { WorkflowSliceShape } from './store/workflow/workflow-slice'
import type {
ConversationVariable,
Edge,
EnvironmentVariable,
Node,
} from './types'
import type { VarInInspect } from '@/types/workflow'
@ -124,15 +127,37 @@ const edgeTypes = {
[CUSTOM_EDGE]: CustomEdge,
}
type WorkflowDataUpdatePayload = {
nodes: Node[]
edges: Edge[]
viewport?: Viewport
hash?: string
features?: unknown
conversation_variables?: ConversationVariable[]
environment_variables?: EnvironmentVariable[]
}
type WorkflowEvent = {
type?: string
payload?: unknown
}
const isWorkflowDataUpdatePayload = (payload: unknown): payload is WorkflowDataUpdatePayload => {
if (!payload || typeof payload !== 'object')
return false
const candidate = payload as WorkflowDataUpdatePayload
return Array.isArray(candidate.nodes) && Array.isArray(candidate.edges)
}
export type WorkflowProps = {
nodes: Node[]
edges: Edge[]
viewport?: Viewport
children?: React.ReactNode
onWorkflowDataUpdate?: (v: any) => void
cursors?: Record<string, any>
onWorkflowDataUpdate?: (v: WorkflowDataUpdatePayload) => void
cursors?: Record<string, CursorPosition>
myUserId?: string | null
onlineUsers?: any[]
onlineUsers?: OnlineUser[]
}
export const Workflow: FC<WorkflowProps> = memo(({
nodes: originalNodes,
@ -236,19 +261,20 @@ export const Workflow: FC<WorkflowProps> = memo(({
const { t } = useTranslation()
const store = useStoreApi()
eventEmitter?.useSubscription((v: any) => {
if (v.type === WORKFLOW_DATA_UPDATE) {
setNodes(v.payload.nodes)
store.getState().setNodes(v.payload.nodes)
setEdges(v.payload.edges)
eventEmitter?.useSubscription((event) => {
const workflowEvent = event as unknown as WorkflowEvent
if (workflowEvent.type === WORKFLOW_DATA_UPDATE && isWorkflowDataUpdatePayload(workflowEvent.payload)) {
setNodes(workflowEvent.payload.nodes)
store.getState().setNodes(workflowEvent.payload.nodes)
setEdges(workflowEvent.payload.edges)
if (v.payload.viewport)
reactflow.setViewport(v.payload.viewport)
if (workflowEvent.payload.viewport)
reactflow.setViewport(workflowEvent.payload.viewport)
if (v.payload.hash)
setSyncWorkflowDraftHash(v.payload.hash)
if (workflowEvent.payload.hash)
setSyncWorkflowDraftHash(workflowEvent.payload.hash)
onWorkflowDataUpdate?.(v.payload)
onWorkflowDataUpdate?.(workflowEvent.payload)
setTimeout(() => setControlPromptEditorRerenderKey(Date.now()))
}
@ -635,9 +661,9 @@ export const Workflow: FC<WorkflowProps> = memo(({
type WorkflowWithInnerContextProps = WorkflowProps & {
hooksStore?: Partial<HooksStoreShape>
cursors?: Record<string, any>
cursors?: Record<string, CursorPosition>
myUserId?: string | null
onlineUsers?: any[]
onlineUsers?: OnlineUser[]
}
export const WorkflowWithInnerContext = memo(({
hooksStore,

View File

@ -42,7 +42,9 @@ export const TitleInput = memo(({
// Sync local state with incoming collaborative updates so remote title edits appear immediately.
useEffect(() => {
setLocalValue(value)
Promise.resolve().then(() => {
setLocalValue(value)
})
}, [value])
return (

View File

@ -22,9 +22,10 @@ export const useReplaceDataSourceNode = (id: string) => {
if (emptyNodeIndex < 0)
return
const {
defaultValue,
} = nodesMetaDataMap![type]
const nodeMetaData = nodesMetaDataMap?.[type]
if (!nodeMetaData)
return
const { defaultValue } = nodeMetaData
const emptyNode = nodes[emptyNodeIndex]
const { newNode } = generateNewNode({
data: {

View File

@ -41,13 +41,15 @@ const useKeyValueList = (value: string, onChange: (value: string) => void, noFil
}, [noFilter, onChange, value])
useEffect(() => {
doSetList((prev) => {
const targetItems = value ? strToKeyValueList(value) : []
const currentValue = stringifyList(prev, noFilter)
const targetValue = stringifyList(targetItems, noFilter)
if (currentValue === targetValue)
return prev
return normalizeList(targetItems)
Promise.resolve().then(() => {
doSetList((prev) => {
const targetItems = value ? strToKeyValueList(value) : []
const currentValue = stringifyList(prev, noFilter)
const targetValue = stringifyList(targetItems, noFilter)
if (currentValue === targetValue)
return prev
return normalizeList(targetItems)
})
})
}, [value, noFilter])
const addItem = useCallback(() => {

View File

@ -115,6 +115,7 @@ export const useNodeIterationInteractions = () => {
const copyChildren = childrenNodes.map((child, index) => {
const childNodeType = child.data.type as BlockEnum
const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType)
const defaultValue = nodesMetaDataMap?.[childNodeType]?.defaultValue ?? {}
if (!childNodeTypeCount[childNodeType])
childNodeTypeCount[childNodeType] = nodesWithSameType.length + 1
@ -124,7 +125,7 @@ export const useNodeIterationInteractions = () => {
const { newNode } = generateNewNode({
type: getNodeCustomTypeByNodeDataType(childNodeType),
data: {
...nodesMetaDataMap![childNodeType].defaultValue,
...defaultValue,
...child.data,
selected: false,
_isBundled: false,

View File

@ -109,7 +109,7 @@ export const useNodeLoopInteractions = () => {
return childrenNodes.map((child, index) => {
const childNodeType = child.data.type as BlockEnum
const { defaultValue } = nodesMetaDataMap![childNodeType]
const defaultValue = nodesMetaDataMap?.[childNodeType]?.defaultValue ?? {}
const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType)
const { newNode } = generateNewNode({
type: getNodeCustomTypeByNodeDataType(childNodeType),

View File

@ -63,9 +63,10 @@ const AddBlock = ({
} = store.getState()
const nodes = getNodes()
const nodesWithSameType = nodes.filter(node => node.data.type === type)
const {
defaultValue,
} = nodesMetaDataMap![type]
const nodeMetaData = nodesMetaDataMap?.[type]
if (!nodeMetaData)
return
const { defaultValue } = nodeMetaData
const { newNode } = generateNewNode({
type: getNodeCustomTypeByNodeDataType(type),
data: {

View File

@ -263,9 +263,6 @@
"app/components/app/app-publisher/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
},
"ts/no-explicit-any": {
"count": 6
}
},
"app/components/app/configuration/config-prompt/advanced-prompt-input.tsx": {
@ -3045,9 +3042,6 @@
"app/components/tools/mcp/mcp-service-card.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"ts/no-explicit-any": {
"count": 4
}
},
"app/components/tools/mcp/modal.tsx": {
@ -3113,11 +3107,6 @@
"count": 1
}
},
"app/components/workflow-app/components/workflow-main.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/workflow-app/components/workflow-onboarding-modal/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 2
@ -3297,11 +3286,6 @@
"count": 2
}
},
"app/components/workflow/hooks-store/store.ts": {
"ts/no-explicit-any": {
"count": 6
}
},
"app/components/workflow/hooks/use-checklist.ts": {
"ts/no-empty-object-type": {
"count": 2
@ -3325,11 +3309,6 @@
"count": 1
}
},
"app/components/workflow/hooks/use-node-data-update.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/workflow/hooks/use-nodes-interactions.ts": {
"react-hooks/immutability": {
"count": 1
@ -3373,11 +3352,6 @@
"count": 1
}
},
"app/components/workflow/index.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/workflow/nodes/_base/components/add-variable-popup-with-position.tsx": {
"ts/no-explicit-any": {
"count": 2

View File

@ -35,7 +35,7 @@ const nextConfig = {
bundler: 'turbopack',
}),
},
webpack: (config, { dev, isServer }) => {
webpack: (config, { dev: _dev, isServer: _isServer }) => {
config.plugins.push(codeInspectorPlugin({ bundler: 'webpack' }))
config.experiments = {