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))

@@ -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 }}
- />
- )}
- }>
- Cancel
-
- {s.authType !== "oauth2.1" && (
- : }
- >
- Test Connection
-
- )}
- : }
- >
- {isEdit ? "Save" : "Add Tool"}
-
-
-
{/* 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.
+
+ }
+ onClick={authorizeNl2sql}
+ sx={{ textTransform: "none", fontSize: "0.72rem", py: 0.4, px: 1.2, backgroundColor: "#F59E0B", "&:hover": { backgroundColor: "#D97706" }, flexShrink: 0 }}
+ >
+ Authorize
+
+
+ ) : 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' && (
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ const returnTo = window.location.pathname + window.location.search;
+ window.location.href = MCPService.buildAuthorizeUrl(server, returnTo);
+ }}
+ sx={{
+ height: 26,
+ textTransform: "none",
+ borderColor: "rgba(46, 125, 50, 0.4)",
+ color: "#2e7d32",
+ fontSize: "0.72rem",
+ fontWeight: 500,
+ "&:hover": { borderColor: "#2e7d32", backgroundColor: "rgba(46, 125, 50, 0.06)" },
+ }}
+ >
+ Re-authorize
+
)}
{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 (
+
+ );
+}
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" }) => (