diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 4cc4c29670..6f4beddc56 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -1044,7 +1044,7 @@ class WorkflowOnlineUsersApi(Resource): workflow_ids = [id.strip() for id in args["workflow_ids"].split(",")] - results = {} + results = [] for workflow_id in workflow_ids: users_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}") @@ -1054,7 +1054,7 @@ class WorkflowOnlineUsersApi(Resource): users.append(json.loads(user_info_json)) except Exception: continue - results[workflow_id] = users + results.append({"workflow_id": workflow_id, "users": users}) return {"data": results} diff --git a/api/fields/online_user_fields.py b/api/fields/online_user_fields.py index cf54f47902..8fe0dc6a64 100644 --- a/api/fields/online_user_fields.py +++ b/api/fields/online_user_fields.py @@ -1,7 +1,7 @@ from flask_restx import fields online_user_partial_fields = { - "id": fields.String, + "user_id": fields.String, "username": fields.String, "avatar": fields.String, "sid": fields.String, diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 2404f81405..36aad4b0fc 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -262,7 +262,7 @@ class WorkflowService: workflow.environment_variables = environment_variables workflow.updated_by = account.id - workflow.updated_at = datetime.now(UTC).replace(tzinfo=None) + workflow.updated_at = naive_utc_now() # commit db session changes db.session.commit() @@ -285,7 +285,7 @@ class WorkflowService: workflow.conversation_variables = conversation_variables workflow.updated_by = account.id - workflow.updated_at = datetime.now(UTC).replace(tzinfo=None) + workflow.updated_at = naive_utc_now() # commit db session changes db.session.commit() @@ -311,7 +311,7 @@ class WorkflowService: workflow.features = json.dumps(features) workflow.updated_by = account.id - workflow.updated_at = datetime.now(UTC).replace(tzinfo=None) + workflow.updated_at = naive_utc_now() # commit db session changes db.session.commit() diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index e96793ff72..05f0582463 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -32,6 +32,8 @@ import { useGlobalPublicStore } from '@/context/global-public-context' import { formatTime } from '@/utils/time' import { useGetUserCanAccessApp } from '@/service/access-control' import dynamic from 'next/dynamic' +import { UserAvatarList } from '@/app/components/base/user-avatar-list' +import type { WorkflowOnlineUser } from '@/models/app' const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), { ssr: false, @@ -55,9 +57,10 @@ const AccessControl = dynamic(() => import('@/app/components/app/app-access-cont export type AppCardProps = { app: App onRefresh?: () => void + onlineUsers?: WorkflowOnlineUser[] } -const AppCard = ({ app, onRefresh }: AppCardProps) => { +const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => { const { t } = useTranslation() const { notify } = useContext(ToastContext) const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) @@ -331,6 +334,19 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { return `${t('datasetDocuments.segment.editedAt')} ${timeText}` }, [app.updated_at, app.created_at]) + const onlineUserAvatars = useMemo(() => { + if (!onlineUsers.length) + return [] + + return onlineUsers + .map(user => ({ + id: user.user_id || user.sid || '', + name: user.username || 'User', + avatar_url: user.avatar || undefined, + })) + .filter(user => !!user.id) + }, [onlineUsers]) + return ( <>
{ }
+
+ {onlineUserAvatars.length > 0 && ( + + )} +
{ }, ) + const apps = useMemo(() => data?.flatMap(page => page.data) ?? [], [data]) + + const workflowIds = useMemo(() => { + const ids = new Set() + apps.forEach((appItem) => { + const workflowId = appItem.id + if (!workflowId) + return + + if (appItem.mode === 'workflow' || appItem.mode === 'advanced-chat') + ids.add(workflowId) + }) + return Array.from(ids) + }, [apps]) + + const { data: onlineUsersByWorkflow, mutate: refreshOnlineUsers } = useSWR>( + workflowIds.length ? { workflowIds } : null, + fetchWorkflowOnlineUsers, + ) + + useEffect(() => { + if (!workflowIds.length) + return + + const timer = window.setInterval(() => { + refreshOnlineUsers() + }, 10000) + + return () => window.clearInterval(timer) + }, [workflowIds.join(','), refreshOnlineUsers]) + const anchorRef = useRef(null) const options = [ { value: 'all', text: t('app.types.all'), icon: }, @@ -213,7 +245,12 @@ const List = () => { {isCurrentWorkspaceEditor && } {data.map(({ data: apps }) => apps.map(app => ( - + )))}
:
diff --git a/web/app/components/base/icons/assets/public/other/comment.svg b/web/app/components/base/icons/assets/public/other/comment.svg new file mode 100644 index 0000000000..7f48f22fbd --- /dev/null +++ b/web/app/components/base/icons/assets/public/other/comment.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/web/app/components/base/icons/src/public/other/Comment.json b/web/app/components/base/icons/src/public/other/Comment.json new file mode 100644 index 0000000000..c4865a010c --- /dev/null +++ b/web/app/components/base/icons/src/public/other/Comment.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "xmlns": "http://www.w3.org/2000/svg", + "width": "14", + "height": "12", + "viewBox": "0 0 14 12", + "fill": "none" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M12.3334 4C12.3334 2.52725 11.1395 1.33333 9.66671 1.33333H4.33337C2.86062 1.33333 1.66671 2.52724 1.66671 4V10.6667H9.66671C11.1395 10.6667 12.3334 9.47274 12.3334 8V4ZM7.66671 6.66667V8H4.33337V6.66667H7.66671ZM9.66671 4V5.33333H4.33337V4H9.66671ZM13.6667 8C13.6667 10.2091 11.8758 12 9.66671 12H0.333374V4C0.333374 1.79086 2.12424 0 4.33337 0H9.66671C11.8758 0 13.6667 1.79086 13.6667 4V8Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "Comment" +} diff --git a/web/app/components/base/icons/src/public/other/Comment.tsx b/web/app/components/base/icons/src/public/other/Comment.tsx new file mode 100644 index 0000000000..85c2559b76 --- /dev/null +++ b/web/app/components/base/icons/src/public/other/Comment.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Comment.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Comment' + +export default Icon diff --git a/web/app/components/base/icons/src/public/other/index.ts b/web/app/components/base/icons/src/public/other/index.ts index a7558ca0ab..3637525202 100644 --- a/web/app/components/base/icons/src/public/other/index.ts +++ b/web/app/components/base/icons/src/public/other/index.ts @@ -1,4 +1,5 @@ export { default as Icon3Dots } from './Icon3Dots' +export { default as Comment } from './Comment' export { default as DefaultToolIcon } from './DefaultToolIcon' export { default as Message3Fill } from './Message3Fill' export { default as RowStruct } from './RowStruct' diff --git a/web/app/components/base/user-avatar-list/index.tsx b/web/app/components/base/user-avatar-list/index.tsx index e9325a985d..b0d1989521 100644 --- a/web/app/components/base/user-avatar-list/index.tsx +++ b/web/app/components/base/user-avatar-list/index.tsx @@ -50,7 +50,7 @@ export const UserAvatarList: FC = memo(({ name={user.name} avatar={user.avatar_url || null} size={size} - className='ring-2 ring-white' + className='ring-2 ring-components-panel-bg' backgroundColor={userColor} />
@@ -60,7 +60,7 @@ export const UserAvatarList: FC = memo(({ )} {shouldShowCount && remainingCount > 0 && (
(null) useEffect(() => { @@ -297,10 +296,12 @@ const WorkflowMain = ({ viewport={viewport} onWorkflowDataUpdate={handleWorkflowDataUpdate} hooksStore={hooksStore as any} + cursors={filteredCursors} + myUserId={myUserId} + onlineUsers={onlineUsers} > -
) } diff --git a/web/app/components/workflow/candidate-node.tsx b/web/app/components/workflow/candidate-node.tsx index 35bcd5c201..708ccc3db4 100644 --- a/web/app/components/workflow/candidate-node.tsx +++ b/web/app/components/workflow/candidate-node.tsx @@ -4,7 +4,6 @@ import { import produce from 'immer' import { useReactFlow, - useStoreApi, useViewport, } from 'reactflow' import { useEventListener } from 'ahooks' @@ -19,9 +18,9 @@ import CustomNode from './nodes' import CustomNoteNode from './note-node' import { CUSTOM_NOTE_NODE } from './note-node/constants' import { BlockEnum } from './types' +import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' const CandidateNode = () => { - const store = useStoreApi() const reactflow = useReactFlow() const workflowStore = useWorkflowStore() const candidateNode = useStore(s => s.candidateNode) @@ -29,18 +28,15 @@ const CandidateNode = () => { const { zoom } = useViewport() const { handleNodeSelect } = useNodesInteractions() const { saveStateToHistory } = useWorkflowHistory() + const collaborativeWorkflow = useCollaborativeWorkflow() useEventListener('click', (e) => { const { candidateNode, mousePosition } = workflowStore.getState() if (candidateNode) { e.preventDefault() - const { - getNodes, - setNodes, - } = store.getState() + const { nodes, setNodes } = collaborativeWorkflow.getState() const { screenToFlowPosition } = reactflow - const nodes = getNodes() const { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY }) const newNodes = produce(nodes, (draft) => { draft.push({ diff --git a/web/app/components/workflow/collaboration/components/user-cursors.tsx b/web/app/components/workflow/collaboration/components/user-cursors.tsx index bcc94960e3..fff4d5bb8c 100644 --- a/web/app/components/workflow/collaboration/components/user-cursors.tsx +++ b/web/app/components/workflow/collaboration/components/user-cursors.tsx @@ -37,7 +37,7 @@ const UserCursors: FC = ({ return (
void + isActive?: boolean } -export const CommentIcon: FC = memo(({ comment, onClick }) => { +export const CommentIcon: FC = memo(({ comment, onClick, isActive = false }) => { const { flowToScreenPosition } = useReactFlow() const viewport = useViewport() + const [showPreview, setShowPreview] = useState(false) + + const handlePreviewClick = () => { + setShowPreview(false) + onClick() + } const screenPosition = useMemo(() => { return flowToScreenPosition({ @@ -35,30 +43,58 @@ export const CommentIcon: FC = memo(({ comment, onClick }) => ) return ( -
+ <>
-
-
- +
setShowPreview(true)} + onMouseLeave={isActive ? undefined : () => setShowPreview(false)} + > +
+
+
+ +
+
-
+ + {/* Preview panel */} + {showPreview && !isActive && ( +
setShowPreview(true)} + onMouseLeave={() => setShowPreview(false)} + > + +
+ )} + ) }, (prevProps, nextProps) => { return ( @@ -66,6 +102,7 @@ export const CommentIcon: FC = memo(({ comment, onClick }) => && prevProps.comment.position_x === nextProps.comment.position_x && prevProps.comment.position_y === nextProps.comment.position_y && prevProps.onClick === nextProps.onClick + && prevProps.isActive === nextProps.isActive ) }) diff --git a/web/app/components/workflow/comment/comment-input.tsx b/web/app/components/workflow/comment/comment-input.tsx index bd8555ab76..8196b990a2 100644 --- a/web/app/components/workflow/comment/comment-input.tsx +++ b/web/app/components/workflow/comment/comment-input.tsx @@ -1,5 +1,6 @@ import type { FC } from 'react' import { memo, useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import Avatar from '@/app/components/base/avatar' import { useAppContext } from '@/context/app-context' import { MentionInput } from './mention-input' @@ -13,6 +14,7 @@ type CommentInputProps = { export const CommentInput: FC = memo(({ position, onSubmit, onCancel }) => { const [content, setContent] = useState('') + const { t } = useTranslation() const { userProfile } = useAppContext() useEffect(() => { @@ -46,8 +48,8 @@ export const CommentInput: FC = memo(({ position, onSubmit, o >
-
-
+
+
= memo(({ position, onSubmit, o
-
+
diff --git a/web/app/components/workflow/comment/comment-preview.tsx b/web/app/components/workflow/comment/comment-preview.tsx new file mode 100644 index 0000000000..5ed78aaab1 --- /dev/null +++ b/web/app/components/workflow/comment/comment-preview.tsx @@ -0,0 +1,44 @@ +'use client' + +import type { FC } from 'react' +import { memo } from 'react' +import { UserAvatarList } from '@/app/components/base/user-avatar-list' +import type { WorkflowCommentList } from '@/service/workflow-comment' +import { useFormatTimeFromNow } from '@/app/components/workflow/hooks' + +type CommentPreviewProps = { + comment: WorkflowCommentList + onClick?: () => void +} + +const CommentPreview: FC = ({ comment, onClick }) => { + const { formatTimeFromNow } = useFormatTimeFromNow() + + return ( +
+
+ +
+ +
+
+
{comment.created_by_account.name}
+
+ {formatTimeFromNow(comment.updated_at * 1000)} +
+
+
+ +
{comment.content}
+
+ ) +} + +export default memo(CommentPreview) diff --git a/web/app/components/workflow/comment/cursor.tsx b/web/app/components/workflow/comment/cursor.tsx index dbd430b019..aafd2a4fb9 100644 --- a/web/app/components/workflow/comment/cursor.tsx +++ b/web/app/components/workflow/comment/cursor.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import { memo } from 'react' import { useStore } from '../store' import { ControlMode } from '../types' +import { Comment } from '@/app/components/base/icons/src/public/other' type CommentCursorProps = { mousePosition: { elementX: number; elementY: number } @@ -22,10 +23,7 @@ export const CommentCursor: FC = memo(({ mousePosition }) => transform: 'translate(-50%, -50%)', }} > - - - - +
) }) diff --git a/web/app/components/workflow/comment/mention-input.tsx b/web/app/components/workflow/comment/mention-input.tsx index 3510ac7aa9..3213942572 100644 --- a/web/app/components/workflow/comment/mention-input.tsx +++ b/web/app/components/workflow/comment/mention-input.tsx @@ -1,9 +1,10 @@ 'use client' -import type { FC } from 'react' +import type { FC, ReactNode } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useParams } from 'next/navigation' +import { useTranslation } from 'react-i18next' import { RiArrowUpLine, RiAtLine } from '@remixicon/react' import Textarea from 'react-textarea-autosize' import Button from '@/app/components/base/button' @@ -29,7 +30,7 @@ export const MentionInput: FC = memo(({ onChange, onSubmit, onCancel, - placeholder = 'Add a comment', + placeholder, disabled = false, loading = false, className, @@ -37,6 +38,7 @@ export const MentionInput: FC = memo(({ autoFocus = false, }) => { const params = useParams() + const { t } = useTranslation() const appId = params.appId as string const textareaRef = useRef(null) @@ -46,6 +48,77 @@ export const MentionInput: FC = memo(({ const [mentionPosition, setMentionPosition] = useState(0) const [selectedMentionIndex, setSelectedMentionIndex] = useState(0) const [mentionedUserIds, setMentionedUserIds] = useState([]) + const resolvedPlaceholder = placeholder ?? t('workflow.comments.placeholder.add') + + const mentionNameList = useMemo(() => { + const names = mentionUsers + .map(user => user.name?.trim()) + .filter((name): name is string => Boolean(name)) + + const uniqueNames = Array.from(new Set(names)) + uniqueNames.sort((a, b) => b.length - a.length) + return uniqueNames + }, [mentionUsers]) + + const highlightedValue = useMemo(() => { + if (!value) + return '' + + if (mentionNameList.length === 0) + return value + + const segments: ReactNode[] = [] + let cursor = 0 + let hasMention = false + + while (cursor < value.length) { + let nextMatchStart = -1 + let matchedName = '' + + for (const name of mentionNameList) { + const searchStart = value.indexOf(`@${name}`, cursor) + if (searchStart === -1) + continue + + const previousChar = searchStart > 0 ? value[searchStart - 1] : '' + if (searchStart > 0 && !/\s/.test(previousChar)) + continue + + if ( + nextMatchStart === -1 + || searchStart < nextMatchStart + || (searchStart === nextMatchStart && name.length > matchedName.length) + ) { + nextMatchStart = searchStart + matchedName = name + } + } + + if (nextMatchStart === -1) + break + + if (nextMatchStart > cursor) + segments.push({value.slice(cursor, nextMatchStart)}) + + const mentionEnd = nextMatchStart + matchedName.length + 1 + segments.push( + + {value.slice(nextMatchStart, mentionEnd)} + , + ) + + hasMention = true + cursor = mentionEnd + } + + if (!hasMention) + return value + + if (cursor < value.length) + segments.push({value.slice(cursor)}) + + return segments + }, [value, mentionNameList]) const loadMentionableUsers = useCallback(async () => { if (!appId) return @@ -220,12 +293,23 @@ export const MentionInput: FC = memo(({ return ( <>
+
+ {highlightedValue} + {'​'} +