Skip to content
Closed
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
52 changes: 50 additions & 2 deletions agents-manage-ui/src/components/agent/copilot/copilot-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

import { InkeepSidebarChat } from '@inkeep/agents-ui';
import type { InkeepCallbackEvent } from '@inkeep/agents-ui/types';
import { AlertCircle, Loader2, RefreshCw } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { useRuntimeConfig } from '@/contexts/runtime-config-context';
import { useCopilotToken } from '@/hooks/use-copilot-token';
import { useOAuthLogin } from '@/hooks/use-oauth-login';
import { generateId } from '@/lib/utils/id-utils';
import { useCopilotContext } from './copilot-context';
Expand Down Expand Up @@ -57,12 +61,19 @@ export function CopilotChat({ agentId, tenantId, projectId, refreshAgentGraph }:

const {
PUBLIC_INKEEP_AGENTS_RUN_API_URL,
PUBLIC_INKEEP_AGENTS_RUN_API_BYPASS_SECRET,
PUBLIC_INKEEP_COPILOT_AGENT_ID,
PUBLIC_INKEEP_COPILOT_PROJECT_ID,
PUBLIC_INKEEP_COPILOT_TENANT_ID,
} = useRuntimeConfig();

const {
apiKey: copilotToken,
isLoading: isLoadingToken,
error: tokenError,
retryCount,
refresh: refreshToken,
} = useCopilotToken();

if (
!PUBLIC_INKEEP_COPILOT_AGENT_ID ||
!PUBLIC_INKEEP_COPILOT_PROJECT_ID ||
Expand All @@ -74,6 +85,43 @@ export function CopilotChat({ agentId, tenantId, projectId, refreshAgentGraph }:
return null;
}

// Show error state when token fetch failed
if (tokenError && !isLoadingToken) {
return (
<div className="p-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Agent Editor Unavailable</AlertTitle>
<AlertDescription className="mt-2">
<p className="mb-3">
Unable to connect to the Agent Editor. This may be due to a temporary network issue or
configuration problem.
</p>
<Button variant="outline" size="sm" onClick={() => refreshToken()} className="gap-2">
<RefreshCw className="h-4 w-4" />
Try Again
</Button>
</AlertDescription>
</Alert>
</div>
);
}

// Show loading state (including retries)
if (isLoadingToken) {
return (
<div className="flex items-center justify-center p-4 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span>{retryCount > 0 ? `Reconnecting (attempt ${retryCount}/3)...` : 'Loading...'}</span>
</div>
);
}

// Token not available (shouldn't happen if no error, but safety check)
if (!copilotToken) {
return null;
}

return (
<div className="h-full flex flex-row gap-4">
<div className="flex-1 min-w-0 h-full">
Expand Down Expand Up @@ -165,7 +213,7 @@ export function CopilotChat({ agentId, tenantId, projectId, refreshAgentGraph }:
agentUrl: `${PUBLIC_INKEEP_AGENTS_RUN_API_URL}/api/chat`,
headers: {
'x-emit-operations': 'true',
Authorization: `Bearer ${PUBLIC_INKEEP_AGENTS_RUN_API_BYPASS_SECRET}`,
Authorization: `Bearer ${copilotToken}`,
'x-inkeep-tenant-id': PUBLIC_INKEEP_COPILOT_TENANT_ID,
'x-inkeep-project-id': PUBLIC_INKEEP_COPILOT_PROJECT_ID,
'x-inkeep-agent-id': PUBLIC_INKEEP_COPILOT_AGENT_ID,
Expand Down
124 changes: 124 additions & 0 deletions agents-manage-ui/src/hooks/use-copilot-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getCopilotTokenAction } from '@/lib/actions/copilot-token';

const MAX_RETRIES = 3;
const INITIAL_RETRY_DELAY_MS = 1000;

export interface UseCopilotTokenResult {
apiKey: string | null;
isLoading: boolean;
error: Error | null;
retryCount: number;
refresh: () => Promise<void>;
}

async function fetchWithRetry(
maxRetries: number,
onRetry?: (attempt: number, delay: number) => void
): Promise<{ apiKey: string; expiresAt: string }> {
let lastError: Error | null = null;

for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await getCopilotTokenAction();

if (result.success) {
return result.data;
}

// Non-retryable errors (configuration issues)
if (result.code === 'configuration_error') {
throw new Error(result.error);
}

lastError = new Error(result.error);
} catch (err) {
lastError = err instanceof Error ? err : new Error('Unknown error');
}

// Don't retry after the last attempt
if (attempt < maxRetries) {
const delay = INITIAL_RETRY_DELAY_MS * 2 ** attempt;
onRetry?.(attempt + 1, delay);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}

throw lastError || new Error('Failed to fetch copilot token after retries');
}

export function useCopilotToken(): UseCopilotTokenResult {
const [apiKey, setApiKey] = useState<string | null>(null);
const [expiresAt, setExpiresAt] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [retryCount, setRetryCount] = useState(0);
const isMountedRef = useRef(true);

const fetchToken = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
setRetryCount(0);

const data = await fetchWithRetry(MAX_RETRIES, (attempt, delay) => {
if (isMountedRef.current) {
setRetryCount(attempt);
console.log(`Copilot token fetch retry ${attempt}/${MAX_RETRIES} after ${delay}ms`);
}
});

if (isMountedRef.current) {
setApiKey(data.apiKey);
setExpiresAt(data.expiresAt);
setError(null);
setRetryCount(0);
}
} catch (err) {
if (isMountedRef.current) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
setError(new Error(errorMessage));
console.error('Copilot token fetch failed after all retries:', errorMessage);
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}, []);

// Track mounted state
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);

// Initial fetch
useEffect(() => {
fetchToken();
}, [fetchToken]);

// Auto-refresh before expiry
useEffect(() => {
if (!expiresAt) return;

const expiryTime = new Date(expiresAt).getTime();
const now = Date.now();
const timeUntilExpiry = expiryTime - now;

// Refresh 5 minutes before expiry (or immediately if already expired)
const refreshTime = Math.max(0, timeUntilExpiry - 5 * 60 * 1000);

const timer = setTimeout(() => {
console.log('Auto-refreshing copilot token before expiry...');
fetchToken();
}, refreshTime);

return () => clearTimeout(timer);
}, [expiresAt, fetchToken]);

return { apiKey, isLoading, error, retryCount, refresh: fetchToken };
}

