mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
feat: support workspace lobehub (#13977)
feat: support workspace (full) — store→business-hook + workspace router
This commit is contained in:
@@ -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']
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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<string, string>, await getBusinessTrpcHeaders());
|
||||
|
||||
log('Headers: %O', headers);
|
||||
return headers;
|
||||
},
|
||||
|
||||
@@ -66,7 +66,13 @@ export const toolsClient = createTRPCClient<ToolsRouter>({
|
||||
// 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<string, string>, await getBusinessTrpcHeaders());
|
||||
|
||||
return headers;
|
||||
},
|
||||
maxURLLength: 2083,
|
||||
transformer: superjson,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface BusinessKnowledgeBaseImportActionProps {
|
||||
knowledgeBaseId: string;
|
||||
}
|
||||
|
||||
const BusinessKnowledgeBaseImportAction = (_props: BusinessKnowledgeBaseImportActionProps) => null;
|
||||
|
||||
export default BusinessKnowledgeBaseImportAction;
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function WorkspaceBillingBilling() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function WorkspaceBillingCredits() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function WorkspaceBillingPlans() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function WorkspaceBillingUsage() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function WorkspaceGeneral() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function WorkspaceMembers() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
export default function WorkspaceContextSlot({ children }: PropsWithChildren) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
interface UserPanelWorkspaceSectionProps {
|
||||
onSwitch?: () => void;
|
||||
}
|
||||
|
||||
export default function UserPanelWorkspaceSection(_props: UserPanelWorkspaceSectionProps) {
|
||||
return null;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
export const getActiveWorkspaceId = (): string | null => null;
|
||||
|
||||
export const useActiveWorkspaceId = (): string | null => null;
|
||||
@@ -0,0 +1,3 @@
|
||||
export const getActiveWorkspaceSlug = (): string | null => null;
|
||||
|
||||
export const useActiveWorkspaceSlug = (): string | null => null;
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { ItemType } from 'antd/es/menu/interface';
|
||||
|
||||
export const useAgentGroupTransferMenuItem = (_groupId?: string): ItemType[] | null => null;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { type ItemType } from 'antd/es/menu/interface';
|
||||
|
||||
export const useAgentTransferMenuItem = (_agentId?: string): ItemType[] | null => null;
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface AuthorInfo {
|
||||
avatar?: string | null;
|
||||
fullName?: string | null;
|
||||
}
|
||||
|
||||
export const useAuthorInfo = (_userId?: string): AuthorInfo | undefined => undefined;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { type ItemType } from 'antd/es/menu/interface';
|
||||
|
||||
export const useBusinessAgentImportMenuItem = (_agentId?: string): ItemType | null => null;
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
export const useCommunityWorkspaceMembers = (): CommunityWorkspaceMembersState => ({
|
||||
canSync: false,
|
||||
isLoading: false,
|
||||
members: [],
|
||||
refresh: async () => {},
|
||||
});
|
||||
@@ -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<void>;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export const useCommunityWorkspaceProfile = (): CommunityWorkspaceProfileState => ({
|
||||
avatarUrl: null,
|
||||
bannerUrl: null,
|
||||
canEdit: false,
|
||||
description: null,
|
||||
displayName: null,
|
||||
isLoading: false,
|
||||
isWorkspaceScope: false,
|
||||
profile: null,
|
||||
refresh: async () => {},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { type ItemType } from 'antd/es/menu/interface';
|
||||
|
||||
export const useDocumentTransferMenuItem = (_documentId?: string): ItemType[] | null => null;
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface FetchWorkspaceMembersOptions {
|
||||
includeDeleted?: boolean;
|
||||
}
|
||||
|
||||
export const useFetchWorkspaceMembers = (_options: FetchWorkspaceMembersOptions = {}) => ({
|
||||
data: [],
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ItemType } from 'antd/es/menu/interface';
|
||||
|
||||
export const useFileTransferMenuItem = (
|
||||
_id?: string,
|
||||
_entityType?: 'document' | 'file' | 'folder',
|
||||
): ItemType[] | null => null;
|
||||
@@ -0,0 +1 @@
|
||||
export const useHasActiveWorkspace = (): boolean => false;
|
||||
@@ -0,0 +1 @@
|
||||
export const useHasWorkspace = (): boolean => false;
|
||||
@@ -0,0 +1 @@
|
||||
export const useIsWorkspaceLoading = (): boolean => false;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { type ItemType } from 'antd/es/menu/interface';
|
||||
|
||||
export const useKnowledgeBaseTransferMenuItem = (_id?: string): ItemType[] | null => null;
|
||||
@@ -0,0 +1 @@
|
||||
export const useShowWorkspaceApiKey = (): boolean => true;
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface SwitchWorkspaceActions {
|
||||
switchToPersonal: () => Promise<void>;
|
||||
switchWorkspace: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const noop = async (): Promise<void> => {};
|
||||
|
||||
export const useSwitchWorkspace = (): SwitchWorkspaceActions => ({
|
||||
switchToPersonal: noop,
|
||||
switchWorkspace: noop,
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { type ItemType } from 'antd/es/menu/interface';
|
||||
|
||||
export const useTaskTransferMenuItem = (_taskId?: string): ItemType[] | null => null;
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { FormGroupItemType } from '@lobehub/ui';
|
||||
|
||||
export const useTransferAgentsFormItem = (): FormGroupItemType['children'] | null => null;
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { WorkspaceMemberItem } from '@lobechat/database/schemas';
|
||||
|
||||
export const useWorkspaceMembers = (): WorkspaceMemberItem[] => [];
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { WorkspaceListItem } from './useActiveWorkspace';
|
||||
|
||||
export const useWorkspaces = (): WorkspaceListItem[] => [];
|
||||
@@ -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<void> => {};
|
||||
|
||||
export const updateCommunityWorkspaceProfile = async (
|
||||
_input: UpdateCommunityWorkspaceProfileInput,
|
||||
): Promise<void> => {};
|
||||
|
||||
export const syncCommunityWorkspaceMembers = async (): Promise<void> => {};
|
||||
|
||||
export const checkCommunityWorkspaceNamespaceAvailable = async (
|
||||
_namespace: string,
|
||||
): Promise<boolean> => true;
|
||||
|
||||
export const isCommunityWorkspaceNamespaceTakenError = (_error: unknown): boolean => false;
|
||||
@@ -0,0 +1 @@
|
||||
export const getBusinessTrpcHeaders = async (): Promise<Record<string, string>> => ({});
|
||||
@@ -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 };
|
||||
|
||||
@@ -3,16 +3,18 @@ import { Switch } from 'antd';
|
||||
import { memo, useState } from 'react';
|
||||
|
||||
interface InstantSwitchProps {
|
||||
disabled?: boolean;
|
||||
enabled: boolean;
|
||||
onChange: (enabled: boolean) => Promise<void>;
|
||||
size?: SwitchProps['size'];
|
||||
}
|
||||
|
||||
const InstantSwitch = memo<InstantSwitchProps>(({ enabled, onChange, size }) => {
|
||||
const InstantSwitch = memo<InstantSwitchProps>(({ disabled, enabled, onChange, size }) => {
|
||||
const [value, setValue] = useState(enabled);
|
||||
const [loading, setLoading] = useState(false);
|
||||
return (
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
size={size}
|
||||
value={value}
|
||||
|
||||
@@ -7,10 +7,12 @@ import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FormAction } from '@/features/Conversation/Error/style';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { aiProviderSelectors, useAiInfraStore } from '@/store/aiInfra';
|
||||
|
||||
const BedrockForm = memo<{ description: string }>(({ 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 }) => {
|
||||
>
|
||||
<InputPassword
|
||||
autoComplete={'new-password'}
|
||||
disabled={!canManageProvider}
|
||||
placeholder={'Aws Access Key Id'}
|
||||
value={accessKeyId}
|
||||
variant={'filled'}
|
||||
onChange={(e) => {
|
||||
if (!canManageProvider) return;
|
||||
|
||||
setConfig(ModelProvider.Bedrock, { keyVaults: { accessKeyId: e.target.value } });
|
||||
}}
|
||||
/>
|
||||
<InputPassword
|
||||
autoComplete={'new-password'}
|
||||
disabled={!canManageProvider}
|
||||
placeholder={'Aws Secret Access Key'}
|
||||
value={secretAccessKey}
|
||||
variant={'filled'}
|
||||
onChange={(e) => {
|
||||
if (!canManageProvider) return;
|
||||
|
||||
setConfig(ModelProvider.Bedrock, { keyVaults: { secretAccessKey: e.target.value } });
|
||||
}}
|
||||
/>
|
||||
{showSessionToken ? (
|
||||
<InputPassword
|
||||
autoComplete={'new-password'}
|
||||
disabled={!canManageProvider}
|
||||
placeholder={'Aws Session Token'}
|
||||
value={sessionToken}
|
||||
variant={'filled'}
|
||||
onChange={(e) => {
|
||||
if (!canManageProvider) return;
|
||||
|
||||
setConfig(ModelProvider.Bedrock, { keyVaults: { sessionToken: e.target.value } });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
block
|
||||
disabled={!canManageProvider}
|
||||
icon={ShieldPlus}
|
||||
type={'text'}
|
||||
onClick={() => {
|
||||
if (!canManageProvider) return;
|
||||
|
||||
setShowSessionToken(true);
|
||||
}}
|
||||
>
|
||||
@@ -66,6 +80,7 @@ const BedrockForm = memo<{ description: string }>(({ description }) => {
|
||||
)}
|
||||
{showRegion ? (
|
||||
<Select
|
||||
disabled={!canManageProvider}
|
||||
placeholder={'https://api.openai.com/v1'}
|
||||
style={{ width: '100%' }}
|
||||
value={region}
|
||||
@@ -74,15 +89,20 @@ const BedrockForm = memo<{ description: string }>(({ description }) => {
|
||||
value: i,
|
||||
}))}
|
||||
onChange={(region) => {
|
||||
if (!canManageProvider) return;
|
||||
|
||||
setConfig('bedrock', { keyVaults: { region } });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
block
|
||||
disabled={!canManageProvider}
|
||||
icon={<Icon icon={Network} />}
|
||||
type={'text'}
|
||||
onClick={() => {
|
||||
if (!canManageProvider) return;
|
||||
|
||||
setShow(true);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { FormInput, FormPassword } from '@/components/FormInput';
|
||||
import KeyValueEditor from '@/components/KeyValueEditor';
|
||||
import { FormAction } from '@/features/Conversation/Error/style';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useAiInfraStore } from '@/store/aiInfra';
|
||||
import { type ComfyUIKeyVault } from '@/types/user/settings';
|
||||
|
||||
@@ -43,6 +44,7 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
|
||||
const { t } = useTranslation('error');
|
||||
const { t: s } = useTranslation('modelProvider');
|
||||
const { allowed: canManageProvider } = usePermission('manage_provider_key');
|
||||
|
||||
// Use aiInfraStore for updating config (same as settings page)
|
||||
const updateAiProviderConfig = useAiInfraStore((s) => s.updateAiProviderConfig);
|
||||
@@ -103,6 +105,8 @@ const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
|
||||
];
|
||||
|
||||
const handleValueChange = async (field: string, value: any) => {
|
||||
if (!canManageProvider) return;
|
||||
|
||||
const newValues = {
|
||||
...formValues,
|
||||
[field]: value,
|
||||
@@ -148,6 +152,7 @@ const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
|
||||
<Flexbox gap={4}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{s('comfyui.baseURL.title')}</div>
|
||||
<FormInput
|
||||
disabled={!canManageProvider}
|
||||
placeholder={s('comfyui.baseURL.placeholder')}
|
||||
suffix={<div>{loading && <Icon spin icon={Loader2Icon} />}</div>}
|
||||
value={formValues.baseURL}
|
||||
@@ -156,6 +161,7 @@ const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
|
||||
</Flexbox>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!canManageProvider}
|
||||
icon={<Icon icon={Network} />}
|
||||
type={'text'}
|
||||
onClick={() => setShowBaseURL(true)}
|
||||
@@ -169,6 +175,7 @@ const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{s('comfyui.authType.title')}</div>
|
||||
<Select
|
||||
allowClear={false}
|
||||
disabled={!canManageProvider}
|
||||
options={authTypeOptions}
|
||||
placeholder={s('comfyui.authType.placeholder')}
|
||||
value={formValues.authType}
|
||||
@@ -183,6 +190,7 @@ const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{s('comfyui.username.title')}</div>
|
||||
<FormInput
|
||||
autoComplete="username"
|
||||
disabled={!canManageProvider}
|
||||
placeholder={s('comfyui.username.placeholder')}
|
||||
suffix={<div>{loading && <Icon spin icon={Loader2Icon} />}</div>}
|
||||
value={formValues.username}
|
||||
@@ -193,6 +201,7 @@ const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{s('comfyui.password.title')}</div>
|
||||
<FormPassword
|
||||
autoComplete="new-password"
|
||||
disabled={!canManageProvider}
|
||||
placeholder={s('comfyui.password.placeholder')}
|
||||
suffix={<div>{loading && <Icon spin icon={Loader2Icon} />}</div>}
|
||||
value={formValues.password}
|
||||
@@ -208,6 +217,7 @@ const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{s('comfyui.apiKey.title')}</div>
|
||||
<FormPassword
|
||||
autoComplete="new-password"
|
||||
disabled={!canManageProvider}
|
||||
placeholder={s('comfyui.apiKey.placeholder')}
|
||||
suffix={<div>{loading && <Icon spin icon={Loader2Icon} />}</div>}
|
||||
value={formValues.apiKey}
|
||||
@@ -228,6 +238,7 @@ const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
|
||||
<KeyValueEditor
|
||||
addButtonText={s('comfyui.customHeaders.addButton')}
|
||||
deleteTooltip={s('comfyui.customHeaders.deleteTooltip')}
|
||||
disabled={!canManageProvider}
|
||||
duplicateKeyErrorText={s('comfyui.customHeaders.duplicateKeyError')}
|
||||
keyPlaceholder={s('comfyui.customHeaders.keyPlaceholder')}
|
||||
value={formValues.customHeaders}
|
||||
|
||||
@@ -27,7 +27,7 @@ const ProviderApiKeyForm = memo<ProviderApiKeyFormProps>(
|
||||
const { t: errorT } = useTranslation('error');
|
||||
const [showProxy, setShow] = useState(false);
|
||||
|
||||
const { apiKey, baseURL, setConfig } = useApiKey(provider);
|
||||
const { apiKey, baseURL, canManageProvider, setConfig } = useApiKey(provider);
|
||||
const { showOpenAIProxyUrl } = useServerConfigStore(featureFlagsSelectors);
|
||||
const providerName = useProviderName(provider);
|
||||
const { loading } = use(LoadingContext);
|
||||
@@ -40,6 +40,7 @@ const ProviderApiKeyForm = memo<ProviderApiKeyFormProps>(
|
||||
>
|
||||
<FormPassword
|
||||
autoComplete={'new-password'}
|
||||
disabled={!canManageProvider}
|
||||
placeholder={apiKeyPlaceholder || 'sk-***********************'}
|
||||
suffix={<div>{loading && <Icon spin icon={Loader2Icon} />}</div>}
|
||||
value={apiKey}
|
||||
@@ -52,6 +53,7 @@ const ProviderApiKeyForm = memo<ProviderApiKeyFormProps>(
|
||||
showOpenAIProxyUrl &&
|
||||
(showProxy ? (
|
||||
<FormInput
|
||||
disabled={!canManageProvider}
|
||||
placeholder={'https://api.openai.com/v1'}
|
||||
suffix={<div>{loading && <Icon spin icon={Loader2Icon} />}</div>}
|
||||
value={baseURL}
|
||||
@@ -61,6 +63,7 @@ const ProviderApiKeyForm = memo<ProviderApiKeyFormProps>(
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!canManageProvider}
|
||||
icon={<Icon icon={Network} />}
|
||||
type={'text'}
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { aiProviderSelectors, useAiInfraStore } from '@/store/aiInfra';
|
||||
|
||||
import { LoadingContext } from './LoadingContext';
|
||||
|
||||
export const useApiKey = (provider: string) => {
|
||||
const { setLoading } = useContext(LoadingContext);
|
||||
const { allowed: canManageProvider } = usePermission('manage_provider_key');
|
||||
const updateAiProviderConfig = useAiInfraStore((s) => s.updateAiProviderConfig);
|
||||
const data = useAiInfraStore(aiProviderSelectors.providerConfigById(provider), isEqual);
|
||||
|
||||
return {
|
||||
apiKey: data?.keyVaults.apiKey,
|
||||
baseURL: data?.keyVaults?.baseURL,
|
||||
canManageProvider,
|
||||
setConfig: async (id: string, params: Record<string, string>) => {
|
||||
if (!canManageProvider) return;
|
||||
|
||||
const next = { ...data?.keyVaults, ...params };
|
||||
if (isEqual(data?.keyVaults, next)) return;
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
export interface KeyValueEditorProps {
|
||||
addButtonText?: string;
|
||||
deleteTooltip?: string;
|
||||
disabled?: boolean;
|
||||
duplicateKeyErrorText?: string;
|
||||
keyPlaceholder?: string;
|
||||
onChange?: (value: Record<string, string>) => void;
|
||||
@@ -58,6 +59,7 @@ const KeyValueEditor = memo<KeyValueEditorProps>(
|
||||
addButtonText,
|
||||
duplicateKeyErrorText,
|
||||
deleteTooltip,
|
||||
disabled,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useTranslation('components');
|
||||
@@ -73,6 +75,8 @@ const KeyValueEditor = memo<KeyValueEditorProps>(
|
||||
}, [value]);
|
||||
|
||||
const triggerChange = (newItems: KeyValueItem[]) => {
|
||||
if (disabled) return;
|
||||
|
||||
const keysCount: Record<string, number> = {};
|
||||
newItems.forEach((item) => {
|
||||
const trimmedKey = item.key.trim();
|
||||
@@ -89,21 +93,29 @@ const KeyValueEditor = memo<KeyValueEditorProps>(
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
if (disabled) return;
|
||||
|
||||
const newItems = [...items, { id: uuidv4(), key: '', value: '' }];
|
||||
triggerChange(newItems);
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
const newItems = items.filter((item) => item.id !== id);
|
||||
triggerChange(newItems);
|
||||
};
|
||||
|
||||
const handleKeyChange = (id: string, newKey: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
const newItems = items.map((item) => (item.id === id ? { ...item, key: newKey } : item));
|
||||
triggerChange(newItems);
|
||||
};
|
||||
|
||||
const handleValueChange = (id: string, newValue: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
const newItems = items.map((item) => (item.id === id ? { ...item, value: newValue } : item));
|
||||
triggerChange(newItems);
|
||||
};
|
||||
@@ -147,6 +159,7 @@ const KeyValueEditor = memo<KeyValueEditorProps>(
|
||||
<Flexbox flex={1} style={{ position: 'relative' }}>
|
||||
<FormInput
|
||||
className={styles.input}
|
||||
disabled={disabled}
|
||||
placeholder={keyPlaceholder || t('KeyValueEditor.keyPlaceholder')}
|
||||
status={isDuplicate ? 'error' : undefined}
|
||||
value={item.key}
|
||||
@@ -169,6 +182,7 @@ const KeyValueEditor = memo<KeyValueEditorProps>(
|
||||
<Flexbox flex={2}>
|
||||
<FormInput
|
||||
className={styles.input}
|
||||
disabled={disabled}
|
||||
placeholder={valuePlaceholder || t('KeyValueEditor.valuePlaceholder')}
|
||||
value={item.value}
|
||||
variant={'filled'}
|
||||
@@ -176,6 +190,7 @@ const KeyValueEditor = memo<KeyValueEditorProps>(
|
||||
/>
|
||||
</Flexbox>
|
||||
<ActionIcon
|
||||
disabled={disabled}
|
||||
icon={LucideTrash}
|
||||
size={'small'}
|
||||
style={{ marginTop: 4 }}
|
||||
@@ -187,6 +202,7 @@ const KeyValueEditor = memo<KeyValueEditorProps>(
|
||||
})}
|
||||
<Button
|
||||
block
|
||||
disabled={disabled}
|
||||
icon={<Icon icon={LucidePlus} />}
|
||||
size={'small'}
|
||||
style={{ marginTop: items.length > 0 ? 16 : 8 }}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { memo } from 'react';
|
||||
import DragUploadZone, { useUploadFiles } from '@/components/DragUploadZone';
|
||||
import { type ActionKeys } from '@/features/ChatInput';
|
||||
import { ChatInput, ChatList } from '@/features/Conversation';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors } from '@/store/agent/selectors';
|
||||
|
||||
@@ -25,13 +26,18 @@ const AgentBuilderConversation = memo<AgentBuilderConversationProps>(({ agentId
|
||||
const model = useAgentStore((s) => agentByIdSelectors.getAgentModelById(agentId)(s));
|
||||
const provider = useAgentStore((s) => agentByIdSelectors.getAgentModelProviderById(agentId)(s));
|
||||
const { handleUploadFiles } = useUploadFiles({ model, provider });
|
||||
const { allowed: canCreate } = usePermission('create_content');
|
||||
|
||||
return (
|
||||
<DragUploadZone style={{ flex: 1, height: '100%' }} onUploadFiles={handleUploadFiles}>
|
||||
<DragUploadZone
|
||||
disabled={!canCreate}
|
||||
style={{ flex: 1, height: '100%' }}
|
||||
onUploadFiles={handleUploadFiles}
|
||||
>
|
||||
<Flexbox flex={1} height={'100%'}>
|
||||
<TopicSelector agentId={agentId} />
|
||||
<TopicSelector agentId={agentId} disabled={!canCreate} />
|
||||
<Flexbox flex={1} style={{ overflow: 'hidden' }}>
|
||||
<ChatList welcome={<AgentBuilderWelcome />} />
|
||||
<ChatList welcome={<AgentBuilderWelcome disabled={!canCreate} />} />
|
||||
</Flexbox>
|
||||
<ChatInput leftActions={actions} rightActions={rightActions} showControlBar={false} />
|
||||
</Flexbox>
|
||||
|
||||
@@ -12,10 +12,11 @@ import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors } from '@/store/agent/selectors';
|
||||
|
||||
interface AgentBuilderWelcomeProps {
|
||||
disabled?: boolean;
|
||||
mode?: SuggestMode;
|
||||
}
|
||||
|
||||
const AgentBuilderWelcome = memo<AgentBuilderWelcomeProps>(({ mode = 'agent' }) => {
|
||||
const AgentBuilderWelcome = memo<AgentBuilderWelcomeProps>(({ disabled, mode = 'agent' }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const agentId = useConversationStore(conversationSelectors.agentId);
|
||||
const agent = useAgentStore(agentByIdSelectors.getAgentConfigById(agentId));
|
||||
@@ -37,7 +38,7 @@ const AgentBuilderWelcome = memo<AgentBuilderWelcomeProps>(({ mode = 'agent' })
|
||||
<Markdown fontSize={14} variant={'chat'}>
|
||||
{t('agentBuilder.welcome')}
|
||||
</Markdown>
|
||||
<SuggestQuestions count={3} mode={mode} />
|
||||
<SuggestQuestions count={3} disabled={disabled} mode={mode} />
|
||||
</Flexbox>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import TopicSelector from './TopicSelector';
|
||||
|
||||
const switchTopic = vi.fn();
|
||||
const useFetchTopics = vi.fn();
|
||||
|
||||
vi.mock('@lobehub/ui', () => ({
|
||||
ActionIcon: ({ disabled, onClick, title }: any) => (
|
||||
<button disabled={disabled} type="button" onClick={onClick}>
|
||||
{title}
|
||||
</button>
|
||||
),
|
||||
DropdownMenu: ({ children }: any) => <div>{children}</div>,
|
||||
Flexbox: ({ children }: any) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
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) => (
|
||||
<div>
|
||||
{left}
|
||||
{right}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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(<TopicSelector disabled agentId="agent-builder" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'actions.addNewTopic' }));
|
||||
|
||||
expect(switchTopic).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -29,9 +29,10 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
|
||||
interface TopicSelectorProps {
|
||||
agentId: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const TopicSelector = memo<TopicSelectorProps>(({ agentId }) => {
|
||||
const TopicSelector = memo<TopicSelectorProps>(({ agentId, disabled }) => {
|
||||
const { t } = useTranslation('topic');
|
||||
|
||||
// Fetch topics for the agent builder
|
||||
@@ -70,6 +71,7 @@ const TopicSelector = memo<TopicSelectorProps>(({ agentId }) => {
|
||||
</Flexbox>
|
||||
),
|
||||
onCheckedChange: (checked) => {
|
||||
if (disabled) return;
|
||||
if (checked) {
|
||||
switchTopic(topic.id);
|
||||
}
|
||||
@@ -90,18 +92,23 @@ const TopicSelector = memo<TopicSelectorProps>(({ agentId }) => {
|
||||
right={
|
||||
<>
|
||||
<ActionIcon
|
||||
disabled={disabled}
|
||||
icon={PlusIcon}
|
||||
size={DESKTOP_HEADER_ICON_SMALL_SIZE}
|
||||
title={t('actions.addNewTopic')}
|
||||
onClick={() => switchTopic()}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
|
||||
switchTopic();
|
||||
}}
|
||||
/>
|
||||
<DropdownMenu
|
||||
items={items}
|
||||
placement="bottomRight"
|
||||
popupProps={{ style: { maxHeight: 400, minWidth: 280, overflowY: 'auto' } }}
|
||||
triggerProps={{ disabled: isEmpty }}
|
||||
triggerProps={{ disabled: disabled || isEmpty }}
|
||||
>
|
||||
<ActionIcon disabled={isEmpty} icon={Clock3Icon} />
|
||||
<ActionIcon disabled={disabled || isEmpty} icon={Clock3Icon} />
|
||||
</DropdownMenu>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
/>
|
||||
<Flexbox horizontal gap={12} style={{ overflowX: 'auto', paddingBottom: 4 }}>
|
||||
{topics.map((topic) => (
|
||||
<Link
|
||||
<WorkspaceLink
|
||||
key={topic.id}
|
||||
style={{ color: 'inherit', flexShrink: 0, textDecoration: 'none' }}
|
||||
to={SESSION_CHAT_TOPIC_URL(aid!, topic.id)}
|
||||
@@ -60,7 +61,7 @@ const AgentRecentTopics = memo(() => {
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</Block>
|
||||
</Link>
|
||||
</WorkspaceLink>
|
||||
))}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
|
||||
@@ -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<SectionHeaderProps>(({ icon, title, actionLabel, acti
|
||||
<Text color={cssVar.colorTextSecondary}>{title}</Text>
|
||||
</Flexbox>
|
||||
{actionLabel && actionUrl && (
|
||||
<Link to={actionUrl} style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<WorkspaceLink style={{ color: 'inherit', textDecoration: 'none' }} to={actionUrl}>
|
||||
<Text fontSize={12} type={'secondary'}>
|
||||
{actionLabel}
|
||||
</Text>
|
||||
</Link>
|
||||
</WorkspaceLink>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
@@ -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<AgentProfilePopupProps>(
|
||||
({ 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);
|
||||
|
||||
|
||||
@@ -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<AutoGenerateAvatarProps>(
|
||||
({ 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<AutoGenerateAvatarProps>(
|
||||
value={value}
|
||||
style={{
|
||||
background: cssVar.colorFillTertiary,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
onChange={(next) => {
|
||||
if (disabled) return;
|
||||
|
||||
onChange?.(next);
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<ActionIcon
|
||||
disabled={!canAutoGenerate}
|
||||
disabled={disabled || !canAutoGenerate}
|
||||
icon={Wand2}
|
||||
loading={loading}
|
||||
size="small"
|
||||
|
||||
@@ -6,11 +6,19 @@ import { useTranslation } from 'react-i18next';
|
||||
import { DEFAULT_BACKGROUND_COLOR } from '@/const/meta';
|
||||
|
||||
interface BackgroundSwatchesProps extends Omit<ColorSwatchesProps, 'colors'> {
|
||||
disabled?: boolean;
|
||||
onValuesChange?: ColorSwatchesProps['onChange'];
|
||||
}
|
||||
|
||||
const BackgroundSwatches = memo<BackgroundSwatchesProps>(
|
||||
({ 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<BackgroundSwatchesProps>(
|
||||
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);
|
||||
}}
|
||||
|
||||
@@ -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) => [
|
||||
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: (
|
||||
<AutoGenerate
|
||||
canAutoGenerate={hasSystemRole}
|
||||
canAutoGenerate={hasSystemRole && !disabled}
|
||||
disabled={disabled}
|
||||
loading={loadingState?.[item.key]}
|
||||
placeholder={item.placeholder}
|
||||
onGenerate={() => {
|
||||
if (disabled) return;
|
||||
|
||||
autocompleteMeta(item.key as keyof typeof meta);
|
||||
}}
|
||||
/>
|
||||
@@ -85,9 +91,14 @@ const AgentMeta = memo(() => {
|
||||
children: (
|
||||
<AutoGenerateAvatar
|
||||
background={background}
|
||||
canAutoGenerate={hasSystemRole}
|
||||
canAutoGenerate={hasSystemRole && !disabled}
|
||||
disabled={disabled}
|
||||
loading={loadingState?.['avatar']}
|
||||
onGenerate={() => autocompleteMeta('avatar')}
|
||||
onGenerate={() => {
|
||||
if (disabled) return;
|
||||
|
||||
autocompleteMeta('avatar');
|
||||
}}
|
||||
/>
|
||||
),
|
||||
label: t('settingAgent.avatar.title'),
|
||||
@@ -96,7 +107,9 @@ const AgentMeta = memo(() => {
|
||||
name: 'avatar',
|
||||
},
|
||||
{
|
||||
children: <BackgroundSwatches onValuesChange={(c) => setBackground(c)} />,
|
||||
children: (
|
||||
<BackgroundSwatches disabled={disabled} onValuesChange={(c) => setBackground(c)} />
|
||||
),
|
||||
label: t('settingAgent.backgroundColor.title'),
|
||||
minWidth: undefined,
|
||||
name: 'backgroundColor',
|
||||
@@ -112,7 +125,7 @@ const AgentMeta = memo(() => {
|
||||
}
|
||||
>
|
||||
<Button
|
||||
disabled={!hasSystemRole}
|
||||
disabled={disabled || !hasSystemRole}
|
||||
icon={Wand2}
|
||||
iconPlacement={'end'}
|
||||
loading={Object.values(loadingState as any).some((i) => !!i)}
|
||||
@@ -122,6 +135,8 @@ const AgentMeta = memo(() => {
|
||||
}}
|
||||
onClick={(e: any) => {
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
|
||||
autocompleteAllMeta(true);
|
||||
}}
|
||||
>
|
||||
@@ -134,14 +149,18 @@ const AgentMeta = memo(() => {
|
||||
|
||||
return (
|
||||
<Form
|
||||
disabled={!isAgentEditable}
|
||||
disabled={disabled || !isAgentEditable}
|
||||
footer={<Form.SubmitFooter />}
|
||||
form={form}
|
||||
initialValues={meta}
|
||||
items={[metaData]}
|
||||
itemsType={'group'}
|
||||
variant={'borderless'}
|
||||
onFinish={updateMeta}
|
||||
onFinish={(values) => {
|
||||
if (disabled) return;
|
||||
|
||||
updateMeta(values);
|
||||
}}
|
||||
{...FORM_STYLE}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -28,22 +28,26 @@ const OpeningMessage = memo(() => {
|
||||
const { t } = useTranslation('setting');
|
||||
|
||||
const openingMessage = useStore(selectors.openingMessage);
|
||||
const updateConfig = useStore((s) => s.setAgentConfig);
|
||||
const [disabled, updateConfig] = useStore((s) => [s.disabled, s.setAgentConfig]);
|
||||
const setOpeningMessage = useCallback(
|
||||
(message: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
updateConfig({ openingMessage: message });
|
||||
},
|
||||
[updateConfig],
|
||||
[disabled, updateConfig],
|
||||
);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
setEditing(true);
|
||||
}, []);
|
||||
if (disabled) return;
|
||||
|
||||
const editIconButton = !editing && openingMessage && (
|
||||
<Button size={'small'} onClick={handleEdit}>
|
||||
setEditing(true);
|
||||
}, [disabled]);
|
||||
|
||||
const editIconButton = !editing && openingMessage && !disabled && (
|
||||
<Button disabled={disabled} size={'small'} onClick={handleEdit}>
|
||||
<PencilLine size={16} />
|
||||
</Button>
|
||||
);
|
||||
@@ -52,11 +56,11 @@ const OpeningMessage = memo(() => {
|
||||
<div className={styles.wrapper}>
|
||||
<Flexbox direction={'horizontal'}>
|
||||
<EditableMessage
|
||||
showEditWhenEmpty
|
||||
editButtonSize={'small'}
|
||||
editing={editing}
|
||||
height={'auto'}
|
||||
placeholder={t('settingOpening.openingMessage.placeholder')}
|
||||
showEditWhenEmpty={!disabled}
|
||||
value={openingMessage ?? ''}
|
||||
variant={'borderless'}
|
||||
classNames={{
|
||||
@@ -67,7 +71,11 @@ const OpeningMessage = memo(() => {
|
||||
confirm: t('ok', { ns: 'common' }),
|
||||
}}
|
||||
onChange={setOpeningMessage}
|
||||
onEditingChange={setEditing}
|
||||
onEditingChange={(next) => {
|
||||
if (disabled) return;
|
||||
|
||||
setEditing(next);
|
||||
}}
|
||||
/>
|
||||
{editIconButton}
|
||||
</Flexbox>
|
||||
|
||||
@@ -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(() => {
|
||||
<Flexbox gap={4} width={'100%'}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
placeholder={t('settingOpening.openingQuestions.placeholder')}
|
||||
style={{ flex: 1 }}
|
||||
value={questionInput}
|
||||
@@ -98,7 +108,7 @@ const OpeningQuestions = memo(() => {
|
||||
/>
|
||||
<Button
|
||||
// don't allow repeat
|
||||
disabled={openingQuestions.includes(questionInput.trim())}
|
||||
disabled={disabled || openingQuestions.includes(questionInput.trim())}
|
||||
icon={PlusIcon}
|
||||
onClick={addQuestion}
|
||||
/>
|
||||
@@ -119,9 +129,10 @@ const OpeningQuestions = memo(() => {
|
||||
id={item.id}
|
||||
variant={'filled'}
|
||||
>
|
||||
<SortableList.DragHandle />
|
||||
{!disabled && <SortableList.DragHandle />}
|
||||
<div className={styles.questionItemContent}>{item.content}</div>
|
||||
<ActionIcon
|
||||
disabled={disabled}
|
||||
icon={Trash}
|
||||
size={'small'}
|
||||
onClick={() => removeQuestion(item.content)}
|
||||
|
||||
@@ -9,8 +9,11 @@ import DevModal from '@/features/PluginDevModal';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
|
||||
import { useStore } from '../store';
|
||||
|
||||
const AddPluginButton = ({ ref, ...props }: ButtonProps & { ref?: Ref<HTMLButtonElement> }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const disabled = useStore((s) => s.disabled);
|
||||
const [showModal, setModal] = useState(false);
|
||||
const toggleAgentPlugin = useAgentStore((s) => s.toggleAgentPlugin);
|
||||
const [installCustomPlugin, updateNewDevPlugin] = useToolStore((s) => [
|
||||
@@ -25,19 +28,29 @@ const AddPluginButton = ({ ref, ...props }: ButtonProps & { ref?: Ref<HTMLButton
|
||||
}}
|
||||
>
|
||||
<DevModal
|
||||
open={showModal}
|
||||
onOpenChange={setModal}
|
||||
open={!disabled && showModal}
|
||||
onValueChange={updateNewDevPlugin}
|
||||
onOpenChange={(next) => {
|
||||
if (disabled) return;
|
||||
|
||||
setModal(next);
|
||||
}}
|
||||
onSave={async (devPlugin) => {
|
||||
if (disabled) return;
|
||||
|
||||
await installCustomPlugin(devPlugin);
|
||||
toggleAgentPlugin(devPlugin.identifier);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
{...props}
|
||||
disabled={disabled}
|
||||
icon={Grid2x2Plus}
|
||||
ref={ref}
|
||||
size={'small'}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
|
||||
setModal(true);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -5,14 +5,21 @@ import { memo } from 'react';
|
||||
import { useStore } from '../store';
|
||||
|
||||
const MarketList = memo<{ id: string }>(({ id }) => {
|
||||
const [toggleAgentPlugin, hasPlugin] = useStore((s) => [s.toggleAgentPlugin, !!s.config.plugins]);
|
||||
const [toggleAgentPlugin, hasPlugin, disabled] = useStore((s) => [
|
||||
s.toggleAgentPlugin,
|
||||
!!s.config.plugins,
|
||||
s.disabled,
|
||||
]);
|
||||
const plugins = useStore((s) => s.config.plugins || []);
|
||||
|
||||
return (
|
||||
<Flexbox horizontal align={'center'} gap={8}>
|
||||
<Switch
|
||||
checked={!hasPlugin ? false : plugins.includes(id)}
|
||||
disabled={disabled}
|
||||
onChange={() => {
|
||||
if (disabled) return;
|
||||
|
||||
toggleAgentPlugin(id);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -9,15 +9,17 @@ import { useStore } from '../../store';
|
||||
|
||||
const PluginSwitch = memo<{ identifier: string }>(({ identifier }) => {
|
||||
const pluginManifestLoading = useToolStore((s) => s.pluginInstallLoading, isEqual);
|
||||
const [userEnabledPlugins, hasPlugin, toggleAgentPlugin] = useStore((s) => [
|
||||
const [userEnabledPlugins, hasPlugin, disabled, toggleAgentPlugin] = useStore((s) => [
|
||||
s.config.plugins || [],
|
||||
!!s.config.plugins,
|
||||
s.disabled,
|
||||
s.toggleAgentPlugin,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Flexbox horizontal align={'center'} gap={8}>
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
loading={pluginManifestLoading[identifier]}
|
||||
checked={
|
||||
// If loading, it means it's activated
|
||||
@@ -26,6 +28,8 @@ const PluginSwitch = memo<{ identifier: string }>(({ identifier }) => {
|
||||
: userEnabledPlugins.includes(identifier)
|
||||
}
|
||||
onChange={() => {
|
||||
if (disabled) return;
|
||||
|
||||
toggleAgentPlugin(identifier);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -7,12 +7,13 @@ import isEqual from 'fast-deep-equal';
|
||||
import { BlocksIcon, LucideTrash2, Store } from 'lucide-react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import PluginAvatar from '@/components/Plugins/PluginAvatar';
|
||||
import PluginTag from '@/components/Plugins/PluginTag';
|
||||
import { FORM_STYLE } from '@/const/layoutTokens';
|
||||
import { createSkillStoreModal } from '@/features/SkillStore';
|
||||
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
|
||||
import WorkspaceLink from '@/features/Workspace/WorkspaceLink';
|
||||
import { useFetchInstalledPlugins } from '@/hooks/useFetchInstalledPlugins';
|
||||
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||
import { pluginHelpers, useToolStore } from '@/store/tool';
|
||||
@@ -27,14 +28,15 @@ import PluginAction from './PluginAction';
|
||||
const AgentPlugin = memo(() => {
|
||||
const { t } = useTranslation('setting');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const navigate = useWorkspaceAwareNavigate();
|
||||
|
||||
const handleOpenStore = useCallback(() => {
|
||||
createSkillStoreModal();
|
||||
}, []);
|
||||
|
||||
const [userEnabledPlugins, toggleAgentPlugin] = useStore((s) => [
|
||||
const [userEnabledPlugins, disabled, toggleAgentPlugin] = useStore((s) => [
|
||||
s.config.plugins || [],
|
||||
s.disabled,
|
||||
s.toggleAgentPlugin,
|
||||
]);
|
||||
|
||||
@@ -79,7 +81,10 @@ const AgentPlugin = memo(() => {
|
||||
children: (
|
||||
<Switch
|
||||
checked={true}
|
||||
disabled={disabled}
|
||||
onChange={() => {
|
||||
if (disabled) return;
|
||||
|
||||
toggleAgentPlugin(id);
|
||||
}}
|
||||
/>
|
||||
@@ -105,10 +110,13 @@ const AgentPlugin = memo(() => {
|
||||
{hasDeprecated ? (
|
||||
<Tooltip title={t('plugin.clearDeprecated')}>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
icon={LucideTrash2}
|
||||
size={'small'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
|
||||
for (const i of deprecatedList) {
|
||||
toggleAgentPlugin(i.tag as string);
|
||||
}
|
||||
@@ -140,7 +148,7 @@ const AgentPlugin = memo(() => {
|
||||
description={
|
||||
<Trans i18nKey={'plugin.empty'} ns={'setting'}>
|
||||
暂无安装插件,
|
||||
<Link
|
||||
<WorkspaceLink
|
||||
to={'/community/mcp'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -150,7 +158,7 @@ const AgentPlugin = memo(() => {
|
||||
}}
|
||||
>
|
||||
前往插件市场
|
||||
</Link>
|
||||
</WorkspaceLink>
|
||||
安装
|
||||
</Trans>
|
||||
}
|
||||
|
||||
@@ -16,9 +16,13 @@ const AgentPrompt = memo(() => {
|
||||
const { t } = useTranslation('setting');
|
||||
const isMobile = useServerConfigStore((s) => s.isMobile);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [systemRole, updateConfig] = useStore((s) => [s.config.systemRole, s.setAgentConfig]);
|
||||
const [systemRole, disabled, updateConfig] = useStore((s) => [
|
||||
s.config.systemRole,
|
||||
s.disabled,
|
||||
s.setAgentConfig,
|
||||
]);
|
||||
|
||||
const editButton = !editing && !!systemRole && (
|
||||
const editButton = !editing && !!systemRole && !disabled && (
|
||||
<Button
|
||||
icon={PenLineIcon}
|
||||
iconPlacement={'end'}
|
||||
@@ -45,10 +49,10 @@ const AgentPrompt = memo(() => {
|
||||
children: (
|
||||
<Flexbox paddingBlock={isMobile ? 16 : 0}>
|
||||
<EditableMessage
|
||||
showEditWhenEmpty
|
||||
editing={editing}
|
||||
height={'auto'}
|
||||
placeholder={t('settingAgent.prompt.placeholder')}
|
||||
showEditWhenEmpty={!disabled}
|
||||
value={systemRole}
|
||||
variant={'borderless'}
|
||||
markdownProps={{
|
||||
@@ -58,10 +62,16 @@ const AgentPrompt = memo(() => {
|
||||
cancel: t('cancel', { ns: 'common' }),
|
||||
confirm: t('ok', { ns: 'common' }),
|
||||
}}
|
||||
onEditingChange={setEditing}
|
||||
onChange={(e) => {
|
||||
if (disabled) return;
|
||||
|
||||
updateConfig({ systemRole: e });
|
||||
}}
|
||||
onEditingChange={(next) => {
|
||||
if (disabled) return;
|
||||
|
||||
setEditing(next);
|
||||
}}
|
||||
/>
|
||||
{!editing && !!systemRole && <Tokens value={systemRole} />}
|
||||
</Flexbox>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { selectors, useStore } from '../store';
|
||||
const AgentSelfIteration = memo(() => {
|
||||
const { t } = useTranslation('setting');
|
||||
const [form] = Form.useForm();
|
||||
const updateConfig = useStore((s) => s.setChatConfig);
|
||||
const [disabled, updateConfig] = useStore((s) => [s.disabled, s.setChatConfig]);
|
||||
const config = useStore(selectors.currentChatConfig, isEqual);
|
||||
const isInbox = useAgentStore(builtinAgentSelectors.isInboxAgent);
|
||||
|
||||
@@ -45,13 +45,18 @@ const AgentSelfIteration = memo(() => {
|
||||
|
||||
return (
|
||||
<Form
|
||||
disabled={disabled}
|
||||
footer={isInbox ? undefined : <Form.SubmitFooter />}
|
||||
form={form}
|
||||
initialValues={config}
|
||||
items={[selfIteration]}
|
||||
itemsType={'group'}
|
||||
variant={'borderless'}
|
||||
onFinish={updateConfig}
|
||||
onFinish={(values) => {
|
||||
if (disabled) return;
|
||||
|
||||
updateConfig(values);
|
||||
}}
|
||||
{...FORM_STYLE}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ const AgentTTS = memo(() => {
|
||||
return (all?: boolean) => new VoiceList(all ? undefined : locale);
|
||||
});
|
||||
const config = useStore(selectors.currentTtsConfig, isEqual);
|
||||
const updateConfig = useStore((s) => s.setAgentConfig);
|
||||
const [disabled, updateConfig] = useStore((s) => [s.disabled, s.setAgentConfig]);
|
||||
|
||||
const { edgeVoiceOptions, microsoftVoiceOptions } = useMemo(
|
||||
() => voiceList(config.showAllLocaleVoice),
|
||||
@@ -96,6 +96,7 @@ const AgentTTS = memo(() => {
|
||||
|
||||
return (
|
||||
<Form
|
||||
disabled={disabled}
|
||||
footer={<Form.SubmitFooter />}
|
||||
form={form}
|
||||
items={[tts]}
|
||||
@@ -104,7 +105,11 @@ const AgentTTS = memo(() => {
|
||||
initialValues={{
|
||||
[TTS_SETTING_KEY]: config,
|
||||
}}
|
||||
onFinish={updateConfig}
|
||||
onFinish={(values) => {
|
||||
if (disabled) return;
|
||||
|
||||
updateConfig(values);
|
||||
}}
|
||||
{...FORM_STYLE}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -10,13 +10,13 @@ import { type State } from './store';
|
||||
import { useStoreApi } from './store';
|
||||
|
||||
export interface StoreUpdaterProps extends Partial<
|
||||
Pick<State, 'onMetaChange' | 'onConfigChange' | 'meta' | 'config' | 'id' | 'loading'>
|
||||
Pick<State, 'onMetaChange' | 'onConfigChange' | 'meta' | 'config' | 'disabled' | 'id' | 'loading'>
|
||||
> {
|
||||
instanceRef?: ForwardedRef<AgentSettingsInstance> | null;
|
||||
}
|
||||
|
||||
const StoreUpdater = memo<StoreUpdaterProps>(
|
||||
({ onConfigChange, instanceRef, id, onMetaChange, meta, config, loading }) => {
|
||||
({ onConfigChange, instanceRef, id, onMetaChange, meta, config, disabled, loading }) => {
|
||||
const storeApi = useStoreApi();
|
||||
const useStoreUpdater = createStoreUpdater(storeApi);
|
||||
|
||||
@@ -24,6 +24,7 @@ const StoreUpdater = memo<StoreUpdaterProps>(
|
||||
useStoreUpdater('config', config!);
|
||||
useStoreUpdater('onConfigChange', onConfigChange);
|
||||
useStoreUpdater('onMetaChange', onMetaChange);
|
||||
useStoreUpdater('disabled', disabled);
|
||||
useStoreUpdater('loading', loading);
|
||||
useStoreUpdater('id', id);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export type SaveStatus = 'idle' | 'saving' | 'saved';
|
||||
|
||||
export interface State {
|
||||
config: LobeAgentConfig;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
lastUpdatedTime?: Date | null;
|
||||
loading?: boolean;
|
||||
@@ -20,6 +21,7 @@ export interface State {
|
||||
|
||||
export const initialState: State = {
|
||||
config: DEFAULT_AGENT_CONFIG,
|
||||
disabled: false,
|
||||
lastUpdatedTime: null,
|
||||
loading: true,
|
||||
loadingState: {
|
||||
|
||||
@@ -48,13 +48,15 @@ export interface SkillEditFormValues {
|
||||
}
|
||||
|
||||
interface SkillEditFormProps {
|
||||
disabled?: boolean;
|
||||
form: FormInstance;
|
||||
initialValues: SkillEditFormValues;
|
||||
name?: string;
|
||||
onSubmit: (values: SkillEditFormValues) => void;
|
||||
}
|
||||
|
||||
const SkillEditForm = memo<SkillEditFormProps>(({ name, form, initialValues, onSubmit }) => {
|
||||
const SkillEditForm = memo<SkillEditFormProps>(
|
||||
({ name, disabled, form, initialValues, onSubmit }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const editor = useEditor();
|
||||
const currentValueRef = useRef(initialValues.content);
|
||||
@@ -84,13 +86,14 @@ const SkillEditForm = memo<SkillEditFormProps>(({ name, form, initialValues, onS
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(e: any) => {
|
||||
if (disabled) return;
|
||||
const nextContent = (e.getDocument('markdown') as unknown as string) || '';
|
||||
if (nextContent !== currentValueRef.current) {
|
||||
currentValueRef.current = nextContent;
|
||||
form.setFieldValue('content', nextContent);
|
||||
}
|
||||
},
|
||||
[form],
|
||||
[disabled, form],
|
||||
);
|
||||
|
||||
const items: FormItemProps[] = [
|
||||
@@ -103,6 +106,7 @@ const SkillEditForm = memo<SkillEditFormProps>(({ name, form, initialValues, onS
|
||||
children: (
|
||||
<Input.TextArea
|
||||
autoSize={{ maxRows: 4, minRows: 2 }}
|
||||
disabled={disabled}
|
||||
placeholder={t('agentSkillModal.descriptionPlaceholder')}
|
||||
/>
|
||||
),
|
||||
@@ -112,7 +116,10 @@ const SkillEditForm = memo<SkillEditFormProps>(({ name, form, initialValues, onS
|
||||
},
|
||||
{
|
||||
children: (
|
||||
<div className={styles.editorWrapper}>
|
||||
<div
|
||||
className={styles.editorWrapper}
|
||||
style={{ pointerEvents: disabled ? 'none' : undefined }}
|
||||
>
|
||||
<Editor
|
||||
content={''}
|
||||
editor={editor}
|
||||
@@ -149,7 +156,8 @@ const SkillEditForm = memo<SkillEditFormProps>(({ name, form, initialValues, onS
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
SkillEditForm.displayName = 'SkillEditForm';
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ContentViewer from '@/features/AgentSkillDetail/ContentViewer';
|
||||
import FileTree from '@/features/FileTree';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
|
||||
import SkillEditForm, { type SkillEditFormValues } from './SkillEditForm';
|
||||
@@ -60,6 +61,7 @@ const AgentSkillEdit = memo<AgentSkillEditProps>(({ skillId, open, onClose }) =>
|
||||
const { t: tp } = useTranslation('plugin');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const { message } = App.useApp();
|
||||
const { allowed: canEdit } = usePermission('edit_own_content');
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState('SKILL.md');
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -84,6 +86,7 @@ const AgentSkillEdit = memo<AgentSkillEditProps>(({ skillId, open, onClose }) =>
|
||||
);
|
||||
|
||||
const handleSubmit = async (values: SkillEditFormValues) => {
|
||||
if (!canEdit) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateAgentSkill({
|
||||
@@ -99,6 +102,7 @@ const AgentSkillEdit = memo<AgentSkillEditProps>(({ skillId, open, onClose }) =>
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!canEdit) return;
|
||||
await deleteAgentSkill(skillId);
|
||||
message.success(tp('dev.deleteSuccess'));
|
||||
onClose();
|
||||
@@ -114,15 +118,19 @@ const AgentSkillEdit = memo<AgentSkillEditProps>(({ skillId, open, onClose }) =>
|
||||
title={tp('dev.confirmDeleteDevPlugin')}
|
||||
okButtonProps={{
|
||||
danger: true,
|
||||
disabled: !canEdit,
|
||||
type: 'primary',
|
||||
}}
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
<Button danger>{tc('delete')}</Button>
|
||||
<Button danger disabled={!canEdit}>
|
||||
{tc('delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Flexbox horizontal gap={12}>
|
||||
<Button onClick={onClose}>{tc('cancel')}</Button>
|
||||
<Button
|
||||
disabled={!canEdit}
|
||||
loading={saving}
|
||||
type={'primary'}
|
||||
onClick={() => {
|
||||
@@ -181,6 +189,7 @@ const AgentSkillEdit = memo<AgentSkillEditProps>(({ skillId, open, onClose }) =>
|
||||
}}
|
||||
>
|
||||
<SkillEditForm
|
||||
disabled={!canEdit}
|
||||
form={form}
|
||||
initialValues={initialValues}
|
||||
name={skillDetail?.name}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
insertFilesIntoEditor,
|
||||
} from '@/features/EditorCanvas/editorAttachments';
|
||||
import { useEnterToSend } from '@/hooks/useEnterToSend';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useUserAvatar } from '@/hooks/useUserAvatar';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
|
||||
@@ -18,6 +19,7 @@ import { styles } from '../shared/style';
|
||||
|
||||
const CommentInput = memo<{ taskId: string }>(({ taskId }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const { allowed: canEditTask } = usePermission('create_content');
|
||||
const editor = useEditor();
|
||||
const addComment = useTaskStore((s) => s.addComment);
|
||||
const userAvatar = useUserAvatar();
|
||||
@@ -46,7 +48,7 @@ const CommentInput = memo<{ taskId: string }>(({ taskId }) => {
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (submitting) return;
|
||||
if (!canEditTask || submitting) return;
|
||||
const json = editor?.getDocument?.('json') as unknown;
|
||||
const markdown = String(editor?.getDocument?.('markdown') ?? '').trim();
|
||||
const hasFiles = getAttachmentFileIdsFromEditor(editor).length > 0;
|
||||
@@ -61,7 +63,7 @@ const CommentInput = memo<{ taskId: string }>(({ taskId }) => {
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [taskId, editor, addComment, submitting]);
|
||||
}, [canEditTask, taskId, editor, addComment, submitting]);
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.commentInputCard} gap={6}>
|
||||
@@ -75,6 +77,7 @@ const CommentInput = memo<{ taskId: string }>(({ taskId }) => {
|
||||
style={{ fontSize: 14, minHeight: 24, paddingBlock: 0 }}
|
||||
onContentChange={handleContentChange}
|
||||
onPressEnter={({ event }) => {
|
||||
if (!canEditTask) return true;
|
||||
if (shouldSendOnEnter(event)) {
|
||||
handleSubmit();
|
||||
return true;
|
||||
@@ -85,7 +88,7 @@ const CommentInput = memo<{ taskId: string }>(({ taskId }) => {
|
||||
<Flexbox horizontal align={'center'} gap={4} style={{ flexShrink: 0 }}>
|
||||
<AttachmentUploadButton onFiles={handleAttach} />
|
||||
<SendButton
|
||||
disabled={!canSubmit && !submitting}
|
||||
disabled={!canEditTask || (!canSubmit && !submitting)}
|
||||
loading={submitting}
|
||||
shape={'round'}
|
||||
type={'text'}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import TaskDetailHeaderActions from './TaskDetailHeaderActions';
|
||||
|
||||
interface MenuItem {
|
||||
key?: string;
|
||||
label?: ReactNode;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
deleteTask: vi.fn(),
|
||||
dropdownItems: [] as MenuItem[],
|
||||
messageSuccess: vi.fn(),
|
||||
modalConfirm: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
taskState: {
|
||||
activeTaskId: 'T-1' as string | undefined,
|
||||
taskDetailMap: {} as Record<string, unknown>,
|
||||
},
|
||||
transferItems: [
|
||||
{ key: 'transfer-task', label: 'Transfer to...' },
|
||||
{ key: 'copy-task', label: 'Copy to...' },
|
||||
] as MenuItem[],
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/ui', () => ({
|
||||
ActionIcon: ({ title }: { title?: string }) => <button type="button">{title}</button>,
|
||||
DropdownMenu: ({ children, items }: { children?: ReactNode; items: MenuItem[] }) => {
|
||||
mocks.dropdownItems = items;
|
||||
return <>{children}</>;
|
||||
},
|
||||
Icon: () => <span />,
|
||||
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(<TaskDetailHeaderActions />);
|
||||
|
||||
expect(mocks.dropdownItems.map((item) => item?.key)).toContain('transfer-task');
|
||||
expect(mocks.dropdownItems.map((item) => item?.key)).toContain('copy-task');
|
||||
});
|
||||
});
|
||||
@@ -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<DropdownItem[]>(() => {
|
||||
if (!taskId) return [];
|
||||
|
||||
const taskUrl = `${appOrigin}${taskDetailPath(taskId, taskAgentId ?? undefined)}`;
|
||||
|
||||
return [
|
||||
const baseItems: DropdownItem[] = [
|
||||
{
|
||||
icon: <Icon icon={CopyIcon} />,
|
||||
key: 'copyId',
|
||||
@@ -62,13 +67,18 @@ const TaskDetailHeaderActions = memo(() => {
|
||||
{ type: 'divider' },
|
||||
{
|
||||
danger: true,
|
||||
disabled: !canEditTask,
|
||||
icon: <Icon icon={Trash} />,
|
||||
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;
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
<Flexbox horizontal align={'center'} gap={12}>
|
||||
<Space.Compact>
|
||||
<Button
|
||||
disabled={isRunningNow}
|
||||
disabled={!canEditTask || isRunningNow}
|
||||
icon={CalendarOffIcon}
|
||||
loading={isCancellingSchedule}
|
||||
title={canEditTask ? undefined : reason}
|
||||
onClick={handleCancelSchedule}
|
||||
>
|
||||
{t('taskDetail.cancelSchedule')}
|
||||
@@ -143,7 +150,7 @@ const TaskDetailRunPauseAction = memo(() => {
|
||||
<DropdownMenu
|
||||
items={[
|
||||
{
|
||||
disabled: isRunningNow || isCancellingSchedule,
|
||||
disabled: !canEditTask || isRunningNow || isCancellingSchedule,
|
||||
icon: PlayIcon,
|
||||
key: 'runNow',
|
||||
label: t('taskDetail.runNow'),
|
||||
@@ -151,7 +158,12 @@ const TaskDetailRunPauseAction = memo(() => {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button disabled={isCancellingSchedule} icon={ChevronDown} loading={isRunningNow} />
|
||||
<Button
|
||||
disabled={!canEditTask || isCancellingSchedule}
|
||||
icon={ChevronDown}
|
||||
loading={isRunningNow}
|
||||
title={canEditTask ? undefined : reason}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
</Space.Compact>
|
||||
{countdownText && (
|
||||
@@ -176,7 +188,12 @@ const TaskDetailRunPauseAction = memo(() => {
|
||||
|
||||
if (canPause) {
|
||||
return (
|
||||
<Button icon={StopLoadingIcon} onClick={handleRunOrPause}>
|
||||
<Button
|
||||
disabled={!canEditTask}
|
||||
icon={StopLoadingIcon}
|
||||
title={reason}
|
||||
onClick={handleRunOrPause}
|
||||
>
|
||||
{t('taskDetail.stopTask')}
|
||||
</Button>
|
||||
);
|
||||
@@ -186,7 +203,13 @@ const TaskDetailRunPauseAction = memo(() => {
|
||||
const runIcon = isRerun ? RotateCcwIcon : PlayIcon;
|
||||
|
||||
return (
|
||||
<Button icon={runIcon} type={'primary'} onClick={handleRunOrPause}>
|
||||
<Button
|
||||
disabled={!canEditTask}
|
||||
icon={runIcon}
|
||||
title={canEditTask ? undefined : reason}
|
||||
type={'primary'}
|
||||
onClick={handleRunOrPause}
|
||||
>
|
||||
{runLabel}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -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(() => {
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 1 }}
|
||||
className={styles.titleInput}
|
||||
disabled={!canEditTask}
|
||||
placeholder={t('taskDetail.titlePlaceholder')}
|
||||
value={localName}
|
||||
variant={'borderless'}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { EditorCanvas } from '@/features/EditorCanvas';
|
||||
import { seedAttachments } from '@/features/EditorCanvas/attachmentRegistry';
|
||||
import { pickAndInsertAttachments } from '@/features/EditorCanvas/editorAttachments';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
import { taskDetailSelectors } from '@/store/task/selectors';
|
||||
|
||||
@@ -14,6 +15,7 @@ const DEBOUNCE_MS = 300;
|
||||
|
||||
const TaskInstruction = 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);
|
||||
|
||||
@@ -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 (
|
||||
<ModelSelect
|
||||
initialWidth
|
||||
disabled={!canEditTask}
|
||||
popupWidth={400}
|
||||
value={{ model, provider }}
|
||||
onChange={handleChange}
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { TaskDetailData, TaskDetailSubtask } from '@lobechat/types';
|
||||
import { Button, Flexbox, Text } from '@lobehub/ui';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
|
||||
import { taskService } from '@/services/task';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
import { taskDetailSelectors } from '@/store/task/selectors';
|
||||
@@ -28,7 +28,7 @@ const toTaskStatus = (status?: string): TaskStatus =>
|
||||
|
||||
const TaskParentBar = memo(() => {
|
||||
const { t } = useTranslation('chat');
|
||||
const navigate = useNavigate();
|
||||
const navigate = useWorkspaceAwareNavigate();
|
||||
const parent = useTaskStore(taskDetailSelectors.activeTaskParent);
|
||||
const currentIdentifier = useTaskStore(taskDetailSelectors.activeTaskDetail)?.identifier;
|
||||
|
||||
|
||||
@@ -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<IntervalTabProps>(({ currentInterval, taskId }) => {
|
||||
const IntervalTab = memo<IntervalTabProps>(({ currentInterval, disabled, taskId }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const updatePeriodicInterval = useTaskStore((s) => s.updatePeriodicInterval);
|
||||
|
||||
@@ -98,23 +100,25 @@ const IntervalTab = memo<IntervalTabProps>(({ 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<IntervalTabProps>(({ currentInterval, taskId }) => {
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
<Text type="secondary">{t('taskSchedule.every')}</Text>
|
||||
<InputNumber
|
||||
disabled={disabled}
|
||||
min={localUnit === 'minutes' ? MIN_MINUTES : 1}
|
||||
placeholder={localUnit === 'minutes' ? String(MIN_MINUTES) : '1'}
|
||||
style={{ width: 100 }}
|
||||
@@ -131,6 +136,7 @@ const IntervalTab = memo<IntervalTabProps>(({ currentInterval, taskId }) => {
|
||||
onChange={handleValueChange}
|
||||
/>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
value={localUnit}
|
||||
variant="filled"
|
||||
@@ -147,10 +153,11 @@ const IntervalTab = memo<IntervalTabProps>(({ currentInterval, taskId }) => {
|
||||
});
|
||||
|
||||
interface SchedulerTabProps {
|
||||
disabled?: boolean;
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
const SchedulerTab = memo<SchedulerTabProps>(({ taskId }) => {
|
||||
const SchedulerTab = memo<SchedulerTabProps>(({ disabled, taskId }) => {
|
||||
const updateSchedule = useTaskStore((s) => s.updateSchedule);
|
||||
const pattern = useTaskStore(taskDetailSelectors.activeTaskSchedulePattern);
|
||||
const timezone = useTaskStore(taskDetailSelectors.activeTaskScheduleTimezone);
|
||||
@@ -158,10 +165,11 @@ const SchedulerTab = memo<SchedulerTabProps>(({ taskId }) => {
|
||||
|
||||
const handleChange = useCallback(
|
||||
(change: SchedulerFormChange) => {
|
||||
if (disabled) return;
|
||||
if (!taskId) return;
|
||||
updateSchedule(taskId, change);
|
||||
},
|
||||
[taskId, updateSchedule],
|
||||
[disabled, taskId, updateSchedule],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -187,6 +195,7 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
|
||||
taskId,
|
||||
}: TaskScheduleConfigProps) {
|
||||
const { t, i18n } = useTranslation('chat');
|
||||
const { allowed: canEditTask, reason } = usePermission('create_content');
|
||||
const activeTaskId = useTaskStore(taskDetailSelectors.activeTaskId);
|
||||
const activeTaskInterval = useTaskStore(taskDetailSelectors.activeTaskPeriodicInterval);
|
||||
const automationMode = useTaskStore(taskDetailSelectors.activeTaskAutomationMode);
|
||||
@@ -263,23 +272,26 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
|
||||
|
||||
const handleEnableChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (!canEditTask) return;
|
||||
if (!finalTaskId) return;
|
||||
// Schedule (cron) is the more common, predictable choice; users who want
|
||||
// a fixed interval can switch to the heartbeat tab from there.
|
||||
setAutomationMode(finalTaskId, checked ? 'schedule' : null);
|
||||
},
|
||||
[finalTaskId, setAutomationMode],
|
||||
[canEditTask, finalTaskId, setAutomationMode],
|
||||
);
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(value: string | number) => {
|
||||
if (!canEditTask) return;
|
||||
if (!finalTaskId) return;
|
||||
setAutomationMode(finalTaskId, value as TaskAutomationMode);
|
||||
},
|
||||
[finalTaskId, setAutomationMode],
|
||||
[canEditTask, finalTaskId, setAutomationMode],
|
||||
);
|
||||
|
||||
const handleStartScheduling = useCallback(async () => {
|
||||
if (!canEditTask) return;
|
||||
if (!finalTaskId) return;
|
||||
setIsStartingSchedule(true);
|
||||
try {
|
||||
@@ -287,7 +299,7 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
|
||||
} finally {
|
||||
setIsStartingSchedule(false);
|
||||
}
|
||||
}, [finalTaskId, updateTaskStatus]);
|
||||
}, [canEditTask, finalTaskId, updateTaskStatus]);
|
||||
|
||||
const content = (
|
||||
<Flexbox gap={16} style={{ padding: 4, width: 440 }} onClick={(e) => e.stopPropagation()}>
|
||||
@@ -309,7 +321,7 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
<Switch checked={enabled} onChange={handleEnableChange} />
|
||||
<Switch checked={enabled} disabled={!canEditTask} onChange={handleEnableChange} />
|
||||
</Flexbox>
|
||||
|
||||
{enabled && nextRunText && (
|
||||
@@ -326,6 +338,7 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
|
||||
<>
|
||||
<Segmented
|
||||
block
|
||||
disabled={!canEditTask}
|
||||
value={automationMode ?? 'heartbeat'}
|
||||
options={[
|
||||
{
|
||||
@@ -350,12 +363,19 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
|
||||
onChange={handleModeChange}
|
||||
/>
|
||||
{automationMode === 'heartbeat' && (
|
||||
<IntervalTab currentInterval={finalCurrentInterval} taskId={finalTaskId} />
|
||||
<IntervalTab
|
||||
currentInterval={finalCurrentInterval}
|
||||
disabled={!canEditTask}
|
||||
taskId={finalTaskId}
|
||||
/>
|
||||
)}
|
||||
{automationMode === 'schedule' && (
|
||||
<SchedulerTab disabled={!canEditTask} taskId={finalTaskId} />
|
||||
)}
|
||||
{automationMode === 'schedule' && <SchedulerTab taskId={finalTaskId} />}
|
||||
{canStartSchedule && (
|
||||
<Button
|
||||
block
|
||||
disabled={!canEditTask}
|
||||
icon={CalendarClockIcon}
|
||||
loading={isStartingSchedule}
|
||||
type="primary"
|
||||
@@ -370,11 +390,20 @@ const TaskScheduleConfig = memo(function TaskScheduleConfig({
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover className={styles.popover} content={content} placement="bottomRight" trigger="click">
|
||||
<Popover
|
||||
className={styles.popover}
|
||||
content={content}
|
||||
disabled={!canEditTask}
|
||||
placement="bottomRight"
|
||||
trigger="click"
|
||||
>
|
||||
{children ? (
|
||||
<div onClick={(e) => e.stopPropagation()}>{children}</div>
|
||||
<div title={canEditTask ? undefined : reason} onClick={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<ActionIcon
|
||||
disabled={!canEditTask}
|
||||
icon={TimerIcon}
|
||||
size="small"
|
||||
title={t('taskSchedule.title')}
|
||||
|
||||
@@ -8,8 +8,9 @@ import { ChevronDown, ListTodoIcon, PlayCircle, Plus } from 'lucide-react';
|
||||
import type { Key, MouseEvent } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { taskService } from '@/services/task';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
import { taskDetailSelectors } from '@/store/task/selectors';
|
||||
@@ -129,7 +130,8 @@ const toTreeData = (tree: TaskTreeNode[]): DataNode[] => {
|
||||
const TaskSubtasks = memo(() => {
|
||||
const { t } = useTranslation('chat');
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const navigate = useWorkspaceAwareNavigate();
|
||||
const { allowed: canEditTask, reason } = usePermission('create_content');
|
||||
const agentId = useTaskStore(taskDetailSelectors.activeTaskAgentId);
|
||||
const subtasks = useTaskStore(taskDetailSelectors.activeTaskSubtasks);
|
||||
const taskId = useTaskStore(taskDetailSelectors.activeTaskId);
|
||||
@@ -168,6 +170,7 @@ const TaskSubtasks = memo(() => {
|
||||
|
||||
const handleRightClick = useCallback(
|
||||
({ event, node }: { event: MouseEvent; node: { key: Key } }) => {
|
||||
if (!canEditTask) return;
|
||||
const subtask = subtaskMap.get(String(node.key));
|
||||
if (!subtask) return;
|
||||
event.preventDefault();
|
||||
@@ -186,12 +189,16 @@ const TaskSubtasks = memo(() => {
|
||||
status: subtask.status,
|
||||
});
|
||||
},
|
||||
[subtaskMap, buildItems, installKeyboardHandlers],
|
||||
[canEditTask, subtaskMap, buildItems, installKeyboardHandlers],
|
||||
);
|
||||
|
||||
const toggleCreating = useCallback(() => setIsCreating((prev) => !prev), []);
|
||||
const toggleCreating = useCallback(() => {
|
||||
if (!canEditTask) return;
|
||||
setIsCreating((prev) => !prev);
|
||||
}, [canEditTask]);
|
||||
|
||||
const handleRunAll = useCallback(async () => {
|
||||
if (!canEditTask) return;
|
||||
if (!taskId || isPlanning) return;
|
||||
setIsPlanning(true);
|
||||
try {
|
||||
@@ -241,7 +248,7 @@ const TaskSubtasks = memo(() => {
|
||||
} finally {
|
||||
setIsPlanning(false);
|
||||
}
|
||||
}, [taskId, isPlanning, message, t, runReadySubtasks]);
|
||||
}, [canEditTask, taskId, isPlanning, message, t, runReadySubtasks]);
|
||||
|
||||
if (!taskId) return null;
|
||||
|
||||
@@ -281,17 +288,18 @@ const TaskSubtasks = memo(() => {
|
||||
</Flexbox>
|
||||
<Flexbox horizontal align="center" gap={4}>
|
||||
<ActionIcon
|
||||
disabled={isPlanning}
|
||||
disabled={!canEditTask || isPlanning}
|
||||
icon={PlayCircle}
|
||||
loading={isPlanning}
|
||||
size="small"
|
||||
title={t('taskDetail.runAll')}
|
||||
title={canEditTask ? t('taskDetail.runAll') : reason}
|
||||
onClick={handleRunAll}
|
||||
/>
|
||||
<ActionIcon
|
||||
disabled={!canEditTask}
|
||||
icon={Plus}
|
||||
size="small"
|
||||
title={t('taskDetail.addSubtask')}
|
||||
title={canEditTask ? t('taskDetail.addSubtask') : reason}
|
||||
onClick={toggleCreating}
|
||||
/>
|
||||
</Flexbox>
|
||||
@@ -337,6 +345,7 @@ const TaskSubtasks = memo(() => {
|
||||
paddingBlock={4}
|
||||
paddingInline={8}
|
||||
style={{ width: 'fit-content' }}
|
||||
title={canEditTask ? undefined : reason}
|
||||
variant="borderless"
|
||||
onClick={toggleCreating}
|
||||
>
|
||||
|
||||
@@ -15,6 +15,10 @@ const mocks = vi.hoisted(() => ({
|
||||
dbMessagesMap: {} as Record<string, unknown[]>,
|
||||
replaceMessages: vi.fn(),
|
||||
},
|
||||
permission: {
|
||||
allowed: true,
|
||||
reason: 'requires member',
|
||||
},
|
||||
serverConfigState: {
|
||||
serverConfig: {
|
||||
enableBusinessFeatures: false,
|
||||
@@ -49,10 +53,38 @@ const mocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/ui', () => ({
|
||||
ActionIcon: ({ onClick }: { onClick?: () => void }) => <button onClick={onClick} />,
|
||||
ActionIcon: ({
|
||||
disabled,
|
||||
onClick,
|
||||
title,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
}) => (
|
||||
<button disabled={disabled} title={title} onClick={onClick}>
|
||||
{title}
|
||||
</button>
|
||||
),
|
||||
copyToClipboard: vi.fn(),
|
||||
Drawer: ({ children, open }: { children?: ReactNode; open?: boolean }) =>
|
||||
open ? <div data-testid="topic-drawer">{children}</div> : null,
|
||||
Drawer: ({
|
||||
children,
|
||||
extra,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
extra?: ReactNode;
|
||||
open?: boolean;
|
||||
title?: ReactNode;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-testid="topic-drawer">
|
||||
{title}
|
||||
{extra}
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
DropdownMenu: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
Flexbox: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
Freeze: ({ children }: { children?: ReactNode; frozen?: boolean }) => <>{children}</>,
|
||||
@@ -102,6 +134,10 @@ vi.mock('@/hooks/useOperationState', () => ({
|
||||
useOperationState: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/usePermission', () => ({
|
||||
usePermission: () => ({ allowed: mocks.permission.allowed, reason: mocks.permission.reason }),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/agent', () => ({
|
||||
useAgentStore: (selector: (state: typeof mocks.agentState) => unknown) =>
|
||||
selector(mocks.agentState),
|
||||
@@ -142,6 +178,8 @@ describe('TopicChatDrawer', () => {
|
||||
mocks.chatState.replaceMessages.mockClear();
|
||||
mocks.taskState.closeTopicDrawer.mockClear();
|
||||
mocks.taskState.activeTopicDrawerTopicId = 'topic-1';
|
||||
mocks.permission.allowed = true;
|
||||
mocks.serverConfigState.serverConfig.enableBusinessFeatures = false;
|
||||
});
|
||||
|
||||
it('hydrates the task assignee agent config for drawer messages', () => {
|
||||
@@ -149,4 +187,13 @@ describe('TopicChatDrawer', () => {
|
||||
|
||||
expect(mocks.agentState.useHydrateAgentConfig).toHaveBeenCalledWith(true, 'agt_assignee');
|
||||
});
|
||||
|
||||
it('disables topic sharing for workspace viewers', () => {
|
||||
mocks.permission.allowed = false;
|
||||
mocks.serverConfigState.serverConfig.enableBusinessFeatures = true;
|
||||
|
||||
const { getByTitle } = render(<TopicChatDrawer />);
|
||||
|
||||
expect(getByTitle('requires member')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useShareModal } from '@/features/ShareModal';
|
||||
import { LazySharePopover as SharePopover } from '@/features/SharePopover/lazy';
|
||||
import { useGatewayReconnect } from '@/hooks/useGatewayReconnect';
|
||||
import { useOperationState } from '@/hooks/useOperationState';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
|
||||
@@ -114,6 +115,7 @@ const TopicChatDrawer = memo(() => {
|
||||
const closeTopicDrawer = useTaskStore((s) => s.closeTopicDrawer);
|
||||
const useFetchTaskDetail = useTaskStore((s) => s.useFetchTaskDetail);
|
||||
const enableTopicLinkShare = useServerConfigStore(serverConfigSelectors.enableBusinessFeatures);
|
||||
const { allowed: canShare, reason } = usePermission('edit_own_content');
|
||||
|
||||
// Hydrate task detail when the drawer is opened outside of TaskDetailPage
|
||||
// (e.g. from a brief on home) so the header has agentId / status / seq.
|
||||
@@ -175,15 +177,16 @@ const TopicChatDrawer = memo(() => {
|
||||
|
||||
const shareIcon = (
|
||||
<ActionIcon
|
||||
disabled={!canShare}
|
||||
icon={Share2}
|
||||
size={'small'}
|
||||
title={t('share', { ns: 'common' })}
|
||||
onClick={enableTopicLinkShare ? undefined : openShareModal}
|
||||
title={canShare ? t('share', { ns: 'common' }) : reason}
|
||||
onClick={enableTopicLinkShare || !canShare ? undefined : openShareModal}
|
||||
/>
|
||||
);
|
||||
|
||||
const extra = topicId ? (
|
||||
enableTopicLinkShare ? (
|
||||
enableTopicLinkShare && canShare ? (
|
||||
<SharePopover topicId={topicId} onOpenModal={openShareModal}>
|
||||
{shareIcon}
|
||||
</SharePopover>
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTaskCreateActionBehavior } from './AgentTasksPage';
|
||||
import { shouldRenderTaskAgentPanelToggle } from './taskAgentPanelToggle';
|
||||
|
||||
describe('AgentTasksPage', () => {
|
||||
describe('getTaskCreateActionBehavior', () => {
|
||||
it('should allow workspace viewers to reopen the collapsed inline entry in list view', () => {
|
||||
expect(
|
||||
getTaskCreateActionBehavior({
|
||||
canCreateTask: false,
|
||||
inlineCollapsed: true,
|
||||
viewMode: 'list',
|
||||
}),
|
||||
).toEqual({ disabled: false, mode: 'inline' });
|
||||
});
|
||||
|
||||
it('should keep the modal create action disabled for workspace viewers in kanban view', () => {
|
||||
expect(
|
||||
getTaskCreateActionBehavior({
|
||||
canCreateTask: false,
|
||||
inlineCollapsed: false,
|
||||
viewMode: 'kanban',
|
||||
}),
|
||||
).toEqual({ disabled: true, mode: 'modal' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRenderTaskAgentPanelToggle', () => {
|
||||
it('should render the task agent panel toggle on desktop layouts', () => {
|
||||
expect(shouldRenderTaskAgentPanelToggle(false)).toBe(true);
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { ActionIcon, Flexbox } from '@lobehub/ui';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { DESKTOP_HEADER_ICON_SMALL_SIZE } from '@/const/layoutTokens';
|
||||
import NavHeader from '@/features/NavHeader';
|
||||
import ToggleRightPanelButton from '@/features/RightPanel/ToggleRightPanelButton';
|
||||
import WideScreenContainer from '@/features/WideScreenContainer';
|
||||
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
import { taskListSelectors } from '@/store/task/selectors';
|
||||
import type { TaskViewMode } from '@/store/task/slices/list/initialState';
|
||||
|
||||
import { createTaskModal } from '../CreateTaskModal';
|
||||
import Breadcrumb from '../shared/Breadcrumb';
|
||||
@@ -25,9 +27,29 @@ import { shouldRenderTaskAgentPanelToggle } from './taskAgentPanelToggle';
|
||||
import TaskList from './TaskList';
|
||||
import TasksGroupConfig from './TasksGroupConfig';
|
||||
|
||||
interface TaskCreateActionBehaviorParams {
|
||||
canCreateTask: boolean;
|
||||
inlineCollapsed: boolean;
|
||||
viewMode: TaskViewMode;
|
||||
}
|
||||
|
||||
export const getTaskCreateActionBehavior = ({
|
||||
canCreateTask,
|
||||
inlineCollapsed,
|
||||
viewMode,
|
||||
}: TaskCreateActionBehaviorParams) => {
|
||||
const shouldExpandInline = inlineCollapsed && viewMode === 'list';
|
||||
|
||||
return {
|
||||
disabled: shouldExpandInline ? false : !canCreateTask,
|
||||
mode: shouldExpandInline ? 'inline' : 'modal',
|
||||
} as const;
|
||||
};
|
||||
|
||||
const AgentTasksPage = memo(() => {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useWorkspaceAwareNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const { allowed: canCreateTask, reason } = usePermission('create_content');
|
||||
const viewMode = useTaskStore(taskListSelectors.viewMode);
|
||||
const useFetchTaskList = useTaskStore((s) => s.useFetchTaskList);
|
||||
useFetchTaskList({ allAgents: true });
|
||||
@@ -48,13 +70,29 @@ const AgentTasksPage = memo(() => {
|
||||
[updateSystemStatus, viewOptions],
|
||||
);
|
||||
|
||||
const createActionBehavior = useMemo(
|
||||
() =>
|
||||
getTaskCreateActionBehavior({
|
||||
canCreateTask,
|
||||
inlineCollapsed,
|
||||
viewMode,
|
||||
}),
|
||||
[canCreateTask, inlineCollapsed, viewMode],
|
||||
);
|
||||
|
||||
const handleCreateTask = useCallback(() => {
|
||||
if (createActionBehavior.mode === 'inline') {
|
||||
updateSystemStatus({ taskCreateInlineCollapsed: false }, 'expandTaskCreateInline');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canCreateTask) return;
|
||||
createTaskModal({
|
||||
onCreated: (task) => {
|
||||
navigate(taskDetailPath(task.identifier, task.agentId));
|
||||
},
|
||||
});
|
||||
}, [navigate]);
|
||||
}, [canCreateTask, createActionBehavior.mode, navigate, updateSystemStatus]);
|
||||
|
||||
const handleShowHiddenCompleted = useCallback(() => {
|
||||
setViewOptions((prev) => ({ ...prev, hideCompleted: false }));
|
||||
@@ -70,8 +108,10 @@ const AgentTasksPage = memo(() => {
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
{(inlineCollapsed || viewMode === 'kanban') && (
|
||||
<ActionIcon
|
||||
disabled={createActionBehavior.disabled}
|
||||
icon={Plus}
|
||||
size={DESKTOP_HEADER_ICON_SMALL_SIZE}
|
||||
title={createActionBehavior.disabled ? reason : undefined}
|
||||
onClick={handleCreateTask}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import CreateTaskInlineEntry from './CreateTaskInlineEntry';
|
||||
|
||||
const permissionMock = vi.hoisted(() => ({
|
||||
allowed: true,
|
||||
}));
|
||||
|
||||
const focusMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@lobehub/editor/react', () => ({
|
||||
useEditor: () => ({
|
||||
cleanDocument: vi.fn(),
|
||||
focus: focusMock,
|
||||
getLexicalEditor: () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/EditorCanvas', () => ({
|
||||
EditorCanvas: ({ disabled }: { disabled?: boolean }) => (
|
||||
<div data-disabled={String(!!disabled)} data-testid="task-editor" />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/usePermission', () => ({
|
||||
usePermission: () => ({
|
||||
allowed: permissionMock.allowed,
|
||||
reason: permissionMock.allowed ? '' : 'requires member',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/task', () => ({
|
||||
useTaskStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
createTask: vi.fn(),
|
||||
isCreatingTask: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/global', () => ({
|
||||
useGlobalStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
updateSystemStatus: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../features/TaskPriorityTag', () => ({
|
||||
default: ({ children }: { children?: ReactNode }) => (
|
||||
<div data-testid="priority">{children ?? 'priority'}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../features/AssigneeAgentSelector', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../features/AssigneeAvatar', () => ({
|
||||
default: () => <div />,
|
||||
}));
|
||||
|
||||
vi.mock('../shared/useAgentDisplayMeta', () => ({
|
||||
useAgentDisplayMeta: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
describe('CreateTaskInlineEntry', () => {
|
||||
beforeEach(() => {
|
||||
permissionMock.allowed = true;
|
||||
focusMock.mockReset();
|
||||
});
|
||||
|
||||
it('renders the task editor as disabled when the user cannot create content', () => {
|
||||
permissionMock.allowed = false;
|
||||
|
||||
render(<CreateTaskInlineEntry variant="hero" />);
|
||||
|
||||
expect(screen.getByTestId('task-editor')).toHaveAttribute('data-disabled', 'true');
|
||||
expect(focusMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getAttachmentFileIdsFromEditor,
|
||||
pickAndInsertAttachments,
|
||||
} from '@/features/EditorCanvas/editorAttachments';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
|
||||
@@ -48,6 +49,7 @@ const CreateTaskInlineEntry = memo<CreateTaskInlineEntryProps>((props) => {
|
||||
} = props;
|
||||
const isHero = variant === 'hero';
|
||||
const { t } = useTranslation('chat');
|
||||
const { allowed: canCreateTask, reason } = usePermission('create_content');
|
||||
|
||||
const createTask = useTaskStore((s) => s.createTask);
|
||||
const isCreating = useTaskStore((s) => s.isCreatingTask);
|
||||
@@ -63,8 +65,9 @@ const CreateTaskInlineEntry = memo<CreateTaskInlineEntryProps>((props) => {
|
||||
const assigneeMeta = useAgentDisplayMeta(assigneeAgentId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canCreateTask) return;
|
||||
if (autoFocus || isHero) editor?.focus?.();
|
||||
}, [autoFocus, editor, isHero]);
|
||||
}, [autoFocus, canCreateTask, editor, isHero]);
|
||||
|
||||
const handleCollapse = useCallback(() => {
|
||||
if (onCollapse) {
|
||||
@@ -75,19 +78,21 @@ const CreateTaskInlineEntry = memo<CreateTaskInlineEntryProps>((props) => {
|
||||
}, [onCollapse, updateSystemStatus]);
|
||||
|
||||
const handleContentChange = useCallback(() => {
|
||||
if (!canCreateTask) return;
|
||||
const lexicalEditor = editor?.getLexicalEditor?.();
|
||||
if (!lexicalEditor) return;
|
||||
lexicalEditor.getEditorState().read(() => {
|
||||
setInstruction($getRoot().getTextContent());
|
||||
});
|
||||
setHasAttachments(getAttachmentFileIdsFromEditor(editor).length > 0);
|
||||
}, [editor]);
|
||||
}, [canCreateTask, editor]);
|
||||
|
||||
const handleAttach = useCallback(() => {
|
||||
pickAndInsertAttachments(editor);
|
||||
}, [editor]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!canCreateTask) return;
|
||||
const markdown = String(editor?.getDocument?.('markdown') ?? '').trim();
|
||||
const trimmedText = instruction.trim();
|
||||
const hasFiles = getAttachmentFileIdsFromEditor(editor).length > 0;
|
||||
@@ -133,6 +138,7 @@ const CreateTaskInlineEntry = memo<CreateTaskInlineEntryProps>((props) => {
|
||||
onCreated,
|
||||
parentTaskId,
|
||||
priority,
|
||||
canCreateTask,
|
||||
]);
|
||||
|
||||
const handleSubmitRef = useRef(handleSubmit);
|
||||
@@ -170,6 +176,7 @@ const CreateTaskInlineEntry = memo<CreateTaskInlineEntryProps>((props) => {
|
||||
}}
|
||||
>
|
||||
<EditorCanvas
|
||||
disabled={!canCreateTask}
|
||||
editor={editor}
|
||||
floatingToolbar={false}
|
||||
placeholder={placeholder ?? t('createTask.instructionPlaceholder')}
|
||||
@@ -248,10 +255,11 @@ const CreateTaskInlineEntry = memo<CreateTaskInlineEntryProps>((props) => {
|
||||
</Flexbox>
|
||||
|
||||
<Button
|
||||
disabled={isCreating || (!instruction.trim() && !hasAttachments)}
|
||||
disabled={!canCreateTask || isCreating || (!instruction.trim() && !hasAttachments)}
|
||||
loading={isCreating}
|
||||
shape={'round'}
|
||||
size={'small'}
|
||||
title={canCreateTask ? undefined : reason}
|
||||
type={'primary'}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
|
||||
@@ -14,8 +14,9 @@ import { createStaticStyles } from 'antd-style';
|
||||
import { ClipboardCheckIcon } from 'lucide-react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
@@ -75,7 +76,8 @@ const optimisticMoveTask = (
|
||||
|
||||
const KanbanBoard = memo(() => {
|
||||
const { t } = useTranslation('chat');
|
||||
const navigate = useNavigate();
|
||||
const navigate = useWorkspaceAwareNavigate();
|
||||
const { allowed: canEditTask } = usePermission('create_content');
|
||||
|
||||
const useFetchTaskGroupList = useTaskStore((s) => s.useFetchTaskGroupList);
|
||||
useFetchTaskGroupList({ allAgents: true });
|
||||
@@ -95,14 +97,19 @@ const KanbanBoard = memo(() => {
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
if (!canEditTask) return;
|
||||
const task = event.active.data.current?.task as TaskListItem | undefined;
|
||||
setActiveTask(task ?? null);
|
||||
}, []);
|
||||
},
|
||||
[canEditTask],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
setActiveTask(null);
|
||||
if (!canEditTask) return;
|
||||
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
@@ -126,7 +133,7 @@ const KanbanBoard = memo(() => {
|
||||
useTaskStore.setState({ taskGroups: prevGroups }, false, 'kanban/revertMove');
|
||||
}
|
||||
},
|
||||
[updateTaskStatus],
|
||||
[canEditTask, updateTaskStatus],
|
||||
);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
@@ -134,13 +141,14 @@ const KanbanBoard = memo(() => {
|
||||
}, []);
|
||||
|
||||
const handleCreateTask = useCallback(() => {
|
||||
if (!canEditTask) return;
|
||||
createTaskModal({
|
||||
onCreated: (task) => {
|
||||
navigate(taskDetailPath(task.identifier, task.agentId));
|
||||
},
|
||||
showInlineToggle: false,
|
||||
});
|
||||
}, [navigate]);
|
||||
}, [canEditTask, navigate]);
|
||||
|
||||
const handleHideColumn = useCallback(
|
||||
(columnKey: string) => {
|
||||
@@ -213,7 +221,7 @@ const KanbanBoard = memo(() => {
|
||||
return (
|
||||
<DndContext
|
||||
collisionDetection={pointerWithin}
|
||||
sensors={sensors}
|
||||
sensors={canEditTask ? sensors : []}
|
||||
onDragCancel={handleDragCancel}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
@@ -224,7 +232,7 @@ const KanbanBoard = memo(() => {
|
||||
return (
|
||||
<KanbanColumn
|
||||
columnKey={col.key}
|
||||
droppable={col.droppable}
|
||||
droppable={canEditTask && col.droppable}
|
||||
key={col.key}
|
||||
tasks={(group?.tasks ?? []) as TaskListItem[]}
|
||||
total={group?.total ?? 0}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getAttachmentFileIdsFromEditor,
|
||||
pickAndInsertAttachments,
|
||||
} from '@/features/EditorCanvas/editorAttachments';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
|
||||
@@ -36,6 +37,7 @@ const CreateTaskContent = memo<CreateTaskContentProps>(
|
||||
({ agentId, onCreated, showInlineToggle = true }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const { close } = useModalContext();
|
||||
const { allowed: canCreateTask, reason } = usePermission('create_content');
|
||||
|
||||
const createTask = useTaskStore((s) => s.createTask);
|
||||
const isCreating = useTaskStore((s) => s.isCreatingTask);
|
||||
@@ -56,15 +58,17 @@ const CreateTaskContent = memo<CreateTaskContentProps>(
|
||||
}, [close, updateSystemStatus]);
|
||||
|
||||
const handleContentChange = useCallback(() => {
|
||||
if (!canCreateTask) return;
|
||||
if (!editor) return;
|
||||
instructionRef.current = String(editor.getDocument('markdown') ?? '');
|
||||
}, [editor]);
|
||||
}, [canCreateTask, editor]);
|
||||
|
||||
const handleAttach = useCallback(() => {
|
||||
pickAndInsertAttachments(editor);
|
||||
}, [editor]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!canCreateTask) return;
|
||||
const instruction = instructionRef.current.trim();
|
||||
const hasFiles = getAttachmentFileIdsFromEditor(editor).length > 0;
|
||||
if (!instruction && !title.trim() && !hasFiles) return;
|
||||
@@ -86,7 +90,7 @@ const CreateTaskContent = memo<CreateTaskContentProps>(
|
||||
identifier: result.identifier,
|
||||
});
|
||||
}
|
||||
}, [assigneeAgentId, close, createTask, editor, onCreated, priority, title]);
|
||||
}, [assigneeAgentId, canCreateTask, close, createTask, editor, onCreated, priority, title]);
|
||||
|
||||
const handleSubmitRef = useRef(handleSubmit);
|
||||
useEffect(() => {
|
||||
@@ -106,7 +110,8 @@ const CreateTaskContent = memo<CreateTaskContentProps>(
|
||||
<Flexbox horizontal style={{ padding: '16px 24px 0' }}>
|
||||
<Flexbox flex={1} style={{ minHeight: 180 }}>
|
||||
<input
|
||||
autoFocus
|
||||
autoFocus={canCreateTask}
|
||||
disabled={!canCreateTask}
|
||||
placeholder={t('createTask.titlePlaceholder')}
|
||||
value={title}
|
||||
style={{
|
||||
@@ -124,6 +129,7 @@ const CreateTaskContent = memo<CreateTaskContentProps>(
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<EditorCanvas
|
||||
disabled={!canCreateTask}
|
||||
editor={editor}
|
||||
floatingToolbar={false}
|
||||
placeholder={t('createTask.instructionPlaceholder')}
|
||||
@@ -205,10 +211,11 @@ const CreateTaskContent = memo<CreateTaskContentProps>(
|
||||
</Flexbox>
|
||||
|
||||
<Button
|
||||
disabled={isCreating}
|
||||
disabled={!canCreateTask || isCreating}
|
||||
loading={isCreating}
|
||||
shape={'round'}
|
||||
size={'small'}
|
||||
title={canCreateTask ? undefined : reason}
|
||||
type={'primary'}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Block, Flexbox } from '@lobehub/ui';
|
||||
import { Divider } from 'antd';
|
||||
import { Fragment, memo, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
import { taskListSelectors } from '@/store/task/selectors';
|
||||
@@ -13,7 +13,7 @@ import TaskListHeader from './TaskListHeader';
|
||||
|
||||
const AgentTaskCardList = memo(() => {
|
||||
const agentId = useAgentStore((s) => s.activeAgentId);
|
||||
const navigate = useNavigate();
|
||||
const navigate = useWorkspaceAwareNavigate();
|
||||
const useFetchTaskList = useTaskStore((s) => s.useFetchTaskList);
|
||||
useFetchTaskList({ agentId });
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { TaskStatus } from '@lobechat/types';
|
||||
import { Block, ContextMenuTrigger, Flexbox, Text } from '@lobehub/ui';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
import type { TaskListItem } from '@/store/task/slices/list/initialState';
|
||||
|
||||
@@ -47,7 +47,7 @@ const AgentTaskItem = memo<TaskItemProps>(({ task, variant = 'default' }) => {
|
||||
const taskDetail = useTaskStore((s) => s.taskDetailMap[task.identifier]);
|
||||
const { items: contextMenuItems, onContextMenu: handleContextMenuOpen } =
|
||||
useTaskItemContextMenu(task);
|
||||
const navigate = useNavigate();
|
||||
const navigate = useWorkspaceAwareNavigate();
|
||||
|
||||
const time = formatTaskItemDate(task.updatedAt || task.createdAt, {
|
||||
formatOtherYear: t('time.formatOtherYear'),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
|
||||
import AgentItem from '@/features/PageEditor/Copilot/AgentSelector/AgentItem';
|
||||
import { useFetchAgentList } from '@/hooks/useFetchAgentList';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentSelectors, builtinAgentSelectors } from '@/store/agent/selectors';
|
||||
import { useHomeStore } from '@/store/home';
|
||||
@@ -53,6 +54,7 @@ const triggerStyle: CSSProperties = {
|
||||
const AssigneeAgentSelector = memo<AssigneeAgentSelectorProps>(
|
||||
({ children, currentAgentId, disabled, onChange, taskIdentifier }) => {
|
||||
const { t } = useTranslation(['chat', 'common']);
|
||||
const { allowed: canEditTask, reason } = usePermission('create_content');
|
||||
const [key, setKey] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
@@ -108,6 +110,7 @@ const AssigneeAgentSelector = memo<AssigneeAgentSelectorProps>(
|
||||
|
||||
const handleAgentChange = useCallback(
|
||||
(agentId: string) => {
|
||||
if (!canEditTask) return;
|
||||
if (agentId === currentAgentId) return;
|
||||
setKey((k) => k + 1);
|
||||
setSearch('');
|
||||
@@ -119,7 +122,7 @@ const AssigneeAgentSelector = memo<AssigneeAgentSelectorProps>(
|
||||
void updateTask(taskIdentifier, { assigneeAgentId: agentId });
|
||||
}
|
||||
},
|
||||
[currentAgentId, onChange, taskIdentifier, updateTask],
|
||||
[canEditTask, currentAgentId, onChange, taskIdentifier, updateTask],
|
||||
);
|
||||
|
||||
const handleSearchKeyDown = useCallback(
|
||||
@@ -148,8 +151,9 @@ const AssigneeAgentSelector = memo<AssigneeAgentSelectorProps>(
|
||||
active?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeIndex]);
|
||||
|
||||
const trigger = disabled ? (
|
||||
<Tooltip title={t('taskDetail.reassignDisabled', { ns: 'chat' })}>
|
||||
const blocked = disabled || !canEditTask;
|
||||
const trigger = blocked ? (
|
||||
<Tooltip title={disabled ? t('taskDetail.reassignDisabled', { ns: 'chat' }) : reason}>
|
||||
<div
|
||||
style={{ ...triggerStyle, cursor: 'not-allowed', opacity: 0.5 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -165,7 +169,7 @@ const AssigneeAgentSelector = memo<AssigneeAgentSelectorProps>(
|
||||
|
||||
return (
|
||||
<Popover
|
||||
disabled={disabled}
|
||||
disabled={blocked}
|
||||
key={key}
|
||||
placement="bottomLeft"
|
||||
styles={{ content: { padding: 0, width: 260 } }}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ReactNode } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
|
||||
import PriorityHighIcon from './icons/PriorityHighIcon';
|
||||
@@ -53,6 +54,15 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
align-items: center;
|
||||
color: ${cssVar.orange};
|
||||
`,
|
||||
triggerDisabled: css`
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
color: ${cssVar.colorTextDescription};
|
||||
filter: none;
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
interface TaskPriorityTagProps {
|
||||
@@ -69,6 +79,7 @@ const TaskPriorityTag = memo<TaskPriorityTagProps>(
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation('chat');
|
||||
const { allowed: canEditTask, reason } = usePermission('create_content');
|
||||
const updateTask = useTaskStore((s) => s.updateTask);
|
||||
const refreshTaskList = useTaskStore((s) => s.refreshTaskList);
|
||||
|
||||
@@ -77,6 +88,7 @@ const TaskPriorityTag = memo<TaskPriorityTagProps>(
|
||||
|
||||
const handlePriorityChange = useCallback(
|
||||
async (nextPriority: number) => {
|
||||
if (!canEditTask) return;
|
||||
if (nextPriority === currentLevel) return;
|
||||
if (onChange) {
|
||||
onChange(nextPriority);
|
||||
@@ -88,7 +100,7 @@ const TaskPriorityTag = memo<TaskPriorityTagProps>(
|
||||
await refreshTaskList();
|
||||
setLoading(false);
|
||||
},
|
||||
[currentLevel, onChange, refreshTaskList, taskIdentifier, updateTask],
|
||||
[canEditTask, currentLevel, onChange, refreshTaskList, taskIdentifier, updateTask],
|
||||
);
|
||||
|
||||
const handlePriorityChangeRef = useRef(handlePriorityChange);
|
||||
@@ -156,6 +168,19 @@ const TaskPriorityTag = memo<TaskPriorityTagProps>(
|
||||
|
||||
if (disableDropdown) return <>{triggerNode}</>;
|
||||
|
||||
if (!canEditTask)
|
||||
return (
|
||||
<Tooltip title={reason}>
|
||||
<span
|
||||
className={styles.triggerDisabled}
|
||||
style={{ display: 'inline-flex' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{triggerNode}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu items={menuItems} open={open} onOpenChange={setOpen}>
|
||||
{triggerNode}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { ReactNode } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
|
||||
import { renderMenuExtra } from './menuExtra';
|
||||
@@ -90,6 +91,15 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
filter: brightness(0.85);
|
||||
}
|
||||
`,
|
||||
triggerDisabled: css`
|
||||
cursor: not-allowed;
|
||||
display: inline-flex;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
filter: none;
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
interface TaskStatusTagProps {
|
||||
@@ -106,6 +116,7 @@ const TaskStatusTag = memo<TaskStatusTagProps>(
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation('chat');
|
||||
const { allowed: canEditTask, reason } = usePermission('create_content');
|
||||
const updateTaskStatus = useTaskStore((s) => s.updateTaskStatus);
|
||||
|
||||
const displayStatus = status ?? 'backlog';
|
||||
@@ -113,6 +124,7 @@ const TaskStatusTag = memo<TaskStatusTagProps>(
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
async (nextStatus: TaskStatus) => {
|
||||
if (!canEditTask) return;
|
||||
if (nextStatus === displayStatus) return;
|
||||
if (onChange) {
|
||||
onChange(nextStatus);
|
||||
@@ -127,7 +139,7 @@ const TaskStatusTag = memo<TaskStatusTagProps>(
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[displayStatus, onChange, taskIdentifier, updateTaskStatus],
|
||||
[canEditTask, displayStatus, onChange, taskIdentifier, updateTaskStatus],
|
||||
);
|
||||
|
||||
const handleStatusChangeRef = useRef(handleStatusChange);
|
||||
@@ -182,6 +194,15 @@ const TaskStatusTag = memo<TaskStatusTagProps>(
|
||||
|
||||
if (disableDropdown) return <>{triggerNode}</>;
|
||||
|
||||
if (!canEditTask)
|
||||
return (
|
||||
<Tooltip title={reason}>
|
||||
<span className={styles.triggerDisabled} onClick={(e) => e.stopPropagation()}>
|
||||
{triggerNode}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu items={menuItems} open={open} onOpenChange={setOpen}>
|
||||
{triggerNode}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useTaskItemContextMenu } from './useTaskItemContextMenu';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
messageSuccess: vi.fn(),
|
||||
modalConfirm: vi.fn(),
|
||||
refreshTaskList: vi.fn(),
|
||||
runTask: vi.fn(),
|
||||
transferItems: [
|
||||
{ key: 'transfer-task', label: 'Transfer to...' },
|
||||
{ key: 'copy-task', label: 'Copy to...' },
|
||||
],
|
||||
updateTask: vi.fn(),
|
||||
updateTaskStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/ui', () => ({
|
||||
closeContextMenu: vi.fn(),
|
||||
copyToClipboard: mocks.copyToClipboard,
|
||||
Flexbox: ({ children }: { children?: ReactNode }) => React.createElement('div', {}, children),
|
||||
Icon: ({ icon: Icon }: { icon?: React.ComponentType }) =>
|
||||
Icon ? React.createElement(Icon) : React.createElement('span'),
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
App: {
|
||||
useApp: () => ({
|
||||
message: { success: mocks.messageSuccess },
|
||||
modal: { confirm: mocks.modalConfirm },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/business/client/hooks/useTaskTransferMenuItem', () => ({
|
||||
useTaskTransferMenuItem: () => mocks.transferItems,
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useAppOrigin', () => ({
|
||||
useAppOrigin: () => 'https://example.com',
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/usePermission', () => ({
|
||||
usePermission: () => ({ allowed: true }),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/agent', () => ({
|
||||
useAgentStore: (selector: (state: { inboxAgentId: string }) => unknown) =>
|
||||
selector({ inboxAgentId: 'inbox-agent' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/agent/selectors', () => ({
|
||||
builtinAgentSelectors: {
|
||||
inboxAgentId: (state: { inboxAgentId: string }) => state.inboxAgentId,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/store/task', () => ({
|
||||
useTaskStore: (
|
||||
selector: (state: {
|
||||
deleteTask: typeof mocks.deleteTask;
|
||||
refreshTaskList: typeof mocks.refreshTaskList;
|
||||
runTask: typeof mocks.runTask;
|
||||
updateTask: typeof mocks.updateTask;
|
||||
updateTaskStatus: typeof mocks.updateTaskStatus;
|
||||
}) => unknown,
|
||||
) =>
|
||||
selector({
|
||||
deleteTask: mocks.deleteTask,
|
||||
refreshTaskList: mocks.refreshTaskList,
|
||||
runTask: mocks.runTask,
|
||||
updateTask: mocks.updateTask,
|
||||
updateTaskStatus: mocks.updateTaskStatus,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { defaultValue?: ReactNode; ns?: string }) =>
|
||||
options?.defaultValue ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useTaskItemContextMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not render adjacent dividers around transfer actions', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTaskItemContextMenu({
|
||||
identifier: 'T-1',
|
||||
priority: 0,
|
||||
status: 'backlog',
|
||||
}),
|
||||
);
|
||||
|
||||
const itemTypes = result.current.items.map((item) =>
|
||||
item && typeof item === 'object' && 'type' in item ? item.type : 'item',
|
||||
);
|
||||
|
||||
expect(
|
||||
itemTypes.some((type, index) => type === 'divider' && itemTypes[index + 1] === 'divider'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useTaskTransferMenuItem } from '@/business/client/hooks/useTaskTransferMenuItem';
|
||||
import { useAppOrigin } from '@/hooks/useAppOrigin';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { builtinAgentSelectors } from '@/store/agent/selectors';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
@@ -58,6 +60,7 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
|
||||
const { t } = useTranslation(['chat', 'common']);
|
||||
const { message } = App.useApp();
|
||||
const appOrigin = useAppOrigin();
|
||||
const { allowed: canEditTask } = usePermission('create_content');
|
||||
|
||||
const updateTaskStatus = useTaskStore((s) => s.updateTaskStatus);
|
||||
const updateTask = useTaskStore((s) => s.updateTask);
|
||||
@@ -73,6 +76,7 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
|
||||
|
||||
return useMemo<TaskContextMenuActions>(() => {
|
||||
const triggerDelete = (identifier: string) => {
|
||||
if (!canEditTask) return;
|
||||
confirmModal({
|
||||
content: t('taskDetail.deleteConfirm.content'),
|
||||
okButtonProps: { danger: true },
|
||||
@@ -96,8 +100,10 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
|
||||
icon: <Icon color={meta.color} icon={meta.icon} />,
|
||||
key: `status-${status}`,
|
||||
label: t(`taskDetail.status.${status}`, { defaultValue: meta.label }),
|
||||
disabled: !canEditTask,
|
||||
onClick: ({ domEvent }: MenuInfo) => {
|
||||
domEvent.stopPropagation();
|
||||
if (!canEditTask) return;
|
||||
if (status === currentStatus) return;
|
||||
void updateTaskStatus(task.identifier, status);
|
||||
},
|
||||
@@ -116,8 +122,10 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
|
||||
),
|
||||
key: `priority-${level}`,
|
||||
label: t(`taskDetail.${meta.labelKey}` as never, { defaultValue: meta.label }),
|
||||
disabled: !canEditTask,
|
||||
onClick: async ({ domEvent }: MenuInfo) => {
|
||||
domEvent.stopPropagation();
|
||||
if (!canEditTask) return;
|
||||
if (level === currentPriority) return;
|
||||
await updateTask(task.identifier, { priority: level });
|
||||
await refreshTaskList();
|
||||
@@ -138,8 +146,10 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
|
||||
icon: <Icon icon={PlayIcon} />,
|
||||
key: 'runNow',
|
||||
label: t('taskList.contextMenu.runNow'),
|
||||
disabled: !canEditTask,
|
||||
onClick: async ({ domEvent }: MenuInfo) => {
|
||||
domEvent.stopPropagation();
|
||||
if (!canEditTask) return;
|
||||
if (!task.assigneeAgentId && inboxAgentId) {
|
||||
await updateTask(task.identifier, { assigneeAgentId: inboxAgentId });
|
||||
}
|
||||
@@ -151,6 +161,7 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
|
||||
: []),
|
||||
{
|
||||
children: statusChildren,
|
||||
disabled: !canEditTask,
|
||||
icon: <Icon icon={CircleDashedIcon} />,
|
||||
key: 'status',
|
||||
label: t('taskList.contextMenu.status'),
|
||||
@@ -160,6 +171,7 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
|
||||
},
|
||||
{
|
||||
children: priorityChildren,
|
||||
disabled: !canEditTask,
|
||||
icon: <Icon icon={BarChart3Icon} />,
|
||||
key: 'priority',
|
||||
label: t('taskList.contextMenu.priority'),
|
||||
@@ -191,11 +203,13 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
|
||||
{ type: 'divider' },
|
||||
{
|
||||
danger: true,
|
||||
disabled: !canEditTask,
|
||||
icon: <Icon icon={Trash2Icon} />,
|
||||
key: 'delete',
|
||||
label: t('delete', { ns: 'common' }),
|
||||
onClick: ({ domEvent }: MenuInfo) => {
|
||||
domEvent.stopPropagation();
|
||||
if (!canEditTask) return;
|
||||
triggerDelete(task.identifier);
|
||||
},
|
||||
},
|
||||
@@ -203,6 +217,7 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
|
||||
};
|
||||
|
||||
const installKeyboardHandlers = (task: TaskContextMenuTarget) => {
|
||||
if (!canEditTask) return;
|
||||
cleanupRef.current?.();
|
||||
activeSubmenuRef.current = null;
|
||||
|
||||
@@ -276,6 +291,7 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
|
||||
|
||||
return { buildItems, installKeyboardHandlers };
|
||||
}, [
|
||||
canEditTask,
|
||||
message,
|
||||
t,
|
||||
appOrigin,
|
||||
@@ -290,7 +306,39 @@ export const useTaskContextMenuActions = (): TaskContextMenuActions => {
|
||||
|
||||
export const useTaskItemContextMenu = (task: TaskContextMenuTarget): TaskItemContextMenu => {
|
||||
const { buildItems, installKeyboardHandlers } = useTaskContextMenuActions();
|
||||
const items = useMemo(() => buildItems(task), [buildItems, task]);
|
||||
const transferItems = useTaskTransferMenuItem(task.identifier) as ContextMenuItem[] | null;
|
||||
const items = useMemo(() => {
|
||||
const base = buildItems(task);
|
||||
if (!transferItems || transferItems.length === 0) return base;
|
||||
|
||||
// Insert transfer/copy entries above the final divider + delete pair so
|
||||
// they sit next to the other lifecycle actions but kept distinct from
|
||||
// in-place state changes.
|
||||
const deleteAnchor = base.findIndex(
|
||||
(item) =>
|
||||
item !== null &&
|
||||
typeof item === 'object' &&
|
||||
'key' in item &&
|
||||
(item as { key?: string }).key === 'delete',
|
||||
);
|
||||
if (deleteAnchor === -1) return [...base, ...transferItems];
|
||||
|
||||
const insertAt =
|
||||
deleteAnchor > 0 &&
|
||||
base[deleteAnchor - 1] !== null &&
|
||||
typeof base[deleteAnchor - 1] === 'object' &&
|
||||
'type' in (base[deleteAnchor - 1] as object) &&
|
||||
(base[deleteAnchor - 1] as { type?: string }).type === 'divider'
|
||||
? deleteAnchor - 1
|
||||
: deleteAnchor;
|
||||
|
||||
return [
|
||||
...base.slice(0, insertAt),
|
||||
...transferItems,
|
||||
{ type: 'divider' } as ContextMenuItem,
|
||||
...base.slice(deleteAnchor),
|
||||
];
|
||||
}, [buildItems, task, transferItems]);
|
||||
const onContextMenu = useCallback(
|
||||
() => installKeyboardHandlers(task),
|
||||
[installKeyboardHandlers, task],
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Breadcrumb as AntBreadcrumb } from 'antd';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import WorkspaceLink from '@/features/Workspace/WorkspaceLink';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
|
||||
import { styles } from './style';
|
||||
@@ -49,11 +49,11 @@ const Breadcrumb = memo<BreadcrumbProps>(({ taskId }) => {
|
||||
const ancestorCrumbs = ancestors.map(({ identifier, agentId }) => ({
|
||||
key: identifier,
|
||||
title: (
|
||||
<Link to={taskDetailPath(identifier, agentId ?? undefined)}>
|
||||
<WorkspaceLink to={taskDetailPath(identifier, agentId ?? undefined)}>
|
||||
<Text color={'inherit'} weight={500}>
|
||||
{identifier}
|
||||
</Text>
|
||||
</Link>
|
||||
</WorkspaceLink>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -100,7 +100,11 @@ const Breadcrumb = memo<BreadcrumbProps>(({ taskId }) => {
|
||||
separator={<Icon icon={ChevronRight} />}
|
||||
items={[
|
||||
{
|
||||
title: taskId ? <Link to={'/tasks'}>{allTasksLabel}</Link> : allTasksLabel,
|
||||
title: taskId ? (
|
||||
<WorkspaceLink to={'/tasks'}>{allTasksLabel}</WorkspaceLink>
|
||||
) : (
|
||||
allTasksLabel
|
||||
),
|
||||
},
|
||||
...ancestorCrumbs,
|
||||
...(currentTaskCrumb ? [currentTaskCrumb] : []),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
|
||||
import { lambdaClient } from '@/libs/trpc/client';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
|
||||
@@ -11,7 +11,7 @@ const ChangelogModal = memo<{ currentId?: string }>(({ currentId: propCurrentId
|
||||
s.status.latestChangelogId,
|
||||
s.updateSystemStatus,
|
||||
]);
|
||||
const navigate = useNavigate();
|
||||
const navigate = useWorkspaceAwareNavigate();
|
||||
const [currentId, setCurrentId] = useState(propCurrentId);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
option: css`
|
||||
cursor: pointer;
|
||||
|
||||
@@ -111,6 +121,7 @@ const AgentMode = 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 AgentMode = 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 AgentMode = memo(() => {
|
||||
);
|
||||
|
||||
const button = (
|
||||
<div className={styles.button}>
|
||||
<div className={cx(styles.button, !canCreateContent && styles.buttonDisabled)}>
|
||||
<Icon icon={CurrentIcon} size={14} />
|
||||
<span>{t(`chatMode.${currentMode}`)}</span>
|
||||
<Icon icon={ChevronDownIcon} size={12} />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!canCreateContent)
|
||||
return (
|
||||
<Tooltip title={reason}>
|
||||
<div>{button}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={popoverContent}
|
||||
open={open}
|
||||
open={canCreateContent && open}
|
||||
placement="bottomLeft"
|
||||
trigger="click"
|
||||
styles={{
|
||||
content: { border: `1px solid ${cssVar.colorBorderSecondary}`, padding: 4 },
|
||||
}}
|
||||
onOpenChange={setOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<div>
|
||||
{open ? (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { memo, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { useFileStore } from '@/store/file';
|
||||
|
||||
@@ -25,6 +26,7 @@ const Clear = memo(() => {
|
||||
const clearCurrentMessages = useClearCurrentMessages();
|
||||
const [confirmOpened, updateConfirmOpened] = useState(false);
|
||||
const mobile = useIsMobile();
|
||||
const { allowed: canCreate } = usePermission('create_content');
|
||||
|
||||
const actionTitle: any = confirmOpened ? void 0 : t('clearCurrentMessages', { ns: 'chat' });
|
||||
|
||||
@@ -33,7 +35,7 @@ const Clear = memo(() => {
|
||||
return (
|
||||
<Popconfirm
|
||||
arrow={false}
|
||||
okButtonProps={{ danger: true, type: 'primary' }}
|
||||
okButtonProps={{ danger: true, disabled: !canCreate, type: 'primary' }}
|
||||
open={confirmOpened}
|
||||
placement={popconfirmPlacement}
|
||||
title={
|
||||
@@ -41,8 +43,14 @@ const Clear = memo(() => {
|
||||
{t('confirmClearCurrentMessages', { ns: 'chat' })}
|
||||
</div>
|
||||
}
|
||||
onConfirm={clearCurrentMessages}
|
||||
onOpenChange={updateConfirmOpened}
|
||||
onConfirm={() => {
|
||||
if (!canCreate) return;
|
||||
clearCurrentMessages();
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!canCreate && open) return;
|
||||
updateConfirmOpened(open);
|
||||
}}
|
||||
>
|
||||
<Action
|
||||
icon={Eraser}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import LevelSlider from '@/features/ModelSwitchPanel/components/ControlsForm/LevelSlider';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { chatConfigByIdSelectors } from '@/store/agent/selectors';
|
||||
|
||||
@@ -63,6 +64,7 @@ const ToggleItem = memo<ToggleOption>(({ value, description, icon, label }) => {
|
||||
const agentId = useAgentId();
|
||||
const { updateAgentChatConfig } = useUpdateAgentConfig();
|
||||
const isEnabled = useMemoryEnabled(agentId);
|
||||
const { allowed: canCreate } = usePermission('create_content');
|
||||
|
||||
const isActive = value === 'on' ? isEnabled : !isEnabled;
|
||||
|
||||
@@ -72,7 +74,12 @@ const ToggleItem = memo<ToggleOption>(({ value, description, icon, label }) => {
|
||||
align={'flex-start'}
|
||||
className={cx(styles.option, isActive && styles.active)}
|
||||
gap={12}
|
||||
style={{
|
||||
cursor: canCreate ? undefined : 'not-allowed',
|
||||
opacity: canCreate ? undefined : 0.5,
|
||||
}}
|
||||
onClick={async () => {
|
||||
if (!canCreate) return;
|
||||
await updateAgentChatConfig({ memory: { enabled: value === 'on' } });
|
||||
}}
|
||||
>
|
||||
@@ -92,6 +99,7 @@ const Controls = memo(() => {
|
||||
const agentId = useAgentId();
|
||||
const { updateAgentChatConfig } = useUpdateAgentConfig();
|
||||
const isEnabled = useMemoryEnabled(agentId);
|
||||
const { allowed: canCreate } = usePermission('create_content');
|
||||
const effort = useAgentStore((s) => chatConfigByIdSelectors.getMemoryToolEffortById(agentId)(s));
|
||||
|
||||
const toggleOptions: ToggleOption[] = [
|
||||
@@ -122,7 +130,13 @@ const Controls = memo(() => {
|
||||
<div className={styles.title}>{t('memory.effort.title')}</div>
|
||||
<div className={styles.description}>{t('memory.effort.desc')}</div>
|
||||
</Flexbox>
|
||||
<Flexbox flex={1}>
|
||||
<Flexbox
|
||||
flex={1}
|
||||
style={{
|
||||
opacity: canCreate ? undefined : 0.5,
|
||||
pointerEvents: canCreate ? undefined : 'none',
|
||||
}}
|
||||
>
|
||||
<LevelSlider<UserMemoryEffort>
|
||||
defaultValue="medium"
|
||||
levels={MEMORY_EFFORT_LEVELS}
|
||||
@@ -133,6 +147,7 @@ const Controls = memo(() => {
|
||||
2: t('memory.effort.high.title'),
|
||||
}}
|
||||
onChange={async (value) => {
|
||||
if (!canCreate) return;
|
||||
await updateAgentChatConfig({ memory: { effort: value, enabled: true } });
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ModelIcon } from '@lobehub/icons';
|
||||
import { Center } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { Center, Tooltip } from '@lobehub/ui';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { memo, useCallback } from 'react';
|
||||
|
||||
import { useBusinessModelModeConfig } from '@/business/client/hooks/useBusinessAgentMode';
|
||||
import ModelSwitchPanel from '@/features/ModelSwitchPanel';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors } from '@/store/agent/selectors';
|
||||
|
||||
@@ -15,6 +16,20 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
icon: css`
|
||||
transition: scale 400ms cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
`,
|
||||
modelDisabled: css`
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
|
||||
:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:active {
|
||||
div {
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
model: css`
|
||||
cursor: pointer;
|
||||
border-radius: 24px;
|
||||
@@ -35,6 +50,7 @@ const ModelSwitch = memo(() => {
|
||||
const { actionSize, dropdownPlacement } = useActionBarContext();
|
||||
const blockSize = actionSize?.blockSize ?? 32;
|
||||
const iconSize = actionSize?.size ?? 20;
|
||||
const { allowed: canCreateContent, reason } = usePermission('create_content');
|
||||
|
||||
const agentId = useAgentId();
|
||||
const [model, provider, updateAgentConfigById] = useAgentStore((s) => [
|
||||
@@ -46,9 +62,30 @@ const ModelSwitch = memo(() => {
|
||||
|
||||
const handleModelChange = useCallback(
|
||||
async (params: { model: string; provider: string }) => {
|
||||
if (!canCreateContent) return;
|
||||
|
||||
await updateAgentConfigById(agentId, applyBusinessModelModeConfig(params));
|
||||
},
|
||||
[agentId, applyBusinessModelModeConfig, updateAgentConfigById],
|
||||
[agentId, applyBusinessModelModeConfig, canCreateContent, updateAgentConfigById],
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
<Center
|
||||
className={cx(styles.model, !canCreateContent && styles.modelDisabled)}
|
||||
height={blockSize}
|
||||
width={blockSize}
|
||||
>
|
||||
<div className={styles.icon}>
|
||||
<ModelIcon model={model} size={iconSize} />
|
||||
</div>
|
||||
</Center>
|
||||
);
|
||||
|
||||
if (!canCreateContent)
|
||||
return (
|
||||
<Tooltip title={reason}>
|
||||
<div>{trigger}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -58,11 +95,7 @@ const ModelSwitch = memo(() => {
|
||||
provider={provider}
|
||||
onModelChange={handleModelChange}
|
||||
>
|
||||
<Center className={styles.model} height={blockSize} width={blockSize}>
|
||||
<div className={styles.icon}>
|
||||
<ModelIcon model={model} size={iconSize} />
|
||||
</div>
|
||||
</Center>
|
||||
{trigger}
|
||||
</ModelSwitchPanel>
|
||||
);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user