Skip to content

Commit e41a859

Browse files
committed
finish merge
1 parent e45ad72 commit e41a859

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+329
-9578
lines changed

ai/ai-react-app/src/components/Layout/RightSidebar.tsx

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
AVAILABLE_GENERATIVE_MODELS,
66
AVAILABLE_IMAGEN_MODELS,
77
defaultFunctionCallingTool,
8+
defaultGoogleSearchTool,
89
} from "../../services/firebaseAIService";
910
import {
1011
ModelParams,
@@ -158,6 +159,19 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
158159
nextState.tools = undefined;
159160
nextState.toolConfig = undefined; // Clear config when turning off
160161
}
162+
} else if (name === "google-search-toggle") {
163+
if (checked) {
164+
// Turn ON Google Search Grounding
165+
nextState.tools = [defaultGoogleSearchTool];
166+
167+
// Turn OFF JSON mode and Function Calling
168+
nextState.generationConfig.responseMimeType = undefined;
169+
nextState.generationConfig.responseSchema = undefined;
170+
nextState.toolConfig = undefined;
171+
} else {
172+
// Turn OFF Google Search Grounding
173+
nextState.tools = undefined;
174+
}
161175
}
162176
console.log("[RightSidebar] Updated generative params state:", nextState);
163177
return nextState;
@@ -219,6 +233,9 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
219233
generativeParams.toolConfig?.functionCallingConfig?.mode ===
220234
FunctionCallingMode.ANY) &&
221235
!!generativeParams.tools?.length;
236+
const isGroundingWithGoogleSearchActive = !!generativeParams.tools?.some(
237+
(tool) => "googleSearch" in tool,
238+
);
222239

223240
return (
224241
<div className={styles.rightSidebarContainer}>
@@ -360,15 +377,17 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
360377
name="structured-output-toggle"
361378
checked={isStructuredOutputActive}
362379
onChange={handleToggleChange}
363-
disabled={isFunctionCallingActive}
380+
disabled={
381+
isFunctionCallingActive || isGroundingWithGoogleSearchActive
382+
}
364383
/>
365384
<span
366-
className={`${styles.slider} ${isFunctionCallingActive ? styles.disabled : ""}`}
385+
className={`${styles.slider} ${isFunctionCallingActive || isGroundingWithGoogleSearchActive ? styles.disabled : ""}`}
367386
></span>
368387
</label>
369388
</div>
370389
<div
371-
className={`${styles.toggleGroup} ${isStructuredOutputActive ? styles.disabledText : ""}`}
390+
className={`${styles.toggleGroup} ${isStructuredOutputActive || isGroundingWithGoogleSearchActive ? styles.disabledText : ""}`}
372391
>
373392
<label htmlFor="function-call-toggle">Function calling</label>
374393
<label className={styles.switch}>
@@ -378,13 +397,44 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
378397
name="function-call-toggle"
379398
checked={isFunctionCallingActive}
380399
onChange={handleToggleChange}
381-
disabled={isStructuredOutputActive}
400+
disabled={
401+
isStructuredOutputActive ||
402+
isGroundingWithGoogleSearchActive
403+
}
382404
/>
383405
<span
384406
className={`${styles.slider} ${isStructuredOutputActive ? styles.disabled : ""}`}
385407
></span>
386408
</label>
387409
</div>
410+
<div
411+
className={`${styles.toggleGroup} ${
412+
isStructuredOutputActive || isFunctionCallingActive
413+
? styles.disabledText
414+
: ""
415+
}`}
416+
>
417+
<label htmlFor="google-search-toggle">
418+
Grounding with Google Search
419+
</label>
420+
<label className={styles.switch}>
421+
<input
422+
type="checkbox"
423+
id="google-search-toggle"
424+
name="google-search-toggle"
425+
checked={isGroundingWithGoogleSearchActive}
426+
onChange={handleToggleChange}
427+
disabled={isStructuredOutputActive || isFunctionCallingActive}
428+
/>
429+
<span
430+
className={`${styles.slider} ${
431+
isStructuredOutputActive || isFunctionCallingActive
432+
? styles.disabled
433+
: ""
434+
}`}
435+
></span>
436+
</label>
437+
</div>
388438
</div>
389439
</>
390440
)}

