diff --git a/docs/development/database-schema.dbml b/docs/development/database-schema.dbml index c07df45c5a..bff5481879 100644 --- a/docs/development/database-schema.dbml +++ b/docs/development/database-schema.dbml @@ -128,6 +128,7 @@ table agent_cron_jobs { agent_id [name: 'agent_cron_jobs_agent_id_idx'] group_id [name: 'agent_cron_jobs_group_id_idx'] user_id [name: 'agent_cron_jobs_user_id_idx'] + workspace_id [name: 'agent_cron_jobs_workspace_id_idx'] enabled [name: 'agent_cron_jobs_enabled_idx'] remaining_executions [name: 'agent_cron_jobs_remaining_executions_idx'] last_executed_at [name: 'agent_cron_jobs_last_executed_at_idx'] @@ -379,6 +380,7 @@ table agent_operations { indexes { user_id [name: 'agent_operations_user_id_idx'] + workspace_id [name: 'agent_operations_workspace_id_idx'] agent_id [name: 'agent_operations_agent_id_idx'] topic_id [name: 'agent_operations_topic_id_idx'] thread_id [name: 'agent_operations_thread_id_idx'] @@ -461,7 +463,8 @@ table ai_models { updated_at "timestamp with time zone" [not null, default: `now()`] indexes { - (id, provider_id, user_id) [pk] + (id, provider_id, user_id) [name: 'ai_models_id_provider_id_user_id_unique', unique] + (id, provider_id, user_id, workspace_id) [name: 'ai_models_id_provider_id_user_id_workspace_id_unique', unique] user_id [name: 'ai_models_user_id_idx'] workspace_id [name: 'ai_models_workspace_id_idx'] } @@ -488,7 +491,8 @@ table ai_providers { updated_at "timestamp with time zone" [not null, default: `now()`] indexes { - (id, user_id) [pk] + (id, user_id) [name: 'ai_providers_id_user_id_unique', unique] + (id, user_id, workspace_id) [name: 'ai_providers_id_user_id_workspace_id_unique', unique] user_id [name: 'ai_providers_user_id_idx'] workspace_id [name: 'ai_providers_workspace_id_idx'] } @@ -778,6 +782,7 @@ table document_histories { indexes { document_id [name: 'document_histories_document_id_idx'] user_id [name: 'document_histories_user_id_idx'] + workspace_id [name: 'document_histories_workspace_id_idx'] saved_at [name: 'document_histories_saved_at_idx'] workspace_id [name: 'document_histories_workspace_id_idx'] } @@ -1027,6 +1032,7 @@ table llm_generation_tracing { user_id [name: 'llm_generation_tracing_user_id_idx'] agent_id [name: 'llm_generation_tracing_agent_id_idx'] topic_id [name: 'llm_generation_tracing_topic_id_idx'] + workspace_id [name: 'llm_generation_tracing_workspace_id_idx'] provider [name: 'llm_generation_tracing_provider_idx'] model [name: 'llm_generation_tracing_model_idx'] success [name: 'llm_generation_tracing_success_idx'] @@ -1793,6 +1799,7 @@ table file_chunks { indexes { (file_id, chunk_id) [pk] user_id [name: 'file_chunks_user_id_idx'] + workspace_id [name: 'file_chunks_workspace_id_idx'] file_id [name: 'file_chunks_file_id_idx'] chunk_id [name: 'file_chunks_chunk_id_idx'] workspace_id [name: 'file_chunks_workspace_id_idx'] @@ -1808,6 +1815,7 @@ table files_to_sessions { indexes { (file_id, session_id) [pk] user_id [name: 'files_to_sessions_user_id_idx'] + workspace_id [name: 'files_to_sessions_workspace_id_idx'] file_id [name: 'files_to_sessions_file_id_idx'] session_id [name: 'files_to_sessions_session_id_idx'] workspace_id [name: 'files_to_sessions_workspace_id_idx'] @@ -2169,8 +2177,8 @@ table topics { provider [name: 'topics_provider_idx'] (user_id, completed_at) [name: 'topics_user_id_completed_at_idx'] sender_id [name: 'topics_sender_id_idx'] - () [name: 'topics_extract_status_gin_idx'] workspace_id [name: 'topics_workspace_id_idx'] + () [name: 'topics_extract_status_gin_idx'] } } diff --git a/packages/business-server/src/lambda-routers/workspace.ts b/packages/business-server/src/lambda-routers/workspace.ts index 844d43eb8d..e77c78f626 100644 --- a/packages/business-server/src/lambda-routers/workspace.ts +++ b/packages/business-server/src/lambda-routers/workspace.ts @@ -1,4 +1,17 @@ -import { router } from '@/libs/trpc/lambda'; +import { TRPCError } from '@trpc/server'; + +import { authedProcedure, router } from '@/libs/trpc/lambda'; // Cloud overrides this at the same path with the real workspaceRouter backed by cloudDB. -export const workspaceRouter = router({}); +// Only the procedures consumed by submodule (open-source) UI are declared here as +// typed no-op stubs so the contract type-checks; cloud supplies the real implementations. +export const workspaceRouter = router({ + ensureMarketOrganization: authedProcedure.mutation( + async (): Promise<{ marketAccountId: number }> => { + throw new TRPCError({ + code: 'NOT_IMPLEMENTED', + message: 'Workspace market organization is a cloud-only feature.', + }); + }, + ), +}); diff --git a/packages/trpc/src/client/lambda.ts b/packages/trpc/src/client/lambda.ts index 3bfaee9aac..5d39b2b12e 100644 --- a/packages/trpc/src/client/lambda.ts +++ b/packages/trpc/src/client/lambda.ts @@ -124,6 +124,12 @@ const linkOptions = { // Only include provider in JWT for image operations // For other operations (like knowledge base embedding), let server use its own config const headers = await createHeaderWithAuth(provider ? { provider } : undefined); + + // Let business layer contribute extra headers (e.g. workspace context in Cloud). + // Community ships an empty stub at this slot. + const { getBusinessTrpcHeaders } = await import('@/business/client/trpc-headers'); + Object.assign(headers as Record, await getBusinessTrpcHeaders()); + log('Headers: %O', headers); return headers; }, diff --git a/packages/trpc/src/client/tools.ts b/packages/trpc/src/client/tools.ts index 8c0121a342..9e1700dac7 100644 --- a/packages/trpc/src/client/tools.ts +++ b/packages/trpc/src/client/tools.ts @@ -66,7 +66,13 @@ export const toolsClient = createTRPCClient({ // dynamic import to avoid circular dependency const { createHeaderWithAuth } = await import('@/services/_auth'); - return createHeaderWithAuth(); + const headers = await createHeaderWithAuth(); + + // Let business layer contribute extra headers (e.g. workspace context in Cloud). + const { getBusinessTrpcHeaders } = await import('@/business/client/trpc-headers'); + Object.assign(headers as Record, await getBusinessTrpcHeaders()); + + return headers; }, maxURLLength: 2083, transformer: superjson, diff --git a/src/business/client/BusinessKnowledgeBaseImportAction.tsx b/src/business/client/BusinessKnowledgeBaseImportAction.tsx new file mode 100644 index 0000000000..8254aadcb5 --- /dev/null +++ b/src/business/client/BusinessKnowledgeBaseImportAction.tsx @@ -0,0 +1,7 @@ +export interface BusinessKnowledgeBaseImportActionProps { + knowledgeBaseId: string; +} + +const BusinessKnowledgeBaseImportAction = (_props: BusinessKnowledgeBaseImportActionProps) => null; + +export default BusinessKnowledgeBaseImportAction; diff --git a/src/business/client/BusinessSettingPages/WorkspaceBillingBilling.tsx b/src/business/client/BusinessSettingPages/WorkspaceBillingBilling.tsx new file mode 100644 index 0000000000..b420182a28 --- /dev/null +++ b/src/business/client/BusinessSettingPages/WorkspaceBillingBilling.tsx @@ -0,0 +1,3 @@ +export default function WorkspaceBillingBilling() { + return null; +} diff --git a/src/business/client/BusinessSettingPages/WorkspaceBillingCredits.tsx b/src/business/client/BusinessSettingPages/WorkspaceBillingCredits.tsx new file mode 100644 index 0000000000..dabc17ba94 --- /dev/null +++ b/src/business/client/BusinessSettingPages/WorkspaceBillingCredits.tsx @@ -0,0 +1,3 @@ +export default function WorkspaceBillingCredits() { + return null; +} diff --git a/src/business/client/BusinessSettingPages/WorkspaceBillingPlans.tsx b/src/business/client/BusinessSettingPages/WorkspaceBillingPlans.tsx new file mode 100644 index 0000000000..b98003df11 --- /dev/null +++ b/src/business/client/BusinessSettingPages/WorkspaceBillingPlans.tsx @@ -0,0 +1,3 @@ +export default function WorkspaceBillingPlans() { + return null; +} diff --git a/src/business/client/BusinessSettingPages/WorkspaceBillingUsage.tsx b/src/business/client/BusinessSettingPages/WorkspaceBillingUsage.tsx new file mode 100644 index 0000000000..5079c086f0 --- /dev/null +++ b/src/business/client/BusinessSettingPages/WorkspaceBillingUsage.tsx @@ -0,0 +1,3 @@ +export default function WorkspaceBillingUsage() { + return null; +} diff --git a/src/business/client/BusinessSettingPages/WorkspaceGeneral.tsx b/src/business/client/BusinessSettingPages/WorkspaceGeneral.tsx new file mode 100644 index 0000000000..35370a8a9b --- /dev/null +++ b/src/business/client/BusinessSettingPages/WorkspaceGeneral.tsx @@ -0,0 +1,3 @@ +export default function WorkspaceGeneral() { + return null; +} diff --git a/src/business/client/BusinessSettingPages/WorkspaceMembers.tsx b/src/business/client/BusinessSettingPages/WorkspaceMembers.tsx new file mode 100644 index 0000000000..fbb41d5e03 --- /dev/null +++ b/src/business/client/BusinessSettingPages/WorkspaceMembers.tsx @@ -0,0 +1,3 @@ +export default function WorkspaceMembers() { + return null; +} diff --git a/src/business/client/WorkspaceContextSlot.tsx b/src/business/client/WorkspaceContextSlot.tsx new file mode 100644 index 0000000000..7618238b51 --- /dev/null +++ b/src/business/client/WorkspaceContextSlot.tsx @@ -0,0 +1,5 @@ +import { type PropsWithChildren } from 'react'; + +export default function WorkspaceContextSlot({ children }: PropsWithChildren) { + return <>{children}; +} diff --git a/src/business/client/features/User/UserPanelWorkspaceSection.tsx b/src/business/client/features/User/UserPanelWorkspaceSection.tsx new file mode 100644 index 0000000000..2886da6367 --- /dev/null +++ b/src/business/client/features/User/UserPanelWorkspaceSection.tsx @@ -0,0 +1,7 @@ +interface UserPanelWorkspaceSectionProps { + onSwitch?: () => void; +} + +export default function UserPanelWorkspaceSection(_props: UserPanelWorkspaceSectionProps) { + return null; +} diff --git a/src/business/client/hooks/useActiveIdentity.ts b/src/business/client/hooks/useActiveIdentity.ts new file mode 100644 index 0000000000..0481f0c9b9 --- /dev/null +++ b/src/business/client/hooks/useActiveIdentity.ts @@ -0,0 +1,13 @@ +/** + * Active identity shown in the user button / panel header. In the open-source + * build there's only one identity (the signed-in user), so this stub returns + * `null` everywhere — callers fall back to user data. Cloud overrides this + * hook to return the active workspace's avatar / name when a team workspace + * is selected, so the header reflects the current context. + */ +export interface ActiveIdentity { + avatar?: string | null; + name?: string | null; +} + +export const useActiveIdentity = (): ActiveIdentity | null => null; diff --git a/src/business/client/hooks/useActiveWorkspace.ts b/src/business/client/hooks/useActiveWorkspace.ts new file mode 100644 index 0000000000..d1486c2437 --- /dev/null +++ b/src/business/client/hooks/useActiveWorkspace.ts @@ -0,0 +1,5 @@ +import type { WorkspaceItem } from '@lobechat/database/schemas'; + +export type WorkspaceListItem = WorkspaceItem & { plan?: 'hobby' | 'pro'; role?: string }; + +export const useActiveWorkspace = (): WorkspaceListItem | null => null; diff --git a/src/business/client/hooks/useActiveWorkspaceId.ts b/src/business/client/hooks/useActiveWorkspaceId.ts new file mode 100644 index 0000000000..bb65ffacb1 --- /dev/null +++ b/src/business/client/hooks/useActiveWorkspaceId.ts @@ -0,0 +1,3 @@ +export const getActiveWorkspaceId = (): string | null => null; + +export const useActiveWorkspaceId = (): string | null => null; diff --git a/src/business/client/hooks/useActiveWorkspaceSlug.ts b/src/business/client/hooks/useActiveWorkspaceSlug.ts new file mode 100644 index 0000000000..383deee0ee --- /dev/null +++ b/src/business/client/hooks/useActiveWorkspaceSlug.ts @@ -0,0 +1,3 @@ +export const getActiveWorkspaceSlug = (): string | null => null; + +export const useActiveWorkspaceSlug = (): string | null => null; diff --git a/src/business/client/hooks/useAgentGroupTransferMenuItem.ts b/src/business/client/hooks/useAgentGroupTransferMenuItem.ts new file mode 100644 index 0000000000..338e52f7ff --- /dev/null +++ b/src/business/client/hooks/useAgentGroupTransferMenuItem.ts @@ -0,0 +1,3 @@ +import type { ItemType } from 'antd/es/menu/interface'; + +export const useAgentGroupTransferMenuItem = (_groupId?: string): ItemType[] | null => null; diff --git a/src/business/client/hooks/useAgentTransferMenuItem.ts b/src/business/client/hooks/useAgentTransferMenuItem.ts new file mode 100644 index 0000000000..c54c8c52aa --- /dev/null +++ b/src/business/client/hooks/useAgentTransferMenuItem.ts @@ -0,0 +1,3 @@ +import { type ItemType } from 'antd/es/menu/interface'; + +export const useAgentTransferMenuItem = (_agentId?: string): ItemType[] | null => null; diff --git a/src/business/client/hooks/useAuthorInfo.ts b/src/business/client/hooks/useAuthorInfo.ts new file mode 100644 index 0000000000..a99bcf8f6c --- /dev/null +++ b/src/business/client/hooks/useAuthorInfo.ts @@ -0,0 +1,6 @@ +export interface AuthorInfo { + avatar?: string | null; + fullName?: string | null; +} + +export const useAuthorInfo = (_userId?: string): AuthorInfo | undefined => undefined; diff --git a/src/business/client/hooks/useBusinessAgentImportMenuItem.ts b/src/business/client/hooks/useBusinessAgentImportMenuItem.ts new file mode 100644 index 0000000000..50d62316d1 --- /dev/null +++ b/src/business/client/hooks/useBusinessAgentImportMenuItem.ts @@ -0,0 +1,3 @@ +import { type ItemType } from 'antd/es/menu/interface'; + +export const useBusinessAgentImportMenuItem = (_agentId?: string): ItemType | null => null; diff --git a/src/business/client/hooks/useCommunityWorkspaceMembers.ts b/src/business/client/hooks/useCommunityWorkspaceMembers.ts new file mode 100644 index 0000000000..e2d2c1d13f --- /dev/null +++ b/src/business/client/hooks/useCommunityWorkspaceMembers.ts @@ -0,0 +1,25 @@ +'use client'; + +export interface CommunityWorkspaceMember { + accountId: number; + avatarUrl: string | null; + createdAt: string; + displayName: string | null; + namespace: string | null; + role: 'admin' | 'member'; + userName: string | null; +} + +export interface CommunityWorkspaceMembersState { + canSync: boolean; + isLoading: boolean; + members: CommunityWorkspaceMember[]; + refresh: () => Promise; +} + +export const useCommunityWorkspaceMembers = (): CommunityWorkspaceMembersState => ({ + canSync: false, + isLoading: false, + members: [], + refresh: async () => {}, +}); diff --git a/src/business/client/hooks/useCommunityWorkspaceProfile.ts b/src/business/client/hooks/useCommunityWorkspaceProfile.ts new file mode 100644 index 0000000000..d24db9d278 --- /dev/null +++ b/src/business/client/hooks/useCommunityWorkspaceProfile.ts @@ -0,0 +1,36 @@ +'use client'; + +export interface CommunityWorkspaceMarketOrganizationProfile { + accountId: number; + avatarUrl: string | null; + bannerUrl: string | null; + description: string | null; + displayName: string | null; + namespace: string; + websiteUrl: string | null; +} + +export interface CommunityWorkspaceProfileState { + avatarUrl?: string | null; + bannerUrl?: string | null; + canEdit: boolean; + description?: string | null; + displayName?: string | null; + isLoading: boolean; + isWorkspaceScope: boolean; + profile: CommunityWorkspaceMarketOrganizationProfile | null; + refresh: () => Promise; + username?: string; +} + +export const useCommunityWorkspaceProfile = (): CommunityWorkspaceProfileState => ({ + avatarUrl: null, + bannerUrl: null, + canEdit: false, + description: null, + displayName: null, + isLoading: false, + isWorkspaceScope: false, + profile: null, + refresh: async () => {}, +}); diff --git a/src/business/client/hooks/useDocumentTransferMenuItem.ts b/src/business/client/hooks/useDocumentTransferMenuItem.ts new file mode 100644 index 0000000000..642cf61425 --- /dev/null +++ b/src/business/client/hooks/useDocumentTransferMenuItem.ts @@ -0,0 +1,3 @@ +import { type ItemType } from 'antd/es/menu/interface'; + +export const useDocumentTransferMenuItem = (_documentId?: string): ItemType[] | null => null; diff --git a/src/business/client/hooks/useFetchWorkspaceMembers.ts b/src/business/client/hooks/useFetchWorkspaceMembers.ts new file mode 100644 index 0000000000..231e2f535a --- /dev/null +++ b/src/business/client/hooks/useFetchWorkspaceMembers.ts @@ -0,0 +1,7 @@ +export interface FetchWorkspaceMembersOptions { + includeDeleted?: boolean; +} + +export const useFetchWorkspaceMembers = (_options: FetchWorkspaceMembersOptions = {}) => ({ + data: [], +}); diff --git a/src/business/client/hooks/useFileBatchTransferActions.ts b/src/business/client/hooks/useFileBatchTransferActions.ts new file mode 100644 index 0000000000..912c40dd3a --- /dev/null +++ b/src/business/client/hooks/useFileBatchTransferActions.ts @@ -0,0 +1,12 @@ +import type { LucideIcon } from 'lucide-react'; + +export interface FileBatchTransferAction { + icon: LucideIcon; + key: string; + label: string; + onClick: () => void; +} + +export const useFileBatchTransferActions = ( + _selectCount: number, +): FileBatchTransferAction[] | null => null; diff --git a/src/business/client/hooks/useFileTransferMenuItem.ts b/src/business/client/hooks/useFileTransferMenuItem.ts new file mode 100644 index 0000000000..5e50e265d0 --- /dev/null +++ b/src/business/client/hooks/useFileTransferMenuItem.ts @@ -0,0 +1,6 @@ +import { type ItemType } from 'antd/es/menu/interface'; + +export const useFileTransferMenuItem = ( + _id?: string, + _entityType?: 'document' | 'file' | 'folder', +): ItemType[] | null => null; diff --git a/src/business/client/hooks/useHasActiveWorkspace.ts b/src/business/client/hooks/useHasActiveWorkspace.ts new file mode 100644 index 0000000000..f375420bf3 --- /dev/null +++ b/src/business/client/hooks/useHasActiveWorkspace.ts @@ -0,0 +1 @@ +export const useHasActiveWorkspace = (): boolean => false; diff --git a/src/business/client/hooks/useHasWorkspace.ts b/src/business/client/hooks/useHasWorkspace.ts new file mode 100644 index 0000000000..1c0de71e81 --- /dev/null +++ b/src/business/client/hooks/useHasWorkspace.ts @@ -0,0 +1 @@ +export const useHasWorkspace = (): boolean => false; diff --git a/src/business/client/hooks/useIsWorkspaceLoading.ts b/src/business/client/hooks/useIsWorkspaceLoading.ts new file mode 100644 index 0000000000..f1ecc8bd14 --- /dev/null +++ b/src/business/client/hooks/useIsWorkspaceLoading.ts @@ -0,0 +1 @@ +export const useIsWorkspaceLoading = (): boolean => false; diff --git a/src/business/client/hooks/useKnowledgeBaseTransferMenuItem.ts b/src/business/client/hooks/useKnowledgeBaseTransferMenuItem.ts new file mode 100644 index 0000000000..a28bb3fe95 --- /dev/null +++ b/src/business/client/hooks/useKnowledgeBaseTransferMenuItem.ts @@ -0,0 +1,3 @@ +import { type ItemType } from 'antd/es/menu/interface'; + +export const useKnowledgeBaseTransferMenuItem = (_id?: string): ItemType[] | null => null; diff --git a/src/business/client/hooks/useShowWorkspaceApiKey.ts b/src/business/client/hooks/useShowWorkspaceApiKey.ts new file mode 100644 index 0000000000..12e11ca555 --- /dev/null +++ b/src/business/client/hooks/useShowWorkspaceApiKey.ts @@ -0,0 +1 @@ +export const useShowWorkspaceApiKey = (): boolean => true; diff --git a/src/business/client/hooks/useSwitchWorkspace.ts b/src/business/client/hooks/useSwitchWorkspace.ts new file mode 100644 index 0000000000..2389fce7b0 --- /dev/null +++ b/src/business/client/hooks/useSwitchWorkspace.ts @@ -0,0 +1,11 @@ +export interface SwitchWorkspaceActions { + switchToPersonal: () => Promise; + switchWorkspace: (id: string) => Promise; +} + +const noop = async (): Promise => {}; + +export const useSwitchWorkspace = (): SwitchWorkspaceActions => ({ + switchToPersonal: noop, + switchWorkspace: noop, +}); diff --git a/src/business/client/hooks/useTaskTransferMenuItem.ts b/src/business/client/hooks/useTaskTransferMenuItem.ts new file mode 100644 index 0000000000..277d424247 --- /dev/null +++ b/src/business/client/hooks/useTaskTransferMenuItem.ts @@ -0,0 +1,3 @@ +import { type ItemType } from 'antd/es/menu/interface'; + +export const useTaskTransferMenuItem = (_taskId?: string): ItemType[] | null => null; diff --git a/src/business/client/hooks/useTransferAgentsFormItem.ts b/src/business/client/hooks/useTransferAgentsFormItem.ts new file mode 100644 index 0000000000..8838e616f0 --- /dev/null +++ b/src/business/client/hooks/useTransferAgentsFormItem.ts @@ -0,0 +1,3 @@ +import type { FormGroupItemType } from '@lobehub/ui'; + +export const useTransferAgentsFormItem = (): FormGroupItemType['children'] | null => null; diff --git a/src/business/client/hooks/useWorkspaceMembers.ts b/src/business/client/hooks/useWorkspaceMembers.ts new file mode 100644 index 0000000000..1b557dea0f --- /dev/null +++ b/src/business/client/hooks/useWorkspaceMembers.ts @@ -0,0 +1,3 @@ +import type { WorkspaceMemberItem } from '@lobechat/database/schemas'; + +export const useWorkspaceMembers = (): WorkspaceMemberItem[] => []; diff --git a/src/business/client/hooks/useWorkspaces.ts b/src/business/client/hooks/useWorkspaces.ts new file mode 100644 index 0000000000..006ef92e01 --- /dev/null +++ b/src/business/client/hooks/useWorkspaces.ts @@ -0,0 +1,3 @@ +import type { WorkspaceListItem } from './useActiveWorkspace'; + +export const useWorkspaces = (): WorkspaceListItem[] => []; diff --git a/src/business/client/services/communityWorkspaceProfile.ts b/src/business/client/services/communityWorkspaceProfile.ts new file mode 100644 index 0000000000..d756a6f62e --- /dev/null +++ b/src/business/client/services/communityWorkspaceProfile.ts @@ -0,0 +1,29 @@ +export interface UpdateCommunityWorkspaceProfileInput { + avatarUrl?: string | null; + bannerUrl?: string | null; + description?: string; + displayName?: string; + namespace?: string; + websiteUrl?: string; +} + +export interface SetupCommunityWorkspaceProfileInput extends UpdateCommunityWorkspaceProfileInput { + displayName: string; + namespace: string; +} + +export const setupCommunityWorkspaceProfile = async ( + _input: SetupCommunityWorkspaceProfileInput, +): Promise => {}; + +export const updateCommunityWorkspaceProfile = async ( + _input: UpdateCommunityWorkspaceProfileInput, +): Promise => {}; + +export const syncCommunityWorkspaceMembers = async (): Promise => {}; + +export const checkCommunityWorkspaceNamespaceAvailable = async ( + _namespace: string, +): Promise => true; + +export const isCommunityWorkspaceNamespaceTakenError = (_error: unknown): boolean => false; diff --git a/src/business/client/trpc-headers.ts b/src/business/client/trpc-headers.ts new file mode 100644 index 0000000000..d0980ed4d8 --- /dev/null +++ b/src/business/client/trpc-headers.ts @@ -0,0 +1 @@ +export const getBusinessTrpcHeaders = async (): Promise> => ({}); diff --git a/src/components/DragUploadZone/useUploadFiles.ts b/src/components/DragUploadZone/useUploadFiles.ts index 09fb6991d0..fd2f1368fa 100644 --- a/src/components/DragUploadZone/useUploadFiles.ts +++ b/src/components/DragUploadZone/useUploadFiles.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react'; +import { usePermission } from '@/hooks/usePermission'; import { useVisualMediaUploadAbility } from '@/hooks/useVisualMediaUploadAbility'; import { useFileStore } from '@/store/file'; @@ -20,9 +21,12 @@ export const useUploadFiles = (options: UseUploadFilesOptions = {}) => { const { canUploadImage, canUploadVideo } = useVisualMediaUploadAbility(model, provider); const uploadFiles = useFileStore((s) => s.uploadChatFiles); + const { allowed: canUpload } = usePermission('create_content'); const handleUploadFiles = useCallback( async (files: File[]) => { + if (!canUpload) return; + // Filter out visual files if the model cannot receive them directly or via fallback. const filteredFiles = files.filter((file) => { if (file.type.startsWith('image')) return canUploadImage; @@ -34,7 +38,7 @@ export const useUploadFiles = (options: UseUploadFilesOptions = {}) => { uploadFiles(filteredFiles); } }, - [canUploadImage, canUploadVideo, uploadFiles], + [canUpload, canUploadImage, canUploadVideo, uploadFiles], ); return { canUploadImage, canUploadVideo, handleUploadFiles }; diff --git a/src/components/InstantSwitch/index.tsx b/src/components/InstantSwitch/index.tsx index 2cd4c2c853..1489b1b0d2 100644 --- a/src/components/InstantSwitch/index.tsx +++ b/src/components/InstantSwitch/index.tsx @@ -3,16 +3,18 @@ import { Switch } from 'antd'; import { memo, useState } from 'react'; interface InstantSwitchProps { + disabled?: boolean; enabled: boolean; onChange: (enabled: boolean) => Promise; size?: SwitchProps['size']; } -const InstantSwitch = memo(({ enabled, onChange, size }) => { +const InstantSwitch = memo(({ disabled, enabled, onChange, size }) => { const [value, setValue] = useState(enabled); const [loading, setLoading] = useState(false); return ( (({ description }) => { const { t } = useTranslation('modelProvider'); + const { allowed: canManageProvider } = usePermission('manage_provider_key'); const [showRegion, setShow] = useState(false); const [showSessionToken, setShowSessionToken] = useState(false); @@ -26,38 +28,50 @@ const BedrockForm = memo<{ description: string }>(({ description }) => { > { + if (!canManageProvider) return; + setConfig(ModelProvider.Bedrock, { keyVaults: { accessKeyId: e.target.value } }); }} /> { + if (!canManageProvider) return; + setConfig(ModelProvider.Bedrock, { keyVaults: { secretAccessKey: e.target.value } }); }} /> {showSessionToken ? ( { + if (!canManageProvider) return; + setConfig(ModelProvider.Bedrock, { keyVaults: { sessionToken: e.target.value } }); }} /> ) : ( + ), + DropdownMenu: ({ children }: any) =>
{children}
, + Flexbox: ({ children }: any) =>
{children}
, +})); + +vi.mock('antd-style', () => ({ + createStaticStyles: () => ({ + time: 'time', + title: 'title', + }), +})); + +vi.mock('dayjs', () => { + const dayjs = () => ({ + diff: () => 0, + format: () => '2026-05-24', + fromNow: () => 'now', + }); + return { default: dayjs }; +}); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('@/const/layoutTokens', () => ({ + DESKTOP_HEADER_ICON_SMALL_SIZE: 24, +})); + +vi.mock('@/features/NavHeader', () => ({ + default: ({ left, right }: any) => ( +
+ {left} + {right} +
+ ), +})); + +vi.mock('@/store/chat', () => ({ + useChatStore: (selector: any) => + selector({ + activeTopicId: 'topic-1', + switchTopic, + topics: [{ id: 'topic-1', title: 'First topic', updatedAt: new Date() }], + useFetchTopics, + }), +})); + +vi.mock('@/store/chat/slices/topic/selectors', () => ({ + topicSelectors: { + getTopicsByAgentId: () => (s: any) => s.topics, + }, +})); + +describe('AgentBuilder TopicSelector', () => { + beforeEach(() => { + switchTopic.mockReset(); + useFetchTopics.mockReset(); + }); + + it('does not create a new topic when disabled', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'actions.addNewTopic' })); + + expect(switchTopic).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/AgentBuilder/TopicSelector.tsx b/src/features/AgentBuilder/TopicSelector.tsx index 1fc8b2bbd6..9c87c3f664 100644 --- a/src/features/AgentBuilder/TopicSelector.tsx +++ b/src/features/AgentBuilder/TopicSelector.tsx @@ -29,9 +29,10 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ interface TopicSelectorProps { agentId: string; + disabled?: boolean; } -const TopicSelector = memo(({ agentId }) => { +const TopicSelector = memo(({ agentId, disabled }) => { const { t } = useTranslation('topic'); // Fetch topics for the agent builder @@ -70,6 +71,7 @@ const TopicSelector = memo(({ agentId }) => { ), onCheckedChange: (checked) => { + if (disabled) return; if (checked) { switchTopic(topic.id); } @@ -90,18 +92,23 @@ const TopicSelector = memo(({ agentId }) => { right={ <> switchTopic()} + onClick={() => { + if (disabled) return; + + switchTopic(); + }} /> - + } diff --git a/src/features/AgentHome/RecentTopics.tsx b/src/features/AgentHome/RecentTopics.tsx index 268f8ce2ad..bc401a692d 100644 --- a/src/features/AgentHome/RecentTopics.tsx +++ b/src/features/AgentHome/RecentTopics.tsx @@ -5,10 +5,11 @@ import { cssVar } from 'antd-style'; import { BotMessageSquareIcon } from 'lucide-react'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Link, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import useSWR from 'swr'; import { SESSION_CHAT_TOPIC_URL } from '@/const/url'; +import WorkspaceLink from '@/features/Workspace/WorkspaceLink'; import { topicService } from '@/services/topic'; import SectionHeader from './SectionHeader'; @@ -35,7 +36,7 @@ const AgentRecentTopics = memo(() => { /> {topics.map((topic) => ( - { - + ))} diff --git a/src/features/AgentHome/SectionHeader.tsx b/src/features/AgentHome/SectionHeader.tsx index 285f68495d..ea4a0c0be4 100644 --- a/src/features/AgentHome/SectionHeader.tsx +++ b/src/features/AgentHome/SectionHeader.tsx @@ -4,7 +4,8 @@ import { Flexbox, Icon, Text } from '@lobehub/ui'; import { cssVar } from 'antd-style'; import { type LucideIcon } from 'lucide-react'; import { memo } from 'react'; -import { Link } from 'react-router-dom'; + +import WorkspaceLink from '@/features/Workspace/WorkspaceLink'; interface SectionHeaderProps { actionLabel?: string; @@ -21,11 +22,11 @@ const SectionHeader = memo(({ icon, title, actionLabel, acti {title} {actionLabel && actionUrl && ( - + {actionLabel} - + )} ); diff --git a/src/features/AgentProfileCard/AgentProfilePopup.tsx b/src/features/AgentProfileCard/AgentProfilePopup.tsx index deb5a7a8ca..f54a4144ff 100644 --- a/src/features/AgentProfileCard/AgentProfilePopup.tsx +++ b/src/features/AgentProfileCard/AgentProfilePopup.tsx @@ -8,10 +8,10 @@ import { createStaticStyles } from 'antd-style'; import { BookOpen, FileText, Settings } from 'lucide-react'; import { memo, type PropsWithChildren, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; import useSWR from 'swr'; import ModelSelect from '@/features/ModelSelect'; +import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate'; import { agentService } from '@/services/agent'; import { useAgentGroupStore } from '@/store/agentGroup'; @@ -71,7 +71,7 @@ interface AgentProfilePopupProps extends PropsWithChildren { const AgentProfilePopup = memo( ({ agent, agentId, groupId, children, trigger = 'click' }) => { const { t } = useTranslation('chat'); - const navigate = useNavigate(); + const navigate = useWorkspaceAwareNavigate(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); diff --git a/src/features/AgentSetting/AgentMeta/AutoGenerateAvatar.tsx b/src/features/AgentSetting/AgentMeta/AutoGenerateAvatar.tsx index 4bfaa3016e..16449fd4ba 100644 --- a/src/features/AgentSetting/AgentMeta/AutoGenerateAvatar.tsx +++ b/src/features/AgentSetting/AgentMeta/AutoGenerateAvatar.tsx @@ -11,6 +11,7 @@ import { globalGeneralSelectors } from '@/store/global/selectors'; export interface AutoGenerateAvatarProps { background?: string; canAutoGenerate?: boolean; + disabled?: boolean; loading?: boolean; onChange?: (value: string) => void; onGenerate?: () => void; @@ -18,7 +19,7 @@ export interface AutoGenerateAvatarProps { } const AutoGenerateAvatar = memo( - ({ loading, background, value, onChange, onGenerate, canAutoGenerate }) => { + ({ loading, background, value, onChange, onGenerate, canAutoGenerate, disabled }) => { const { t } = useTranslation('common'); const locale = useGlobalStore(globalGeneralSelectors.currentLanguage); @@ -45,11 +46,16 @@ const AutoGenerateAvatar = memo( value={value} style={{ background: cssVar.colorFillTertiary, + opacity: disabled ? 0.5 : 1, + }} + onChange={(next) => { + if (disabled) return; + + onChange?.(next); }} - onChange={onChange} /> { + disabled?: boolean; onValuesChange?: ColorSwatchesProps['onChange']; } const BackgroundSwatches = memo( - ({ defaultValue = DEFAULT_BACKGROUND_COLOR, value, onChange, onValuesChange, ...rest }) => { + ({ + defaultValue = DEFAULT_BACKGROUND_COLOR, + value, + onChange, + onValuesChange, + disabled, + ...rest + }) => { const { t } = useTranslation('color'); const colors = useMemo( @@ -77,7 +85,15 @@ const BackgroundSwatches = memo( colors={colors} defaultValue={defaultValue} value={value} + style={{ + cursor: disabled ? 'not-allowed' : undefined, + opacity: disabled ? 0.5 : undefined, + pointerEvents: disabled ? 'none' : undefined, + ...rest.style, + }} onChange={(v) => { + if (disabled) return; + onChange?.(v); onValuesChange?.(v); }} diff --git a/src/features/AgentSetting/AgentMeta/index.tsx b/src/features/AgentSetting/AgentMeta/index.tsx index 5340777e02..0d00b0df13 100644 --- a/src/features/AgentSetting/AgentMeta/index.tsx +++ b/src/features/AgentSetting/AgentMeta/index.tsx @@ -22,12 +22,15 @@ const AgentMeta = memo(() => { const { t } = useTranslation('setting'); const [form] = Form.useForm(); const { isAgentEditable } = useServerConfigStore(featureFlagsSelectors); - const [hasSystemRole, updateMeta, autocompleteMeta, autocompleteAllMeta] = useStore((s) => [ - !!s.config.systemRole, - s.setAgentMeta, - s.autocompleteMeta, - s.autocompleteAllMeta, - ]); + const [hasSystemRole, disabled, updateMeta, autocompleteMeta, autocompleteAllMeta] = useStore( + (s) => [ + !!s.config.systemRole, + s.disabled, + s.setAgentMeta, + s.autocompleteMeta, + s.autocompleteAllMeta, + ], + ); const [isInbox, loadingState] = useStore((s) => [s.id === INBOX_SESSION_ID, s.loadingState]); const meta = useStore(selectors.currentMetaConfig, isEqual); const [background, setBackground] = useState(meta.backgroundColor); @@ -66,10 +69,13 @@ const AgentMeta = memo(() => { return { children: ( { + if (disabled) return; + autocompleteMeta(item.key as keyof typeof meta); }} /> @@ -85,9 +91,14 @@ const AgentMeta = memo(() => { children: ( autocompleteMeta('avatar')} + onGenerate={() => { + if (disabled) return; + + autocompleteMeta('avatar'); + }} /> ), label: t('settingAgent.avatar.title'), @@ -96,7 +107,9 @@ const AgentMeta = memo(() => { name: 'avatar', }, { - children: setBackground(c)} />, + children: ( + setBackground(c)} /> + ), label: t('settingAgent.backgroundColor.title'), minWidth: undefined, name: 'backgroundColor', @@ -112,7 +125,7 @@ const AgentMeta = memo(() => { } > ); @@ -52,11 +56,11 @@ const OpeningMessage = memo(() => {
{ confirm: t('ok', { ns: 'common' }), }} onChange={setOpeningMessage} - onEditingChange={setEditing} + onEditingChange={(next) => { + if (disabled) return; + + setEditing(next); + }} /> {editIconButton} diff --git a/src/features/AgentSetting/AgentOpening/OpeningQuestions.tsx b/src/features/AgentSetting/AgentOpening/OpeningQuestions.tsx index 04bbf2afdd..7453273b69 100644 --- a/src/features/AgentSetting/AgentOpening/OpeningQuestions.tsx +++ b/src/features/AgentSetting/AgentOpening/OpeningQuestions.tsx @@ -43,11 +43,15 @@ const OpeningQuestions = memo(() => { const [questionInput, setQuestionInput] = useState(''); const openingQuestions = useStore(selectors.openingQuestions); - const updateConfig = useStore((s) => s.setAgentConfig); + const [disabled, updateConfig] = useStore((s) => [s.disabled, s.setAgentConfig]); // Optimistic update to avoid jitter const [questions, setQuestions] = useMergeState(openingQuestions, { - onChange: (questions: string[]) => updateConfig({ openingQuestions: questions }), + onChange: (questions: string[]) => { + if (disabled) return; + + updateConfig({ openingQuestions: questions }); + }, value: openingQuestions, }); @@ -59,28 +63,33 @@ const OpeningQuestions = memo(() => { }, [questions]); const addQuestion = useCallback(() => { + if (disabled) return; if (!questionInput.trim()) return; setQuestions([...openingQuestions, questionInput.trim()]); setQuestionInput(''); - }, [openingQuestions, questionInput, setQuestions]); + }, [disabled, openingQuestions, questionInput, setQuestions]); const removeQuestion = useCallback( (content: string) => { + if (disabled) return; + const newQuestions = [...openingQuestions]; const index = newQuestions.indexOf(content); newQuestions.splice(index, 1); setQuestions(newQuestions); }, - [openingQuestions, setQuestions], + [disabled, openingQuestions, setQuestions], ); // Handle logic after drag-and-drop sorting const handleSortEnd = useCallback( (items: QuestionItem[]) => { + if (disabled) return; + setQuestions(items.map((item) => item.content)); }, - [setQuestions], + [disabled, setQuestions], ); const isRepeat = openingQuestions.includes(questionInput.trim()); @@ -90,6 +99,7 @@ const OpeningQuestions = memo(() => { { /> + , + DropdownMenu: ({ children, items }: { children?: ReactNode; items: MenuItem[] }) => { + mocks.dropdownItems = items; + return <>{children}; + }, + Icon: () => , + copyToClipboard: vi.fn(), +})); + +vi.mock('antd', () => ({ + App: { + useApp: () => ({ + message: { success: mocks.messageSuccess }, + modal: { confirm: mocks.modalConfirm }, + }), + }, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@/business/client/hooks/useTaskTransferMenuItem', () => ({ + useTaskTransferMenuItem: vi.fn(() => mocks.transferItems), +})); + +vi.mock('@/features/Workspace/useWorkspaceAwareNavigate', () => ({ + useWorkspaceAwareNavigate: () => mocks.navigate, +})); + +vi.mock('@/hooks/useAppOrigin', () => ({ + useAppOrigin: () => 'https://example.com', +})); + +vi.mock('@/hooks/usePermission', () => ({ + usePermission: () => ({ allowed: true }), +})); + +vi.mock('@/store/task', () => ({ + useTaskStore: ( + selector: (state: { activeTaskId?: string; deleteTask: typeof mocks.deleteTask }) => unknown, + ) => selector({ ...mocks.taskState, deleteTask: mocks.deleteTask }), +})); + +describe('TaskDetailHeaderActions', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.dropdownItems = []; + mocks.taskState.activeTaskId = 'T-1'; + }); + + it('includes task transfer and copy actions in the detail menu', () => { + render(); + + expect(mocks.dropdownItems.map((item) => item?.key)).toContain('transfer-task'); + expect(mocks.dropdownItems.map((item) => item?.key)).toContain('copy-task'); + }); +}); diff --git a/src/features/AgentTasks/AgentTaskDetail/TaskDetailHeaderActions.tsx b/src/features/AgentTasks/AgentTaskDetail/TaskDetailHeaderActions.tsx index cd7c6e1514..0b16db0579 100644 --- a/src/features/AgentTasks/AgentTaskDetail/TaskDetailHeaderActions.tsx +++ b/src/features/AgentTasks/AgentTaskDetail/TaskDetailHeaderActions.tsx @@ -4,9 +4,11 @@ import { App } from 'antd'; import { CopyIcon, LinkIcon, MoreHorizontal, Trash } from 'lucide-react'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; +import { useTaskTransferMenuItem } from '@/business/client/hooks/useTaskTransferMenuItem'; +import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate'; import { useAppOrigin } from '@/hooks/useAppOrigin'; +import { usePermission } from '@/hooks/usePermission'; import { useTaskStore } from '@/store/task'; import { taskDetailSelectors } from '@/store/task/selectors'; @@ -15,13 +17,16 @@ import { taskDetailPath } from '../shared/taskDetailPath'; const TaskDetailHeaderActions = memo(() => { const { t } = useTranslation(['chat', 'common']); const { message } = App.useApp(); - const navigate = useNavigate(); + const navigate = useWorkspaceAwareNavigate(); const appOrigin = useAppOrigin(); + const { allowed: canEditTask } = usePermission('create_content'); const taskId = useTaskStore(taskDetailSelectors.activeTaskId); const taskAgentId = useTaskStore(taskDetailSelectors.activeTaskAgentId); const deleteTask = useTaskStore((s) => s.deleteTask); + const transferItems = useTaskTransferMenuItem(taskId) as DropdownItem[] | null; const triggerDelete = useCallback(() => { + if (!canEditTask) return; if (!taskId) return; confirmModal({ content: t('taskDetail.deleteConfirm.content'), @@ -33,14 +38,14 @@ const TaskDetailHeaderActions = memo(() => { }, title: t('taskDetail.deleteConfirm.title'), }); - }, [taskId, t, deleteTask, navigate]); + }, [canEditTask, taskId, t, deleteTask, navigate]); const menuItems = useMemo(() => { if (!taskId) return []; const taskUrl = `${appOrigin}${taskDetailPath(taskId, taskAgentId ?? undefined)}`; - return [ + const baseItems: DropdownItem[] = [ { icon: , key: 'copyId', @@ -62,13 +67,18 @@ const TaskDetailHeaderActions = memo(() => { { type: 'divider' }, { danger: true, + disabled: !canEditTask, icon: , key: 'delete', label: t('delete', { ns: 'common' }), onClick: triggerDelete, }, ]; - }, [taskId, taskAgentId, appOrigin, t, message, triggerDelete]); + + if (!transferItems || transferItems.length === 0) return baseItems; + + return [...baseItems.slice(0, 3), ...transferItems, { type: 'divider' }, ...baseItems.slice(3)]; + }, [taskId, taskAgentId, appOrigin, t, message, triggerDelete, canEditTask, transferItems]); if (!taskId) return null; diff --git a/src/features/AgentTasks/AgentTaskDetail/TaskDetailRunPauseAction.tsx b/src/features/AgentTasks/AgentTaskDetail/TaskDetailRunPauseAction.tsx index 8aa1d9c7a8..44b78d1c81 100644 --- a/src/features/AgentTasks/AgentTaskDetail/TaskDetailRunPauseAction.tsx +++ b/src/features/AgentTasks/AgentTaskDetail/TaskDetailRunPauseAction.tsx @@ -5,6 +5,7 @@ import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import StopLoadingIcon from '@/components/StopLoading'; +import { usePermission } from '@/hooks/usePermission'; import { useAgentStore } from '@/store/agent'; import { builtinAgentSelectors } from '@/store/agent/selectors'; import { useTaskStore } from '@/store/task'; @@ -25,6 +26,7 @@ const formatCountdown = (msRemaining: number): string => { const TaskDetailRunPauseAction = memo(() => { const { t } = useTranslation('chat'); + const { allowed: canEditTask, reason } = usePermission('create_content'); const taskId = useTaskStore(taskDetailSelectors.activeTaskId); const canRun = useTaskStore(taskDetailSelectors.canRunActiveTask); const canPause = useTaskStore(taskDetailSelectors.canPauseActiveTask); @@ -47,6 +49,7 @@ const TaskDetailRunPauseAction = memo(() => { const [isRunningNow, setIsRunningNow] = useState(false); const handleRunOrPause = useCallback(async () => { + if (!canEditTask) return; if (!taskId) return; if (canPause) { await updateTaskStatus(taskId, 'paused'); @@ -71,9 +74,11 @@ const TaskDetailRunPauseAction = memo(() => { runTask, updateTask, updateTaskStatus, + canEditTask, ]); const handleRunNow = useCallback(async () => { + if (!canEditTask) return; if (!taskId) return; setIsRunningNow(true); try { @@ -84,9 +89,10 @@ const TaskDetailRunPauseAction = memo(() => { } finally { setIsRunningNow(false); } - }, [taskId, assigneeAgentId, inboxAgentId, runTask, updateTask]); + }, [canEditTask, taskId, assigneeAgentId, inboxAgentId, runTask, updateTask]); const handleCancelSchedule = useCallback(async () => { + if (!canEditTask) return; if (!taskId) return; setIsCancellingSchedule(true); try { @@ -97,7 +103,7 @@ const TaskDetailRunPauseAction = memo(() => { } finally { setIsCancellingSchedule(false); } - }, [taskId, setAutomationMode, updateTaskStatus, status]); + }, [canEditTask, taskId, setAutomationMode, updateTaskStatus, status]); const isScheduled = status === 'scheduled'; @@ -133,9 +139,10 @@ const TaskDetailRunPauseAction = memo(() => { ); @@ -186,7 +203,13 @@ const TaskDetailRunPauseAction = memo(() => { const runIcon = isRerun ? RotateCcwIcon : PlayIcon; return ( - ); diff --git a/src/features/AgentTasks/AgentTaskDetail/TaskDetailTitleInput.tsx b/src/features/AgentTasks/AgentTaskDetail/TaskDetailTitleInput.tsx index 344ae65ffa..c3db732275 100644 --- a/src/features/AgentTasks/AgentTaskDetail/TaskDetailTitleInput.tsx +++ b/src/features/AgentTasks/AgentTaskDetail/TaskDetailTitleInput.tsx @@ -3,6 +3,7 @@ import { Input } from 'antd'; import { memo, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { usePermission } from '@/hooks/usePermission'; import { useTaskStore } from '@/store/task'; import { taskDetailSelectors } from '@/store/task/selectors'; @@ -12,6 +13,7 @@ const DEBOUNCE_MS = 300; const TaskDetailTitleInput = memo(() => { const { t } = useTranslation('chat'); + const { allowed: canEditTask } = usePermission('create_content'); const name = useTaskStore(taskDetailSelectors.activeTaskName); const taskId = useTaskStore(taskDetailSelectors.activeTaskId); const updateTask = useTaskStore((s) => s.updateTask); @@ -24,6 +26,7 @@ const TaskDetailTitleInput = memo(() => { const { run: debouncedSave } = useDebounceFn( (value: string) => { + if (!canEditTask) return; if (taskId) updateTask(taskId, { name: value }); }, { wait: DEBOUNCE_MS }, @@ -41,6 +44,7 @@ const TaskDetailTitleInput = memo(() => { { const { t } = useTranslation('chat'); + const { allowed: canEditTask } = usePermission('create_content'); const instruction = useTaskStore(taskDetailSelectors.activeTaskInstruction); const persistedEditorData = useTaskStore(taskDetailSelectors.activeTaskEditorData); const taskId = useTaskStore(taskDetailSelectors.activeTaskId); @@ -47,6 +49,7 @@ const TaskInstruction = memo(() => { }, [taskId]); const handleContentChange = useCallback(() => { + if (!canEditTask) return; if (!editor || !taskId) return; if (debounceRef.current) clearTimeout(debounceRef.current); @@ -61,7 +64,7 @@ const TaskInstruction = memo(() => { console.error('[TaskInstruction] Failed to save:', e); }); }, DEBOUNCE_MS); - }, [editor, taskId, updateTask]); + }, [canEditTask, editor, taskId, updateTask]); const handleAttach = useCallback(() => { pickAndInsertAttachments(editor); diff --git a/src/features/AgentTasks/AgentTaskDetail/TaskModelConfig.tsx b/src/features/AgentTasks/AgentTaskDetail/TaskModelConfig.tsx index ce0177af1b..f0fefbf155 100644 --- a/src/features/AgentTasks/AgentTaskDetail/TaskModelConfig.tsx +++ b/src/features/AgentTasks/AgentTaskDetail/TaskModelConfig.tsx @@ -1,12 +1,14 @@ import { memo, useCallback } from 'react'; import ModelSelect from '@/features/ModelSelect'; +import { usePermission } from '@/hooks/usePermission'; import { useAgentStore } from '@/store/agent'; import { agentSelectors } from '@/store/agent/selectors'; import { useTaskStore } from '@/store/task'; import { taskDetailSelectors } from '@/store/task/selectors'; const TaskModelConfig = memo(() => { + const { allowed: canEditTask } = usePermission('create_content'); const taskId = useTaskStore(taskDetailSelectors.activeTaskId); const taskModel = useTaskStore(taskDetailSelectors.activeTaskModel); const taskProvider = useTaskStore(taskDetailSelectors.activeTaskProvider); @@ -20,15 +22,17 @@ const TaskModelConfig = memo(() => { const handleChange = useCallback( async (params: { model: string; provider: string }) => { + if (!canEditTask) return; if (!taskId) return; await updateTaskModelConfig(taskId, params); }, - [taskId, updateTaskModelConfig], + [canEditTask, taskId, updateTaskModelConfig], ); return ( const TaskParentBar = memo(() => { const { t } = useTranslation('chat'); - const navigate = useNavigate(); + const navigate = useWorkspaceAwareNavigate(); const parent = useTaskStore(taskDetailSelectors.activeTaskParent); const currentIdentifier = useTaskStore(taskDetailSelectors.activeTaskDetail)?.identifier; diff --git a/src/features/AgentTasks/AgentTaskDetail/TaskScheduleConfig.tsx b/src/features/AgentTasks/AgentTaskDetail/TaskScheduleConfig.tsx index e23af8a1f9..79cba44ee2 100644 --- a/src/features/AgentTasks/AgentTaskDetail/TaskScheduleConfig.tsx +++ b/src/features/AgentTasks/AgentTaskDetail/TaskScheduleConfig.tsx @@ -19,6 +19,7 @@ import type { ReactNode } from 'react'; import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { usePermission } from '@/hooks/usePermission'; import { useTaskStore } from '@/store/task'; import { taskDetailSelectors } from '@/store/task/selectors'; @@ -55,10 +56,11 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ interface IntervalTabProps { currentInterval: number; + disabled?: boolean; taskId?: string; } -const IntervalTab = memo(({ currentInterval, taskId }) => { +const IntervalTab = memo(({ currentInterval, disabled, taskId }) => { const { t } = useTranslation('chat'); const updatePeriodicInterval = useTaskStore((s) => s.updatePeriodicInterval); @@ -98,23 +100,25 @@ const IntervalTab = memo(({ currentInterval, taskId }) => { normalized = val; } setLocalValue(normalized); + if (disabled) return; if (!taskId) return; const seconds = toSeconds(normalized ?? null, localUnit); updatePeriodicInterval(taskId, seconds); }, - [taskId, localUnit, updatePeriodicInterval], + [disabled, taskId, localUnit, updatePeriodicInterval], ); const handleUnitChange = useCallback( (u: IntervalUnit) => { setLocalUnit(u); + if (disabled) return; if (!taskId || !localValue) return; const clamped = u === 'minutes' ? Math.max(MIN_MINUTES, localValue) : localValue; if (clamped !== localValue) setLocalValue(clamped); const seconds = toSeconds(clamped, u); updatePeriodicInterval(taskId, seconds); }, - [taskId, localValue, updatePeriodicInterval], + [disabled, taskId, localValue, updatePeriodicInterval], ); return ( @@ -123,6 +127,7 @@ const IntervalTab = memo(({ currentInterval, taskId }) => { {t('taskSchedule.every')} (({ currentInterval, taskId }) => { onChange={handleValueChange} /> ( onChange={(e) => setTitle(e.target.value)} /> ( ); + if (!canCreateContent) + return ( + +
{button}
+
+ ); + return (
{dropdownOpen ? ( diff --git a/src/features/ChatInput/ControlBar/CloudRepoSwitcher.tsx b/src/features/ChatInput/ControlBar/CloudRepoSwitcher.tsx index 648d954d30..67d8166c2a 100644 --- a/src/features/ChatInput/ControlBar/CloudRepoSwitcher.tsx +++ b/src/features/ChatInput/ControlBar/CloudRepoSwitcher.tsx @@ -1,12 +1,13 @@ 'use client'; import { Github } from '@lobehub/icons'; -import { Flexbox, Icon, Popover } from '@lobehub/ui'; -import { createStaticStyles, cssVar } from 'antd-style'; +import { Flexbox, Icon, Popover, Tooltip } from '@lobehub/ui'; +import { createStaticStyles, cssVar, cx } from 'antd-style'; import { CheckIcon, ChevronDownIcon, SquircleDashed } from 'lucide-react'; import { memo, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { usePermission } from '@/hooks/usePermission'; import { useAgentStore } from '@/store/agent'; import { agentByIdSelectors } from '@/store/agent/selectors'; import { useChatStore } from '@/store/chat'; @@ -34,6 +35,14 @@ const styles = createStaticStyles(({ css }) => ({ background: ${cssVar.colorFillTertiary}; } `, + buttonDisabled: css` + cursor: not-allowed; + opacity: 0.5; + + &:hover { + background: transparent; + } + `, checkIndicator: css` display: flex; flex: none; @@ -101,6 +110,7 @@ interface CloudRepoSwitcherProps { const CloudRepoSwitcher = memo(({ agentId }) => { const { t } = useTranslation('chat'); const [open, setOpen] = useState(false); + const { allowed: canCreateContent, reason } = usePermission('create_content'); // Incremented to trigger re-renders when the module-singleton pending selection changes. const [, forceUpdate] = useState(0); @@ -129,6 +139,8 @@ const CloudRepoSwitcher = memo(({ agentId }) => { const toggleRepo = useCallback( async (repo: string) => { + if (!canCreateContent) return; + if (!activeTopicId) { // No topic yet — buffer in the module singleton keyed by agentId. // gateway.ts will read and consume this when the first message creates a topic. @@ -152,7 +164,23 @@ const CloudRepoSwitcher = memo(({ agentId }) => { await updateTopicMetadata(activeTopicId, patch); }, - [agentId, activeTopicId, currentWorkingDirectory, topicRepos, updateTopicMetadata], + [ + agentId, + activeTopicId, + canCreateContent, + currentWorkingDirectory, + topicRepos, + updateTopicMetadata, + ], + ); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!canCreateContent) return; + + setOpen(nextOpen); + }, + [canCreateContent], ); if (availableRepos.length === 0) return null; @@ -201,20 +229,31 @@ const CloudRepoSwitcher = memo(({ agentId }) => { ); + const button = ( +
+ {displayRepos.length > 0 ? : } + {buttonLabel} + +
+ ); + + if (!canCreateContent) + return ( + +
{button}
+
+ ); + return ( -
- {displayRepos.length > 0 ? : } - {buttonLabel} - -
+ {button}
); }); diff --git a/src/features/ChatInput/ControlBar/ModeSelector.tsx b/src/features/ChatInput/ControlBar/ModeSelector.tsx index e3cf2cfdb1..e97c35b638 100644 --- a/src/features/ChatInput/ControlBar/ModeSelector.tsx +++ b/src/features/ChatInput/ControlBar/ModeSelector.tsx @@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next'; import { useBusinessAgentModeSync } from '@/business/client/hooks/useBusinessAgentMode'; import { useAgentId } from '@/features/ChatInput/hooks/useAgentId'; import { useToggleAgentMode } from '@/features/ChatInput/hooks/useToggleAgentMode'; +import { usePermission } from '@/hooks/usePermission'; import { useAgentStore } from '@/store/agent'; import { agentByIdSelectors } from '@/store/agent/selectors'; @@ -65,6 +66,15 @@ const styles = createStaticStyles(({ css }) => ({ background: ${cssVar.colorFillSecondary}; } `, + buttonDisabled: css` + cursor: not-allowed; + opacity: 0.5; + + &:hover { + color: ${cssVar.colorTextSecondary}; + background: transparent; + } + `, option: css` cursor: pointer; @@ -111,6 +121,7 @@ const ModeSelector = memo(() => { const toggleAgentMode = useToggleAgentMode(); useBusinessAgentModeSync(agentId); const [open, setOpen] = useState(false); + const { allowed: canCreateContent, reason } = usePermission('create_content'); const enableAgentMode = useAgentStore(agentByIdSelectors.getAgentEnableModeById(agentId)); @@ -119,10 +130,21 @@ const ModeSelector = memo(() => { const handleSelect = useCallback( async (mode: 'chat' | 'agent') => { + if (!canCreateContent) return; + setOpen(false); await toggleAgentMode(mode === 'agent'); }, - [toggleAgentMode], + [canCreateContent, toggleAgentMode], + ); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!canCreateContent) return; + + setOpen(nextOpen); + }, + [canCreateContent], ); const agentTooltip = ( @@ -188,23 +210,30 @@ const ModeSelector = memo(() => { ); const button = ( -
+
{t(`chatMode.${currentMode}`)}
); + if (!canCreateContent) + return ( + +
{button}
+
+ ); + return (
{open ? ( diff --git a/src/features/ChatInput/InputEditor/index.test.tsx b/src/features/ChatInput/InputEditor/index.test.tsx new file mode 100644 index 0000000000..ed76a4ce62 --- /dev/null +++ b/src/features/ChatInput/InputEditor/index.test.tsx @@ -0,0 +1,168 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import InputEditor from './index'; + +const permission = vi.hoisted(() => ({ + allowed: false, +})); + +type StoreSelector = (state: Record) => T; + +vi.mock('@lobechat/const', () => ({ isDesktop: false })); +vi.mock('@lobechat/const/hotkeys', () => ({ + HotkeyEnum: { AddUserMessage: 'add-user-message' }, + KeyEnum: { Alt: 'alt', Enter: 'enter' }, +})); +vi.mock('@lobechat/heterogeneous-agents', () => ({ HETEROGENEOUS_TYPE_LABELS: {} })); +vi.mock('@lobechat/prompts', () => ({ + chainInputCompletion: vi.fn(), + escapeXmlAttr: (value: string) => value, +})); +vi.mock('@lobechat/utils', () => ({ + isCommandPressed: vi.fn(() => false), + merge: vi.fn((...args) => Object.assign({}, ...args)), +})); +vi.mock('@lobehub/editor', () => ({ + INSERT_MENTION_COMMAND: 'insert-mention', + ReactAutoCompletePlugin: vi.fn(), + ReactMathPlugin: vi.fn(), +})); +vi.mock('@lobehub/editor/react', () => { + const Editor = Object.assign( + vi.fn(({ editable }: { editable?: boolean }) => ( +
+ )), + { + withProps: vi.fn((plugin, props) => [plugin, props]), + }, + ); + + return { + Editor, + FloatMenu: vi.fn(() => null), + useEditorState: vi.fn(() => ({ isEmpty: true })), + }; +}); +vi.mock('@lobehub/ui', () => ({ combineKeys: vi.fn(() => 'alt+enter') })); +vi.mock('fuse.js', () => ({ + default: class Fuse { + search() { + return []; + } + }, +})); +vi.mock('lexical', () => ({ KEY_ESCAPE_COMMAND: 'escape' })); +vi.mock('react-hotkeys-hook', () => ({ + useHotkeysContext: () => ({ + disableScope: vi.fn(), + enableScope: vi.fn(), + }), +})); + +vi.mock('@/components/DragUploadZone', () => ({ + usePasteFile: vi.fn(), + useUploadFiles: () => ({ handleUploadFiles: vi.fn() }), +})); +vi.mock('@/hooks/useEnterToSend', () => ({ useEnterToSend: () => vi.fn(() => false) })); +vi.mock('@/hooks/useIMECompositionEvent', () => ({ + useIMECompositionEvent: () => ({ + compositionProps: { + onCompositionEnd: vi.fn(), + onCompositionStart: vi.fn(), + }, + isComposingRef: { current: false }, + }), +})); +vi.mock('@/hooks/usePermission', () => ({ + usePermission: () => ({ allowed: permission.allowed, reason: '' }), +})); +vi.mock('@/services/chat', () => ({ chatService: { fetchPresetTaskResult: vi.fn() } })); +vi.mock('@/services/aiChat', () => ({ aiChatService: new Proxy({}, { get: () => vi.fn() }) })); +vi.mock('@/store/chat', () => ({ + useChatStore: Object.assign((selector: StoreSelector) => selector({}), { + getState: () => ({ activeTopicId: undefined }), + }), +})); +vi.mock('../hooks/useChatInputDraft', () => ({ + useChatInputDraft: () => ({ restoreDraft: vi.fn(), saveDraftDebounced: vi.fn() }), +})); +vi.mock('@/store/agent', () => ({ + useAgentStore: (selector: StoreSelector) => selector({}), +})); +vi.mock('@/store/agent/selectors', () => ({ + agentByIdSelectors: { + getAgencyConfigById: () => () => undefined, + getAgentModelById: () => () => undefined, + getAgentModelProviderById: () => () => undefined, + }, +})); +vi.mock('@/store/user', () => { + const useUserStore = Object.assign((selector: StoreSelector) => selector({}), { + getState: () => ({}), + }); + + return { useUserStore }; +}); +vi.mock('@/store/user/selectors', () => ({ + labPreferSelectors: { enableInputMarkdown: () => false }, + settingsSelectors: { getHotkeyById: () => () => 'alt+enter' }, + systemAgentSelectors: { + inputCompletion: () => ({ enabled: false }), + }, +})); + +vi.mock('../hooks/useAgentId', () => ({ useAgentId: () => 'agent-id' })); +vi.mock('../store', () => { + const editor = { + dispatchCommand: vi.fn(), + }; + const state = { + disableMention: true, + disableSlash: true, + editor, + expand: false, + handleSendButton: vi.fn(), + slashMenuRef: { current: null }, + slashPlacement: 'top', + updateMarkdownContent: vi.fn(), + }; + + return { + useChatInputStore: (selector: StoreSelector) => selector(state), + useStoreApi: () => ({ + getState: () => ({ getMessages: vi.fn() }), + subscribe: vi.fn(() => vi.fn()), + }), + }; +}); +vi.mock('./ActionTag', () => ({ + INSERT_ACTION_TAG_COMMAND: 'insert-action-tag', + useSlashActionItems: () => [], +})); +vi.mock('./MentionMenu', () => ({ createMentionMenu: vi.fn(() => vi.fn(() => null)) })); +vi.mock('./Placeholder', () => ({ + default: () => placeholder, +})); +vi.mock('./plugins', () => ({ + CHAT_INPUT_EMBED_PLUGINS: [], + createChatInputRichPlugins: () => [], +})); +vi.mock('./ReferTopic', () => ({ INSERT_REFER_TOPIC_COMMAND: 'insert-refer-topic' })); +vi.mock('./useLocalFileMention', () => ({ + useLocalFileMention: () => ({ + enableLocalFileMention: false, + searchLocalFiles: vi.fn(async () => []), + }), +})); +vi.mock('./useMentionCategories', () => ({ useMentionCategories: () => [] })); + +describe('ChatInput InputEditor', () => { + it('renders as read-only when create-content permission is denied', () => { + permission.allowed = false; + + render(); + + expect(screen.getByTestId('mock-editor')).toHaveAttribute('data-editable', 'false'); + }); +}); diff --git a/src/features/ChatInput/InputEditor/index.tsx b/src/features/ChatInput/InputEditor/index.tsx index 1dc1263919..d9cdc69020 100644 --- a/src/features/ChatInput/InputEditor/index.tsx +++ b/src/features/ChatInput/InputEditor/index.tsx @@ -21,6 +21,7 @@ import { useHotkeysContext } from 'react-hotkeys-hook'; import { usePasteFile, useUploadFiles } from '@/components/DragUploadZone'; import { useEnterToSend } from '@/hooks/useEnterToSend'; import { useIMECompositionEvent } from '@/hooks/useIMECompositionEvent'; +import { usePermission } from '@/hooks/usePermission'; import { aiChatService } from '@/services/aiChat'; import { useAgentStore } from '@/store/agent'; import { agentByIdSelectors } from '@/store/agent/selectors'; @@ -89,6 +90,7 @@ const InputEditor = memo<{ const { restoreDraft, saveDraftDebounced } = useChatInputDraft(); const restoredDraftEditorRef = useRef(null); const state = useEditorState(editor); + const { allowed: canCreateContent } = usePermission('create_content'); const hotkey = useUserStore(settingsSelectors.getHotkeyById(HotkeyEnum.AddUserMessage)); const { enableScope, disableScope } = useHotkeysContext(); @@ -453,6 +455,7 @@ const InputEditor = memo<{ pasteAsPlainText className={className} content={''} + editable={canCreateContent} editor={editor} {...{ slashPlacement }} {...richRenderProps} diff --git a/src/features/ChatInput/SendArea/ExpandButton.tsx b/src/features/ChatInput/SendArea/ExpandButton.tsx index 21dfcfbed0..4472c9b036 100644 --- a/src/features/ChatInput/SendArea/ExpandButton.tsx +++ b/src/features/ChatInput/SendArea/ExpandButton.tsx @@ -4,20 +4,26 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { useChatInputStore } from '@/features/ChatInput/store'; +import { usePermission } from '@/hooks/usePermission'; const ExpandButton = memo(() => { const { t } = useTranslation('editor'); const [expand, setExpand, editor] = useChatInputStore((s) => [s.expand, s.setExpand, s.editor]); + const { allowed: canUseChatInputAction, reason } = usePermission('create_content'); return ( { + if (!canUseChatInputAction) return; setExpand?.(!expand); editor?.focus(); }} diff --git a/src/features/ChatInput/SendArea/SendButton.tsx b/src/features/ChatInput/SendArea/SendButton.tsx index 8d59f3a54b..dc2d908a39 100644 --- a/src/features/ChatInput/SendArea/SendButton.tsx +++ b/src/features/ChatInput/SendArea/SendButton.tsx @@ -1,7 +1,10 @@ import { SendButton as Send } from '@lobehub/editor/react'; +import { Tooltip } from '@lobehub/ui'; import isEqual from 'fast-deep-equal'; import { memo } from 'react'; +import { usePermission } from '@/hooks/usePermission'; + import { selectors, useChatInputStore } from '../store'; const SendButton = memo(() => { @@ -11,19 +14,26 @@ const SendButton = memo(() => { const { generating, disabled } = useChatInputStore(selectors.sendButtonProps, isEqual); const [send, handleStop] = useChatInputStore((s) => [s.handleSendButton, s.handleStop]); - return ( + // Workspace viewer doesn't have `message:create` → backend would 403. + // OR the permission gate into the existing disabled prop so the button + // visibly grays out and a tooltip explains why. + const { allowed: canCreate, reason } = usePermission('create_content'); + + const button = ( send()} + onClick={generating || !canCreate ? undefined : () => send()} onStop={() => handleStop()} /> ); + + return canCreate ? button : {button}; }); SendButton.displayName = 'SendButton'; diff --git a/src/features/CommandMenu/AskAIMenu.tsx b/src/features/CommandMenu/AskAIMenu.tsx index 0862a898aa..e5e6ad237c 100644 --- a/src/features/CommandMenu/AskAIMenu.tsx +++ b/src/features/CommandMenu/AskAIMenu.tsx @@ -5,8 +5,8 @@ import { Command } from 'cmdk'; import { Bot, Image } from 'lucide-react'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; +import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate'; import { useHomeStore } from '@/store/home'; import { homeAgentListSelectors } from '@/store/home/selectors'; @@ -17,7 +17,7 @@ import { useCommandMenu } from './useCommandMenu'; const AskAIMenu = memo(() => { const { t } = useTranslation(['common', 'chat', 'home']); - const navigate = useNavigate(); + const navigate = useWorkspaceAwareNavigate(); const { handleAskLobeAI, handleAIPainting, closeCommandMenu } = useCommandMenu(); const { search } = useCommandMenuContext(); diff --git a/src/features/CommandMenu/MainMenu.tsx b/src/features/CommandMenu/MainMenu.tsx index 7ca09f8dd0..24228bb4e6 100644 --- a/src/features/CommandMenu/MainMenu.tsx +++ b/src/features/CommandMenu/MainMenu.tsx @@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next'; import { openFeedbackModal } from '@/components/FeedbackModal'; import { getNavigableRoutes, getRouteById } from '@/config/routes'; import { FEEDBACK } from '@/const/url'; +import { usePermission } from '@/hooks/usePermission'; import { useCommandMenuContext } from './CommandMenuContext'; import { CommandItem } from './components'; @@ -25,6 +26,7 @@ import { useCommandMenu } from './useCommandMenu'; const MainMenu = memo(() => { const { pathname, menuContext, setPages, pages } = useCommandMenuContext(); const { t } = useTranslation('common'); + const { allowed: canCreate } = usePermission('create_content'); const { handleCreateSession, @@ -42,6 +44,7 @@ const MainMenu = memo(() => { } unpinned={menuContext === 'agent' || menuContext === 'page'} value="create new agent assistant" @@ -51,6 +54,7 @@ const MainMenu = memo(() => { } unpinned={menuContext === 'agent' || menuContext === 'page'} value="create new agent team" @@ -61,6 +65,7 @@ const MainMenu = memo(() => { {menuContext === 'agent' && ( } unpinned={menuContext !== 'agent'} value="create new topic" @@ -70,11 +75,17 @@ const MainMenu = memo(() => { )} - } value="create new page" onSelect={handleCreatePage}> + } + value="create new page" + onSelect={handleCreatePage} + > {t('cmdk.newPage')} } unpinned={menuContext !== 'resource'} value="create new library" diff --git a/src/features/CommandMenu/SearchResults.tsx b/src/features/CommandMenu/SearchResults.tsx index 1e317192be..ad22ce84c3 100644 --- a/src/features/CommandMenu/SearchResults.tsx +++ b/src/features/CommandMenu/SearchResults.tsx @@ -18,11 +18,11 @@ import { } from 'lucide-react'; import { memo, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; import { SESSION_CHAT_TOPIC_URL } from '@/const/url'; import { type SearchResult } from '@/database/repositories/search'; import { useCommandMenuContext } from '@/features/CommandMenu/CommandMenuContext'; +import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate'; import { useImageStore } from '@/store/image'; import { generationTopicSelectors as imageGenerationTopicSelectors } from '@/store/image/slices/generationTopic/selectors'; import { useVideoStore } from '@/store/video'; @@ -57,7 +57,7 @@ const SearchResults = memo( const { t } = useTranslation('common'); const { t: tImage } = useTranslation('image'); const { t: tVideo } = useTranslation('video'); - const navigate = useNavigate(); + const navigate = useWorkspaceAwareNavigate(); const { menuContext } = useCommandMenuContext(); const imageTopics = useImageStore(imageGenerationTopicSelectors.generationTopics); const activeImageTopicId = useImageStore((s) => s.activeGenerationTopicId); diff --git a/src/features/CommandMenu/useCommandMenu.ts b/src/features/CommandMenu/useCommandMenu.ts index 33c6b35222..41f1f7d92e 100644 --- a/src/features/CommandMenu/useCommandMenu.ts +++ b/src/features/CommandMenu/useCommandMenu.ts @@ -1,12 +1,13 @@ import { useDebounce } from 'ahooks'; import { useTheme as useNextThemesTheme } from 'next-themes'; import { useCallback, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; import useSWR from 'swr'; import { isDesktop } from '@/const/version'; import { type SearchResult } from '@/database/repositories/search'; import { useCreateNewModal } from '@/features/LibraryModal'; +import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate'; +import { usePermission } from '@/hooks/usePermission'; import { useGroupWizard } from '@/layout/GlobalProvider/GroupWizardProvider'; import { lambdaClient } from '@/libs/trpc/client'; import { useCreateMenuItems } from '@/routes/(main)/home/_layout/hooks'; @@ -42,7 +43,8 @@ export const useCommandMenu = () => { activeAgentId: agentId, } = useCommandMenuContext(); - const navigate = useNavigate(); + const navigate = useWorkspaceAwareNavigate(); + const { allowed: canCreate } = usePermission('create_content'); const { setTheme } = useNextThemesTheme(); const createAgent = useAgentStore((s) => s.createAgent); const refreshAgentList = useHomeStore((s) => s.refreshAgentList); @@ -152,6 +154,8 @@ export const useCommandMenu = () => { }, [selectedAgent, search, navigate, setSelectedAgent, onClose]); const handleCreateSession = useCallback(async () => { + if (!canCreate) return; + const result = await createAgent({}); await refreshAgentList(); @@ -161,30 +165,38 @@ export const useCommandMenu = () => { } onClose(); - }, [createAgent, refreshAgentList, navigate, onClose]); + }, [canCreate, createAgent, refreshAgentList, navigate, onClose]); const openNewTopicOrSaveTopic = useChatStore((s) => s.openNewTopicOrSaveTopic); const handleCreateTopic = useCallback(() => { + if (!canCreate) return; + openNewTopicOrSaveTopic(); onClose(); - }, [openNewTopicOrSaveTopic, onClose]); + }, [canCreate, openNewTopicOrSaveTopic, onClose]); const handleCreateLibrary = useCallback(() => { + if (!canCreate) return; + onClose(); openCreateLibraryModal({ onSuccess: (id) => { navigate(`/resource/library/${id}`); }, }); - }, [onClose, openCreateLibraryModal, navigate]); + }, [canCreate, onClose, openCreateLibraryModal, navigate]); const handleCreatePage = useCallback(async () => { + if (!canCreate) return; + await createPage(); onClose(); - }, [createPage, onClose]); + }, [canCreate, createPage, onClose]); const handleCreateAgentTeam = useCallback(() => { + if (!canCreate) return; + onClose(); openGroupWizard({ onCreateCustom: async (selectedAgents) => { @@ -194,7 +206,7 @@ export const useCommandMenu = () => { await createGroupFromTemplate(templateId, selectedMemberTitles); }, }); - }, [onClose, openGroupWizard, createGroupWithMembers, createGroupFromTemplate]); + }, [canCreate, onClose, openGroupWizard, createGroupWithMembers, createGroupFromTemplate]); return { closeCommandMenu, diff --git a/src/features/CommunityWorkspaceSettings/index.tsx b/src/features/CommunityWorkspaceSettings/index.tsx new file mode 100644 index 0000000000..8eebc74775 --- /dev/null +++ b/src/features/CommunityWorkspaceSettings/index.tsx @@ -0,0 +1,674 @@ +'use client'; + +import { OFFICIAL_URL } from '@lobechat/const'; +import { + Avatar, + Block, + Button, + Center, + Flexbox, + Icon, + Input, + Tabs, + Tag, + Text, + TextArea, + Tooltip, +} from '@lobehub/ui'; +import type { TableColumnsType, UploadProps } from 'antd'; +import { App, Input as AntInput, Table, Upload } from 'antd'; +import { createStaticStyles, cssVar } from 'antd-style'; +import { + ArrowLeft, + CircleHelp, + Globe, + ImagePlus, + RefreshCw, + Settings, + Trash2, + Users, +} from 'lucide-react'; +import { + memo, + type PropsWithChildren, + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + type CommunityWorkspaceMember, + useCommunityWorkspaceMembers, +} from '@/business/client/hooks/useCommunityWorkspaceMembers'; +import { useCommunityWorkspaceProfile } from '@/business/client/hooks/useCommunityWorkspaceProfile'; +import { + isCommunityWorkspaceNamespaceTakenError, + syncCommunityWorkspaceMembers, + updateCommunityWorkspaceProfile, +} from '@/business/client/services/communityWorkspaceProfile'; +import EmojiPicker from '@/components/EmojiPicker'; +import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate'; +import { usePermission } from '@/hooks/usePermission'; +import { useFileStore } from '@/store/file'; + +const MAX_FILE_SIZE = 2 * 1024 * 1024; +const NAMESPACE_MAX = 32; +const NAMESPACE_MIN = 3; +const DESCRIPTION_MAX = 200; +const DISPLAY_NAME_MAX = 50; +const ORGANIZATION_URL_PREFIX = `${OFFICIAL_URL.replace(/^https?:\/\//, '')}/community/org/`; +const NAMESPACE_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + footer: css` + padding-block: 12px; + padding-inline: 20px; + border-block-start: 1px solid ${cssVar.colorFillTertiary}; + background: ${cssVar.colorFillQuaternary}; + `, + hint: css` + font-size: 13px; + color: ${cssVar.colorTextSecondary}; + `, +})); + +interface SettingCardProps { + action?: ReactNode; + children?: ReactNode; + description?: ReactNode; + hint?: ReactNode; + title: ReactNode; +} + +const SettingCard = memo(({ title, description, children, hint, action }) => ( + + + + + {title} + + {description && ( + + {description} + + )} + + {children} + + {(hint || action) && ( + + {hint ?? ''} + {action} + + )} + +)); + +SettingCard.displayName = 'CommunityWorkspaceSettingCard'; + +const PageContainer = memo(({ children }) => ( + + + {children} + + +)); + +PageContainer.displayName = 'CommunityWorkspaceSettingsPageContainer'; + +const MembersCard = memo<{ canManage: boolean }>(({ canManage }) => { + const { t } = useTranslation('discover'); + const { message } = App.useApp(); + const { canSync, isLoading, members, refresh } = useCommunityWorkspaceMembers(); + const [syncing, setSyncing] = useState(false); + + const canTriggerSync = canManage && canSync; + + const handleSync = useCallback(async () => { + setSyncing(true); + try { + await syncCommunityWorkspaceMembers(); + await refresh(); + message.success(t('user.workspaceProfile.settings.members.syncSuccess')); + } catch (error) { + message.error( + (error as Error).message || t('user.workspaceProfile.settings.members.syncFailed'), + ); + } finally { + setSyncing(false); + } + }, [message, refresh, t]); + + const columns = useMemo>( + () => [ + { + dataIndex: 'displayName', + render: (_, member) => { + const name = + member.displayName || member.userName || member.namespace || `#${member.accountId}`; + return ( + + + + + {name} + + {member.namespace && ( + + @{member.namespace} + + )} + + + ); + }, + title: t('user.workspaceProfile.settings.members.column.member'), + }, + { + align: 'right', + dataIndex: 'role', + render: (role: CommunityWorkspaceMember['role']) => ( + + {role === 'admin' + ? t('user.workspaceProfile.settings.members.role.admin') + : t('user.workspaceProfile.settings.members.role.member')} + + ), + title: t('user.workspaceProfile.settings.members.column.role'), + width: 120, + }, + ], + [t], + ); + + return ( + + {t('user.workspaceProfile.settings.members.sync')} + + } + > + + {t('user.workspaceProfile.settings.members.empty')} + + ), + }} + /> + + ); +}); + +MembersCard.displayName = 'CommunityWorkspaceMembersCard'; + +const trimOptional = (value: string | undefined) => { + const trimmed = value?.trim(); + return trimmed || undefined; +}; + +const isValidUrl = (value: string) => { + if (!value.trim()) return true; + try { + const { protocol } = new URL(value.trim()); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +}; + +const CommunityWorkspaceSettings = memo(() => { + const { t } = useTranslation('discover'); + const { message } = App.useApp(); + const navigate = useWorkspaceAwareNavigate(); + const { allowed: canManageSettings, reason: permissionReason } = usePermission('manage_settings'); + const uploadWithProgress = useFileStore((s) => s.uploadWithProgress); + const { + avatarUrl: remoteAvatarUrl, + bannerUrl: remoteBannerUrl, + canEdit: canEditCommunityProfile, + description: remoteDescription, + displayName: remoteDisplayName, + isLoading, + profile, + refresh, + username, + } = useCommunityWorkspaceProfile(); + + const canEdit = canManageSettings && canEditCommunityProfile && !!profile; + const disabledReason = !canManageSettings + ? permissionReason + : t('user.workspaceProfile.settings.noPermission'); + + const [displayName, setDisplayName] = useState(remoteDisplayName ?? ''); + const [namespace, setNamespace] = useState(username ?? ''); + const [description, setDescription] = useState(remoteDescription ?? ''); + const [websiteUrl, setWebsiteUrl] = useState(profile?.websiteUrl ?? ''); + const [avatarUrl, setAvatarUrl] = useState(remoteAvatarUrl ?? null); + const [bannerUrl, setBannerUrl] = useState(remoteBannerUrl ?? null); + const [savingField, setSavingField] = useState(null); + const [avatarUploading, setAvatarUploading] = useState(false); + const [bannerUploading, setBannerUploading] = useState(false); + const [namespaceError, setNamespaceError] = useState(); + const [websiteError, setWebsiteError] = useState(); + const [activeTab, setActiveTab] = useState<'members' | 'profile'>('profile'); + + const handleBack = useCallback(() => { + if (window.history.length > 1) { + navigate(-1); + return; + } + + navigate('/community/workspace'); + }, [navigate]); + + const renderHeader = () => ( + + + + ); + + useEffect(() => { + setDisplayName(remoteDisplayName ?? ''); + setNamespace(username ?? ''); + setDescription(remoteDescription ?? ''); + setWebsiteUrl(profile?.websiteUrl ?? ''); + setAvatarUrl(remoteAvatarUrl ?? null); + setBannerUrl(remoteBannerUrl ?? null); + setNamespaceError(undefined); + setWebsiteError(undefined); + }, [ + profile?.accountId, + profile?.websiteUrl, + remoteAvatarUrl, + remoteBannerUrl, + remoteDescription, + remoteDisplayName, + username, + ]); + + const updateProfile = useCallback( + async (field: string, input: Parameters[0]) => { + if (!canEdit) return; + setSavingField(field); + try { + await updateCommunityWorkspaceProfile(input); + await refresh(); + message.success(t('user.workspaceProfile.settings.updateSuccess')); + } catch (error) { + if (field === 'namespace' && isCommunityWorkspaceNamespaceTakenError(error)) { + setNamespaceError(t('user.workspaceProfile.settings.namespaceTaken')); + } else { + message.error( + (error as Error).message || t('user.workspaceProfile.settings.updateFailed'), + ); + } + } finally { + setSavingField(null); + } + }, + [canEdit, message, refresh, t], + ); + + const buildSaveButton = (field: string, disabled: boolean, onClick: () => void) => { + const button = ( + + ); + + if (canEdit) return button; + + return ( + + {button} + + ); + }; + + const namespaceValidation = useMemo(() => { + const value = namespace.trim(); + if (!value) return t('user.workspaceProfile.errors.namespace.required'); + if (value.length < NAMESPACE_MIN || value.length > NAMESPACE_MAX) + return t('user.workspaceProfile.errors.namespace.length'); + if (!NAMESPACE_PATTERN.test(value)) return t('user.workspaceProfile.errors.namespace.pattern'); + return namespaceError; + }, [namespace, namespaceError, t]); + + const handleAvatarUpload = useCallback( + async (file: File) => { + if (file.size > MAX_FILE_SIZE) { + message.error(t('user.workspaceProfile.errors.fileTooLarge')); + return; + } + + setAvatarUploading(true); + try { + const result = await uploadWithProgress({ file }); + if (!result?.url) { + message.error(t('user.workspaceProfile.errors.uploadFailed')); + return; + } + setAvatarUrl( + result.url.startsWith('/') ? `${window.location.origin}${result.url}` : result.url, + ); + } catch (error) { + console.error('[CommunityWorkspaceSettings] Avatar upload failed:', error); + message.error(t('user.workspaceProfile.errors.uploadFailed')); + } finally { + setAvatarUploading(false); + } + }, + [message, t, uploadWithProgress], + ); + + const handleBannerUpload: UploadProps['customRequest'] = useCallback( + async (options: Parameters>[0]) => { + const file = options.file as File; + + if (file.size > MAX_FILE_SIZE) { + message.error(t('user.workspaceProfile.errors.fileTooLarge')); + options.onError?.(new Error('File too large')); + return; + } + + setBannerUploading(true); + try { + const result = await uploadWithProgress({ file }); + if (!result?.url) { + message.error(t('user.workspaceProfile.errors.uploadFailed')); + options.onError?.(new Error('Upload failed')); + return; + } + const url = result.url.startsWith('/') + ? `${window.location.origin}${result.url}` + : result.url; + setBannerUrl(url); + options.onSuccess?.(result); + } catch (error) { + console.error('[CommunityWorkspaceSettings] Banner upload failed:', error); + message.error(t('user.workspaceProfile.errors.uploadFailed')); + options.onError?.(error as Error); + } finally { + setBannerUploading(false); + } + }, + [message, t, uploadWithProgress], + ); + + if (!profile) { + return {renderHeader()}; + } + + const displayNameDirty = displayName.trim() !== (remoteDisplayName ?? ''); + const namespaceDirty = namespace.trim() !== (username ?? ''); + const descriptionDirty = description.trim() !== (remoteDescription ?? ''); + const websiteDirty = websiteUrl.trim() !== (profile.websiteUrl ?? ''); + const avatarDirty = avatarUrl !== (remoteAvatarUrl ?? null); + const bannerDirty = bannerUrl !== (remoteBannerUrl ?? null); + + return ( + + {renderHeader()} + + , + key: 'profile', + label: t('user.workspaceProfile.settings.tabs.profile'), + }, + { + icon: , + key: 'members', + label: t('user.workspaceProfile.settings.tabs.members'), + }, + ]} + onChange={(key) => setActiveTab(key as 'members' | 'profile')} + /> + + {activeTab === 'members' && } + + {activeTab === 'profile' && ( + <> + + updateProfile('displayName', { displayName: displayName.trim() }), + )} + > + setDisplayName(e.target.value)} + /> + + + + updateProfile('namespace', { namespace: namespace.trim() }), + )} + hint={ + namespaceValidation || + t('user.workspaceProfile.settings.namespace.hint', { + max: NAMESPACE_MAX, + }) + } + > + { + setNamespace(e.target.value); + setNamespaceError(undefined); + }} + /> + + + + updateProfile('description', { description: trimOptional(description) }), + )} + > +