Skip to content

Commit 07cdf0e

Browse files
committed
feat(agents): add Microsoft Teams bridge integration
Add AIAgentTeamsBridge proto message and wire it into the console UI as a new Integrations tab on the agent detail page. The tab exposes enable toggle, bot app ID, tenant ID, and client secret fields. When saved and enabled, the computed messaging endpoint URL is displayed for Azure Bot registration. Proto: AIAgentTeamsBridge message added to AIAgent (field 18), AIAgentCreate (field 15), and AIAgentUpdate (field 15).
1 parent 6ee88a1 commit 07cdf0e

9 files changed

Lines changed: 1405 additions & 891 deletions

File tree

backend/pkg/protogen/redpanda/api/dataplane/v1alpha3/ai_agent.pb.go

Lines changed: 1000 additions & 869 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/components/pages/agents/details/ai-agent-details-page.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { getRouteApi, useNavigate } from '@tanstack/react-router';
1414
const routeApi = getRouteApi('/agents/$id/');
1515

1616
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'components/redpanda-ui/components/tabs';
17-
import { AlertCircle, FileText, Loader2, Network, Search, Settings } from 'lucide-react';
17+
import { AlertCircle, FileText, Loader2, Network, Plug, Search, Settings } from 'lucide-react';
1818
import { useEffect } from 'react';
1919
import { useGetAIAgentQuery } from 'react-query/api/ai-agent';
2020
import { uiState } from 'state/ui-state';
@@ -23,6 +23,7 @@ import { AIAgentCardTab } from './ai-agent-card-tab';
2323
import { AIAgentConfigurationTab } from './ai-agent-configuration-tab';
2424
import { AIAgentDetailsHeader } from './ai-agent-details-header';
2525
import { AIAgentInspectorTab } from './ai-agent-inspector-tab';
26+
import { AIAgentIntegrationsTab } from './ai-agent-integrations-tab';
2627
import { AIAgentTranscriptsTab } from './ai-agent-transcripts-tab';
2728