ai/ai-react-app/src/components/Specific/ChatMessage.module.css

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,71 @@
4747
white-space: pre-wrap;
4848
word-wrap: break-word;
4949
}
50+
51+
.sourcesSection {
52+
margin-top: 10px;
53+
padding-top: 8px;
54+
border-top: 1px solid var(--fb-gray-40);
55+
font-size: 0.8rem;
56+
}
57+
58+
.searchEntryPoint {
59+
margin-bottom: 8px;
60+
}
61+
.searchEntryPoint p {
62+
margin: 0 0 4px 0;
63+
}
64+
.searchEntryPoint a {
65+
color: var(--google-blue);
66+
text-decoration: none;
67+
}
68+
.searchEntryPoint a:hover {
69+
text-decoration: underline;
70+
}
71+
72+
.sourcesTitle {
73+
font-weight: 500;
74+
color: var(--fb-gray-70);
75+
margin: 0 0 4px 0;
76+
font-size: 0.8rem;
77+
}
78+
79+
.sourcesList {
80+
list-style: none;
81+
padding-left: 0;
82+
margin: 0;
83+
}
84+
85+
.sourceItem {
86+
margin-bottom: 4px;
87+
}
88+
89+
.sourceItem a {
90+
color: var(--google-blue);
91+
text-decoration: none;
92+
word-break: break-all;
93+
}
94+
95+
.sourceItem a:hover {
96+
text-decoration: underline;
97+
}
98+
99+
.highlightedSegment {
100+
background-color: rgba(
101+
var(--google-blue-rgb),
102+
0.15
103+
); /* Use RGB version of your blue for opacity */
104+
/* border-bottom: 1.5px dashed rgba(var(--google-blue-rgb), 0.6); */
105+
padding: 1px 0;
106+
border-radius: 2px;
107+
cursor: default; /* Or pointer if you add click interactivity */
108+
}
109+
110+
.sourceSuperscript {
111+
font-size: 0.7em;
112+
vertical-align: super;
113+
color: var(--google-blue); /* Or a color that stands out */
114+
margin-left: 2px;
115+
font-weight: bold;
116+
user-select: none; /* Makes it non-selectable */
117+
}

ai/ai-react-app/src/components/Specific/ChatMessage.tsx

Lines changed: 169 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
import React from "react";
2-
import { Content } from "firebase/ai";
2+
import {
3+
Content,
4+
GroundingChunk,
5+
GroundingMetadata,
6+
GroundingSupport,
7+
} from "firebase/ai";
38
import styles from "./ChatMessage.module.css";
49

510
interface ChatMessageProps {
11+
/** The message content object containing role and parts. */
612
message: Content;
13+
groundingMetadata?: GroundingMetadata | null;
14+
}
15+
16+
interface ProcessedSegment {
17+
startIndex: number;
18+
endIndex: number;
19+
chunkIndices: number[]; // 1-based for display
20+
originalSupportIndex: number; // To link back if needed
721
}
822

