From eeb937c9ca0b3a3ac6e55bb11995973873afca0e Mon Sep 17 00:00:00 2001 From: Erik M Jacobs Date: Wed, 5 Nov 2025 13:50:40 -0500 Subject: [PATCH] [WIP] auto-include some context about console "state" --- src/components/Prompt.tsx | 30 ++- src/hooks/useAutoContextDescription.ts | 256 +++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 src/hooks/useAutoContextDescription.ts diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 9e8c278c..2bb663d8 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -29,6 +29,7 @@ import { getFetchErrorMessage } from '../error'; import { getRequestInitWithAuthHeader } from '../hooks/useAuth'; import { useBoolean } from '../hooks/useBoolean'; import { useLocationContext } from '../hooks/useLocationContext'; +import { useAutoContextDescription } from '../hooks/useAutoContextDescription'; import { attachmentDelete, attachmentsClear, @@ -108,6 +109,7 @@ const Prompt: React.FC = ({ scrollIntoView }) => { const fileInputRef = React.useRef(null); const [kind, name, namespace] = useLocationContext(); + const autoContextDescription = useAutoContextDescription(); const k8sContext = useK8sWatchResource( kind && kind !== 'Alert' && name ? { isList: false, kind, name, namespace } : null, @@ -480,8 +482,23 @@ const Prompt: React.FC = ({ scrollIntoView }) => { ); scrollIntoView(); + // Prepare attachments including hidden auto-context + const userAttachments = attachments.valueSeq().map(toOLSAttachment); + const allAttachments = autoContextDescription + ? [ + ...userAttachments, + { + // eslint-disable-next-line camelcase + attachment_type: 'error message', + content: autoContextDescription, + // eslint-disable-next-line camelcase + content_type: 'text/plain', + }, + ] + : userAttachments; + const requestJSON = { - attachments: attachments.valueSeq().map(toOLSAttachment), + attachments: allAttachments, // eslint-disable-next-line camelcase conversation_id: conversationID, // eslint-disable-next-line camelcase @@ -586,7 +603,16 @@ const Prompt: React.FC = ({ scrollIntoView }) => { dispatch(setQuery('')); dispatch(attachmentsClear()); focusPromptInput(); - }, [attachments, conversationID, dispatch, isStreaming, query, scrollIntoView, t]); + }, [ + attachments, + autoContextDescription, + conversationID, + dispatch, + isStreaming, + query, + scrollIntoView, + t, + ]); const streamingResponseID: string = isStreaming ? (chatHistory.last()?.get('id') as string) diff --git a/src/hooks/useAutoContextDescription.ts b/src/hooks/useAutoContextDescription.ts new file mode 100644 index 00000000..1b31afb7 --- /dev/null +++ b/src/hooks/useAutoContextDescription.ts @@ -0,0 +1,256 @@ +import * as React from 'react'; +import { useLocation } from 'react-router-dom-v5-compat'; +import { useLocationContext } from './useLocationContext'; + +/** + * Converts API resource names to user-friendly display names + */ +const formatResourceType = (resourceType: string): string => { + // Handle console API format like "core~v1~Pod" or "apps~v1~Deployment" + if (resourceType.includes('~')) { + const parts = resourceType.split('~'); + if (parts.length === 3) { + const [, , kind] = parts; + // Return the kind directly for tilde-separated formats + return kind; + } + } + + const typeMap: { [key: string]: string } = { + // Core resources + pods: 'Pod', + deployments: 'Deployment', + services: 'Service', + configmaps: 'ConfigMap', + secrets: 'Secret', + persistentvolumes: 'PersistentVolume', + persistentvolumeclaims: 'PersistentVolumeClaim', + nodes: 'Node', + namespaces: 'Namespace', + events: 'Event', + + // Apps + replicasets: 'ReplicaSet', + daemonsets: 'DaemonSet', + statefulsets: 'StatefulSet', + jobs: 'Job', + cronjobs: 'CronJob', + + // Network + ingresses: 'Ingress', + networkpolicies: 'NetworkPolicy', + + // RBAC + roles: 'Role', + rolebindings: 'RoleBinding', + clusterroles: 'ClusterRole', + clusterrolebindings: 'ClusterRoleBinding', + serviceaccounts: 'ServiceAccount', + + // Storage + storageclasses: 'StorageClass', + volumeattachments: 'VolumeAttachment', + + // OpenShift specific + routes: 'Route', + buildconfigs: 'BuildConfig', + builds: 'Build', + deploymentconfigs: 'DeploymentConfig', + imagestreams: 'ImageStream', + + // Operators + operators: 'Operator', + operatorgroups: 'OperatorGroup', + subscriptions: 'Subscription', + installplans: 'InstallPlan', + catalogsources: 'CatalogSource', + + // Monitoring + servicemonitors: 'ServiceMonitor', + prometheusrules: 'PrometheusRule', + alertmanagers: 'Alertmanager', + + // Machine API + machines: 'Machine', + machinesets: 'MachineSet', + machineconfigpools: 'MachineConfigPool', + + // Virtualization + virtualmachines: 'VirtualMachine', + virtualmachineinstances: 'VirtualMachineInstance', + }; + + return typeMap[resourceType] || resourceType; +}; + +/** + * Extracts resource type from URL path patterns + */ +const extractResourceTypeFromPath = (pathname: string): string | null => { + // Pattern: /k8s/ns/{namespace}/{resourceType} or /k8s/cluster/{resourceType} + const patterns = [ + /\/k8s\/ns\/[^/]+\/([^/]+)\/?$/, // Namespaced list pages + /\/k8s\/cluster\/([^/]+)\/?$/, // Cluster-scoped list pages + /\/k8s\/ns\/[^/]+\/([^/]+)\/[^/]+$/, // Resource detail pages (extract type) + /\/k8s\/cluster\/([^/]+)\/[^/]+$/, // Cluster detail pages (extract type) + ]; + + for (const pattern of patterns) { + const match = pathname.match(pattern); + if (match) { + const resourceType = match[1]; + // Convert plural API resource names to friendly names + return formatResourceType(resourceType); + } + } + + return null; +}; + +/** + * Generates context description based on current page information + */ +const generateContextDescription = ( + pathname: string, + kind?: string, + name?: string, + namespace?: string, +): string | null => { + // Resource detail pages + if (kind && name) { + if (namespace) { + return `User is viewing ${kind} "${name}" in namespace "${namespace}"`; + } + return `User is viewing cluster-scoped ${kind} "${name}"`; + } + + // Extract namespace directly from URL if not provided by useLocationContext + let detectedNamespace = namespace; + const namespaceMatch = pathname.match(/\/k8s\/ns\/([^/]+)/); + if (namespaceMatch && !detectedNamespace) { + detectedNamespace = namespaceMatch[1]; + } + + // List pages - prioritize k8s namespace patterns + if (pathname.includes('/k8s/ns/')) { + const resourceType = extractResourceTypeFromPath(pathname); + if (resourceType && detectedNamespace) { + return `User is looking at the ${resourceType} list in the "${detectedNamespace}" namespace`; + } + // Fallback if we can't extract resource type but have namespace + if (detectedNamespace) { + return `User is viewing resources in the "${detectedNamespace}" namespace`; + } + } + + // Cluster-scoped list pages + if (pathname.includes('/k8s/cluster/')) { + const resourceType = extractResourceTypeFromPath(pathname); + if (resourceType) { + return `User is looking at the cluster-scoped ${resourceType} list`; + } + } + + // Special pages + if (pathname.includes('/monitoring/alerts')) { + return 'User is on the monitoring alerts page'; + } + + if (pathname.includes('/workloads')) { + return 'User is on the workloads overview page'; + } + + if (pathname.includes('/topology')) { + return 'User is on the topology view page'; + } + + if (pathname.includes('/project-details')) { + return 'User is on the project details page'; + } + + if (pathname === '/' || pathname.includes('/overview')) { + return 'User is on the OpenShift console overview page'; + } + + if (pathname.includes('/search')) { + return 'User is on the search page'; + } + + if (pathname.includes('/catalog')) { + return 'User is on the developer catalog page'; + } + + if (pathname.includes('/helm-releases')) { + return 'User is on the Helm releases page'; + } + + if (pathname.includes('/operatorhub')) { + return 'User is on the OperatorHub page'; + } + + if (pathname.includes('/builds')) { + return 'User is on the builds page'; + } + + if (pathname.includes('/pipelines')) { + return 'User is on the pipelines page'; + } + + if (pathname.includes('/secrets')) { + return 'User is on the secrets page'; + } + + if (pathname.includes('/configmaps')) { + return 'User is on the config maps page'; + } + + if (pathname.includes('/networking')) { + return 'User is on the networking page'; + } + + if (pathname.includes('/storage')) { + return 'User is on the storage page'; + } + + if (pathname.includes('/compute')) { + return 'User is on the compute page'; + } + + if (pathname.includes('/administration')) { + return 'User is on the administration page'; + } + + // ACM-specific pages + if (pathname.includes('/multicloud')) { + if (pathname.includes('/clusters')) { + return 'User is on the Advanced Cluster Management clusters page'; + } + if (pathname.includes('/applications')) { + return 'User is on the Advanced Cluster Management applications page'; + } + return 'User is on an Advanced Cluster Management page'; + } + + // For unknown pages, provide basic path info + if (pathname && pathname !== '/') { + return `User is on page: ${pathname}`; + } + + // No useful context available + return null; +}; + +/** + * Generates a human-readable description of the user's current page context + * for automatic inclusion in OpenShift Lightspeed prompts. + * This context is invisible to the user but helps the LLM understand what page they're on. + */ +export const useAutoContextDescription = (): string | null => { + const location = useLocation(); + const [kind, name, namespace] = useLocationContext(); + + return React.useMemo( + () => generateContextDescription(location.pathname, kind, name, namespace), + [location.pathname, kind, name, namespace], + ); +};