Skip to content

Commit a2b8f5a

Browse files
Merge pull request #1063 from Dhruvkumar1-Microsoft/psl-WafImageIssue
fix: Resolve the fix for intermittent issue image not showing in final answer and also fixed the Image is not getting rendered in WAF deployment
2 parents d8be62b + 09d2206 commit a2b8f5a

8 files changed

Lines changed: 69 additions & 51 deletions

File tree

src/App/src/api/config.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,28 @@ export function getApiUrl() {
8585

8686
return API_URL;
8787
}
88+
89+
export function resolveApiAssetUrl(url: string): string {
90+
if (!url) {
91+
return url;
92+
}
93+
const marker = "/api/v4/images/";
94+
const idx = url.indexOf(marker);
95+
if (idx === -1) {
96+
return url;
97+
}
98+
const path = url.slice(idx); // drop any scheme+host before the image path
99+
const apiUrl = getApiUrl();
100+
if (apiUrl && /^https?:\/\//i.test(apiUrl)) {
101+
try {
102+
return new URL(apiUrl).origin + path;
103+
} catch {
104+
return path;
105+
}
106+
}
107+
return path;
108+
}
109+
88110
export function getUserInfoGlobal() {
89111
if (!USER_INFO) {
90112
// Check if window.userInfo exists

src/App/src/commonComponents/modules/Chat.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ToolbarDivider,
1010
} from "@fluentui/react-components";
1111
import { Copy, Send } from "../imports/bundleicons";
12+
import { resolveApiAssetUrl } from "@/api/config";
1213
import { ChatDismiss20Regular, HeartRegular } from "@fluentui/react-icons";
1314
import ChatInput from "./ChatInput";
1415
import "./Chat.css";
@@ -182,7 +183,7 @@ const Chat: React.FC<ChatProps> = ({
182183
<div key={index} className={`message ${msg.role}`}>
183184
<Body1>
184185
<div style={{ display: "flex", flexDirection: "column", whiteSpace: "pre-wrap", width: "100%" }}>
185-
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypePrism]}>
186+
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypePrism]} urlTransform={resolveApiAssetUrl}>
186187
{msg.content}
187188
</ReactMarkdown>
188189
{msg.role === "assistant" && (

src/App/src/components/content/streaming/StreamingAgentMessage.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { TaskService } from "@/store";
88
import { PersonRegular, ArrowDownloadRegular } from "@fluentui/react-icons";
99
import { getAgentIcon, getAgentDisplayName } from '@/utils/agentIconUtils';
1010
import { formatJsonInText } from '@/utils/jsonFormatter';
11+
import { resolveApiAssetUrl } from "@/api/config";
1112

1213
interface StreamingAgentMessageProps {
1314
agentMessages: AgentMessageData[];
@@ -205,7 +206,7 @@ const renderAgentMessages = (
205206
<ReactMarkdown
206207
remarkPlugins={[remarkGfm]}
207208
rehypePlugins={[rehypePrism]}
208-
urlTransform={(url: string) => url}
209+
urlTransform={resolveApiAssetUrl}
209210
components={{
210211
a: ({ node: _node, ...props }) => (
211212
<a

src/App/src/components/content/streaming/StreamingBufferMessage.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ReactMarkdown from "react-markdown";
77
import remarkGfm from "remark-gfm";
88
import rehypePrism from "rehype-prism";
99
import { formatJsonInText } from "@/utils/jsonFormatter";
10+
import { resolveApiAssetUrl } from "@/api/config";
1011

1112
interface StreamingBufferMessageProps {
1213
streamingMessageBuffer: string;
@@ -164,6 +165,7 @@ const StreamingBufferMessage: React.FC<StreamingBufferMessageProps> = ({
164165
<ReactMarkdown
165166
remarkPlugins={[remarkGfm]}
166167
rehypePlugins={[rehypePrism]}
168+
urlTransform={resolveApiAssetUrl}
167169
components={{
168170
a: ({ node, ...props }) => (
169171
<a
@@ -217,6 +219,7 @@ const StreamingBufferMessage: React.FC<StreamingBufferMessageProps> = ({
217219
<ReactMarkdown
218220
remarkPlugins={[remarkGfm]}
219221
rehypePlugins={[rehypePrism]}
222+
urlTransform={resolveApiAssetUrl}
220223
components={{
221224
a: ({ node, ...props }) => (
222225
<a

src/backend/agents/image_agent.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111

1212
import base64
1313
import logging
14-
import os
1514
import uuid
1615
from typing import Any, AsyncIterable, Awaitable
1716

@@ -178,12 +177,11 @@ async def _invoke_stream(
178177
blob_name = await _upload_image_to_blob(png_bytes, image_id)
179178

180179
if blob_name:
181-
# Build the image URL pointing at the backend proxy endpoint
182-
backend_base = (config.AZURE_AI_AGENT_ENDPOINT or "").rstrip("/")
183-
backend_origin = os.environ.get("BACKEND_URL", "").rstrip("/")
184-
if not backend_origin:
185-
backend_origin = backend_base
186-
image_src = f"{backend_origin}/api/v4/images/{blob_name}"
180+
# Relative backend proxy path. The browser resolves it against the
181+
# SPA's configured API origin, so it works in both the standard
182+
# deployment (public backend) and the WAF/private deployment (the
183+
# frontend's same-origin reverse proxy forwards to the internal backend).
184+
image_src = f"/api/v4/images/{blob_name}"
187185
image_content = f"![Generated Marketing Image]({image_src})"
188186
else:
189187
# Fallback: embed base64 directly

src/backend/orchestration/orchestration_manager.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,18 @@
3535
apply_tool_history_leak_patch()
3636

3737
_BARE_IMAGE_URL_RE = re.compile(
38-
r"(?<![\(\]])"
39-
r"(?<!\]\()"
40-
r"(https?://[^\s)]+?"
41-
r"(?:/api/v4/images/[^\s)]+?|[^\s)]+?\.(?:png|jpe?g|gif|webp)))"
42-
r"(?=[\s)\]]|$)",
43-
re.IGNORECASE,
38+
r"(?<![\(\]])"
39+
r"(?<!\]\()"
40+
r"("
41+
# Absolute image URL (any host, or a backend /api/v4/images path)
42+
r"https?://[^\s)]+?(?:/api/v4/images/[^\s)]+?|[^\s)]+?\.(?:png|jpe?g|gif|webp))"
43+
# Bare relative backend image path (emitted by the MCP/backend image tools).
44+
# The (?<![^\s]) guard requires the path to start at whitespace/string-start so
45+
# it never matches the same substring inside an absolute URL.
46+
r"|(?<![^\s])/api/v4/images/[^\s)]+?\.(?:png|jpe?g|gif|webp)"
47+
r")"
48+
r"(?=[\s)\]]|$)",
49+
re.IGNORECASE,
4450
)
4551

4652

src/backend/orchestration/plan_review_helpers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ def get_magentic_prompt_kwargs(*, has_user_responses: bool = False) -> dict:
114114
- Compile ONLY from messages agents actually produced. Quote verbatim where appropriate.
115115
- Do NOT fabricate URLs, results, or content that no agent produced.
116116
- If a required agent step did not run, state it plainly — do not pretend it did.
117+
- If an agent produced an image (a markdown image ![alt](url) or an image URL such as one
118+
under /api/v4/images/), embed it in your answer using markdown image syntax
119+
![description](url). NEVER present an image as a bare URL or a plain link.
117120
- Do NOT offer further help. Provide the answer and end with a polite closing.
118121
"""
119122

src/mcp_server/services/image_service.py

Lines changed: 20 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,13 @@
99
import base64
1010
import logging
1111
import uuid
12-
from datetime import datetime, timedelta, timezone
12+
1313

1414
import httpx
1515
from azure.identity import DefaultAzureCredential, ManagedIdentityCredential, get_bearer_token_provider
1616
from azure.storage.blob import (
17-
BlobSasPermissions,
18-
BlobServiceClient,
19-
ContentSettings,
20-
generate_blob_sas,
17+
BlobServiceClient,
18+
ContentSettings,
2119
)
2220

2321
from config.settings import config
@@ -26,7 +24,7 @@
2624
logger = logging.getLogger(__name__)
2725

2826
_IMAGE_API_VERSION = "2025-04-01-preview"
29-
_SAS_VALIDITY_DAYS = 7
27+
3028

3129

3230
def _get_credential():
@@ -48,11 +46,14 @@ def _ensure_container(blob_service: BlobServiceClient, container_name: str) -> N
4846

4947

5048
def _upload_png_and_get_url(png_bytes: bytes) -> str:
51-
"""Upload PNG bytes to blob storage, return an accessible URL.
49+
"""Upload PNG bytes to blob storage, return a relative backend-proxy path.
50+
5251
53-
Prefers the backend image-proxy URL (BACKEND_URL/api/v4/images/{blob}) so
54-
the browser never needs direct blob access. Falls back to a user-delegation
55-
SAS URL, or bare blob URL as a last resort.
52+
Returns "/api/v4/images/{blob}" — a relative path the browser resolves
53+
against the SPA's configured API origin. The backend proxy reads the blob
54+
with its own credential, so the browser never needs direct blob access (no
55+
SAS or public blob access required), and it works in both the standard and
56+
WAF/private-networking deployments.
5657
"""
5758
if not config.azure_storage_blob_url:
5859
raise RuntimeError("AZURE_STORAGE_BLOB_URL is not configured on the MCP server")
@@ -72,33 +73,16 @@ def _upload_png_and_get_url(png_bytes: bytes) -> str:
7273
content_settings=ContentSettings(content_type="image/png"),
7374
)
7475

75-
# Prefer backend proxy URL — the browser fetches via the backend which has
76-
# its own credential for blob access; no SAS or public access needed.
77-
if config.backend_url:
78-
backend_origin = config.backend_url.rstrip("/")
79-
return f"{backend_origin}/api/v4/images/{blob_name}"
8076

81-
# Fallback: generate a user-delegation SAS URL for direct blob access.
82-
blob_url = f"{account_url}/{container_name}/{blob_name}"
83-
try:
84-
now = datetime.now(timezone.utc)
85-
delegation_key = blob_service.get_user_delegation_key(
86-
key_start_time=now - timedelta(minutes=5),
87-
key_expiry_time=now + timedelta(days=_SAS_VALIDITY_DAYS),
88-
)
89-
sas = generate_blob_sas(
90-
account_name=blob_service.account_name,
91-
container_name=container_name,
92-
blob_name=blob_name,
93-
user_delegation_key=delegation_key,
94-
permission=BlobSasPermissions(read=True),
95-
expiry=now + timedelta(days=_SAS_VALIDITY_DAYS),
96-
start=now - timedelta(minutes=5),
97-
)
98-
return f"{blob_url}?{sas}"
99-
except Exception as exc: # noqa: BLE001
100-
logger.warning("Failed to generate user-delegation SAS, returning bare URL: %s", exc)
101-
return blob_url
77+
# Return a relative backend proxy path. The browser resolves it against the
78+
# SPA's configured API origin, so it works in both the standard deployment
79+
# (resolves to the public backend) and the WAF/private deployment (resolves
80+
# to the frontend's same-origin reverse proxy, which forwards to the internal
81+
# backend over the VNet). The backend proxy reads the blob with its own
82+
# credential, so no SAS or public blob access is needed.
83+
return f"/api/v4/images/{blob_name}"
84+
85+
10286

10387

10488
class ImageService(MCPToolBase):

0 commit comments

Comments
 (0)