923
/**
@@ -23,38 +37,180 @@ const getMessageText = (message: Content): string => {
2337
.join("");
2438
};
2539

40+
const renderTextWithInlineHighlighting = (
41+
text: string,
42+
supports: GroundingSupport[],
43+
chunks: GroundingChunk[], // Pass chunks for tooltips/links
44+
): React.ReactNode[] => {
45+
if (!supports || supports.length === 0 || !text) {
46+
return [text];
47+
}
48+
49+
const segmentsToHighlight: ProcessedSegment[] = [];
50+
51+
supports.forEach((support, supportIndex) => {
52+
if (support.segment && support.groundingChunkIndices) {
53+
// Assuming partIndex is 0 for concatenated text.
54+
// If message.parts can have multiple text parts, this needs adjustment.
55+
const segment = support.segment;
56+
if (segment.partIndex === undefined || segment.partIndex === 0) {
57+
// check if partIndex is specified and is 0 or not specified
58+
segmentsToHighlight.push({
59+
startIndex: segment.startIndex,
60+
endIndex: segment.endIndex, // API's endIndex is typically exclusive
61+
chunkIndices: support.groundingChunkIndices.map((ci) => ci + 1), // 1-based
62+
originalSupportIndex: supportIndex,
63+
});
64+
}
65+
}
66+
});
67+
68+
if (segmentsToHighlight.length === 0) {
69+
return [text];
70+
}
71+
72+
// Sort segments by start index, then by end index (longer segments first if they start at same point)
73+
segmentsToHighlight.sort((a, b) => {
74+
if (a.startIndex !== b.startIndex) {
75+
return a.startIndex - b.startIndex;
76+
}
77+
return b.endIndex - a.endIndex; // Longer segments first
78+
});
79+
80+
const outputNodes: React.ReactNode[] = [];
81+
let lastIndexProcessed = 0;
82+
83+
segmentsToHighlight.forEach((seg, i) => {
84+
// Add un-highlighted text before this segment
85+
if (seg.startIndex > lastIndexProcessed) {
86+
outputNodes.push(text.substring(lastIndexProcessed, seg.startIndex));
87+
}
88+
89+
// Add the highlighted segment
90+
// Ensure we don't re-highlight an already covered portion if a shorter segment comes later
91+
const currentSegmentText = text.substring(seg.startIndex, seg.endIndex);
92+
const tooltipText = seg.chunkIndices
93+
.map((ci) => {
94+
const chunk = chunks[ci - 1]; // ci is 1-based
95+
return chunk.web?.title || chunk.web?.uri || `Source ${ci}`;
96+
})
97+
.join("; ");
98+
99+
outputNodes.push(
100+
<span
101+
key={`seg-${i}`}
102+
className={styles.highlightedSegment}
103+
title={`Sources: ${tooltipText}`} // Tooltip for hover
104+
data-source-indices={seg.chunkIndices.join(",")}
105+
>
106+
{currentSegmentText}
107+
<sup className={styles.sourceSuperscript}>
108+
[{seg.chunkIndices.join(",")}]
109+
</sup>
110+
</span>,
111+
);
112+
lastIndexProcessed = Math.max(lastIndexProcessed, seg.endIndex);
113+
});
114+
115+
// Add any remaining un-highlighted text
116+
if (lastIndexProcessed < text.length) {
117+
outputNodes.push(text.substring(lastIndexProcessed));
118+
}
119+
120+
return outputNodes;
121+
};
122+
26123
/**
27124
* Renders a single chat message bubble, styled based on the message role ('user' or 'model').
28125
* It only renders messages that should be visible in the log (user messages, or model messages
29126
* containing text). Function role messages or model messages consisting only of function calls
30127
* are typically not rendered directly as chat bubbles.
31128
*/
32-
const ChatMessage: React.FC<ChatMessageProps> = ({ message }) => {
129+
const ChatMessage: React.FC<ChatMessageProps> = ({
130+
message,
131+
groundingMetadata,
132+
}) => {
33133
const text = getMessageText(message);
34134
const isUser = message.role === "user";
35135
const isModel = message.role === "model";
36136

37-
// We render:
38-
// 1. User messages (even if they only contain images/files, the 'user' role indicates an entry).
39-
// 2. Model messages *only if* they contain actual text content.
40-
// We *don't* render:
41-
// 1. 'function' role messages (these represent execution results, not direct chat).
42-
// 2. 'model' role messages that *only* contain function calls (these are instructions, not display text).
43-
// 3. 'system' role messages (handled separately, not usually in chat history display).
44-
const shouldRender = isUser || (isModel && text.trim() !== "");
137+
const shouldRender =
138+
isUser ||
139+
(isModel && text.trim() !== "") ||
140+
(isModel &&
141+
groundingMetadata &&
142+
(groundingMetadata.groundingChunks?.length || 0 > 0));
45143

46144
if (!shouldRender) {
47145
return null;
48146
}
49147

148+
// Determine content to render
149+
let messageContentNodes: React.ReactNode[];
150+
if (
151+
isModel &&
152+
groundingMetadata?.groundingSupports &&
153+
groundingMetadata?.groundingChunks
154+
) {
155+
messageContentNodes = renderTextWithInlineHighlighting(
156+
text,
157+
groundingMetadata.groundingSupports,
158+
groundingMetadata.groundingChunks,
159+
);
160+
} else {
161+
messageContentNodes = [text];
162+
}
163+
50164
return (
51165
<div
52166
className={`${styles.messageContainer} ${isUser ? styles.user : styles.model}`}
53167
>
54168
<div className={styles.messageBubble}>
55-
{/* Use <pre> to preserve whitespace and newlines within the text content.
56-
Handles potential multi-line responses correctly. */}
57-
<pre className={styles.messageText}>{text}</pre>
169+
<pre className={styles.messageText}>
170+
{messageContentNodes.map((node, index) => (
171+
<React.Fragment key={index}>{node}</React.Fragment>
172+
))}
173+
</pre>
174+
{/* Source list rendering */}
175+
{isModel &&
176+
groundingMetadata &&
177+
(groundingMetadata.searchEntryPoint?.renderedContent ||
178+
(groundingMetadata.groundingChunks &&
179+
groundingMetadata.groundingChunks.length > 0) ? (
180+
<div className={styles.sourcesSection}>
181+
{groundingMetadata.searchEntryPoint?.renderedContent && (
182+
<div
183+
className={styles.searchEntryPoint}
184+
dangerouslySetInnerHTML={{
185+
__html: groundingMetadata.searchEntryPoint.renderedContent,
186+
}}
187+
/>
188+
)}
189+
{groundingMetadata.groundingChunks &&
190+
groundingMetadata.groundingChunks.length > 0 && (
191+
<>
192+
<h5 className={styles.sourcesTitle}>Sources:</h5>
193+
<ul className={styles.sourcesList}>
194+
{groundingMetadata.groundingChunks.map((chunk, index) => (
195+
<li
196+
key={index}
197+
className={styles.sourceItem}
198+
id={`source-ref-${index + 1}`}
199+
>
200+
<a
201+
href={chunk.web?.uri}
202+
target="_blank"
203+
rel="noopener noreferrer"
204+
>
205+
{`[${index + 1}] ${chunk.web?.title || chunk.web?.uri}`}
206+
</a>
207+
</li>
208+
))}
209+
</ul>
210+
</>
211+
)}
212+
</div>
213+
) : null)}
58214
</div>
59215
</div>
60216
);

0 commit comments

Comments
 (0)