94 changes: 94 additions & 0 deletions agents-manage-ui/src/lib/actions/copilot-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
'use server';

import { DEFAULT_INKEEP_AGENTS_MANAGE_API_URL } from '../runtime-config/defaults';

export type ActionResult<T = void> =
| {
success: true;
data: T;
}
| {
success: false;
error: string;
code?: string;
};

export interface CopilotTokenResponse {
apiKey: string;
expiresAt: string;
}

export async function getCopilotTokenAction(): Promise<ActionResult<CopilotTokenResponse>> {
const copilotApiKey = process.env.INKEEP_COPILOT_API_KEY;
const copilotTenantId = process.env.PUBLIC_INKEEP_COPILOT_TENANT_ID;
const copilotProjectId = process.env.PUBLIC_INKEEP_COPILOT_PROJECT_ID;
const copilotAgentId = process.env.PUBLIC_INKEEP_COPILOT_AGENT_ID;
const manageApiUrl =
process.env.INKEEP_AGENTS_MANAGE_API_URL ||
process.env.PUBLIC_INKEEP_AGENTS_MANAGE_API_URL ||
DEFAULT_INKEEP_AGENTS_MANAGE_API_URL;

if (!copilotApiKey) {
return {
success: false,
error: 'INKEEP_COPILOT_API_KEY is not configured',
code: 'configuration_error',
};
}

if (!copilotTenantId || !copilotProjectId || !copilotAgentId) {
return {
success: false,
error: 'Copilot tenant, project, or agent ID is not configured',
code: 'configuration_error',
};
}

try {
const response = await fetch(
`${manageApiUrl}/tenants/${copilotTenantId}/playground/token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${copilotApiKey}`,
},
body: JSON.stringify({
projectId: copilotProjectId,
agentId: copilotAgentId,
}),
}
);

if (!response.ok) {
let errorMessage = 'Failed to fetch copilot token';
try {
const errorData = await response.json();
errorMessage = errorData?.error?.message || errorData?.message || errorMessage;
} catch {
// Ignore JSON parse errors
}
return {
success: false,
error: errorMessage,
code: 'api_error',
};
}

const data = await response.json();
return {
success: true,
data: {
apiKey: data.apiKey,
expiresAt: data.expiresAt,
},
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
code: 'network_error',
};
}
}

Loading