diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/README.md b/ai/gen-ai-agents/oci-enterprise-ai-chat/README.md index 939c496e3..7f117da8d 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/README.md +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/README.md @@ -73,6 +73,18 @@ LANGFUSE_BASE_URL=https://cloud.langfuse.com LOG_LEVEL=info ``` +### Text-to-SQL via MCP, optional + +```env +NEXT_PUBLIC_NL2SQL_MCP_URL=https://your-nl2sql-mcp.example.com/mcp +``` + +When set, points the in-chat Text-to-SQL flow at a hosted DBTools MCP server that exposes `generate_sql`. When unset, the Text-to-SQL toggle stays hidden. + +### OCI Generative AI Hosted Deployments, optional + +When the app is deployed via OCI Generative AI Hosted Applications, the platform injects `APPLICATION_BASE_URL` (the full invocation prefix) into the container. `entrypoint.sh` honors it automatically and falls back to a manual `BASE_PATH=/your-path` for non-hosted deployments behind a subpath. Nothing to set if you do not use either. + ### Models Models are defined as a static list in `src/app/page.js` (`STATIC_MODELS`). To add or remove a model available in your tenancy, edit that array. No env var needed. @@ -113,7 +125,7 @@ Toggle per-tool from Settings → Tools → Native: - **Web Search** *(coming soon)*, real-time web lookups - **File Search (RAG)**: vector retrieval over Knowledge Bases - **Code Interpreter**: Python sandbox with 420+ libraries -- **Text-to-SQL** *(coming soon)*, natural language → SQL against your semantic stores +- **Text-to-SQL**: natural language to SQL against your semantic stores, fronted by a hosted DBTools MCP server (set `NEXT_PUBLIC_NL2SQL_MCP_URL` in [Configuration](#text-to-sql-via-mcp-optional)) ![Tools settings](images/03-settings-tools.png) @@ -245,7 +257,7 @@ src/ ### MCP tool invocation 1. OCI executes MCP tools natively via the Responses API. The app **does not run tools itself** during chat (only for Test Connection / Refresh in Settings). -2. For OAuth 2.1 MCP servers (e.g. SDD Generator), the client obtains an access token via `/api/mcp/oauth/*` and passes it to OCI in the tool's `authorization` field +2. For OAuth 2.1 MCP servers, the client obtains an access token via `/api/mcp/oauth/*` and passes it to OCI in the tool's `authorization` field 3. Custom servers use the more generic `/api/mcp` JSON-RPC proxy for discovery and direct invocation (outside OCI) ### Settings persistence @@ -355,7 +367,7 @@ oci container-instances container-instance restart \ ### Notes - A `/ready` healthcheck endpoint is exposed for the LB. -- If you mount the app under a subpath, set `BASE_PATH=/your-path`. +- For subpath deployments, set `BASE_PATH=/your-path`. On OCI Generative AI Hosted Deployments the platform injects `APPLICATION_BASE_URL` automatically and `entrypoint.sh` honors it (falling back to `BASE_PATH` otherwise). - When the LB-to-backend connection is HTTP (not HTTPS end-to-end), cookies must **not** carry the `Secure` flag. `mcp-oauth.js` and the IDCS auth code already handle this automatically when running over HTTP. - All secrets (Client Secret, Session Secret, Langfuse keys) should be set as **environment variables on the Container Instance**, never baked into the image. diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/entrypoint.sh b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/entrypoint.sh index 010a4cf58..980227b36 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/entrypoint.sh +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/entrypoint.sh @@ -2,7 +2,9 @@ set -e PLACEHOLDER="/__BASE_PATH_PLACEHOLDER__" -PREFIX="${BASE_PATH:-}" +# OCI Hosted Deployments auto-inject the reserved APPLICATION_BASE_URL (the full +# /.../actions/invoke path). Fall back to BASE_PATH for the Container Instance deploy. +PREFIX="${APPLICATION_BASE_URL:-${BASE_PATH:-}}" PREFIX="${PREFIX%/}" ESCAPED=$(printf '%s' "$PREFIX" | sed 's/[\/&|]/\\&/g') diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/package.json b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/package.json index f1d9a67e7..13bb86b95 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/package.json +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/package.json @@ -1,6 +1,6 @@ { - "name": "oci-agent-light-demo-creator", - "version": "0.4.0", + "name": "oci-enterprise-ai-chat", + "version": "0.9.9", "private": true, "scripts": { "dev": "next dev --turbopack", diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/generate-title/route.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/generate-title/route.js index 96676b69c..3c375fd40 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/generate-title/route.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/generate-title/route.js @@ -19,7 +19,7 @@ export async function POST(request) { "${userMessage.substring(0, 150)}" → "`; const requestBody = { - model: 'openai.gpt-4o-mini', + model: 'google.gemini-2.5-flash-lite', input: [{ role: 'user', content: prompt }], stream: false }; diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/oauth/authorize/route.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/oauth/authorize/route.js index d1ce0d07f..5827b9fdd 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/oauth/authorize/route.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/oauth/authorize/route.js @@ -5,6 +5,7 @@ import { generateCodeVerifier, generateCodeChallenge, signPayload, + basePrefix, PENDING_COOKIE, } from '../../../../lib/mcp-oauth'; import { createLogger } from '../../../../lib/logger'; @@ -27,20 +28,72 @@ export async function GET(request) { return NextResponse.json({ error: 'endpoint query param is required' }, { status: 400 }); } + // Static-client mode (authType `oauth2-user`): the caller supplies the + // pre-registered OAuth credentials in the query string. We skip discovery + // and dynamic client registration entirely and just run PKCE. + const staticClientId = params.get('clientId'); + const staticClientSecret = params.get('clientSecret') || ''; + const staticAuthorizeUrl = params.get('authorizeUrl'); + const staticTokenUrl = params.get('tokenUrl'); + const staticScope = params.get('scope') || ''; + const useStatic = !!(staticClientId && staticAuthorizeUrl && staticTokenUrl); + const baseUrl = getBaseUrl(request); - const redirectUri = `${baseUrl}/api/mcp/oauth/callback`; + // basePrefix() re-adds the OCI Hosted Deployment prefix OCI stripped, so the + // redirect_uri is routable when the IdP sends the browser back. It's stored in + // the PENDING cookie and reused verbatim for the token exchange (callback). + const redirectUri = `${baseUrl}${basePrefix()}/api/mcp/oauth/callback`; - // 1. Discover OAuth metadata from MCP server - const metadata = await fetchOAuthMetadata(endpoint); - if (!metadata) { - return NextResponse.json({ error: 'MCP server does not support OAuth 2.1' }, { status: 502 }); - } - log.info('OAuth metadata fetched', { endpoint }); + let clientId, clientSecret, authorizationEndpoint, tokenEndpoint, scopeList; + + if (useStatic) { + clientId = staticClientId; + clientSecret = staticClientSecret; + authorizationEndpoint = staticAuthorizeUrl; + tokenEndpoint = staticTokenUrl; + scopeList = staticScope.split(/\s+/).filter(Boolean); + log.info('OAuth (static client) authorize', { endpoint, authorizationEndpoint }); + } else { + // 1. Discover OAuth metadata from MCP server (oauth2.1 flow) + const metadata = await fetchOAuthMetadata(endpoint); + if (!metadata) { + return NextResponse.json({ error: 'MCP server does not support OAuth 2.1' }, { status: 502 }); + } + log.info('OAuth metadata fetched', { endpoint }); - // 2. Register this app as an OAuth client (dynamic registration) - const scopes = metadata.scopes_supported || ['read', 'write', 'generate']; - const registration = await registerClient(metadata.registration_endpoint, redirectUri, scopes); - log.info('Client registered', { clientId: registration.client_id }); + // 2. Determine the OAuth client. The discovery above already yielded the + // authorize/token endpoints (works for both one-level and RFC 9728). + scopeList = metadata.scopes_supported || ['read', 'write', 'generate']; + authorizationEndpoint = metadata.authorization_endpoint; + tokenEndpoint = metadata.token_endpoint; + + if (metadata.registration_endpoint) { + // 2a. Dynamic client registration (RFC 7591) + const registration = await registerClient(metadata.registration_endpoint, redirectUri, scopeList); + log.info('Client registered', { clientId: registration.client_id }); + clientId = registration.client_id; + clientSecret = registration.client_secret; + } else { + // 2b. No dynamic registration (OCI IAM Identity Domains don't expose it). + // Use a pre-registered confidential client supplied via env. Wired for the + // NL2SQL MCP endpoint; the secret stays server-side (never hits the browser). + const nl2sqlUrl = process.env.NEXT_PUBLIC_NL2SQL_MCP_URL || process.env.NL2SQL_MCP_URL || ''; + const envClientId = process.env.NL2SQL_OAUTH_CLIENT_ID; + const envClientSecret = process.env.NL2SQL_OAUTH_CLIENT_SECRET || ''; + if (envClientId && nl2sqlUrl && endpoint === nl2sqlUrl) { + clientId = envClientId; + clientSecret = envClientSecret; + // IDCS only returns a refresh_token when offline_access is requested. + if (!scopeList.includes('offline_access')) scopeList = [...scopeList, 'offline_access']; + log.info('OAuth (pre-registered confidential client) authorize', { endpoint }); + } else { + return NextResponse.json({ + error: "This authorization server has no dynamic client registration. Set NL2SQL_OAUTH_CLIENT_ID / NL2SQL_OAUTH_CLIENT_SECRET (a pre-registered confidential app) to enable it.", + code: 'no_dynamic_registration', + }, { status: 502 }); + } + } + } // 3. Generate PKCE pair const codeVerifier = generateCodeVerifier(); @@ -50,25 +103,31 @@ export async function GET(request) { // 4. Store pending state in a signed cookie const pending = await signPayload({ endpoint, - clientId: registration.client_id, - clientSecret: registration.client_secret, + clientId, + clientSecret, codeVerifier, state, - tokenEndpoint: metadata.token_endpoint, + tokenEndpoint, redirectUri, returnTo, }); // 5. First set the cookie, then redirect via an HTML page // (direct 307 redirect may not persist cookies in all browsers) - const authUrl = new URL(metadata.authorization_endpoint); - authUrl.searchParams.set('client_id', registration.client_id); + const authUrl = new URL(authorizationEndpoint); + authUrl.searchParams.set('client_id', clientId); authUrl.searchParams.set('redirect_uri', redirectUri); authUrl.searchParams.set('response_type', 'code'); - authUrl.searchParams.set('scope', scopes.join(' ')); + if (scopeList.length > 0) authUrl.searchParams.set('scope', scopeList.join(' ')); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); authUrl.searchParams.set('state', state); + // Google requires these to issue a refresh_token + force the consent prompt + // the first time. Harmless for other providers (they'll just ignore unknown params). + if (useStatic) { + authUrl.searchParams.set('access_type', 'offline'); + authUrl.searchParams.set('prompt', 'consent'); + } const html = `Redirecting...`; const response = new Response(html, { diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/oauth/callback/route.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/oauth/callback/route.js index 5f3038bc2..a69b2b9aa 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/oauth/callback/route.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/oauth/callback/route.js @@ -4,44 +4,51 @@ import { signPayload, exchangeCode, tokenCookieName, + basePrefix, PENDING_COOKIE, } from '../../../../lib/mcp-oauth'; import { createLogger } from '../../../../lib/logger'; -function returnUrl(request, result, pending) { - const dest = pending?.returnTo || '/settings'; +function returnUrl(request, result, pending, detail) { + // returnTo from the client already carries the deployment prefix (it's the + // browser's window.location.pathname); only the fallback needs basePrefix(). + const dest = pending?.returnTo || `${basePrefix()}/settings`; let host = request.headers.get('x-forwarded-host') || request.headers.get('host'); const proto = request.headers.get('x-forwarded-proto') || 'http'; host = host.replace(/:80$/, '').replace(/:443$/, ''); const base = `${proto}://${host}`; const url = new URL(dest, base); url.searchParams.set('mcp_auth', result); + if (detail) url.searchParams.set('mcp_error', String(detail).slice(0, 300)); return url.toString(); } export async function GET(request) { const log = createLogger('mcp-oauth-callback'); + // Declared at function scope so the catch block (and the early error paths) + // can use it to build returnTo without a ReferenceError. + let pending = null; try { const params = new URL(request.url).searchParams; const code = params.get('code'); - const state = params.get('state'); const error = params.get('error'); + // 1. Read pending state from cookie early — so returnTo works even when the + // IdP redirects back with ?error=... (e.g. unauthorized_client / bad scope). + const pendingCookie = request.cookies.get(PENDING_COOKIE)?.value; + pending = await verifyPayload(pendingCookie); + if (error) { log.error('Authorization denied', { error, description: params.get('error_description') }); - return NextResponse.redirect(returnUrl(request, 'error', pending)); + return NextResponse.redirect(returnUrl(request, 'error', pending, params.get('error_description') || error)); } if (!code) { log.error('Missing code'); - return NextResponse.redirect(returnUrl(request, 'error', null)); + return NextResponse.redirect(returnUrl(request, 'error', pending, 'No authorization code returned')); } - // 1. Read pending state from cookie - const pendingCookie = request.cookies.get(PENDING_COOKIE)?.value; - const pending = await verifyPayload(pendingCookie); - if (!pending) { log.error('Missing or invalid pending cookie', { hasCookie: !!pendingCookie, @@ -67,7 +74,10 @@ export async function GET(request) { clientId: pending.clientId, clientSecret: pending.clientSecret, tokenEndpoint: pending.tokenEndpoint, - accessToken: tokenData.access_token, + // Access JWT (~3KB for IDCS) is NOT stored — it would push the signed cookie + // past the browser's ~4KB limit and the cookie gets dropped (hasToken=false). + // Store only the small refresh token; /api/mcp/oauth/token mints a fresh + // access token on demand. This also fixes access-token expiry. refreshToken: tokenData.refresh_token, expiresAt: Date.now() + (tokenData.expires_in || 3600) * 1000, }); @@ -89,6 +99,6 @@ export async function GET(request) { return response; } catch (error) { log.error('OAuth callback failed', { error: error.message }); - return NextResponse.redirect(returnUrl(request, 'error', pending)); + return NextResponse.redirect(returnUrl(request, 'error', pending, error.message)); } } diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/oauth/token/route.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/oauth/token/route.js index 965fce5f1..6a0d79874 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/oauth/token/route.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/oauth/token/route.js @@ -16,35 +16,37 @@ export async function GET(request) { const tokenCookie = request.cookies.get(cookieName)?.value; const tokens = await verifyPayload(tokenCookie); - if (!tokens) { + if (!tokens || !tokens.refreshToken) { return NextResponse.json({ hasToken: false }); } - // Force refresh if requested, or if expiring within 30s - const forceRefresh = new URL(request.url).searchParams.get('refresh') === 'true'; - if (forceRefresh || tokens.expiresAt < Date.now() + 30000) { - try { - const refreshed = await refreshAccessToken( - tokens.tokenEndpoint, tokens.refreshToken, tokens.clientId, tokens.clientSecret - ); - tokens.accessToken = refreshed.access_token; - tokens.refreshToken = refreshed.refresh_token || tokens.refreshToken; - tokens.expiresAt = Date.now() + (refreshed.expires_in || 3600) * 1000; - - const response = NextResponse.json({ hasToken: true, accessToken: tokens.accessToken }); - const isHttps = (request.headers.get('x-forwarded-proto') || 'http') === 'https'; - response.cookies.set(cookieName, await signPayload(tokens), { - httpOnly: true, - secure: isHttps, - sameSite: 'lax', - maxAge: 30 * 24 * 60 * 60, - path: '/', - }); - return response; - } catch { - return NextResponse.json({ hasToken: false }); - } + // Lightweight presence check (used by Settings to show authorized/needs_auth). + // Does NOT consume the refresh token. + if (new URL(request.url).searchParams.get('probe') === 'true') { + return NextResponse.json({ hasToken: true }); } - return NextResponse.json({ hasToken: true, accessToken: tokens.accessToken }); + // The cookie stores ONLY the refresh token (the IDCS access JWT is ~3KB and would + // blow past the browser's ~4KB cookie limit). Always mint a fresh access token + // from the refresh token, and persist the (possibly rotated) refresh token. + try { + const refreshed = await refreshAccessToken( + tokens.tokenEndpoint, tokens.refreshToken, tokens.clientId, tokens.clientSecret + ); + tokens.refreshToken = refreshed.refresh_token || tokens.refreshToken; + tokens.expiresAt = Date.now() + (refreshed.expires_in || 3600) * 1000; + + const response = NextResponse.json({ hasToken: true, accessToken: refreshed.access_token }); + const isHttps = (request.headers.get('x-forwarded-proto') || 'http') === 'https'; + response.cookies.set(cookieName, await signPayload(tokens), { + httpOnly: true, + secure: isHttps, + sameSite: 'lax', + maxAge: 30 * 24 * 60 * 60, + path: '/', + }); + return response; + } catch { + return NextResponse.json({ hasToken: false }); + } } diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/route.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/route.js index dbb4e9705..bdfb418af 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/route.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/mcp/route.js @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; import { createLogger } from '../../lib/logger'; -import { verifyPayload, signPayload, refreshAccessToken, tokenCookieName } from '../../lib/mcp-oauth'; +import { verifyPayload, signPayload, refreshAccessToken, tokenCookieName, basePrefix } from '../../lib/mcp-oauth'; // Store session IDs per endpoint (in production, use Redis or similar) const sessionIds = new Map(); @@ -8,6 +8,10 @@ const sessionIds = new Map(); // Cache OAuth tokens per token URL + client ID const oauthTokenCache = new Map(); +// Monotonic small int for JSON-RPC `id`. Some MCP servers (e.g. Oracle Analytics) +// reject large int ids (e.g. Date.now()) as "Invalid JSON-RPC message format". +let jsonRpcIdCounter = 0; + async function getOAuthToken(tokenUrl, clientId, clientSecret, scope) { const cacheKey = `${tokenUrl}:${clientId}`; const cached = oauthTokenCache.get(cacheKey); @@ -62,7 +66,7 @@ export async function POST(request) { // Build JSON-RPC request const jsonRpcRequest = { jsonrpc: '2.0', - id: Date.now(), + id: ++jsonRpcIdCounter, method: method, params: params }; @@ -75,8 +79,9 @@ export async function POST(request) { // Add auth headers if provided let updatedTokenCookie = null; // set if oauth2.1 token was refreshed - if (authType === 'oauth2.1') { - // Read tokens from httpOnly cookie set by /api/mcp/oauth/callback + if (authType === 'oauth2.1' || authType === 'oauth2-user') { + // Read tokens from httpOnly cookie set by /api/mcp/oauth/callback. + // Both interactive authTypes share the same callback + cookie storage. const cookieName = tokenCookieName(endpoint); const tokenCookie = request.cookies.get(cookieName)?.value; const tokens = await verifyPayload(tokenCookie); @@ -84,7 +89,7 @@ export async function POST(request) { if (!tokens) { return NextResponse.json({ error: 'needs_auth', - authorizeUrl: `/api/mcp/oauth/authorize?endpoint=${encodeURIComponent(endpoint)}`, + authorizeUrl: `${basePrefix()}/api/mcp/oauth/authorize?endpoint=${encodeURIComponent(endpoint)}`, }, { status: 401 }); } @@ -101,7 +106,7 @@ export async function POST(request) { } catch { return NextResponse.json({ error: 'needs_auth', - authorizeUrl: `/api/mcp/oauth/authorize?endpoint=${encodeURIComponent(endpoint)}`, + authorizeUrl: `${basePrefix()}/api/mcp/oauth/authorize?endpoint=${encodeURIComponent(endpoint)}`, }, { status: 401 }); } } @@ -144,11 +149,11 @@ export async function POST(request) { if (!response.ok) { log.error('MCP server error', { status: response.status, body: responseText.slice(0, 500) }); - // If the MCP server rejects our OAuth 2.1 token, clear it and ask for re-auth - if (authType === 'oauth2.1' && response.status === 401) { + // If the MCP server rejects our OAuth token, clear it and ask for re-auth + if ((authType === 'oauth2.1' || authType === 'oauth2-user') && response.status === 401) { const clearResponse = NextResponse.json({ error: 'needs_auth', - authorizeUrl: `/api/mcp/oauth/authorize?endpoint=${encodeURIComponent(endpoint)}`, + authorizeUrl: `${basePrefix()}/api/mcp/oauth/authorize?endpoint=${encodeURIComponent(endpoint)}`, }, { status: 401 }); clearResponse.cookies.delete(tokenCookieName(endpoint)); return clearResponse; diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/responses/route.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/responses/route.js index e3de8caa0..71a90ca7d 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/responses/route.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/api/responses/route.js @@ -1,8 +1,20 @@ import { NextResponse } from 'next/server'; +import { appendFileSync } from 'node:fs'; import { ociRequest } from '../../lib/oci-proxy'; import { Langfuse } from 'langfuse'; import { createLogger } from '../../lib/logger'; +// Debug: append-only file of raw OCI events for offline analysis. Gated by env +// var so production stays silent. Set `OCI_TRACE_FILE` to an absolute path to +// enable (e.g. /tmp/oci-raw-events.jsonl). One JSON object per line. +const RAW_EVENTS_FILE = process.env.OCI_TRACE_FILE || null; +const appendRawEvent = RAW_EVENTS_FILE + ? (entry) => { + try { appendFileSync(RAW_EVENTS_FILE, JSON.stringify(entry) + '\n'); } + catch { /* never break the stream for a log write */ } + } + : () => {}; + // Allow long-running MCP tool calls (up to 5 minutes) export const maxDuration = 300; @@ -17,7 +29,7 @@ export async function POST(request) { return NextResponse.json({ error: 'Input is required' }, { status: 400 }); } - const modelId = model || 'openai.gpt-4.1'; + const modelId = model || 'openai.gpt-oss-120b'; // Multi-agent models use /v1, all others use /openai/v1 const basePath = modelId.includes('multi-agent') ? '/v1' : '/openai/v1'; @@ -58,7 +70,7 @@ export async function POST(request) { // Add reasoning params (effort + summary) — only for reasoning-capable models if (reasoning && typeof reasoning === 'object') { - const reasoningModels = ['o4-mini', 'gpt-5.4', 'grok-4-reasoning', 'o3', 'o4']; + const reasoningModels = []; // client-safe models (gpt-oss / gemini) don't take a reasoning param here const supportsReasoning = reasoningModels.some(rm => modelId.includes(rm)); if (supportsReasoning) { requestBody.reasoning = reasoning; @@ -86,6 +98,25 @@ export async function POST(request) { max_output_tokens: requestBody.max_output_tokens, }); + // Marcador de inicio de request en el log raw — útil para separar Stream 1 vs Stream 2 vs chain calls + if (RAW_EVENTS_FILE) { + appendRawEvent({ + _kind: 'request_start', + requestId, + ts: new Date().toISOString(), + model: requestBody.model, + hasConversation: !!requestBody.conversation, + hasPreviousResponseId: !!requestBody.previous_response_id, + previousResponseId: requestBody.previous_response_id || null, + conversation: requestBody.conversation || null, + inputPreview: Array.isArray(formattedInput) + ? formattedInput.map(i => ({ type: i.type, role: i.role, call_id: i.call_id, name: i.name, contentPreview: typeof i.content === 'string' ? i.content.slice(0, 120) : Array.isArray(i.content) ? i.content.map(c => c.type) : null })) + : (typeof formattedInput === 'string' ? formattedInput.slice(0, 200) : null), + toolCount: requestBody.tools?.length || 0, + toolLabels: (requestBody.tools || []).map(t => t.server_label || t.type), + }); + } + let response = await ociRequest('POST', '/responses', { body: requestBody, basePath, @@ -203,6 +234,13 @@ export async function POST(request) { const startTime = Date.now(); let fullOutputText = ''; let toolCalls = []; + // Track which function_call call_ids we've already delegated to the + // client. Gemini-via-OCI emits `output_item.done(function_call)` TWICE + // for the same call (once with output_index=0, once aligned with the + // following message item's index). Without this dedup we'd emit two + // mcp_function_call chunks → chain loop runs the tool twice → + // duplicated text/results. Keyed by call_id (stable across the dupes). + const delegatedFunctionCalls = new Set(); const fileSearchOutputsSent = new Set(); let usageData = null; let responseCompleted = false; @@ -212,6 +250,41 @@ export async function POST(request) { // When OCI sends `event: error`, the following `data:` line carries the real // error payload (provider message, code, details). Capture it to surface to UI. let pendingErrorEvent = false; + // For Gemini, OCI runs MCP tools natively but emits results as `function_call` + // items (no standalone `mcp_call` SSE events). However, OCI DOES include the + // executed `mcp_call` with its output in `response.completed.output[]`. That + // is the authoritative signal: if `outputs[]` carries a matching `mcp_call` + // with `output`, OCI ran the tool — do NOT delegate (would double-execute). + // Otherwise the client must execute it and chain a Pass 2. + const pendingDelegations = []; + const resolvePendingDelegations = (finalOutputs = null) => { + for (const fc of pendingDelegations) { + const ranByOci = !!finalOutputs && finalOutputs.some(o => + o.type === 'mcp_call' && o.output && + o.name === fc.tool_name && o.server_label === fc.server_label + ); + if (ranByOci) { + // The late-mcp_call emitter above already pushed mcp.tool_output with + // the real output. Chip is already in "completed" state. Nothing to do. + traceEvent('tool_native_ran', { tool: fc.tool_name, server: fc.server_label, call_id: fc.callId }); + log.info('MCP tool executed by OCI natively (mcp_call in outputs) — no chain', { tool: fc.tool_name, server: fc.server_label }); + } else { + controller.enqueue(encoder.encode(JSON.stringify({ + mcp_function_call: { + item_id: fc.itemId, + call_id: fc.callId, + fn_name: fc.fn_name, + server_label: fc.server_label, + tool_name: fc.tool_name, + arguments: fc.arguments, + } + }) + '\n')); + traceEvent('mcp_call_delegated', { tool: fc.tool_name, server: fc.server_label, call_id: fc.callId }); + log.info('MCP tool delegated to client (no mcp_call.output in finalOutputs)', { tool: fc.tool_name, server: fc.server_label, hadOutputs: !!finalOutputs }); + } + } + pendingDelegations.length = 0; + }; // ── Request trace: structured timeline for diagnostics ── const trace = { @@ -223,10 +296,39 @@ export async function POST(request) { tools: {}, // {itemId: {tool, server, status, startMs, endMs, outputSize}} completion: null, // {status, outputItems, outputTokens, totalTokens, elapsed} error: null, // string if something went wrong + rawOciEvents: [], // [{t, event, item_type, item_name, item_status, delta, output_preview, args_preview}] — uncondicionado, en orden }; const traceEvent = (type, detail) => { trace.events.push({ ts: Date.now() - startTime, type, ...(detail || {}) }); }; + // Raw event recorder — captures EVERY parsed SSE event from OCI in + // arrival order, with no transformation/filtering. Used to verify the + // actual sequence of events vs. what we render in the UI. Gated by + // env var so the per-event work and trace bloat only happen when + // someone is actually debugging. + const recordRawOciEvent = RAW_EVENTS_FILE + ? (data, elapsed) => { + const entry = { _kind: 'oci_event', requestId, t: elapsed, event: data.type }; + if (data.item?.type) entry.item_type = data.item.type; + if (data.item?.id) entry.item_id = data.item.id; + if (data.item?.name) entry.item_name = data.item.name; + if (data.item?.status) entry.item_status = data.item.status; + if (data.item?.server_label) entry.server_label = data.item.server_label; + if (data.item?.call_id) entry.call_id = data.item.call_id; + if (typeof data.delta === 'string') entry.delta = data.delta.length > 80 ? data.delta.slice(0, 80) + '…' : data.delta; + if (typeof data.item?.output === 'string') { + entry.output_preview = data.item.output.length > 120 ? data.item.output.slice(0, 120) + '…' : data.item.output; + entry.output_len = data.item.output.length; + } + if (typeof data.item?.arguments === 'string') { + entry.args_preview = data.item.arguments.length > 120 ? data.item.arguments.slice(0, 120) + '…' : data.item.arguments; + } + if (data.response?.status) entry.response_status = data.response.status; + if (data.response?.id) entry.response_id = data.response.id; + trace.rawOciEvents.push(entry); + appendRawEvent(entry); + } + : () => {}; // Send thinking indicator IMMEDIATELY when stream starts log.info('Stream started'); @@ -353,6 +455,9 @@ export async function POST(request) { lastEventType = eventType; eventCount++; + // Raw event recording — uncondicionado, ANTES de cualquier transformación. + recordRawOciEvent(data, elapsed); + // DEBUG: Log every SSE event type for full visibility log.debug('SSE event', { event: eventType, itemType, itemId: itemId?.slice(-12), elapsed, lineLen: line.length }); @@ -426,12 +531,6 @@ export async function POST(request) { mcp: { type: 'calling', id: itemId, server: 'image_generation', tool: 'image_generation', arguments: '' } }) + '\n')); } - else if (itemType === 'nl2sql_call') { - const query = data.item.input_natural_language_query || data.item.query || ''; - controller.enqueue(encoder.encode(JSON.stringify({ - mcp: { type: 'calling', id: itemId, server: 'nl2sql', tool: 'nl2sql', arguments: query ? JSON.stringify({ query }) : '' } - }) + '\n')); - } // File search added — emit calling chip early with vector_store info // (query isn't available yet — OCI only sends it in output_item.done). else if (itemType === 'file_search_call') { @@ -538,24 +637,111 @@ export async function POST(request) { code_execution: { code: data.item.code || data.item.input || '', output: outputText, containerId: data.item.container_id || null } }) + '\n')); } - // NL2SQL completed - else if (itemType === 'nl2sql_call') { - const sql = data.item.generated_sql || data.item.sql || ''; - const results = data.item.results || data.item.output || ''; - const output = sql ? `**Generated SQL:**\n\`\`\`sql\n${sql}\n\`\`\`${results ? `\n\n**Results:**\n${typeof results === 'string' ? results : JSON.stringify(results, null, 2)}` : ''}` : 'Query completed'; - controller.enqueue(encoder.encode(JSON.stringify({ - mcp: { type: 'tool_output', id: itemId, tool: 'nl2sql', server: 'nl2sql', output, arguments: data.item.input_natural_language_query || data.item.query || '' } - }) + '\n')); - } // Image generation completed else if (itemType === 'image_generation_call') { controller.enqueue(encoder.encode(JSON.stringify({ generated_image: data.item.result || '' }) + '\n')); } - // Function call completed + // Function call completed. + // + // Two distinct flavors: + // (a) mcp____ name → the model wants an MCP tool + // run but OCI did NOT execute it itself (typical with + // gpt-oss-120b). The output field is empty. We must run + // the tool on the client side and submit a + // function_call_output back via a chained Responses call. + // (b) anything else → legacy client-side function_call path. else if (itemType === 'function_call') { - controller.enqueue(encoder.encode(JSON.stringify({ - function_call: { id: itemId, callId: data.item.call_id, name: data.item.name, arguments: data.item.arguments } - }) + '\n')); + const fname = data.item.name || ''; + const mcpMatch = fname.match(/^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/); + if (mcpMatch) { + const callId = data.item.call_id; + // OCI's native tools (file_search, web_search, image_generation, + // code_interpreter) come through the model under names like + // `mcp__hosted_genai___`. The native branches above (e.g. + // file_search_call.done) already produce the chip + tool_output; this + // mirror is a duplicate signal. Skip it — emitting mcp_function_call + // would create a phantom client-side chain trying to call a + // non-existent MCP server. + // + // Note on gpt-oss-120b: OCI's docs claim file_search works with this + // model, but in practice OCI emits ONLY this mirror and never runs + // the search natively. Skipping the mirror here means the model is + // left waiting for a function_call_output that never comes → stream + // closes with `stream_final_event_missing`. That's the correct + // visible failure; the previous client-side workaround that re-ran + // the search ourselves and chained Pass 2 produced low-quality + // generic responses anyway. Use Gemini or Grok for RAG. + // Detect against the FULL fn_name. OCI uses two formats + // for the same op depending on the model/version: + // mcp__hosted_genai_file_search__file_search (single _) + // mcp__hosted_genai__file_search__file_search (double _) + // Both start with "mcp__hosted_genai_" so a single + // prefix check catches both. Fall back to detecting raw + // native names too, just in case some future model + // emits them without the `hosted_genai` prefix. + const NATIVE_OCI_TOOL_NAMES = ['file_search', 'web_search', 'image_generation', 'code_interpreter']; + const isNativeMirror = + fname.startsWith('mcp__hosted_genai_') || + NATIVE_OCI_TOOL_NAMES.some(n => fname === `mcp__${n}__${n}` || fname.startsWith(`mcp__${n}__`)); + if (isNativeMirror) { + log.info('Skipping function_call mirror for native OCI tool', { fname, callId }); + traceEvent('function_call_native_mirror_skipped', { fname, call_id: callId }); + if (callId) delegatedFunctionCalls.add(callId); + continue; + } + // Dedup: OCI emits this event twice for Gemini (different + // output_index, same call_id). Only buffer the first. + if (callId && delegatedFunctionCalls.has(callId)) { + log.info('Skipping duplicate function_call done', { callId, tool: mcpMatch[2] }); + } else { + if (callId) delegatedFunctionCalls.add(callId); + const [, serverLabel, toolName] = mcpMatch; + const tc = toolCalls.find(t => + t.tool === toolName && t.server === serverLabel && !t.outputSent + ); + // gpt-oss-120b emits function_call items without their + // own `id` field — only `call_id`. Without a fallback we + // emit mcp.calling with id=undefined, and a moment later + // emit mcp_function_call also with item_id=undefined. + // useChat then can't match the two (chip mcpItemId ends + // up as null while fc.item_id is undefined) → it pushes + // a SECOND chip for the same call. Fall back to call_id + // so both events carry the same stable identifier. + const chipItemId = tc?.id || itemId || callId || null; + + // Eagerly show a "calling" chip so the user sees the tool was invoked. + // The completion path (delegate vs. mark-done) is decided at response.completed. + controller.enqueue(encoder.encode(JSON.stringify({ + mcp: { + type: 'calling', + id: chipItemId, + server: serverLabel, + tool: toolName, + arguments: data.item.arguments || '{}', + } + }) + '\n')); + + pendingDelegations.push({ + itemId: chipItemId, + callId, + fn_name: fname, + server_label: serverLabel, + tool_name: toolName, + arguments: data.item.arguments || '{}', + }); + if (tc) { + tc.outputSent = true; + tc.delegated = true; + } + traceEvent('function_call_buffered', { tool: toolName, server: serverLabel, call_id: callId }); + log.info('MCP function_call buffered for completion-time decision', { tool: toolName, server: serverLabel, callId }); + } + } else { + // Real client-side function call (legacy path) + controller.enqueue(encoder.encode(JSON.stringify({ + function_call: { id: itemId, callId: data.item.call_id, name: data.item.name, arguments: data.item.arguments } + }) + '\n')); + } } // Reasoning item done — signal end-of-reasoning to the UI else if (itemType === 'reasoning') { @@ -673,22 +859,6 @@ export async function POST(request) { log.debug('File search completed', { itemId, elapsed }); } - // ═══ NL2SQL EVENTS ═══ - else if (eventType === 'response.nl2sql_call.in_progress') { - const query = data.query || data.input_natural_language_query || ''; - controller.enqueue(encoder.encode(JSON.stringify({ - mcp: { type: 'calling', id: itemId, server: 'nl2sql', tool: 'nl2sql', arguments: query ? JSON.stringify({ query }) : '' } - }) + '\n')); - } - else if (eventType === 'response.nl2sql_call.completed') { - const sql = data.generated_sql || data.sql || ''; - const results = data.results || data.output || ''; - const output = sql ? `**Generated SQL:**\n\`\`\`sql\n${sql}\n\`\`\`${results ? `\n\n**Results:**\n${typeof results === 'string' ? results : JSON.stringify(results, null, 2)}` : ''}` : 'Query completed'; - controller.enqueue(encoder.encode(JSON.stringify({ - mcp: { type: 'tool_output', id: itemId, tool: 'nl2sql', server: 'nl2sql', output } - }) + '\n')); - } - // ═══ REASONING EVENTS ═══ else if (eventType === 'response.reasoning_summary_text.delta' && data.delta) { controller.enqueue(encoder.encode(JSON.stringify({ reasoning: { type: 'delta', text: data.delta } }) + '\n')); @@ -812,12 +982,40 @@ export async function POST(request) { // against the vector_store(s) attached to this request so we can emit the // matching documents as `file_citation` annotations the UI renders as chips. // Each citation carries `file_search_call_id` so the chip can count chunks per call. - const fsCalls = outputs.filter(o => o.type === 'file_search_call' && o.queries?.length > 0); + // + // Fallback for queries: Grok runs file_search but leaves the `queries` field + // empty on the call item (semantic search keyed off the conversation rather + // than explicit queries). Without queries our re-search would never run and + // the user would see no sources chip. Fall back to the user's last input as + // the query so we always get citations when a search actually happened. + const deriveUserQuery = () => { + if (typeof input === 'string') return input; + if (Array.isArray(input)) { + for (const item of input) { + if (typeof item === 'string') return item; + if (item?.type === 'input_text' && item.text) return item.text; + if (item?.role === 'user' && Array.isArray(item.content)) { + const t = item.content.find(c => c.type === 'input_text'); + if (t?.text) return t.text; + } + } + } + return ''; + }; + const fsCalls = outputs + .filter(o => o.type === 'file_search_call') + .map(fs => { + const explicit = Array.isArray(fs.queries) ? fs.queries.filter(q => typeof q === 'string' && q.trim()) : []; + if (explicit.length > 0) return { ...fs, _queries: explicit }; + const fallback = deriveUserQuery().trim(); + return fallback ? { ...fs, _queries: [fallback] } : null; + }) + .filter(Boolean); if (fsCalls.length > 0 && vsIds.length > 0) { const fsCitations = []; const seen = new Set(); await Promise.all( - fsCalls.flatMap(fs => fs.queries.flatMap(query => + fsCalls.flatMap(fs => fs._queries.flatMap(query => vsIds.map(async (vsId) => { try { const sr = await ociRequest('POST', `/vector_stores/${vsId}/search`, { @@ -861,16 +1059,6 @@ export async function POST(request) { } } - // Enrich nl2sql with results - for (const ns of outputs.filter(o => o.type === 'nl2sql_call')) { - const sql = ns.generated_sql || ns.sql || ''; - const results = ns.results || ns.output || ''; - const output = sql ? `**Generated SQL:**\n\`\`\`sql\n${sql}\n\`\`\`${results ? `\n\n**Results:**\n${typeof results === 'string' ? results : JSON.stringify(results, null, 2)}` : ''}` : 'Query completed'; - controller.enqueue(encoder.encode(JSON.stringify({ - mcp: { type: 'tool_output', tool: 'nl2sql', server: 'nl2sql', output } - }) + '\n')); - } - // Extract all annotations const allAnnotations = outputs .filter(item => Array.isArray(item.content)) @@ -932,6 +1120,10 @@ export async function POST(request) { }) + '\n')); } + // Resolve buffered function_call delegations using outputs as the + // authoritative source of mcp_call coverage. + resolvePendingDelegations(outputs || []); + // Cache for end-of-stream finalization. // Do NOT declare tools orphaned or emit done:true yet — // OCI sometimes emits tool results (response.output_item.done with mcp_call output) @@ -984,24 +1176,21 @@ export async function POST(request) { } catch (_) { /* controller may already be closing */ } } - // Detect premature close: OCI dropped connection without response.completed + // Stream ended. If OCI didn't explicitly send response.completed we just + // treat the close as a normal end — the UI shows whatever state the + // chips reached. No fabricated error, no soft/hard classification. + // Network-level failures still bubble through the outer try/catch. if (!responseCompleted && !stalled) { - log.error('PREMATURE STREAM CLOSE — OCI never sent response.completed', { - elapsed, eventCount, lastEventType, toolCalls, textLength: fullOutputText.length, opcRequestId, - buffer: buffer.substring(0, 500), + log.info('Stream ended without response.completed (treated as normal end)', { + elapsed, eventCount, lastEventType, toolCalls: toolCalls.map(t => t.tool), opcRequestId, }); + // Still resolve any buffered delegations — without response.completed we + // don't have outputs, so default to delegating (safer than leaving the + // chip stuck in "calling" forever). + resolvePendingDelegations(null); trace.opcRequestId = opcRequestId; - trace.error = `OCI closed connection after ${Math.round(elapsed / 1000)}s without response.completed. ` + - `Received ${eventCount} events, last was "${lastEventType || 'none'}". ` + - `${toolCalls.length > 0 ? `Active tools: ${toolCalls.map(t => t.tool).join(', ')}. ` : ''}` + - `This is an OCI Responses API issue — the upstream closed the SSE stream prematurely.`; - traceEvent('premature_close', { eventCount, lastEventType }); try { - controller.enqueue(encoder.encode(JSON.stringify({ - error: trace.error, - done: true, - trace, - }) + '\n')); + controller.enqueue(encoder.encode(JSON.stringify({ done: true, trace }) + '\n')); } catch (_) { /* controller may already be closing */ } } diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/BasePathInit.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/BasePathInit.js new file mode 100644 index 000000000..556c18faa --- /dev/null +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/BasePathInit.js @@ -0,0 +1,35 @@ +"use client"; + +// Global fetch patch for OCI Hosted Deployments (see lib/withBase.js for the why). +// +// All the app's API calls are root-absolute (`fetch('/api/...')`). Under a base +// path those would resolve to https://host/api/... — dropping the prefix the OCI +// gateway needs to route the request. Rather than touch ~28 call sites, we patch +// window.fetch once to prepend BASE to root-absolute, same-origin URLs. +// +// This also fixes Next's own RSC navigation fetches, which is what lets prefixed +// router.push() do a proper soft navigation under the prefix. +// +// Installed at module scope (before any component effect fires) and only when a +// prefix is actually present, so dev and root deployments are completely untouched. +import { BASE } from "@/lib/withBase"; + +if (BASE && typeof window !== "undefined" && !window.__baseFetchPatched) { + window.__baseFetchPatched = true; + const orig = window.fetch.bind(window); + window.fetch = (input, init) => { + if ( + typeof input === "string" && + input[0] === "/" && // root-absolute + input[1] !== "/" && // not protocol-relative (//cdn) + !input.startsWith(BASE + "/") // not already prefixed (assets, RSC) + ) { + return orig(BASE + input, init); + } + return orig(input, init); + }; +} + +export default function BasePathInit() { + return null; +} diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/chat/ChatInput.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/chat/ChatInput.js index 8ff30a3fc..1be4dc1a3 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/chat/ChatInput.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/chat/ChatInput.js @@ -54,7 +54,6 @@ const DrawioLogoSmall = ({ size = 14, color = "#F08705" }) => ( const ADDON_TOOLS_META = { addon_drawio: { name: "OCI Draw.io", color: "#F08705", LogoComponent: DrawioLogoSmall }, - addon_sdd: { name: "OCI SDD Generator", color: "#0EA5E9", icon: FileText }, addon_ppt: { name: "OCI PPT Generator", color: "#EF4444", icon: Presentation }, }; @@ -104,7 +103,7 @@ const ChatInput = memo(forwardRef(function ChatInput({ }); // Reasoning-capable model detection - const REASONING_PATTERNS = ['o4-mini', 'gpt-5.4', 'grok-4-reasoning', 'o3', 'o4']; + const REASONING_PATTERNS = []; // no client-safe model (gpt-oss / gemini) exposes a reasoning toggle const isReasoningModel = selectedModel && REASONING_PATTERNS.some(p => selectedModel.includes(p)); // Persist reasoning effort @@ -124,8 +123,8 @@ const ChatInput = memo(forwardRef(function ChatInput({ try { const stored = localStorage.getItem('nativeToolsEnabled'); const state = stored ? JSON.parse(stored) : {}; - // Filter out coming-soon tools (native_text_to_sql) - const comingSoon = new Set(['native_text_to_sql']); + // Filter out coming-soon tools (Web Search is still coming soon) + const comingSoon = new Set(['native_web_search']); setNativeTools(Object.entries(state).filter(([id, v]) => v && !comingSoon.has(id)).map(([id]) => id)); } catch { setNativeTools([]); } }; @@ -720,7 +719,7 @@ const ChatInput = memo(forwardRef(function ChatInput({ sx={{ fontSize: "0.95rem", pl: 3, display: "flex", alignItems: "center", gap: 1, fontWeight: selectedModel === m ? 600 : 400 }} > {m.replace(/^[a-z]+\./, "")} - {m === "xai.grok-4-1-fast-reasoning" && ( + {m === "google.gemini-2.5-pro" && ( { setToolsMenuAnchor(null); - window.location.href = '/settings/tools'; + window.location.href = withBase('/settings/tools'); }} sx={{ fontSize: "0.8rem", diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/chat/ChatMessage.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/chat/ChatMessage.js index 6c2298635..4ffc46cb4 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/chat/ChatMessage.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/chat/ChatMessage.js @@ -18,10 +18,12 @@ import { } from "@mui/material"; import { motion, AnimatePresence } from "framer-motion"; import Image from "next/image"; +import { withBase } from "@/lib/withBase"; import { Check, Copy, ChevronDown as ChevronDownIcon, Code, FileText, X, Brain, Terminal, RotateCcw, ShieldAlert, ShieldCheck, ShieldX } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import JsonView from "@uiw/react-json-view"; +import { MCPService } from "../../services/mcpService"; import Sources from "../agent/Sources"; import DotMatrixLoader from "../ui/DotMatrixLoader"; @@ -298,11 +300,15 @@ function CollapsibleUserMessage({ text, fontSize, isLatest }) { const [hasAnimated, setHasAnimated] = useState(false); const isLongMessage = text.length > 150; + // lineHeight stays constant — toggling it on isLatest transitions caused + // the previous user-message to instantly shrink vertically when the next + // turn started, which propagated as a layout bounce on every subsequent + // exchange. Visual de-emphasis is handled via opacity/scale on the wrapper. const textStyles = { color: "inherit", fontSize: fontSize, fontWeight: "100", - lineHeight: isLatest ? 2 : 1.6, + lineHeight: 1.6, }; // Mark animation as complete after initial render @@ -916,12 +922,7 @@ const ChatMessage = memo(function ChatMessage({ const contentReady = !showIndicator || hasContent; // Compute grouping at render time instead of storing in state - const groupedResponses = useMemo(() => { - const groups = groupMessages(exchange.responses); - const sourcesGroups = groups.filter(g => g.type === 'sources'); - if (sourcesGroups.length > 0) console.log('[ChatMessage] Sources groups found:', sourcesGroups.length, sourcesGroups); - return groups; - }, [exchange.responses]); + const groupedResponses = useMemo(() => groupMessages(exchange.responses), [exchange.responses]); const getRawContent = () => { return groupedResponses.map(group => { @@ -953,8 +954,13 @@ const ChatMessage = memo(function ChatMessage({ indicatorStartRef.current = Date.now(); setShowIndicator(true); } else if (!shouldShowIndicator && showIndicator) { - // Calling chips skip MIN_DISPLAY_TIME — the chip IS the progress indicator - if (hasCallingChips) { + // Skip MIN_DISPLAY_TIME when there's a better progress signal already on + // screen — the user does NOT need a spinner held up after content has + // arrived. MIN_DISPLAY_TIME only exists to prevent a flash for short + // initial-loading cases. + // - hasCallingChips: the chip itself is the progress indicator + // - hasContent: the response text/widget is now visible, no need to wait + if (hasCallingChips || hasContent) { setShowIndicator(false); return; } @@ -967,7 +973,7 @@ const ChatMessage = memo(function ChatMessage({ setShowIndicator(false); } } - }, [shouldShowIndicator, showIndicator, hasCallingChips]); + }, [shouldShowIndicator, showIndicator, hasCallingChips, hasContent]); return ( @@ -983,8 +992,13 @@ const ChatMessage = memo(function ChatMessage({ {exchange.widgetResponse ? null : ( @@ -1142,8 +1156,10 @@ const ChatMessage = memo(function ChatMessage({ - {/* Response */} - + {/* Response — marginTop kept constant; toggling it on isLatest caused + the previous exchange to vertically squeeze when the next turn + started, contributing to the perceived UI bounce. */} + {groupedResponses .filter(group => { @@ -1158,7 +1174,9 @@ const ChatMessage = memo(function ChatMessage({ {group.chips.map((chip) => { - const chipKey = `${exchangeIndex}-${groupIndex}-${chip.messageIndex}`; + // Stable: group.messageIndex (set in messageUtils) doesn't + // shift across filter flips like the filtered groupIndex does. + const chipKey = `${exchangeIndex}-${group.messageIndex ?? `g${groupIndex}`}-${chip.messageIndex}`; return ( { window.location.href = `/api/mcp/oauth/authorize?endpoint=${encodeURIComponent(server.endpoint)}&returnTo=${encodeURIComponent(returnTo)}`; }; - const openSettings = () => { window.location.href = server ? `/settings/tools?focus=${encodeURIComponent(server.id)}` : '/settings/tools'; }; + const openOAuth = () => { window.location.href = MCPService.buildAuthorizeUrl(server, returnTo); }; + const openSettings = () => { window.location.href = withBase(server ? `/settings/tools?focus=${encodeURIComponent(server.id)}` : '/settings/tools'); }; - if (authType === 'oauth2.1') { + if (authType === 'oauth2.1' || authType === 'oauth2-user') { title = `Authorization needed — ${displayName}`; description = `Sign in to "${displayName}" to grant access. After authorizing you'll return here.`; buttonLabel = 'Authorize'; @@ -1501,7 +1522,7 @@ const ChatMessage = memo(function ChatMessage({ • Switch to an {friendlyMsg.providers} model
- • Disable {friendlyMsg.tools} in Settings → Tools + • Disable {friendlyMsg.tools} in Settings → Tools
) : ( @@ -1571,7 +1592,10 @@ const ChatMessage = memo(function ChatMessage({ )} {group.type === "mcp_chip_row" && (() => { - const rowKey = `mcp-row-${exchangeIndex}-${groupIndex}`; + // Stable row id keyed off the FIRST chip's messageIndex (set in + // messageUtils). Falls back to groupIndex only if missing — + // groupIndex is unstable across the contentReady filter flip. + const rowKey = `mcp-row-${exchangeIndex}-${group.messageIndex ?? `g${groupIndex}`}`; const selectedChipIndex = activeChips[rowKey]?.chipIndex; const selectedChip = selectedChipIndex !== undefined ? group.chips[selectedChipIndex] : null; const hasError = selectedChip?.status === "failed"; @@ -1684,7 +1708,7 @@ const ChatMessage = memo(function ChatMessage({ : "var(--dm-muted, rgba(0, 0, 0, 0.6))", transition: "all 0.2s ease", cursor: isClickable ? "pointer" : "default", - userSelect: "none", + userSelect: "text", "&:hover": isClickable ? { backgroundColor: chipHasError ? "rgba(211, 47, 47, 0.12)" : "rgba(76, 175, 80, 0.12)", diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/chat/ChatSidebar.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/chat/ChatSidebar.js index 7d77561dc..f84806c31 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/chat/ChatSidebar.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/chat/ChatSidebar.js @@ -3,7 +3,7 @@ import { Box, IconButton, Typography } from "@mui/material"; import { AnimatePresence, motion } from "framer-motion"; import { Settings, Upload } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { useBaseRouter as useRouter } from "@/lib/useBaseRouter"; import BlinkingEye from "../ui/BlinkingEye"; import { memo, useState, useCallback, useRef, useEffect } from "react"; import TypingEffect from "../ui/TypingEffect"; diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/settings/FlowsTab.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/settings/FlowsTab.js index d93d82c5c..c019cd965 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/settings/FlowsTab.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/settings/FlowsTab.js @@ -2,7 +2,7 @@ import { Plus, Trash2 } from "lucide-react"; import { Box, IconButton, List, ListItem, ListItemText, Typography, Button, Switch, FormControlLabel } from "@mui/material"; -import { useRouter } from "next/navigation"; +import { useBaseRouter as useRouter } from "@/lib/useBaseRouter"; export default function FlowsTab({ flows, onDeleteFlow, onToggleFlow }) { const router = useRouter(); diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/settings/SettingsPage.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/settings/SettingsPage.js index d3f87ca92..a921a00f0 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/settings/SettingsPage.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/settings/SettingsPage.js @@ -11,7 +11,8 @@ import PromptsTab from "./PromptsTab"; import ToolsTab from "./ToolsTab"; import MemoryTab from "./MemoryTab"; import ObservabilityTab from "./ObservabilityTab"; -import { useRouter } from "next/navigation"; +import { useBaseRouter as useRouter } from "@/lib/useBaseRouter"; +import { withBase } from "@/lib/withBase"; import { useState, useEffect } from "react"; import { APP_VERSION } from "../../config/version"; import { INTERNAL_MODELS } from "../../config/models-internal"; @@ -76,7 +77,7 @@ export default function SettingsPage({ defaultTab = 'prompts' }) { setActiveTab(newTab); const route = TAB_ROUTES[newTab]; if (route) { - window.history.replaceState(null, '', route); + window.history.replaceState(null, '', withBase(route)); } }; diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/settings/ToolForm.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/settings/ToolForm.js index e0c8d81c9..f05b44dfc 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/settings/ToolForm.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/settings/ToolForm.js @@ -1,6 +1,6 @@ "use client"; -import { useReducer } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useReducer } from "react"; import { Box, TextField, @@ -19,6 +19,7 @@ import { FormControlLabel, } from "@mui/material"; import { Plug, Eye, EyeOff, X, Check, Wand2, Wrench, ShieldAlert } from "lucide-react"; +import { Icon } from "@iconify/react"; import mcpService from "../../services/mcpService"; function makeInitial(initial) { @@ -28,6 +29,7 @@ function makeInitial(initial) { authType: initial?.authType || "none", authKey: initial?.authKey || "", oauthTokenUrl: initial?.oauth?.tokenUrl || "", + oauthAuthorizeUrl: initial?.oauth?.authorizeUrl || "", oauthClientId: initial?.oauth?.clientId || "", oauthClientSecret: initial?.oauth?.clientSecret || "", oauthScope: initial?.oauth?.scope || "", @@ -46,6 +48,36 @@ function makeInitial(initial) { }; } +// Presets: one-click filler for popular OAuth providers that don't support dynamic +// client registration. User still has to paste their own clientId/clientSecret from +// the provider's developer console, but URLs and scopes are auto-populated. +const OAUTH_USER_PRESETS = [ + { + id: 'google-gmail', + label: 'Google Gmail', + icon: 'logos:google-icon', + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + scope: 'https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.compose', + }, + { + id: 'github', + label: 'GitHub', + icon: 'logos:github-icon', + authorizeUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + scope: 'repo read:user', + }, + { + id: 'slack', + label: 'Slack', + icon: 'logos:slack-icon', + authorizeUrl: 'https://slack.com/oauth/v2/authorize', + tokenUrl: 'https://slack.com/api/oauth.v2.access', + scope: 'channels:read chat:write', + }, +]; + function reducer(state, action) { switch (action.type) { case "set": @@ -65,7 +97,7 @@ function formatTestError(err) { if (/MCP request failed:\s*403/i.test(raw)) return "Forbidden (403). Credentials valid but lack permission."; if (/MCP request failed:\s*404/i.test(raw)) return "Endpoint not found (404). Check the URL."; if (/MCP request failed:\s*5\d\d/i.test(raw)) return `Server error: ${raw.replace(/MCP request failed:\s*/i, "")}`; - if (/Failed to fetch|NetworkError|ERR_NETWORK/i.test(raw)) return "Network error — endpoint unreachable, CORS blocked, or DNS issue."; + if (/Failed to fetch|NetworkError|ERR_NETWORK/i.test(raw)) return "Network error: endpoint unreachable, CORS blocked, or DNS issue."; if (/needs_auth/i.test(raw)) return "OAuth flow required. Use authType OAuth 2.1 (interactive) for this endpoint."; return raw.length > 240 ? raw.slice(0, 240) + "…" : raw; } @@ -79,7 +111,7 @@ function formatTestError(err) { * - onSave: (serverData, testTools) => void — receives the data to persist * - onCancel: () => void */ -export default function ToolForm({ mode = "add", initialValues = null, onSave, onCancel }) { +function ToolFormInner({ mode = "add", initialValues = null, onSave, onCancel, onStateChange }, ref) { const isEdit = mode === "edit"; const [s, d] = useReducer(reducer, initialValues, makeInitial); @@ -111,6 +143,17 @@ export default function ToolForm({ mode = "add", initialValues = null, onSave, o clientSecret: s.oauthClientSecret.trim(), scope: s.oauthScope.trim() || undefined, }; + } else if (s.authType === "oauth2-user") { + // OAuth 2.0 authorization_code + PKCE with a pre-registered client. + // Used for providers (Google, GitHub, Slack…) that do not implement + // RFC 7591 dynamic client registration. + server.oauth = { + authorizeUrl: s.oauthAuthorizeUrl.trim(), + tokenUrl: s.oauthTokenUrl.trim(), + clientId: s.oauthClientId.trim(), + clientSecret: s.oauthClientSecret.trim(), + scope: s.oauthScope.trim() || undefined, + }; } // oauth2.1 is handled by the OAuth flow (no creds in form) server.requireApproval = !!s.requireApproval; @@ -128,6 +171,10 @@ export default function ToolForm({ mode = "add", initialValues = null, onSave, o if (!s.oauthTokenUrl.trim() || !s.oauthClientId.trim()) return false; if (!s.oauthClientSecret.trim() && !(isEdit && initialValues?.oauth?.clientSecret)) return false; } + if (s.authType === "oauth2-user") { + if (!s.oauthAuthorizeUrl.trim() || !s.oauthTokenUrl.trim() || !s.oauthClientId.trim()) return false; + if (!s.oauthClientSecret.trim() && !(isEdit && initialValues?.oauth?.clientSecret)) return false; + } return true; })(); @@ -149,12 +196,33 @@ export default function ToolForm({ mode = "add", initialValues = null, onSave, o // Save first; the user authorizes from the chat banner or explicitly. let tools = s.testTools; if (s.authType !== "oauth2.1" && s.testStatus !== "connected") { + // Best-effort test so we capture tools when possible, but don't block + // save on failure — the user can refresh tools later from ToolsTab. tools = await handleTest(); - if (!tools) return; } onSave(buildServer(), tools || []); }; + // Expose imperative handles so a parent (e.g. a Dialog rendering its own + // DialogActions footer) can trigger save/test without owning the buttons. + useImperativeHandle(ref, () => ({ + save: handleSave, + test: handleTest, + }), [handleSave, handleTest]); + + // Notify the parent about state changes that should drive button + // enabled/loading states. Only emits primitive snapshots, no handlers, + // so the dependency list stays simple and there is no risk of loops. + useEffect(() => { + onStateChange?.({ + isValid, + isLoading: s.testLoading, + authType: s.authType, + testStatus: s.testStatus, + testToolsCount: s.testTools?.length || 0, + }); + }, [isValid, s.testLoading, s.authType, s.testStatus, s.testTools, onStateChange]); + // Auto-discovery: try /.well-known/oauth-authorization-server on the endpoint origin const handleDetect = async () => { if (!s.endpoint.trim()) return; @@ -185,13 +253,13 @@ export default function ToolForm({ mode = "add", initialValues = null, onSave, o patch: { authType: "oauth2.1", oauthTokenUrl: meta.token_endpoint || s.oauthTokenUrl, - detectMsg: "OAuth 2.1 metadata detected — switched auth type to OAuth 2.1 (interactive). Click Add to register, then Authorize from the chat.", + detectMsg: "OAuth 2.1 metadata detected, switched auth type to OAuth 2.1 (interactive). Click Add to register, then Authorize from the chat.", detecting: false, }, }); return; } - d({ type: "patch", patch: { detecting: false, detectMsg: "No OAuth metadata at this URL — keep current auth type." } }); + d({ type: "patch", patch: { detecting: false, detectMsg: "No OAuth metadata at this URL. Keep current auth type." } }); } catch (e) { d({ type: "patch", patch: { detecting: false, detectMsg: `Detect failed: ${e.message || e}` } }); } @@ -208,12 +276,6 @@ export default function ToolForm({ mode = "add", initialValues = null, onSave, o return ( - {mode === "add" && ( - - Add MCP Tool - - )} - {mode === "add" && ( - + Authentication @@ -296,6 +359,16 @@ export default function ToolForm({ mode = "add", initialValues = null, onSave, o )} + {/* Inline guidance: which authType for what scenario */} + + {s.authType === "none" && "No credentials required. The MCP server is publicly reachable."} + {s.authType === "api-key" && "The MCP server validates a fixed API key sent on every request as X-API-KEY."} + {s.authType === "bearer" && "The MCP server validates a fixed bearer token sent as Authorization: Bearer ."} + {s.authType === "oauth2" && "Server-to-server. No user login. You provide a client_id + client_secret, we fetch a short-lived token from the token URL on every request. Use for back-office connectors (e.g. IDCS, OIC) where the principal is the application itself."} + {s.authType === "oauth2.1" && "User sign-in, fully automatic. The MCP server publishes /.well-known/oauth-authorization-server and supports RFC 7591 dynamic client registration. You only paste the endpoint, we register and run the PKCE flow."} + {s.authType === "oauth2-user" && "User sign-in, manual setup. For providers that do NOT support dynamic registration (Google, GitHub, Slack, Microsoft). You pre-create an OAuth client in the provider's developer console, paste client_id + client_secret + the authorize/token URLs here, and we run the PKCE flow against them."} + + {s.authType === "oauth2" && ( - OAuth 2.1 is interactive. After saving, click Authorize from the chat banner the first time - a tool from this server is invoked. PKCE + dynamic client registration is performed automatically using the - server's discovery metadata. + After saving, click Authorize from the chat to sign in. PKCE and client registration are handled automatically. + + )} + + {s.authType === "oauth2-user" && ( + + + + Preset: + + {OAUTH_USER_PRESETS.map(p => ( + } + label={p.label} + size="small" + onClick={() => d({ + type: "patch", + patch: { + oauthAuthorizeUrl: p.authorizeUrl, + oauthTokenUrl: p.tokenUrl, + oauthScope: p.scope, + }, + })} + sx={{ fontSize: "0.72rem", cursor: "pointer", "& .MuiChip-icon": { ml: "8px", mr: "-4px" } }} + /> + ))} + + + + + + + + + + Set your OAuth redirect URI in the provider's console to:{" "} + + {typeof window !== "undefined" ? `${window.location.origin}/api/mcp/oauth/callback` : "/api/mcp/oauth/callback"} + +
After saving, click Authorize from the chat banner to sign in the first time. +
)} @@ -377,42 +551,6 @@ export default function ToolForm({ mode = "add", initialValues = null, onSave, o
)} - - {s.testStatus === "connected" && ( - } - label={s.testTools ? `Connected · ${s.testTools.length} tools` : "Connected"} - size="small" - color="success" - variant="outlined" - sx={{ mr: 1 }} - /> - )} - - {s.authType !== "oauth2.1" && ( - - )} - - - {/* Server-level human approval. OCI Responses API only supports per-server granularity for require_approval, so this is a single switch. */} - Ask the user to confirm before any tool from this server runs. Applies to all tools — OCI does not support per-tool granularity. + Ask the user to confirm before any tool from this server runs. Applies to all tools. OCI does not support per-tool granularity. {tool.name} {tool.description && ( {tool.description} @@ -496,6 +645,10 @@ export default function ToolForm({ mode = "add", initialValues = null, onSave, o
); })()} +
); } + +const ToolForm = forwardRef(ToolFormInner); +export default ToolForm; diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/settings/ToolsTab.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/settings/ToolsTab.js index 1f49beb3c..947d30e8d 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/settings/ToolsTab.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/settings/ToolsTab.js @@ -31,8 +31,16 @@ import { Plus, Trash2, ChevronDown, ChevronRight, ChevronLeft, RefreshCw, Wrench import IOSSwitch from "../ui/IOSSwitch"; import mcpService, { MCPService } from "../../services/mcpService"; import ToolForm from "./ToolForm"; +import { darkCssVars, darkModeOverrides, DARK_SURFACE } from "../../config/darkMode"; import { INTERNAL_TOOL_TABS, INTERNAL_ADDONS } from "../../config/tools-internal"; +// NL2SQL is presented as a native tool ("Text to SQL") but is delivered under +// the hood as a remote MCP tool (server_label "Nl2Sql", tool generate_sql) per +// the OCI NL2SQL User Guide §1.5. The hosted DBTools/NL2SQL MCP endpoint is +// supplied by the service team and configured via this env var. When empty, the +// toggle still works in the UI but no tool is sent to OCI (see genaiAgentsService). +const NL2SQL_MCP_URL = process.env.NEXT_PUBLIC_NL2SQL_MCP_URL || ''; + const NATIVE_TOOLS = [ { id: "native_web_search", @@ -68,7 +76,6 @@ const NATIVE_TOOLS = [ color: "#F59E0B", endpoint: null, hasConfig: true, - comingSoon: true, configType: "semantic_store", }, ]; @@ -123,7 +130,9 @@ export default function ToolsTab() { const [nativeToolsEnabled, setNativeToolsEnabled] = useState({}); const [semanticStores, setSemanticStores] = useState([]); const [loadingSemanticStores, setLoadingSemanticStores] = useState(false); - const [selectedSemanticStoreId, setSelectedSemanticStoreId] = useState(""); + const [selectedSemanticStoreIds, setSelectedSemanticStoreIds] = useState([]); + // OAuth 2.1 status for the NL2SQL MCP endpoint: 'authorized' | 'needs_auth' | null + const [nl2sqlAuth, setNl2sqlAuth] = useState(null); // Edit server — only need to know which one is being edited; values live in const [editingServerId, setEditingServerId] = useState(null); @@ -375,7 +384,19 @@ export default function ToolsTab() { } } catch { /* ignore */ } setNativeToolsEnabled(nativeState); - setSelectedSemanticStoreId(localStorage.getItem('nl2sqlSemanticStoreId') || ''); + try { + let ssIds = JSON.parse(localStorage.getItem('nl2sqlSemanticStoreIds') || '[]'); + // Migrate legacy single-store key → array + if (ssIds.length === 0) { + const legacy = localStorage.getItem('nl2sqlSemanticStoreId'); + if (legacy) { + ssIds = [legacy]; + localStorage.setItem('nl2sqlSemanticStoreIds', JSON.stringify(ssIds)); + localStorage.removeItem('nl2sqlSemanticStoreId'); + } + } + setSelectedSemanticStoreIds(ssIds); + } catch { /* ignore */ } try { const savedVsIds = JSON.parse(localStorage.getItem('ragVectorStoreIds') || '[]'); @@ -499,7 +520,12 @@ export default function ToolsTab() { const res = await fetch('/api/semantic-stores'); if (res.ok) { const data = await res.json(); - setSemanticStores(data.items || []); + // Hide stores being torn down — the control plane lists DELETED/DELETING + // entries for a while after deletion (eventual consistency). + const usable = (data.items || []).filter( + (s) => s.lifecycleState !== 'DELETED' && s.lifecycleState !== 'DELETING' + ); + setSemanticStores(usable); } } catch (error) { console.error('Failed to fetch semantic stores:', error); @@ -509,9 +535,23 @@ export default function ToolsTab() { }; const handleSelectSemanticStore = (storeId) => { - const next = selectedSemanticStoreId === storeId ? '' : storeId; - setSelectedSemanticStoreId(next); - localStorage.setItem('nl2sqlSemanticStoreId', next); + setSelectedSemanticStoreIds((prev) => { + const next = prev.includes(storeId) + ? prev.filter((id) => id !== storeId) + : [...prev, storeId]; + localStorage.setItem('nl2sqlSemanticStoreIds', JSON.stringify(next)); + // Persist {id, name, schema} for each selected store so genaiAgentsService can + // build the system-prompt routing hints (which DB maps to which question). + const meta = semanticStores + .filter((s) => next.includes(s.id)) + .map((s) => ({ + id: s.id, + displayName: s.displayName, + schemas: (s.schemas?.schemas || []).map((x) => x.name).join(', '), + })); + localStorage.setItem('nl2sqlSemanticStores', JSON.stringify(meta)); + return next; + }); }; const deleteVectorStore = async (vsId) => { @@ -801,7 +841,7 @@ export default function ToolsTab() { // Check OAuth 2.1 token presence for each oauth2.1 server useEffect(() => { const checkOauth21Tokens = async () => { - const oauth21Servers = servers.filter(s => s.authType === 'oauth2.1'); + const oauth21Servers = servers.filter(s => s.authType === 'oauth2.1' || s.authType === 'oauth2-user'); if (oauth21Servers.length === 0) return; const updates = {}; await Promise.all( @@ -820,6 +860,32 @@ export default function ToolsTab() { if (isHydrated) checkOauth21Tokens(); }, [servers, isHydrated]); + // Check OAuth 2.1 token for the NL2SQL (Text to SQL) MCP endpoint + useEffect(() => { + if (!isHydrated || !NL2SQL_MCP_URL || !nativeToolsEnabled.native_text_to_sql) { + setNl2sqlAuth(null); + return; + } + let cancelled = false; + (async () => { + try { + const res = await fetch(`/api/mcp/oauth/token?endpoint=${encodeURIComponent(NL2SQL_MCP_URL)}&probe=true`); + const data = await res.json().catch(() => ({})); + if (!cancelled) setNl2sqlAuth(data.hasToken ? 'authorized' : 'needs_auth'); + } catch { + if (!cancelled) setNl2sqlAuth('needs_auth'); + } + })(); + return () => { cancelled = true; }; + }, [isHydrated, nativeToolsEnabled.native_text_to_sql]); + + // Kick off the OAuth 2.1 flow for the NL2SQL MCP endpoint (same flow as custom + // oauth2.1 servers — backend discovers metadata from the endpoint). + const authorizeNl2sql = () => { + const returnTo = typeof window !== 'undefined' ? window.location.pathname : '/settings/tools'; + window.location.href = MCPService.buildAuthorizeUrl({ endpoint: NL2SQL_MCP_URL, authType: 'oauth2.1' }, returnTo); + }; + // Show feedback when returning from an OAuth callback (success or error) useEffect(() => { if (typeof window === 'undefined') return; @@ -828,11 +894,13 @@ export default function ToolsTab() { if (result === 'success') { setToast({ message: 'Tool authorized successfully', severity: 'success' }); } else if (result === 'error') { - setToast({ message: 'Authorization failed. Try again or check the tool configuration.', severity: 'error' }); + const detail = params.get('mcp_error'); + setToast({ message: detail ? `Authorization failed: ${detail}` : 'Authorization failed. Try again or check the tool configuration.', severity: 'error' }); } if (result) { // Clean the URL so a refresh doesn't re-trigger the toast params.delete('mcp_auth'); + params.delete('mcp_error'); const newUrl = window.location.pathname + (params.toString() ? `?${params.toString()}` : '') + window.location.hash; window.history.replaceState({}, '', newUrl); } @@ -897,12 +965,12 @@ export default function ToolsTab() { const toolIds = tools.map(t => `${newServer.id}:${t.name}`); setEnabledTools(prev => [...prev, ...toolIds]); setToast({ message: `${newServer.name} added · ${tools.length} tools enabled`, severity: 'success' }); - } else if (newServer.authType === 'oauth2.1') { + } else if (newServer.authType === 'oauth2.1' || newServer.authType === 'oauth2-user') { // Interactive flow — kick off authorization right away. The user comes back to /settings // when done. After return, the server's tools/list will succeed and the chip will load. setServerStatus(prev => ({ ...prev, [newServer.id]: null })); const returnTo = typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/settings'; - window.location.href = `/api/mcp/oauth/authorize?endpoint=${encodeURIComponent(newServer.endpoint)}&returnTo=${encodeURIComponent(returnTo)}`; + window.location.href = MCPService.buildAuthorizeUrl(newServer, returnTo); } else { loadServerTools(newServer); } @@ -939,9 +1007,18 @@ export default function ToolsTab() { requireApprovalTools: undefined, }; MCPService.updateServer(id, updates); + const updatedServer = { ...(servers.find(s => s.id === id) || {}), ...updates, id }; setServers(prev => prev.map(s => (s.id === id ? { ...s, ...updates } : s))); setEditingServerId(null); setToast({ message: `${updates.name} updated`, severity: 'success' }); + + // Drop any cached runtime state (e.g. an "initialized" flag from a previous + // failed test under the old config) and re-probe so the status chip reflects + // the new config. Skip for interactive OAuth — those need user authorization. + mcpService.resetServerState(id); + if (updates.authType !== 'oauth2.1' && updates.authType !== 'oauth2-user') { + loadServerTools(updatedServer); + } }; // Test connection and update tools list @@ -1199,11 +1276,19 @@ export default function ToolsTab() { }}> {tool.name} - + {tool.description}
@@ -1235,6 +1320,34 @@ export default function ToolsTab() {
+ {!NL2SQL_MCP_URL ? ( + + + ⚠️ NL2SQL endpoint not configured. You can select databases here, but queries won't run until NEXT_PUBLIC_NL2SQL_MCP_URL (the hosted DBTools MCP endpoint) is set. + + + ) : nl2sqlAuth === 'needs_auth' ? ( + + + 🔒 Authorization required to run SQL queries. + + + + ) : nl2sqlAuth === 'authorized' ? ( + + + Authorized + + ) : null} + {loadingSemanticStores ? ( @@ -1245,7 +1358,7 @@ export default function ToolsTab() { ) : semanticStores.length > 0 ? ( {semanticStores.map((ss) => { - const isSelected = selectedSemanticStoreId === ss.id; + const isSelected = selectedSemanticStoreIds.includes(ss.id); const schemas = (ss.schemas?.schemas || []).map(s => s.name).join(', '); return ( - {/* Add Tool Form — single component handles state, validation, test, OAuth discovery */} - {showAddForm && ( - - { + {/* Add/Edit Tool dialog — single modal reused by both flows. The state + drivers are showAddForm (add) and editingServerId (edit). The mode + and initialValues are derived from whichever is set. ToolForm exposes + imperative save()/test() via ref so the buttons can live in + DialogActions (anchored to the bottom while content scrolls). */} + {(showAddForm || editingServerId) && ( + s.id === editingServerId) : null} + serverToolsForEdit={editingServerId ? (serverTools[editingServerId] || []) : []} + onSave={editingServerId ? handleSaveEdit : handleAddServer} + onClose={() => { + if (editingServerId) { + handleCancelEdit(); + } else { setShowAddForm(false); mcpService.servers.delete('test-new'); - }} - /> - + } + }} + /> )} {/* Server List */} @@ -2155,19 +2268,8 @@ export default function ToolsTab() { boxShadow: focusedServerId === server.id ? "0 0 0 4px rgba(255, 152, 0, 0.12)" : "none", }} > - {/* Server Header */} - {editingServerId === server.id ? ( - /* Edit Mode — same form as add, prefilled with server values */ - - - - ) : ( - /* View Mode */ + {/* Server Header — edit form moved to a shared Dialog above */} + {( )} - {/* OAuth 2.1 authorization status — only shown for oauth2.1 servers */} - {server.authType === 'oauth2.1' && oauth21Status[server.id] === 'needs_auth' && ( + {/* User-sign-in authorization status — shown for oauth2.1 (auto-discovery) and oauth2-user (manual setup) */} + {(server.authType === 'oauth2.1' || server.authType === 'oauth2-user') && oauth21Status[server.id] === 'needs_auth' && ( )} - {server.authType === 'oauth2.1' && oauth21Status[server.id] === 'authorized' && ( - - { - e.stopPropagation(); - const returnTo = window.location.pathname + window.location.search; - window.location.href = `/api/mcp/oauth/authorize?endpoint=${encodeURIComponent(server.endpoint)}&returnTo=${encodeURIComponent(returnTo)}`; - }} - sx={{ color: "rgba(46, 125, 50, 0.7)" }} - > - - - + {(server.authType === 'oauth2.1' || server.authType === 'oauth2-user') && oauth21Status[server.id] === 'authorized' && ( + )} {loadingServers[server.id] && ( @@ -2357,7 +2467,19 @@ export default function ToolsTab() { {tool.name} - + {tool.description} @@ -2413,3 +2535,98 @@ export default function ToolsTab() { ); } + +/** + * Add/Edit Tool dialog. Encapsulates the local ref + formState plumbing so + * the buttons can live in DialogActions (anchored to the bottom while the + * form content scrolls naturally inside DialogContent). + */ +function ToolDialog({ mode, server, serverToolsForEdit, onSave, onClose }) { + const isEdit = mode === "edit"; + const formRef = useRef(null); + const [formState, setFormState] = useState({ isValid: false, isLoading: false, authType: null, testStatus: null, testToolsCount: 0 }); + + // The Dialog renders inside a React portal at document.body, so the page- + // level dark-mode wrapper does NOT cascade into it. Read the flag locally + // and apply darkCssVars + darkModeOverrides directly to the Paper so the + // entire dialog subtree is themed correctly. + const [isDark, setIsDark] = useState(false); + useEffect(() => { + try { + const ui = JSON.parse(localStorage.getItem("uiSettings") || "{}"); + setIsDark(ui.darkMode === true); + } catch { /* ignore */ } + }, []); + + return ( + { + if (reason === 'backdropClick' || reason === 'escapeKeyDown') return; + onClose(); + }} + maxWidth="md" + fullWidth + scroll="paper" + PaperProps={{ + sx: { + borderRadius: 2, + ...(isDark && { + backgroundColor: DARK_SURFACE, + ...darkCssVars, + ...darkModeOverrides, + }), + }, + }} + > + + {isEdit ? "Edit Tool" : "Add MCP Tool"} + + + + + + + + + {formState.testStatus === "connected" && ( + } + label={formState.testToolsCount > 0 ? `Connected · ${formState.testToolsCount} tools` : "Connected"} + size="small" + color="success" + variant="outlined" + sx={{ mr: 1 }} + /> + )} + {formState.authType !== "oauth2.1" && ( + + )} + + + + ); +} diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/ui/BubbleModelSelector.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/ui/BubbleModelSelector.js index 77c5f6895..54d58958b 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/ui/BubbleModelSelector.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/ui/BubbleModelSelector.js @@ -34,45 +34,16 @@ function formatModelName(model) { return model.replace(/^[a-z]+\./, ""); } -// Extract model family from a full model id (without provider prefix) -// Each point release is its own family: -// "gpt-5-mini" → "gpt-5", "gpt-5.1-codex" → "gpt-5.1", "gpt-5.2-pro" → "gpt-5.2" -// "gpt-4o-mini" → "gpt-4o", "gpt-4.1" → "gpt-4.1" -// "grok-3-mini-fast" → "grok-3", "grok-4-1-fast-reasoning" → "grok-4-1" -// "o1" → "o-series", "o3-mini" → "o-series", "o4-mini" → "o-series" +// Extract model family from a full model id (without provider prefix). +// gpt-oss-20b / gpt-oss-120b → "gpt-oss". Anything else (e.g. gemini-2.5-pro, +// gemini-2.5-flash) is treated as its own family. function getFamily(modelName) { - // o-series: o1, o3-mini, o4-mini → "o-series" - if (/^o\d/.test(modelName)) return "o-series"; - // gpt-oss special case if (modelName.startsWith("gpt-oss")) return "gpt-oss"; - // gpt-X.Y (e.g. gpt-5.1, gpt-5.2, gpt-4.1) → family "gpt-X.Y" - const gptDotMatch = modelName.match(/^(gpt-\d+\.\d+)/); - if (gptDotMatch) return gptDotMatch[1]; - // gpt-Xo (e.g. gpt-4o, gpt-4o-mini) → family "gpt-4o" - const gptOMatch = modelName.match(/^(gpt-\d+o)/); - if (gptOMatch) return gptOMatch[1]; - // gpt-X (e.g. gpt-5, gpt-5-mini) → family "gpt-X" - const gptMatch = modelName.match(/^(gpt-\d+)/); - if (gptMatch) return gptMatch[1]; - // grok-X-Y (e.g. grok-4-1-fast-reasoning) → family "grok-X-Y" where Y is a single digit - const grokSubMatch = modelName.match(/^(grok-\d+-\d+)/); - if (grokSubMatch) return grokSubMatch[1]; - // grok-X (e.g. grok-3, grok-4) → family "grok-X" - const grokMatch = modelName.match(/^(grok-\d+)/); - if (grokMatch) return grokMatch[1]; - // fallback return modelName; } function formatFamilyLabel(family) { - if (family === "o-series") return "o-series"; if (family === "gpt-oss") return "OSS"; - // gpt-5.1 → "GPT-5.1", gpt-4o → "GPT-4o", grok-4-1 → "Grok 4.1" - if (family.startsWith("gpt-")) return "GPT-" + family.slice(4); - if (family.startsWith("grok-")) { - const ver = family.slice(5).replace("-", "."); - return "Grok " + ver; - } return family; } diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/ui/Header.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/ui/Header.js index 1bab72ab8..10171fc72 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/ui/Header.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/components/ui/Header.js @@ -6,6 +6,7 @@ import Image from "next/image"; import { Building2, Check, ChevronDown, ClipboardCopy, Download, LogOut, Menu as MenuIcon, Share2, SquarePen, Users, X } from "lucide-react"; import { useEffect, useState } from "react"; import BubbleModelSelector from "./BubbleModelSelector"; +import { withBase } from "@/lib/withBase"; const BrainFreezeIcon = ({ size = 16, color = "currentColor" }) => ( @@ -242,7 +243,19 @@ export default function Header({ style={{ display: "flex", alignItems: "center", gap: 8 }} > - {/* Model name label hidden temporarily — tooltip still shows it on hover */} + + {selectedModel?.replace(/^[a-z]+\./, "") || "Select model"} + )} @@ -376,7 +389,7 @@ export default function Header({ { window.location.href = '/api/auth/logout'; }} + onClick={() => { window.location.href = withBase('/api/auth/logout'); }} sx={{ fontSize: "0.85rem", color: "error.main" }} > diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/config/version.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/config/version.js index a848f2830..ed06df7da 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/config/version.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/config/version.js @@ -1,3 +1,3 @@ // Bump on every notable change so the running app's version is visible in Settings. // Matches package.json for sanity; either can be the source of truth. -export const APP_VERSION = "0.4.0"; +export const APP_VERSION = "0.9.9"; diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/globals.css b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/globals.css index 1db0aa1f9..7a2b6ad0e 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/globals.css +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/globals.css @@ -4,6 +4,14 @@ body { padding: 0; } +/* Global font baseline — any element that doesn't set its own font-family + inherits Oracle Sans. This is the single source of truth so we don't have + to wrap every loose
/ text in just to get the right + font. Code/pre and explicit monospace usages still override locally. */ +body { + font-family: var(--font-oracle-sans), sans-serif; +} + body, h1, h2, diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/hooks/useChat.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/hooks/useChat.js index 4d97d7450..d47866def 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/hooks/useChat.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/hooks/useChat.js @@ -7,6 +7,7 @@ import { generateTitle } from "../services/titleService"; import { groupMessages, parseContentWithWidgets } from "../utils/messageUtils"; import { createWidgetStreamParser } from "../utils/widgetParser"; import { createWidgetV2StreamParser, serializeWidgetV2Tree } from "../utils/widgetV2Parser"; +import { withBase } from "@/lib/withBase"; // Friendly names for OCI internal tools const OCI_INTERNAL_LABELS = { @@ -83,7 +84,10 @@ export default function useChat({ initialConversationId = null, selectedModel, o const containerRect = container.getBoundingClientRect(); const messageRect = message.getBoundingClientRect(); const scrollOffset = messageRect.top - containerRect.top + container.scrollTop - 60; - container.scrollTo({ top: scrollOffset, behavior: 'smooth' }); + // Instant — smooth scroll fights with mid-stream layout changes (chips, + // text deltas) because the target offset is computed once and the page + // geometry then mutates underneath it, producing a visible "bounce". + container.scrollTo({ top: scrollOffset, behavior: 'auto' }); }, 150); }, []); @@ -102,7 +106,6 @@ export default function useChat({ initialConversationId = null, selectedModel, o id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, userMessage, responses: [], - sources: [], isLatest: true, ...extras, }), []); @@ -110,7 +113,6 @@ export default function useChat({ initialConversationId = null, selectedModel, o const initStreamingState = useCallback(() => { streamingStateRef.current = { responses: [], - sources: [], accumulatedText: "", currentTextContent: "", widgetParser: createWidgetStreamParser(), @@ -122,6 +124,7 @@ export default function useChat({ initialConversationId = null, selectedModel, o streamCompleted: false, // Track if OCI sent response.completed (done: true) streamStartedAt: Date.now(), // For debugging timing streamError: null, // Capture the actual error reason + pendingMcpFunctionCalls: [], // function_calls the client must execute and chain back }; }, []); @@ -173,10 +176,15 @@ export default function useChat({ initialConversationId = null, selectedModel, o }, []); const processStreamingChunk = useCallback((chunk, state) => { - // Track stream completion and capture trace + // Track stream completion and capture trace. + // Also drop any lingering "thinking"/"mcp_connecting" indicators — the stream + // is over so no further chunk will clean them up, and the finally-block + // cleanup runs across `await` boundaries which produces a visible spinner + // flash on the last render before unmount. if (typeof chunk === 'object' && chunk.done) { state.streamCompleted = true; if (chunk.trace) state.trace = chunk.trace; + state.responses = state.responses.filter(r => r.type !== "thinking" && r.type !== "mcp_connecting"); return [...state.responses]; } @@ -351,6 +359,42 @@ export default function useChat({ initialConversationId = null, selectedModel, o return [...state.responses]; } + // MCP tool the model wants to invoke but OCI did NOT execute itself + // (typical with gpt-oss-120b). Show a chip in "calling" state and queue + // the call for client-side execution + chained response after the stream + // ends. Handled in sendAndStreamMessage's finally block. + if (typeof chunk === 'object' && chunk.mcp_function_call) { + const fc = chunk.mcp_function_call; + state.responses = state.responses.filter(r => r.type !== 'thinking' && r.type !== 'mcp_connecting'); + // Normalize id: mcp.calling stores `mcp.id || null`, so any earlier + // chip emitted with a falsy id has `mcpItemId === null`. If we used + // raw `fc.item_id` (which can be undefined for gpt-oss-120b) the + // strict-equals lookup would miss the existing chip and we'd push a + // second one for the same call. Fall back to call_id, then null. + const fcItemId = fc.item_id || fc.call_id || null; + const existing = state.responses.find(r => r.type === 'mcp_chip' && r.mcpItemId === fcItemId); + if (!existing) { + state.responses.push({ + type: 'mcp_chip', + mcpItemId: fcItemId, + server: fc.server_label, + tool: fc.tool_name, + arguments: fc.arguments, + status: 'calling', + label: `Using ${formatToolName(fc.tool_name)}...`, + }); + } + state.pendingMcpFunctionCalls.push({ + item_id: fcItemId, + call_id: fc.call_id, + fn_name: fc.fn_name, + server_label: fc.server_label, + tool_name: fc.tool_name, + arguments: fc.arguments, + }); + return [...state.responses]; + } + // Handle function call (client-side tool or OCI internal tool) if (typeof chunk === 'object' && chunk.function_call) { const fcName = chunk.function_call.name; @@ -469,8 +513,15 @@ export default function useChat({ initialConversationId = null, selectedModel, o state.responses[chipIdx].arguments = mcp.arguments; } } - // Re-add thinking indicator while the model generates text after the tool call - if (!state.responses.some(r => r.type === "thinking")) { + // Re-add the "thinking" placeholder only if the model is expected to + // speak AFTER the tool — i.e. no text has been streamed yet. For models + // that emit native MCP results late (Gemini path: tool_output arrives at + // response.completed AFTER the text), adding thinking here produces a + // visible spinner flash between this event and the final done:true. + const hasStreamedText = state.responses.some( + r => r.type === "text" && (r.content || "").length > 0 + ); + if (!hasStreamedText && !state.responses.some(r => r.type === "thinking")) { state.responses.push({ type: "thinking" }); } } else if (mcp.type === 'error') { @@ -672,59 +723,47 @@ export default function useChat({ initialConversationId = null, selectedModel, o return [...state.responses]; }, []); + // Cleanup after a stream ends. Only acts on REAL errors (user abort, network + // failure, explicit stall). For ambiguous endings (e.g. OCI closed without + // response.completed) we leave the chips as they are — the UI will render + // their natural state. No fabricated error messages. const markIncompleteMcpChipsAsFailed = useCallback(() => { const state = streamingStateRef.current; - const streamCompleted = state?.streamCompleted || false; const streamError = state?.streamError; const elapsed = state?.streamStartedAt ? Math.round((Date.now() - state.streamStartedAt) / 1000) : null; + const isRealError = !!streamError && ( + streamError.name === 'AbortError' || + streamError.name === 'StreamStallError' || + streamError.message?.includes('STREAM_STALL') || + streamError.message?.includes('Failed to fetch') || + streamError.message?.includes('NetworkError') + ); + updateLatestExchange(exchange => { - const sourcesCount = exchange.responses.filter(r => r.type === 'sources').length; - if (sourcesCount > 0) console.log('[Sources] Before cleanup:', sourcesCount, 'sources entries in', exchange.responses.length, 'total responses'); const responses = exchange.responses - .filter(r => r.type !== "thinking" && r.type !== "mcp_connecting") // Remove leftover indicators + .filter(r => r.type !== "thinking" && r.type !== "mcp_connecting") .map(r => { - if (r.type === "mcp_chip" && (r.status === "connecting" || r.status === "calling")) { - // Build a descriptive error message for debugging + // Close any reasoning blocks left mid-stream so the UI stops the + // loading dots animation. This is cosmetic only. + if (r.type === "reasoning" && r.done === false) return { ...r, done: true }; + + // Only mark calling chips as failed on REAL errors (abort/network/stall). + // Otherwise leave them — the chain loop or normal flow will update them. + if (r.type === "mcp_chip" && (r.status === "connecting" || r.status === "calling") && isRealError) { const toolName = formatToolName(r.tool) || r.server || "unknown tool"; let reason; - if (r.error) { - reason = r.error; - } else if (streamCompleted) { - reason = `OCI completed the response while ${toolName} was still running (${elapsed}s). The tool was called but OCI closed the stream before receiving its result. This is a known OCI behavior when an MCP tool call is the model's last action.`; - } else if (streamError?.name === 'AbortError') { - reason = `Stopped by user while ${toolName} was running (${elapsed}s)`; - } else if (streamError?.name === 'StreamStallError' || streamError?.message?.includes('STREAM_STALL')) { - reason = `Stream stalled — no data from server for 2+ min while ${toolName} was ${r.status} (${elapsed}s). ${streamError.message}`; - } else if (streamError?.name === 'PrematureStreamClose') { - reason = `OCI closed connection while ${toolName} was ${r.status} (${elapsed}s). ${streamError.message}`; - } else if (streamError?.message?.includes('Failed to fetch') || streamError?.message?.includes('NetworkError')) { - reason = `Network error while ${toolName} was ${r.status} (${elapsed}s) — check connection or server`; - } else if (streamError) { - reason = `${toolName} interrupted after ${elapsed}s: ${streamError.message || String(streamError)}`; - } else { - reason = `${toolName} was still ${r.status} when stream ended (${elapsed}s) — no error captured, possible silent disconnect`; - } - console.error(`[MCP] Tool failed: ${toolName}`, { status: r.status, elapsed, streamCompleted, error: streamError?.message }); - return { - ...r, - status: "failed", - label: formatToolName(r.tool) || r.server || "Failed", - error: reason - }; - } - // Stream ended while a reasoning block was still open — close it so - // the UI stops showing the loading dots. - if (r.type === "reasoning" && r.done === false) { - return { ...r, done: true }; + if (streamError.name === 'AbortError') reason = `Stopped by user while ${toolName} was running (${elapsed}s)`; + else if (streamError.name === 'StreamStallError' || streamError.message?.includes('STREAM_STALL')) + reason = `Stream stalled while ${toolName} was ${r.status} (${elapsed}s)`; + else reason = `Network error while ${toolName} was ${r.status} (${elapsed}s)`; + return { ...r, status: "failed", label: formatToolName(r.tool) || r.server || "Failed", error: reason }; } - // Code interpreter block still writing/interpreting when stream died. - if (r.type === "code_execution" && r.status && r.status !== "completed") { + if (r.type === "code_execution" && r.status && r.status !== "completed" && isRealError) { return { ...r, status: "failed" }; } return r; }); - // Attach trace to exchange for UI diagnostics const requestTrace = streamingStateRef.current?.trace || null; return { ...exchange, responses, ...(requestTrace && { trace: requestTrace }) }; }); @@ -737,32 +776,228 @@ export default function useChat({ initialConversationId = null, selectedModel, o const { isNewConversation = false, inputText = "", previousResponseId } = options; const sessionActiveServers = inputRef.current?.getSessionActiveServers?.() || undefined; - try { - if (isNewConversation) { - await genaiService.createConversation("New conversation"); - const newConvId = genaiService.getConversationId(); - if (newConvId) { - const storedConv = await ConversationStorage.get(newConvId); - if (storedConv?.urlId) { - window.history.pushState(null, "", `/c/${storedConv.urlId}`); - loadedConversationRef.current = storedConv.urlId; - } + if (isNewConversation) { + await genaiService.createConversation("New conversation"); + const newConvId = genaiService.getConversationId(); + if (newConvId) { + const storedConv = await ConversationStorage.get(newConvId); + if (storedConv?.urlId) { + window.history.pushState(null, "", withBase(`/c/${storedConv.urlId}`)); + loadedConversationRef.current = storedConv.urlId; } } + } + + let result = null; + let primaryError = null; + + // Initial send — capture its error (if any) but DON'T let it short-circuit + // the client-side MCP execution loop below. Some failures (e.g. OCI closing + // the stream after a function_call) leave pendingMcpFunctionCalls populated + // and we still want to execute the tool + chain a follow-up. + try { + result = await genaiService.sendMessage( + input, + (chunk) => { + const responsesCopy = processStreamingChunk(chunk, streamingStateRef.current); + updateLatestExchange(exchange => ({ ...exchange, responses: responsesCopy })); + }, + { model: selectedModel || undefined, sessionActiveServers, previousResponseId } + ); + } catch (err) { + primaryError = err; + if (streamingStateRef.current) streamingStateRef.current.streamError = err; + } + + // Client-side MCP execution loop. Some models (gpt-oss-120b) emit MCP + // tool calls as OpenAI-style function_call and expect the client to + // execute them and submit a function_call_output back via a chained + // Responses request. Runs regardless of whether the initial stream errored. + // Hoist server list out of the per-pending loop — same value the whole chain. + const mcpServers = typeof window !== 'undefined' + ? JSON.parse(localStorage.getItem('mcpServers') || '[]') + : []; + const findServerByLabel = (serverLabel) => mcpServers.find(s => { + let label = (s.name || '').replace(/[^a-zA-Z0-9_-]/g, '_'); + if (!/^[a-zA-Z]/.test(label)) label = 'mcp_' + label; + return label === serverLabel; + }); - const result = await genaiService.sendMessage( - input, - (chunk) => { - const responsesCopy = processStreamingChunk(chunk, streamingStateRef.current); + let chainDepth = 0; + const MAX_CHAIN_DEPTH = 5; + let needsAuthFor = null; // { name, endpoint, authType } when an MCP server needs OAuth + while ((streamingStateRef.current.pendingMcpFunctionCalls).length > 0 && chainDepth < MAX_CHAIN_DEPTH) { + chainDepth++; + const pending = streamingStateRef.current.pendingMcpFunctionCalls; + streamingStateRef.current.pendingMcpFunctionCalls = []; + + const chainInput = []; + for (const fc of pending) { + // If a prior tool in this batch already needed auth, stop running. + // The user will authorize + retry; running more tools just wastes + // calls and confuses the user (chips completing while the chain dies). + if (needsAuthFor) { + const markAuthPending = (chip) => { + if (chip.type !== 'mcp_chip') return chip; + const matches = chip.mcpItemId === fc.item_id + || (fc.item_id == null && chip.tool === fc.tool_name && chip.server === fc.server_label && (chip.status === 'calling' || chip.status === 'connecting')); + if (!matches) return chip; + return { ...chip, status: 'failed', label: formatToolName(fc.tool_name) || 'Authorization required', error: 'Authorization required for another tool in this turn' }; + }; + streamingStateRef.current.responses = streamingStateRef.current.responses.map(markAuthPending); + updateLatestExchange(exchange => ({ ...exchange, responses: (exchange.responses || []).map(markAuthPending) })); + continue; + } + + const server = findServerByLabel(fc.server_label); + let parsedArgs; + try { parsedArgs = JSON.parse(fc.arguments || '{}'); } catch { parsedArgs = {}; } + + let output; + let authRequired = false; + if (!server) { + output = JSON.stringify({ error: `Server '${fc.server_label}' not configured` }); + } else { + try { + const res = await fetch('/api/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + endpoint: server.endpoint, + method: 'tools/call', + params: { name: fc.tool_name, arguments: parsedArgs }, + authType: server.authType, + authKey: server.authKey, + oauth: server.oauth, + }), + }); + const data = await res.json(); + if (res.status === 401 && data.error === 'needs_auth') { + // OAuth token missing/expired — abort the chain and surface a + // banner inviting the user to authorize. The model NEVER sees + // the auth failure, so it doesn't try to summarise it. + authRequired = true; + needsAuthFor = { name: server.name, endpoint: server.endpoint, authType: server.authType }; + } else if (data.result?.content) { + output = data.result.content.map(c => c.text || JSON.stringify(c)).join('\n'); + } else if (data.error) { + output = JSON.stringify({ error: data.error.message || JSON.stringify(data.error) }); + } else { + output = JSON.stringify(data); + } + } catch (err) { + output = JSON.stringify({ error: err.message || String(err) }); + } + } + + // Update the chip in BOTH the React exchange AND the streaming state. + // The chained sendMessage will replace exchange.responses with + // state.responses on every chunk, so updating just exchange would be + // overwritten the moment the chained stream starts. We mutate + // state.responses in place so subsequent chunks preserve the update. + const applyChipUpdate = (chip) => { + if (chip.type !== 'mcp_chip') return chip; + // Match by mcpItemId first; fall back to tool+server for the case + // where output_item.added(mcp_call) never fired (rare). + const matches = chip.mcpItemId === fc.item_id + || (fc.item_id == null && chip.tool === fc.tool_name && chip.server === fc.server_label && (chip.status === 'calling' || chip.status === 'connecting')); + if (!matches) return chip; + if (authRequired) { + return { ...chip, status: 'failed', label: formatToolName(fc.tool_name) || 'Authorization required', error: 'Authorization required' }; + } + return { ...chip, status: 'completed', output, label: formatToolName(fc.tool_name) || 'Completed' }; + }; + streamingStateRef.current.responses = streamingStateRef.current.responses.map(applyChipUpdate); updateLatestExchange(exchange => ({ ...exchange, - responses: responsesCopy, + responses: (exchange.responses || []).map(applyChipUpdate), })); - }, - { model: selectedModel || undefined, sessionActiveServers, previousResponseId } - ); - // Persist the raw accumulated text and trace for export/diagnostics + if (!authRequired) { + chainInput.push( + { type: 'function_call', name: fc.fn_name, arguments: fc.arguments, call_id: fc.call_id }, + { type: 'function_call_output', call_id: fc.call_id, output }, + ); + } + } + + if (needsAuthFor) break; // skip chained call, surface banner instead + + // Finalise the previous stream's open text item BEFORE the chained + // sendMessage starts. Otherwise its output_text deltas append to the + // accumulator and we render duplicate text (the first stream's summary + // + the chained stream's identical summary concatenated). + if (streamingStateRef.current) { + const st = streamingStateRef.current; + const idx = (st.responses || []).findIndex(r => r.type === 'text' && r.isStreaming); + if (idx >= 0) st.responses[idx].isStreaming = false; + st.currentTextContent = ''; + } + + try { + result = await genaiService.sendMessage( + chainInput, + (chunk) => { + const responsesCopy = processStreamingChunk(chunk, streamingStateRef.current); + updateLatestExchange(exchange => ({ ...exchange, responses: responsesCopy })); + }, + { model: selectedModel || undefined, sessionActiveServers }, + ); + // If the chained request succeeds we no longer need to surface the + // original error (the tool ran, the model summarised, the user is fine). + primaryError = null; + } catch (err) { + if (streamingStateRef.current) streamingStateRef.current.streamError = err; + primaryError = err; + break; + } + } + + // Chain exhausted its depth budget with pending tool calls still queued. + // Without this, chips stay "calling" forever (markIncompleteMcpChipsAsFailed + // only acts on real errors). Mark them failed with a clear reason and + // surface an error so the user knows the model is stuck looping. + if ( + chainDepth >= MAX_CHAIN_DEPTH && + (streamingStateRef.current.pendingMcpFunctionCalls || []).length > 0 + ) { + const stuck = streamingStateRef.current.pendingMcpFunctionCalls; + const stuckIds = new Set(stuck.map(p => p.item_id).filter(Boolean)); + const failStuck = (chip) => { + if (chip.type !== 'mcp_chip') return chip; + const isStuck = stuckIds.has(chip.mcpItemId) || chip.status === 'calling' || chip.status === 'connecting'; + if (!isStuck) return chip; + return { ...chip, status: 'failed', label: formatToolName(chip.tool) || 'Failed', error: `Chain depth limit reached (${MAX_CHAIN_DEPTH}) — model kept calling tools without finishing.` }; + }; + streamingStateRef.current.responses = streamingStateRef.current.responses.map(failStuck); + updateLatestExchange(exchange => ({ + ...exchange, + responses: [ + ...(exchange.responses || []).map(failStuck), + { type: 'error', content: `The model called tools ${MAX_CHAIN_DEPTH}+ times in a row without producing a final answer. Stopping to avoid an infinite loop. Try rephrasing your request.` }, + ], + })); + streamingStateRef.current.pendingMcpFunctionCalls = []; + } + + // Auth required somewhere along the chain — push the existing + // mcp_auth_expired banner so the user can re-authorize from the chat. + if (needsAuthFor) { + updateLatestExchange(exchange => ({ + ...exchange, + responses: [...(exchange.responses || []), { + type: 'error', + content: 'mcp_auth_expired', + serverLabel: needsAuthFor.name || null, + serverEndpoint: needsAuthFor.endpoint || null, + serverAuthType: needsAuthFor.authType || null, + }], + })); + // Don't surface a generic error on top of the banner. + primaryError = null; + } + + // Persist accumulated text + trace const rawText = streamingStateRef.current.accumulatedText || ''; const requestTrace = streamingStateRef.current.trace || null; if (rawText || requestTrace) { @@ -773,8 +1008,8 @@ export default function useChat({ initialConversationId = null, selectedModel, o })); } - // Generate title for new conversations or conversations that were stopped before getting a title - if (result.answer && inputText) { + // Generate title for new conversations + if (result?.answer && inputText) { const convId = genaiService.getConversationId(); if (convId) { const needsTitle = isNewConversation || await ConversationStorage.get(convId).then(c => !c?.title || c.title === "New conversation").catch(() => false); @@ -788,42 +1023,38 @@ export default function useChat({ initialConversationId = null, selectedModel, o } } - return result; - } catch (error) { - // Capture error for markIncompleteMcpChipsAsFailed - if (streamingStateRef.current) { - streamingStateRef.current.streamError = error; - } - if (error.name === 'AbortError') { - // User stopped generation — keep partial content, don't show error - return { answer: streamingStateRef.current?.accumulatedText || '', stopped: true }; - } - console.error("Error calling agent:", error); - if (error.type === 'mcp_auth_expired') { - updateLatestExchange(exchange => ({ - ...exchange, - responses: [...(exchange.responses || []), { - type: "error", - content: "mcp_auth_expired", - serverLabel: error.serverLabel || null, - serverEndpoint: error.serverEndpoint || null, - serverAuthType: error.serverAuthType || null, - }], - })); - } else { - updateLatestExchange(exchange => ({ - ...exchange, - responses: [...(exchange.responses || []), { - type: "error", - content: error?.message || String(error), - opcRequestId: error?.opcRequestId || null, - model: error?.model || null, - timestamp: error?.timestamp || null, - }], - })); + if (primaryError) { + if (primaryError.name === 'AbortError') { + return { answer: streamingStateRef.current?.accumulatedText || '', stopped: true }; + } + console.error('Error calling agent:', primaryError); + if (primaryError.type === 'mcp_auth_expired') { + updateLatestExchange(exchange => ({ + ...exchange, + responses: [...(exchange.responses || []), { + type: 'error', + content: 'mcp_auth_expired', + serverLabel: primaryError.serverLabel || null, + serverEndpoint: primaryError.serverEndpoint || null, + serverAuthType: primaryError.serverAuthType || null, + }], + })); + } else { + updateLatestExchange(exchange => ({ + ...exchange, + responses: [...(exchange.responses || []), { + type: 'error', + content: primaryError.message || String(primaryError), + opcRequestId: primaryError.opcRequestId || null, + model: primaryError.model || null, + timestamp: primaryError.timestamp || null, + }], + })); + } + throw primaryError; } - throw error; - } + + return result; }, [genaiService, selectedModel, processStreamingChunk, updateLatestExchange, refreshRecentConversations]); useEffect(() => { @@ -845,13 +1076,13 @@ export default function useChat({ initialConversationId = null, selectedModel, o await handleConversationClick(conversation); } else { loadedConversationRef.current = null; - window.history.replaceState(null, "", "/"); + window.history.replaceState(null, "", withBase("/")); setIsLoadingConversation(false); } } catch (error) { console.error("Error loading conversation from URL:", error); loadedConversationRef.current = null; - window.history.replaceState(null, "", "/"); + window.history.replaceState(null, "", withBase("/")); setIsLoadingConversation(false); } }; @@ -871,6 +1102,8 @@ export default function useChat({ initialConversationId = null, selectedModel, o } } else if (path === "/" || path === "") { if (genaiService?.getConversationId()) { + genaiService.abortCurrentRequest?.(); + setIsLoading(false); genaiService.resetConversation(); resetChatState(); } @@ -881,24 +1114,34 @@ export default function useChat({ initialConversationId = null, selectedModel, o return () => window.removeEventListener("popstate", handlePopState); }, [genaiService, resetChatState]); + // Re-attach ResizeObserver ONLY when the latest exchange changes (new turn + // means latestMessageRef points to a different DOM node). Within a streaming + // turn, exchange.responses mutates on every chunk but the latest exchange ID + // is stable — the existing observer already fires for height changes, so + // there's no reason to tear down + recreate on every delta. + const latestExchangeId = chatHistory[chatHistory.length - 1]?.id; useEffect(() => { const updateSpacerHeight = () => { - if (!chatContainerRef.current || !latestMessageRef.current || chatHistory.length === 0) { - setSpacerHeight(0); + if (!chatContainerRef.current || !latestMessageRef.current) { + setSpacerHeight(prev => (prev === 0 ? prev : 0)); return; } const containerHeight = chatContainerRef.current.clientHeight; const messageHeight = latestMessageRef.current.offsetHeight; - setSpacerHeight(Math.max(0, containerHeight - 80 - messageHeight)); + const next = Math.max(0, containerHeight - 80 - messageHeight); + // Avoid a render if the value didn't actually change (very common when + // text streams: many ResizeObserver callbacks resolve to the same value). + setSpacerHeight(prev => (prev === next ? prev : next)); }; updateSpacerHeight(); + if (!latestExchangeId) return; const resizeObserver = new ResizeObserver(updateSpacerHeight); if (chatContainerRef.current) resizeObserver.observe(chatContainerRef.current); if (latestMessageRef.current) resizeObserver.observe(latestMessageRef.current); return () => resizeObserver.disconnect(); - }, [chatHistory]); + }, [latestExchangeId]); const handleCopy = useCallback(async (text, id) => { try { @@ -960,7 +1203,7 @@ export default function useChat({ initialConversationId = null, selectedModel, o genaiService?.resetConversation(); resetChatState(); inputRef.current?.clear(); - window.history.pushState(null, "", "/"); + window.history.pushState(null, "", withBase("/")); setTimeout(() => inputRef.current?.focus(), 100); }, [genaiService, resetChatState]); @@ -979,7 +1222,7 @@ export default function useChat({ initialConversationId = null, selectedModel, o if (genaiService?.getConversationId() === conversation.id) { genaiService.resetConversation(); resetChatState(); - window.history.pushState(null, "", "/"); + window.history.pushState(null, "", withBase("/")); } } catch (error) { console.error("Error deleting conversation:", error); @@ -990,6 +1233,11 @@ export default function useChat({ initialConversationId = null, selectedModel, o const handleConversationClick = useCallback(async (conversation) => { if (!genaiService) return; + // Abort any in-flight stream — otherwise it keeps writing into + // streamingStateRef and the events will land on the new conversation. + genaiService.abortCurrentRequest?.(); + setIsLoading(false); + loadedConversationRef.current = conversation.urlId || conversation.id; genaiService.setConversationId(conversation.id); resetChatState(); @@ -1000,7 +1248,6 @@ export default function useChat({ initialConversationId = null, selectedModel, o if (items?.length > 0) { const sortedItems = [...items].reverse(); - console.log('[History] Items:', sortedItems.map(i => `${i.type}(${i.role||'-'}) ann:${(i.content||[]).flatMap?.(c=>c.annotations||[]).length||0}`)); const exchanges = []; let currentExchange = null; @@ -1101,14 +1348,27 @@ export default function useChat({ initialConversationId = null, selectedModel, o // annotations from the first (rich). const existingHasText = currentExchange.responses.some(r => r.type === 'text'); + // Defensive parse: a corrupted widget (V1 or V2) in stored history would + // throw and abort loading the whole conversation. Fall back to plain text. + const safeParse = (content) => { + try { + return parseContentWithWidgets(content); + } catch (err) { + console.warn('[History] parseContentWithWidgets failed, falling back to plain text:', err.message); + const fallbackText = Array.isArray(content) + ? (content.find(c => c.type === 'output_text' || c.type === 'text')?.text || '') + : (typeof content === 'string' ? content : ''); + return fallbackText ? [{ type: 'text', content: fallbackText, isStreaming: false }] : []; + } + }; + if (existingHasText) { // Second assistant message — replace text with this one (plain) - const parsedMessages = parseContentWithWidgets(item.content); + const parsedMessages = safeParse(item.content); currentExchange.responses = currentExchange.responses.filter(r => r.type !== 'text'); // Merge annotations: from previous (rich) + from current const allCitations = [...(currentExchange._harvestedAnnotations || []), ...urlCitations]; - console.log(`[History] Dedup: replacing text, harvested=${(currentExchange._harvestedAnnotations||[]).length} + current=${urlCitations.length} = ${allCitations.length} citations`); if (allCitations.length > 0) { const lastText = [...parsedMessages].reverse().find(m => m.type === 'text'); if (lastText) lastText.sources = allCitations; @@ -1125,8 +1385,7 @@ export default function useChat({ initialConversationId = null, selectedModel, o ? (item.content.find(c => c.type === 'output_text' || c.type === 'text')?.text || '') : (typeof item.content === 'string' ? item.content : ''); if (rawText) currentExchange.rawAssistantText = rawText; - const parsedMessages = parseContentWithWidgets(item.content); - console.log(`[History] First assistant: ${urlCitations.length} citations, text length=${(parsedMessages.find(m=>m.type==='text')?.content||'').length}`); + const parsedMessages = safeParse(item.content); if (urlCitations.length > 0) { currentExchange._harvestedAnnotations = urlCitations; const lastText = [...parsedMessages].reverse().find(m => m.type === 'text'); @@ -1162,7 +1421,6 @@ export default function useChat({ initialConversationId = null, selectedModel, o const toolType = item.type.replace(/_call$/, ''); const toolOutput = item.output || item.error || (item.action?.query) || ''; const toolStatus = item.status === 'incomplete' ? 'failed' : (item.status || 'completed'); - console.log(`[History] Tool call: ${item.name || toolType}, status=${item.status}, output=${(toolOutput || '').substring(0, 100)}, keys=${Object.keys(item).join(',')}`); currentExchange.responses.push({ type: "mcp_chip", server: item.server_label || toolType, @@ -1257,17 +1515,11 @@ export default function useChat({ initialConversationId = null, selectedModel, o console.warn('[History] file_search enrichment failed:', e.message); } - // Debug: verify sources are set - exchanges.forEach((ex, i) => { - const sourcesCount = groupMessages(ex.responses).filter(g => g.sources?.length > 0).length; - if (sourcesCount > 0) console.log(`[History] Exchange ${i}: ${sourcesCount} groups with sources`); - }); - setChatHistory(exchanges); } if (conversation.urlId && !window.location.pathname.includes(`/c/${conversation.urlId}`)) { - window.history.pushState(null, "", `/c/${conversation.urlId}`); + window.history.pushState(null, "", withBase(`/c/${conversation.urlId}`)); } } catch (error) { console.error("Error loading conversation:", error); @@ -1283,7 +1535,10 @@ export default function useChat({ initialConversationId = null, selectedModel, o const hasTexts = attachedTexts && attachedTexts.length > 0; if (!trimmedInput && !hasImages && !hasTexts) return; - if (!genaiService) return; + if (!genaiService) { + onError?.("Service is still initializing — please try again in a moment."); + return; + } setIsLoading(true); setActiveChips({}); @@ -1344,7 +1599,7 @@ export default function useChat({ initialConversationId = null, selectedModel, o markIncompleteMcpChipsAsFailed(); refreshRecentConversations(); } - }, [genaiService, createNewExchange, scrollToLatestMessage, initStreamingState, sendAndStreamMessage, markIncompleteMcpChipsAsFailed, refreshRecentConversations, updateLatestExchange]); + }, [genaiService, createNewExchange, scrollToLatestMessage, initStreamingState, sendAndStreamMessage, markIncompleteMcpChipsAsFailed, refreshRecentConversations, onError]); const handleWidgetSubmit = useCallback(async (data, widgetId) => { if (!genaiService) return; diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/layout.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/layout.js index 297f00576..8c2356823 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/layout.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/layout.js @@ -4,6 +4,7 @@ import { Roboto } from "next/font/google"; import localFont from "next/font/local"; import "./globals.css"; import theme from "./theme/theme"; +import BasePathInit from "./components/BasePathInit"; const roboto = Roboto({ weight: ["300", "400", "500", "700"], @@ -36,6 +37,7 @@ export default function RootLayout({ children }) { return ( + {children} diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/lib/mcp-oauth.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/lib/mcp-oauth.js index d00ec0f0f..084151bd5 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/lib/mcp-oauth.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/lib/mcp-oauth.js @@ -2,6 +2,15 @@ import { createHash, randomBytes } from 'crypto'; const SESSION_SECRET = process.env.SESSION_SECRET || 'change-me-in-production'; +// ── Base path (OCI Hosted Deployment) ──────────────────────────────────────── +// OCI strips the /.../actions/invoke prefix before requests reach us and injects +// it as APPLICATION_BASE_URL. Server-built browser-facing URLs (OAuth +// redirect_uri, post-login returnTo) must re-add it so the browser can route +// back through the gateway. Empty for dev / root (Container Instance) deploys. +export function basePrefix() { + return (process.env.APPLICATION_BASE_URL || process.env.BASE_PATH || '').replace(/\/+$/, ''); +} + // ── PKCE ──────────────────────────────────────────────────────────────────── export function generateCodeVerifier() { @@ -63,14 +72,52 @@ export const PENDING_COOKIE = 'mcp-oauth-pending'; * Some servers return endpoint URLs missing the MCP base path — we detect and fix that. */ export async function fetchOAuthMetadata(mcpEndpoint) { + // RFC 9728 (OAuth Protected Resource Metadata) — the modern MCP pattern used by + // e.g. the OCI DBTools/NL2SQL MCP server. The MCP endpoint exposes a + // protected-resource document at {origin}/.well-known/oauth-protected-resource{path} + // that points to a SEPARATE authorization server (e.g. an IAM Identity Domain), + // whose own metadata holds the authorize/token/registration endpoints. + const u = new URL(mcpEndpoint); + const resourcePath = u.pathname.replace(/\/$/, ''); + const prCandidates = [ + `${u.origin}/.well-known/oauth-protected-resource${resourcePath}`, // path-based (DBTools) + `${u.origin}/.well-known/oauth-protected-resource`, // origin-based + ]; + for (const prUrl of prCandidates) { + try { + const pr = await fetch(prUrl); + if (!pr.ok) continue; + const prMeta = await pr.json(); + const authServer = (prMeta.authorization_servers || [])[0]; + if (!authServer) continue; + const asMeta = await fetchAuthServerMetadata(authServer); + if (!asMeta) continue; + // The resource's own scopes_supported tells us exactly what to request. + return { ...asMeta, scopes_supported: prMeta.scopes_supported || asMeta.scopes_supported }; + } catch { /* try next candidate */ } + } + + // Fallback: some MCP servers serve the authorization-server metadata directly + // under the MCP endpoint (older one-level pattern). const base = mcpEndpoint.replace(/\/$/, ''); const res = await fetch(`${base}/.well-known/oauth-authorization-server`); if (!res.ok) return null; - const metadata = await res.json(); return fixMetadataUrls(metadata, mcpEndpoint); } +/** Fetch an authorization server's metadata (RFC 8414 or OIDC discovery). */ +async function fetchAuthServerMetadata(authServer) { + const root = authServer.replace(/\/$/, ''); + for (const path of ['/.well-known/oauth-authorization-server', '/.well-known/openid-configuration']) { + try { + const r = await fetch(`${root}${path}`); + if (r.ok) return await r.json(); + } catch { /* try next */ } + } + return null; +} + function fixMetadataUrls(metadata, mcpEndpoint) { const basePath = new URL(mcpEndpoint).pathname.replace(/\/$/, ''); if (!basePath) return metadata; diff --git a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/login/page.js b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/login/page.js index 1a5d53e7f..a9b6b9205 100644 --- a/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/login/page.js +++ b/ai/gen-ai-agents/oci-enterprise-ai-chat/files/src/app/login/page.js @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { Box, Button, Typography, Paper, Alert, CircularProgress } from '@mui/material'; import { FlaskConical } from 'lucide-react'; +import { withBase } from '@/lib/withBase'; export default function LoginPage() { const [error, setError] = useState(''); @@ -14,9 +15,9 @@ export default function LoginPage() { .then(res => res.json()) .then(data => { if (data.authenticated) { - window.location.href = '/'; + window.location.href = withBase('/'); } else if (!data.authEnabled) { - window.location.href = '/'; + window.location.href = withBase('/'); } else { setChecking(false); } @@ -74,7 +75,7 @@ export default function LoginPage() {