2829
export const updatePageTitle = (agentName?: string) => {
@@ -91,6 +92,12 @@ export const AIAgentDetailsPage = () => {
9192
Configuration
9293
</div>
9394
</TabsTrigger>
95+
<TabsTrigger className="gap-2" value="integrations">
96+
<div className="flex items-center gap-2">
97+
<Plug className="h-4 w-4" />
98+
Integrations
99+
</div>
100+
</TabsTrigger>
94101
<TabsTrigger className="gap-2" value="agent-card">
95102
<div className="flex items-center gap-2">
96103
<Network className="h-4 w-4" />
@@ -112,6 +119,9 @@ export const AIAgentDetailsPage = () => {
112119
<TabsContent value="configuration">
113120
<AIAgentConfigurationTab />
114121
</TabsContent>
122+
<TabsContent value="integrations">
123+
<AIAgentIntegrationsTab />
124+
</TabsContent>
115125
<TabsContent value="agent-card">
116126
<AIAgentCardTab />
117127
</TabsContent>
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
/**
2+
* Copyright 2026 Redpanda Data, Inc.
3+
*
4+
* Use of this software is governed by the Business Source License
5+
* included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
6+
*
7+
* As of the Change Date specified in that file, in accordance with
8+
* the Business Source License, use of this software will be governed
9+
* by the Apache License, Version 2.0
10+
*/
11+
12+
import { create } from '@bufbuild/protobuf';
13+
import { FieldMaskSchema } from '@bufbuild/protobuf/wkt';
14+
import { getRouteApi } from '@tanstack/react-router';
15+
import { Button } from 'components/redpanda-ui/components/button';
16+
import { Card, CardContent, CardHeader, CardTitle } from 'components/redpanda-ui/components/card';
17+
import { DynamicCodeBlock } from 'components/redpanda-ui/components/code-block-dynamic';
18+
import { Input } from 'components/redpanda-ui/components/input';
19+
import { Label } from 'components/redpanda-ui/components/label';
20+
import { Switch } from 'components/redpanda-ui/components/switch';
21+
import { Text } from 'components/redpanda-ui/components/typography';
22+
import { SecretSelector } from 'components/ui/secret/secret-selector';
23+
import { Edit, Save } from 'lucide-react';
24+
import { Scope } from 'protogen/redpanda/api/dataplane/v1/secret_pb';
25+
import {
26+
AIAgentTeamsBridgeSchema,
27+
AIAgentUpdateSchema,
28+
UpdateAIAgentRequestSchema,
29+
} from 'protogen/redpanda/api/dataplane/v1alpha3/ai_agent_pb';
30+
import { useMemo, useState } from 'react';
31+
import { useGetAIAgentQuery, useUpdateAIAgentMutation } from 'react-query/api/ai-agent';
32+
import { useListSecretsQuery } from 'react-query/api/secret';
33+
import { toast } from 'sonner';
34+
import { formatToastErrorMessageGRPC } from 'utils/toast.utils';
35+
36+
const routeApi = getRouteApi('/agents/$id/');
37+
38+
const SECRET_TEMPLATE_REGEX = /^\$\{secrets\.([^}]+)\}$/;
39+
40+
const TEAMS_SECRET_TEXT = {
41+
dialogDescription: 'Create a new secret for your Microsoft Teams bot client secret.',
42+
secretNamePlaceholder: 'e.g., TEAMS_CLIENT_SECRET',
43+
secretValuePlaceholder: 'Enter your client secret...',
44+
secretValueDescription: 'Your Microsoft Teams bot application client secret',
45+
emptyStateDescription: 'Create a secret to securely store your Teams bot client secret',
46+
};
47+
48+
type TeamsBridgeState = {
49+
enabled: boolean;
50+
botAppId: string;
51+
botTenantId: string;
52+
botAppSecretName: string;
53+
};
54+
55+
const extractSecretName = (ref: string): string => {
56+
const match = ref.match(SECRET_TEMPLATE_REGEX);
57+
return match ? match[1] : '';
58+
};
59+
60+
const getMessagingEndpointUrl = (agentUrl: string, agentId: string): string => {
61+
try {
62+
const url = new URL(agentUrl);
63+
const hostParts = url.hostname.split('.');
64+
if (hostParts.length < 2) {
65+
return '';
66+
}
67+
const clusterDomain = hostParts.slice(1).join('.');
68+
return `https://msteams-bridge.${clusterDomain}/msteams/v1/${agentId}`;
69+
} catch {
70+
return '';
71+
}
72+
};
73+
74+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Integrations tab with edit/view mode conditionals
75+
export const AIAgentIntegrationsTab = () => {
76+
const { id } = routeApi.useParams();
77+
const { data: aiAgentData } = useGetAIAgentQuery({ id: id || '' }, { enabled: !!id });
78+
const { mutateAsync: updateAIAgent, isPending: isUpdatePending } = useUpdateAIAgentMutation();
79+
const { data: secretsData } = useListSecretsQuery();
80+
81+
const [isEditing, setIsEditing] = useState(false);
82+
const [editedState, setEditedState] = useState<TeamsBridgeState | null>(null);
83+
84+
const agent = aiAgentData?.aiAgent;
85+
86+
const availableSecrets = useMemo(() => {
87+
if (!secretsData?.secrets) {
88+
return [];
89+
}
90+
return secretsData.secrets
91+
.filter((secret): secret is NonNullable<typeof secret> & { id: string } => !!secret?.id)
92+
.map((secret) => ({ id: secret.id, name: secret.id }));
93+
}, [secretsData]);
94+
95+
if (!agent) {
96+
return null;
97+
}
98+
99+
const displayState: TeamsBridgeState = editedState || {
100+
enabled: agent.teamsBridge?.enabled ?? false,
101+
botAppId: agent.teamsBridge?.botAppId ?? '',
102+
botTenantId: agent.teamsBridge?.botTenantId ?? '',
103+
botAppSecretName: extractSecretName(agent.teamsBridge?.botAppSecretRef ?? ''),
104+
};
105+
106+
const updateField = (updates: Partial<TeamsBridgeState>) => {
107+
setEditedState({ ...displayState, ...updates });
108+
};
109+
110+
const handleSave = async () => {
111+
if (!id) {
112+
return;
113+
}
114+
115+
const secretRef = displayState.botAppSecretName ? `\${secrets.${displayState.botAppSecretName}}` : '';
116+
117+
try {
118+
await updateAIAgent(
119+
create(UpdateAIAgentRequestSchema, {
120+
id,
121+
aiAgent: create(AIAgentUpdateSchema, {
122+
teamsBridge: create(AIAgentTeamsBridgeSchema, {
123+
enabled: displayState.enabled,
124+
botAppId: displayState.botAppId,
125+
botTenantId: displayState.botTenantId,
126+
botAppSecretRef: secretRef || undefined,
127+
}),
128+
}),
129+
updateMask: create(FieldMaskSchema, {
130+
paths: [
131+
'teams_bridge.enabled',
132+
'teams_bridge.bot_app_id',
133+
'teams_bridge.bot_tenant_id',
134+
'teams_bridge.bot_app_secret_ref',
135+
],
136+
}),
137+
}),
138+
{
139+
onSuccess: () => {
140+
toast.success('Teams integration updated');
141+
setIsEditing(false);
142+
setEditedState(null);
143+
},
144+
onError: (error) => {
145+
toast.error(formatToastErrorMessageGRPC({ error, action: 'update', entity: 'Teams integration' }));
146+
},
147+
}
148+
);
149+
} catch {
150+
// Error already handled
151+
}
152+
};
153+
154+
const handleCancel = () => {
155+
setIsEditing(false);
156+
setEditedState(null);
157+
};
158+
159+
const messagingEndpointUrl = agent.teamsBridge?.enabled && agent.url ? getMessagingEndpointUrl(agent.url, id) : '';
160+
161+
return (
162+
<div className="space-y-4">
163+
<Card className="px-0 py-0" size="full">
164+
<CardHeader className="flex flex-row items-center justify-between border-b p-4 dark:border-border [.border-b]:pb-4">
165+
<div className="space-y-1">
166+
<CardTitle>
167+
<Text className="font-semibold">Microsoft Teams</Text>
168+
</CardTitle>
169+
<Text className="text-muted-foreground text-sm">
170+
Connect this agent to Microsoft Teams to enable conversational interactions through a Teams bot.
171+
</Text>
172+
</div>
173+
<div className="flex gap-2">
174+
{isEditing ? (
175+
<>
176+
<Button disabled={isUpdatePending} onClick={handleSave} variant="primary">
177+
<Save className="h-4 w-4" />
178+
{isUpdatePending ? 'Saving...' : 'Save Changes'}
179+
</Button>
180+
<Button onClick={handleCancel} variant="outline">
181+
Cancel
182+
</Button>
183+
</>
184+
) : (
185+
<Button onClick={() => setIsEditing(true)} variant="primary">
186+
<Edit className="h-4 w-4" />
187+
Edit Configuration
188+
</Button>
189+
)}
190+
</div>
191+
</CardHeader>
192+
<CardContent className="space-y-6 px-4 pb-4">
193+
{/* Enable toggle */}
194+
<div className="flex items-center justify-between">
195+
<div className="space-y-0.5">
196+
<Label htmlFor="teams-enabled">Enable Teams Integration</Label>
197+
<Text className="text-muted-foreground text-sm">Activate the Microsoft Teams bridge for this agent</Text>
198+
</div>
199+
{isEditing ? (
200+
<Switch
201+
checked={displayState.enabled}
202+
id="teams-enabled"
203+
onCheckedChange={(checked) => updateField({ enabled: checked })}
204+
/>
205+
) : (
206+
<Text className="font-medium text-sm">{displayState.enabled ? 'Enabled' : 'Disabled'}</Text>
207+
)}
208+
</div>
209+
210+
{/* Bot configuration fields */}
211+
<div className="grid gap-4 md:grid-cols-2">
212+
<div className="space-y-2">
213+
<Label htmlFor="teams-bot-app-id">Application (client) ID</Label>
214+
{isEditing ? (
215+
<Input
216+
id="teams-bot-app-id"
217+
onChange={(e) => updateField({ botAppId: e.target.value })}
218+
placeholder="e.g., 12345678-abcd-efgh-ijkl-123456789012"
219+
value={displayState.botAppId}
220+
/>
221+
) : (
222+
<div className="flex h-10 items-center rounded-md border border-gray-200 bg-gray-50 px-3 py-2">
223+
<Text className="truncate">{displayState.botAppId || '-'}</Text>
224+
</div>
225+
)}
226+
</div>
227+
228+
<div className="space-y-2">
229+
<Label htmlFor="teams-bot-tenant-id">Tenant ID</Label>
230+
{isEditing ? (
231+
<Input
232+
id="teams-bot-tenant-id"
233+
onChange={(e) => updateField({ botTenantId: e.target.value })}
234+
placeholder="e.g., 12345678-abcd-efgh-ijkl-123456789012"
235+
value={displayState.botTenantId}
236+
/>
237+
) : (
238+
<div className="flex h-10 items-center rounded-md border border-gray-200 bg-gray-50 px-3 py-2">
239+
<Text className="truncate">{displayState.botTenantId || '-'}</Text>
240+
</div>
241+
)}
242+
</div>
243+
</div>
244+
245+
{/* Client Secret */}
246+
<div className="space-y-2">
247+
<Label>Client Secret</Label>
248+
{isEditing ? (
249+
<div className="[&>div]:flex-col [&>div]:items-stretch [&>div]:gap-2">
250+
<SecretSelector
251+
availableSecrets={availableSecrets}
252+
customText={TEAMS_SECRET_TEXT}
253+
onChange={(value) => updateField({ botAppSecretName: value })}
254+
placeholder="Select from secrets store or create new"
255+
scopes={[Scope.AI_AGENT]}
256+
value={displayState.botAppSecretName}
257+
/>
258+
</div>
259+
) : (
260+
<div className="flex h-10 items-center rounded-md border border-gray-200 bg-gray-50 px-3 py-2">
261+
<Text className="truncate">{displayState.botAppSecretName || '-'}</Text>
262+
</div>
263+
)}
264+
</div>
265+
266+
{/* Messaging endpoint URL - shown when integration is saved and enabled */}
267+
{Boolean(agent.teamsBridge?.enabled && messagingEndpointUrl) && (
268+
<div className="space-y-2">
269+
<Label>Messaging Endpoint</Label>
270+
<Text className="text-muted-foreground text-sm">
271+
Configure this URL as the messaging endpoint in your Azure Bot registration.
272+
</Text>
273+
<DynamicCodeBlock code={messagingEndpointUrl} lang="text" />
274+
</div>
275+
)}
276+
</CardContent>
277+
</Card>
278+
</div>
279+
);
280+
};

0 commit comments

Comments
 (0)