Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 83 additions & 17 deletions hlx_statics/blocks/ai-assistant/ai-assistant.css
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
--container-padding: 40px;
--button-size: 48px;
--opening-transition-duration: 0.25s;
--spectrum-fonts: "adobe-clean", "Source Sans Pro", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, "Trebuchet MS", "Lucida Grande", sans-serif;

display: flex;
flex-direction: column-reverse;
Expand Down Expand Up @@ -725,46 +726,111 @@

.chat-bubble-sources {
grid-area: sources;
width: auto;
width: 100%;
margin: 0;
margin-top: 16px;
padding: 0;

.chat-bubble-sources-heading {
margin: 0 0 4px 0;
font-size: 14px;
font-weight: 700;
line-height: 1.25;
color: #292929;
margin: 0 0 8px 0;

button {
font-size: 14px;
font-weight: 700;
line-height: 18px;
color: #292929;
background: transparent;
border: 0;
width: 100%;
display: flex;
align-items: center;
padding: 3px 8px;
border-radius: 8px;
font-family: var(--spectrum-fonts);

&:hover {
background-color: #e8e8e8;
}

.chat-bubble-sources-status-indicator {
margin-right: 8px;

&.rotated svg {
rotate: 90deg
}

svg {
transition-timing-function: cubic-bezier(.45,0,.4,1);
transition-duration: .15s;
}
}

.chat-bubble-sources-favicons {
margin-left: 12px;
display: flex;
gap: 4px;
align-items: center;

.chat-bubble-sources-favicon-images {
display: flex;
align-items: center;

img {
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid transparent;
background-color: white;
object-fit: cover;
margin-left: -4px;
}
}

span {
font-size: 11px;
font-weight: 400;
}
}
}
}

.chat-bubble-sources-list {
margin: 0;
padding: 0;
list-style-type: decimal;
list-style-position: inside;
font-size: 14px;
padding: 0 0 0 8px;
list-style-type: none;
}

.chat-bubble-sources-item {
display: list-item;
padding: 8px;
border-radius: 8px;
border: 1px solid #e8e8e8;
width: fit-content;
margin-bottom: 8px;
display: flex;
column-gap: 8px;

&:last-child {
margin-bottom: 0;
}

.chat-bubble-sources-item-marker {
background-color: #e1e1e1;
border-radius: 4px;
font-size: 11px;
line-height: 18px;
width: 16px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}

a {
color: #3b63fb;
font-weight: 700;
color: #292929;
text-decoration: underline;
font-size: 14px;
line-height: 18px;

&:hover {
color: #2952e0;
color: #131313;
}
}
}
Expand Down
65 changes: 60 additions & 5 deletions hlx_statics/blocks/ai-assistant/ai-assistant_chat-bubble.js
Original file line number Diff line number Diff line change
Expand Up @@ -424,24 +424,79 @@ export class ChatBubble {
const wrapper = createTag("div", {
class: "chat-bubble-sources",
});
const heading = createTag("p", { class: "chat-bubble-sources-heading" });
heading.textContent = "Sources:";
const heading = createTag("h6", { class: "chat-bubble-sources-heading" });
const button = createTag("button", {
"aria-expanded": false,
"aria-controls": `sources-region-${this.id}`,
id: `sources-button-${this.id}`,
});
const buttonIndicator = createTag("div", {
class: "chat-bubble-sources-status-indicator",
});
buttonIndicator.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path fill="#222" d="M7.965 5.178C7.978 5.118 8 5.061 8 5s-.021-.118-.034-.178c-.01-.05-.01-.102-.03-.15-.023-.058-.068-.107-.104-.16-.03-.042-.047-.09-.084-.127l-.004-.003-.003-.004L3.615.303a.875.875 0 1 0-1.23 1.244L5.88 5 2.385 8.453a.875.875 0 1 0 1.23 1.244L7.74 5.622l.003-.004.004-.003c.037-.038.055-.085.084-.127.036-.053.08-.102.104-.16.02-.048.02-.1.03-.15"></path>
</svg>`;
const buttonText = createTag("span", {});
buttonText.textContent = "Sources";
const faviconsContainer = createTag('div', {class: 'chat-bubble-sources-favicons', 'aria-hidden': true})
const faviconImages = createTag('div', { class: 'chat-bubble-sources-favicon-images' });
faviconsContainer.appendChild(faviconImages);
if (references.length > 3) {
const faviconsContainerText = createTag('span', {});
faviconsContainerText.innerText = `& ${references.length - 3} more`;
faviconsContainer.appendChild(faviconsContainerText);
}

button.appendChild(buttonIndicator);
button.appendChild(buttonText);
button.appendChild(faviconsContainer);
heading.appendChild(button);
wrapper.appendChild(heading);

const sourcesRegion = createTag("div", {
id: `sources-region-${this.id}`,
"aria-labelledby": `sources-button-${this.id}`,
role: "region",
hidden: true,
});
const list = createTag("ol", { class: "chat-bubble-sources-list" });
references.forEach(({ url, title }) => {
references.forEach(({ url, title }, idx) => {
const host = new URL(url).host;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unguarded new URL(url).host — if any reference URL is ever malformed or relative, this throws inside the forEach and aborts the rest of appendReferences (and everything chained after it in onComplete) with no try/catch.

const sourceFavicon = createTag('img', { src: `https://s2.googleusercontent.com/s2/favicons?domain=${host}&sz=16` });
if (idx < 3) {
faviconImages.appendChild(sourceFavicon);
}
const li = createTag("li", { class: "chat-bubble-sources-item" });
const marker = createTag("span", {
"aria-hidden": "true",
class: "chat-bubble-sources-item-marker",
});
marker.textContent = String(idx + 1);
const a = createTag("a", {
href: url,
target: "_blank",
rel: "noopener noreferrer",
});
a.textContent = title || url;
a.setAttribute("daa-ll", `DevsiteAI Assistant:Message:Sources:Link`);
a.setAttribute(
"daa-ll",
`DevsiteAI Assistant:Message:Sources:Link:${a.textContent}|${url}`,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

daa-ll analytics attribute now embeds raw title + URL. If a title contains either :/| of character (common in doc titles), it can corrupt how downstream analytics parses that field.

li.appendChild(marker);
li.appendChild(a);
list.appendChild(li);
});
wrapper.appendChild(list);
sourcesRegion.appendChild(list);
wrapper.appendChild(sourcesRegion);

button.addEventListener("click", () => {
const isExpanded = button.ariaExpanded === "true";
const newState = !isExpanded;
sourcesRegion.hidden = newState === false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this be written as !newState instead of newState === false

button.ariaExpanded = String(newState);
buttonIndicator.classList.toggle("rotated");
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick question on the sources rendering change — looks like we moved from showing sources as soon as a citation comes in, to only rendering them once onComplete fires. What happens if the stream errors out after onCitation has already set accumulatedReferences, but before onComplete gets a chance to run? Since onError doesn't call appendReferences, wouldn't the sources we already got from the backend just get dropped in that case? Previously they'd have shown up immediately, so this feels like it could be a regression on the error path — might be worth appending whatever we've accumulated in onError too, unless I'm missing something.

this.element.appendChild(wrapper);
}
}
10 changes: 5 additions & 5 deletions hlx_statics/blocks/ai-assistant/ai-assistant_chat-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -430,16 +430,13 @@ export const handleUserQuery = async (
})
.filter((r) => !!r);
if (references?.length) {
// Accumulate references but defer rendering until the response
// finishes streaming (see onComplete).
accumulatedReferences = references;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Named accumulatedReferences / commented "Accumulate," but this replaces. If onCitation fires more than once with disjoint payloads, only the last batch renders. Does it fire more than once per response?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the references event only fires once. I delayed when we show them because the browser limits how often you can update an element which lead to situations where you would get the sources section before the bubble with the response finished rendering all the content.

In the future I also intend to improve the response streaming/parsing part so we are not hitting the browser so hard which will also limit the amount of updates that the bubble receives.

I guess the comment is a bit confusing in this case so I'll update it.

targetBubble.appendReferences(references);
chatHistory.updateLast({
content: responseContent,
references,
});
if (!userScrolledUp && ELEMENTS.CHAT_WINDOW_CONTENT) {
ELEMENTS.CHAT_WINDOW_CONTENT.scrollTop =
ELEMENTS.CHAT_WINDOW_CONTENT.scrollHeight;
}
}
}
},
Expand All @@ -462,6 +459,9 @@ export const handleUserQuery = async (
return;
}
targetBubble.completeBubble();
if (accumulatedReferences?.length) {
targetBubble.appendReferences(accumulatedReferences);
}
// a11y: announce the completed reply once, as plain text.
announce(`${CHAT_BUBBLE_AI_LABEL}: ${targetBubble.getPlainText()}`);
chatHistory.updateLast({
Expand Down
Loading