Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface AICopilotOrgConfig {
enabled: boolean
read_only: boolean
instructions?: string
token_mode?: 'jwt' | 'role'
}

export interface AICopilotRoleConfig {
Expand Down Expand Up @@ -145,7 +146,13 @@ export const mutations = {
)

if (!response.ok) {
throw new Error(`Failed to send message: ${response.status}`)
let errorMessage = `Failed to send message: ${response.status}`
try {
const errorBody = await response.json()
if (errorBody?.error) errorMessage = errorBody.error
// eslint-disable-next-line no-empty
} catch (_) {}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you avoid adding linter disabling here?

throw new Error(errorMessage)
}

return response
Expand Down Expand Up @@ -188,18 +195,21 @@ export const mutations = {
readOnly,
instructions,
userEmail,
tokenMode,
}: {
organizationId: string
enabled: boolean
readOnly: boolean
instructions?: string
userEmail?: string
tokenMode?: 'jwt' | 'role'
}) => {
const response = await devopsCopilotAxios.put(`/organization/${organizationId}/config/org`, {
enabled,
read_only: readOnly,
instructions: instructions || '',
user_email: userEmail,
...(tokenMode !== undefined && { token_mode: tokenMode }),
})

return response.data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function AICopilotSettings(props: AICopilotSettingsProps) {
const orgConfig = configData?.org_config
const isEnabled = orgConfig?.enabled ?? false
const currentMode = orgConfig?.read_only ? 'read-only' : 'read-write'
const currentTokenMode = orgConfig?.token_mode ?? 'jwt'

const toggleTaskMutation = useToggleAICopilotRecurringTask({ organizationId: organization.id })
const deleteTaskMutation = useDeleteAICopilotRecurringTask({ organizationId: organization.id })
Expand All @@ -58,7 +59,7 @@ export function AICopilotSettings(props: AICopilotSettingsProps) {
<Section className="px-8 pb-8 pt-6">
<SettingsHeading title="AI Copilot Configuration" description="Configure your Copilot" showNeedHelp={false} />
<div className="max-w-content-with-navigation-left">
<Callout.Root color="purple" className="mb-4">
<Callout.Root color="sky" className="mb-4">
<Callout.Icon>
<Icon iconName="flask" />
</Callout.Icon>
Expand All @@ -83,7 +84,11 @@ export function AICopilotSettings(props: AICopilotSettingsProps) {
isLoading={isLoadingConfig || !orgConfig}
isUpdating={updateConfigMutation.isLoading}
currentMode={currentMode}
currentTokenMode={currentTokenMode}
onModeChange={(mode) => updateConfigMutation.mutate({ enabled: true, readOnly: mode === 'read-only' })}
onTokenModeChange={(tokenMode) =>
updateConfigMutation.mutate({ enabled: true, readOnly: orgConfig?.read_only ?? true, tokenMode })
}
onDisable={() => handleToggleCopilot(false)}
/>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useFeatureFlagVariantKey } from 'posthog-js/react'
import { organizationFactoryMock } from '@qovery/shared/factories'
import { render, renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests'
import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests'
import { SectionAICopilotConfiguration } from './section-ai-copilot-configuration'

jest.mock('@qovery/shared/ui', () => ({
Expand Down Expand Up @@ -37,19 +37,19 @@ describe('SectionAICopilotConfiguration', () => {
})

it('should show loader when loading', () => {
const { container } = render(<SectionAICopilotConfiguration {...defaultProps} isLoading={true} />)
const { container } = renderWithProviders(<SectionAICopilotConfiguration {...defaultProps} isLoading={true} />)

expect(container.querySelector('.w-5')).toBeInTheDocument()
})

it('should display organization name', () => {
render(<SectionAICopilotConfiguration {...defaultProps} />)
renderWithProviders(<SectionAICopilotConfiguration {...defaultProps} />)

expect(screen.getByText(`AI Copilot for ${mockOrganization.name}`)).toBeInTheDocument()
})

it('should display current mode', () => {
render(<SectionAICopilotConfiguration {...defaultProps} currentMode="read-only" />)
renderWithProviders(<SectionAICopilotConfiguration {...defaultProps} currentMode="read-only" />)

expect(screen.getByText('Read-Only Mode')).toBeInTheDocument()
expect(
Expand All @@ -58,7 +58,7 @@ describe('SectionAICopilotConfiguration', () => {
})

it('should display read-write mode description', () => {
render(<SectionAICopilotConfiguration {...defaultProps} currentMode="read-write" />)
renderWithProviders(<SectionAICopilotConfiguration {...defaultProps} currentMode="read-write" />)

expect(screen.getByText('Read-Write Mode')).toBeInTheDocument()
expect(screen.getByText(/AI Copilot can view and modify your infrastructure configuration/)).toBeInTheDocument()
Expand Down Expand Up @@ -112,28 +112,28 @@ describe('SectionAICopilotConfiguration', () => {
})

it('should disable inputs when updating', () => {
render(<SectionAICopilotConfiguration {...defaultProps} isUpdating={true} />)
renderWithProviders(<SectionAICopilotConfiguration {...defaultProps} isUpdating={true} />)

const select = screen.getByLabelText('Right access')
expect(select).toBeDisabled()
})

it('should show loading state on buttons when updating', () => {
render(<SectionAICopilotConfiguration {...defaultProps} isUpdating={true} />)
renderWithProviders(<SectionAICopilotConfiguration {...defaultProps} isUpdating={true} />)

const disableButton = screen.getByRole('button', { name: /disable/i })
expect(disableButton).toBeInTheDocument()
})

it('should render disable button', () => {
render(<SectionAICopilotConfiguration {...defaultProps} />)
renderWithProviders(<SectionAICopilotConfiguration {...defaultProps} />)

const disableButton = screen.getByRole('button', { name: /disable/i })
expect(disableButton).toBeInTheDocument()
})

it('should have mode select with correct options', () => {
render(<SectionAICopilotConfiguration {...defaultProps} />)
renderWithProviders(<SectionAICopilotConfiguration {...defaultProps} />)

const select = screen.getByLabelText('Right access')
expect(select).toBeInTheDocument()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import {
BlockContent,
Button,
Callout,
Heading,
Icon,
IconAwesomeEnum,
InputSelect,
Link,
LoaderSpinner,
RadioGroup,
Section,
Tooltip,
useModal,
} from '@qovery/shared/ui'

Expand All @@ -19,14 +20,16 @@ export interface SectionAICopilotConfigurationProps {
isLoading?: boolean
isUpdating?: boolean
currentMode: 'read-only' | 'read-write'
currentTokenMode?: 'jwt' | 'role'
onModeChange: (mode: 'read-only' | 'read-write') => void
onTokenModeChange?: (tokenMode: 'jwt' | 'role') => void
onDisable: () => void
}

function getDisableConfirmationModal(closeModal: () => void, onDisable: () => void) {
return (
<div className="p-6">
<h2 className="h4 mb-2 text-neutral">Disable AI Copilot</h2>
<h2 className="mb-2 text-base font-medium text-neutral">Disable AI Copilot</h2>
<p className="mb-6 text-sm text-neutral-subtle">
Are you sure you want to disable AI Copilot? This will stop all AI-powered assistance for your organization.
</p>
Expand Down Expand Up @@ -55,7 +58,9 @@ export function SectionAICopilotConfiguration({
isLoading,
isUpdating,
currentMode,
currentTokenMode,
onModeChange,
onTokenModeChange,
onDisable,
}: SectionAICopilotConfigurationProps) {
const { openModal, closeModal } = useModal()
Expand Down Expand Up @@ -87,12 +92,12 @@ export function SectionAICopilotConfiguration({
<Section>
<BlockContent title="Configuration" classNameContent="p-0" className="m-0">
{isLoading ? (
<div className="flex justify-center p-5">
<div className="flex justify-center p-4">
<LoaderSpinner className="w-5" />
</div>
) : (
<div className="space-y-6 p-6">
<div className={`-mx-6 px-6 ${hasReadWriteAccess ? 'border-b border-neutral pb-6' : ''}`}>
<div className="-mx-6 border-b border-neutral px-6 pb-6">
<div className="flex items-center gap-4">
<div className="flex-1">
<div className="mb-2 flex items-center">
Expand All @@ -116,10 +121,58 @@ export function SectionAICopilotConfiguration({
</div>
</div>

<div className="-mx-6 border-b border-neutral px-6 pb-6">
<div className="mb-3 flex items-center gap-1">
<p className="text-sm font-medium text-neutral">Access source</p>
<Tooltip content="Choose which identity the AI Copilot uses to access your infrastructure.">
<span className="relative top-[1px] text-sm text-neutral-subtle">
<Icon iconName="circle-question" iconStyle="regular" />
</span>
</Tooltip>
</div>
<RadioGroup.Root
value={currentTokenMode ?? 'jwt'}
onValueChange={(value) => onTokenModeChange?.(value as 'jwt' | 'role')}
className="flex flex-col gap-3"
>
<div className="flex items-start gap-3">
<RadioGroup.Item value="jwt" id="token-mode-jwt" className="mt-px" />
<label htmlFor="token-mode-jwt" className="cursor-pointer">
<p className="text-sm font-medium text-neutral">My account</p>
<p className="text-xs text-neutral-subtle">The copilot acts as you, with your own permissions.</p>
</label>
</div>
<div className="flex items-start gap-3">
<RadioGroup.Item value="role" id="token-mode-role" className="mt-px" />
<label htmlFor="token-mode-role" className="cursor-pointer">
<p className="text-sm font-medium text-neutral">Copilot role</p>
<p className="text-xs text-neutral-subtle">
The copilot uses a dedicated role with controlled permissions.
</p>
</label>
</div>
</RadioGroup.Root>
{currentTokenMode === 'role' && (
<p className="mt-3 text-xs text-neutral-subtle">
Manage access permissions in{' '}
<Link
to="/organization/$organizationId/settings/roles"
params={{ organizationId: organization?.id ?? '' }}
color="brand"
size="xs"
underline
>
Roles settings
</Link>
.
</p>
)}
</div>

<div className="space-y-4">
{hasReadWriteAccess && (
<>
<div className="space-y-2">
<div className="space-y-1">
<label className="text-sm font-medium text-neutral">Access Mode</label>
<p className="text-xs text-neutral-subtle">
Choose the level of access the AI Copilot will have on your organization
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,17 @@ export function useMessageSubmission({ refs, state, actions }: UseMessageSubmiss
lastSubmitResult.current = response
} catch (error) {
console.error('Error fetching response:', error)
const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred.'
actions.setThread([
...updatedThread,
{
id: Date.now().toString(),
text: errorMessage,
owner: 'assistant',
timestamp: Date.now(),
},
])
actions.setIsLoading(false)
} finally {
actions.setIsFinish(true)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ export const submitMessage = async (
messageId: assistantMessageId || '',
}
} catch (error) {
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: { error?: string } } }
const backendMessage = axiosError.response?.data?.error
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure to understand why do you need to add it ?
console.error('Error', error) should be enough no?

if (backendMessage) {
console.error('Error:', new Error(backendMessage))
return null
}
}
console.error('Error:', error)
return null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,26 @@ export function useUpdateAICopilotConfig({ organizationId, instructions }: UseUp
const queryClient = useQueryClient()

return useMutation({
mutationFn: ({ enabled, readOnly, userEmail }: { enabled: boolean; readOnly: boolean; userEmail?: string }) =>
mutationFn: ({
enabled,
readOnly,
userEmail,
tokenMode,
}: {
enabled: boolean
readOnly: boolean
userEmail?: string
tokenMode?: 'jwt' | 'role'
}) =>
mutations.updateOrgConfig({
organizationId,
enabled,
readOnly,
instructions: instructions || '',
userEmail,
tokenMode,
}),
onMutate: async ({ readOnly }) => {
onMutate: async ({ readOnly, tokenMode }) => {
await queryClient.cancelQueries({
queryKey: devopsCopilot.config({ organizationId }).queryKey,
})
Expand All @@ -36,6 +47,7 @@ export function useUpdateAICopilotConfig({ organizationId, instructions }: UseUp
org_config: {
...old.org_config,
read_only: readOnly,
...(tokenMode !== undefined && { token_mode: tokenMode }),
},
}
: old
Expand All @@ -44,8 +56,14 @@ export function useUpdateAICopilotConfig({ organizationId, instructions }: UseUp
return { previousConfig }
},
onSuccess: (_data, variables) => {
const modeName = variables.readOnly ? 'Read-Only' : 'Read-Write'
if (variables.enabled) {
if (variables.tokenMode !== undefined) {
toast('success', 'Access source updated successfully')
posthog.capture('ai-copilot-token-mode-changed', {
organization_id: organizationId,
token_mode: variables.tokenMode,
})
} else if (variables.enabled) {
const modeName = variables.readOnly ? 'Read-Only' : 'Read-Write'
toast('success', `AI Copilot enabled with ${modeName} mode`)
posthog.capture('ai-copilot-enabled', {
organization_id: organizationId,
Expand All @@ -58,11 +76,13 @@ export function useUpdateAICopilotConfig({ organizationId, instructions }: UseUp
})
}
},
onError: (_err, _variables, context) => {
onError: (err, _variables, context) => {
if (context?.previousConfig) {
queryClient.setQueryData(devopsCopilot.config({ organizationId }).queryKey, context.previousConfig)
}
toast('error', 'Failed to update AI Copilot configuration', 'Please try again later')
const axiosErr = err as { response?: { data?: { error?: string } } }
const message = axiosErr?.response?.data?.error ?? (err instanceof Error ? err.message : 'Please try again later')
toast('error', 'Failed to update AI Copilot configuration', message)
},
onSettled: () => {
queryClient.invalidateQueries({
Expand Down
Loading