Report citations for Agent call stacks (#5199)

This commit is contained in:
Timothy Carambat 2026-03-12 14:49:44 -07:00 committed by GitHub
parent 15a84d5121
commit c76576a9da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 220 additions and 14 deletions

View File

@ -1,4 +1,4 @@
import { Fragment } from "react"; import { Fragment, useState, useEffect } from "react";
import { decode as HTMLDecode } from "he"; import { decode as HTMLDecode } from "he";
import truncate from "truncate"; import truncate from "truncate";
import ModalWrapper from "@/components/ModalWrapper"; import ModalWrapper from "@/components/ModalWrapper";
@ -29,19 +29,51 @@ const CIRCLE_ICONS = {
}; };
/** /**
* Renders a circle with a source type icon inside. * Renders a circle with a source type icon inside, or a favicon if URL is provided.
* @param {"file"|"link"|"youtube"|"github"|"gitlab"|"confluence"|"drupalwiki"|"obsidian"|"paperlessNgx"} props.type * @param {"file"|"link"|"youtube"|"github"|"gitlab"|"confluence"|"drupalwiki"|"obsidian"|"paperlessNgx"} props.type
* @param {number} [props.size] - Circle diameter in px * @param {number} [props.size] - Circle diameter in px
* @param {number} [props.iconSize] - Icon size in px * @param {number} [props.iconSize] - Icon size in px
* @param {string} [props.url] - Optional URL to fetch favicon from
*/ */
export function SourceTypeCircle({ type = "file", size = 22, iconSize = 12 }) { export function SourceTypeCircle({
type = "file",
size = 22,
iconSize = 12,
url = null,
}) {
const Icon = CIRCLE_ICONS[type] || CIRCLE_ICONS.file; const Icon = CIRCLE_ICONS[type] || CIRCLE_ICONS.file;
const [imgError, setImgError] = useState(false);
let faviconUrl = null;
if (type === "link" && url) {
try {
const hostname = new URL(url).hostname;
faviconUrl = `https://www.google.com/s2/favicons?domain=${hostname}&sz=64`;
} catch {
faviconUrl = null;
}
}
useEffect(() => {
setImgError(false);
}, [url]);
return ( return (
<div <div
className="bg-white light:bg-slate-100 rounded-full flex items-center justify-center" className="bg-white light:bg-slate-100 rounded-full flex items-center justify-center overflow-hidden"
style={{ width: size, height: size }} style={{ width: size, height: size }}
> >
<Icon size={iconSize} weight="bold" className="text-black" /> {faviconUrl && !imgError ? (
<img
src={faviconUrl}
alt="favicon"
style={{ width: size, height: size }}
className="object-cover"
onError={() => setImgError(true)}
/>
) : (
<Icon size={iconSize} weight="bold" className="text-black" />
)}
</div> </div>
); );
} }
@ -107,7 +139,12 @@ export default function Citations({ sources = [] }) {
className="absolute top-0 size-[22px] rounded-full border-2 border-zinc-800 light:border-white" className="absolute top-0 size-[22px] rounded-full border-2 border-zinc-800 light:border-white"
style={{ left: `${idx * 17}px`, zIndex: 3 - idx }} style={{ left: `${idx * 17}px`, zIndex: 3 - idx }}
> >
<SourceTypeCircle type={info.icon} size={18} iconSize={10} /> <SourceTypeCircle
type={info.icon}
size={18}
iconSize={10}
url={info.href}
/>
</div> </div>
); );
})} })}

View File

@ -196,7 +196,8 @@ export default memo(
prevProps.message === nextProps.message && prevProps.message === nextProps.message &&
prevProps.isLastMessage === nextProps.isLastMessage && prevProps.isLastMessage === nextProps.isLastMessage &&
prevProps.chatId === nextProps.chatId && prevProps.chatId === nextProps.chatId &&
JSON.stringify(prevProps.metrics) === JSON.stringify(nextProps.metrics) JSON.stringify(prevProps.metrics) === JSON.stringify(nextProps.metrics) &&
JSON.stringify(prevProps.sources) === JSON.stringify(nextProps.sources)
); );
} }
); );

View File

@ -13,7 +13,12 @@ export default function SourceItem({ source, onClick }) {
className="flex flex-col gap-[2px] items-start w-full text-left hover:opacity-75 transition-opacity" className="flex flex-col gap-[2px] items-start w-full text-left hover:opacity-75 transition-opacity"
> >
<div className="flex gap-[6px] items-start w-full"> <div className="flex gap-[6px] items-start w-full">
<SourceTypeCircle type={info.icon} size={16} iconSize={10} /> <SourceTypeCircle
type={info.icon}
size={16}
iconSize={10}
url={info.href}
/>
<p className="flex-1 font-medium text-sm text-white light:text-slate-900 leading-[15px] truncate"> <p className="flex-1 font-medium text-sm text-white light:text-slate-900 leading-[15px] truncate">
{source.title} {source.title}
</p> </p>

View File

@ -138,6 +138,18 @@ export default function handleSocketResponse(socket, event, setChatHistory) {
); );
} }
if (type === "citations") {
if (!data.content.citations) return prev;
return prev.map((msg) =>
msg.uuid === uuid
? {
...msg,
sources: [...(msg.sources || []), ...data.content.citations],
}
: msg
);
}
if (type === "textResponseChunk") { if (type === "textResponseChunk") {
return prev return prev
.map((msg) => .map((msg) =>

View File

@ -37,6 +37,13 @@ class AIbitat {
channels = new Map(); channels = new Map();
functions = new Map(); functions = new Map();
/**
* Buffer for citations collected during tool execution.
* Citations are flushed to the frontend when the response is finalized.
* @type {Array<{id: string, title: string, text: string, chunkSource?: string, score?: number}>}
*/
_pendingCitations = [];
constructor(props = {}) { constructor(props = {}) {
const { const {
chats = [], chats = [],
@ -76,6 +83,41 @@ class AIbitat {
return this; return this;
} }
/**
* Add citation(s) to be reported when the response is finalized.
* Citations are buffered and flushed with the correct message UUID.
* @param {{id: string, title: string, text: string, chunkSource?: string, score?: number}|Array<{id: string, title: string, text: string, chunkSource?: string, score?: number}>} citations - Citation object or array of citation objects
*/
addCitation(citations) {
if (!citations) return;
if (Array.isArray(citations))
this._pendingCitations.push(...citations.filter(Boolean));
else if (typeof citations === "object")
this._pendingCitations.push(citations);
}
/**
* Flush all pending citations to the frontend with the given message UUID.
* Called automatically when the agent response is finalized.
* Note: Does not clear citations - they are cleared by chat-history plugin after persisting.
* @param {string} messageUuid - The UUID of the message to attach citations to
*/
flushCitations(messageUuid) {
if (!messageUuid || this._pendingCitations.length === 0) return;
this.socket?.send?.("reportStreamEvent", {
type: "citations",
uuid: messageUuid,
citations: this._pendingCitations,
});
}
/**
* Clear all pending citations. Called after citations have been persisted.
*/
clearCitations() {
this._pendingCitations = [];
}
/** /**
* Add a new agent to the AIbitat. * Add a new agent to the AIbitat.
* *
@ -671,11 +713,13 @@ ${this.getHistory({ to: route.to })
); );
const finalStream = await provider.stream(messages, [], eventHandler); const finalStream = await provider.stream(messages, [], eventHandler);
const finalUuid = finalStream?.uuid || v4();
eventHandler?.("reportStreamEvent", { eventHandler?.("reportStreamEvent", {
type: "usageMetrics", type: "usageMetrics",
uuid: finalStream?.uuid || v4(), uuid: finalUuid,
metrics: provider.getUsage(), metrics: provider.getUsage(),
}); });
this?.flushCitations?.(finalUuid);
const finalResponse = const finalResponse =
finalStream?.textResponse || finalStream?.textResponse ||
"I reached the maximum number of tool calls allowed for a single response. Here is what I have so far based on the tools I was able to run."; "I reached the maximum number of tool calls allowed for a single response. Here is what I have so far based on the tools I was able to run.";
@ -744,6 +788,7 @@ ${this.getHistory({ to: route.to })
uuid: directOutputUUID, uuid: directOutputUUID,
metrics: provider.getUsage(), metrics: provider.getUsage(),
}); });
this?.flushCitations?.(directOutputUUID);
return result; return result;
} }
@ -764,11 +809,13 @@ ${this.getHistory({ to: route.to })
); );
} }
const responseUuid = completionStream?.uuid || v4();
eventHandler?.("reportStreamEvent", { eventHandler?.("reportStreamEvent", {
type: "usageMetrics", type: "usageMetrics",
uuid: completionStream?.uuid || v4(), uuid: responseUuid,
metrics: provider.getUsage(), metrics: provider.getUsage(),
}); });
this?.flushCitations?.(responseUuid);
return completionStream?.textResponse; return completionStream?.textResponse;
} }
@ -817,6 +864,7 @@ ${this.getHistory({ to: route.to })
uuid: msgUUID, uuid: msgUUID,
metrics: provider.getUsage(), metrics: provider.getUsage(),
}); });
this?.flushCitations?.(msgUUID);
return ( return (
finalCompletion?.textResponse || finalCompletion?.textResponse ||
"I reached the maximum number of tool calls allowed for a single response. Here is what I have so far based on the tools I was able to run." "I reached the maximum number of tool calls allowed for a single response. Here is what I have so far based on the tools I was able to run."
@ -874,6 +922,7 @@ ${this.getHistory({ to: route.to })
uuid: msgUUID, uuid: msgUUID,
metrics: provider.getUsage(), metrics: provider.getUsage(),
}); });
this?.flushCitations?.(msgUUID);
return result; return result;
} }
@ -900,6 +949,7 @@ ${this.getHistory({ to: route.to })
uuid: msgUUID, uuid: msgUUID,
metrics: provider.getUsage(), metrics: provider.getUsage(),
}); });
this?.flushCitations?.(msgUUID);
return completion?.textResponse; return completion?.textResponse;
} }

View File

@ -44,18 +44,20 @@ const chatHistory = {
_store: async function (aibitat, { prompt, response } = {}) { _store: async function (aibitat, { prompt, response } = {}) {
const invocation = aibitat.handlerProps.invocation; const invocation = aibitat.handlerProps.invocation;
const metrics = aibitat.provider?.getUsage?.() ?? {}; const metrics = aibitat.provider?.getUsage?.() ?? {};
const citations = aibitat._pendingCitations ?? [];
await WorkspaceChats.new({ await WorkspaceChats.new({
workspaceId: Number(invocation.workspace_id), workspaceId: Number(invocation.workspace_id),
prompt, prompt,
response: { response: {
text: response, text: response,
sources: [], sources: citations,
type: "chat", type: "chat",
metrics, metrics,
}, },
user: { id: invocation?.user_id || null }, user: { id: invocation?.user_id || null },
threadId: invocation?.thread_id || null, threadId: invocation?.thread_id || null,
}); });
aibitat.clearCitations?.();
}, },
_storeSpecial: async function ( _storeSpecial: async function (
aibitat, aibitat,
@ -63,11 +65,13 @@ const chatHistory = {
) { ) {
const invocation = aibitat.handlerProps.invocation; const invocation = aibitat.handlerProps.invocation;
const metrics = aibitat.provider?.getUsage?.() ?? {}; const metrics = aibitat.provider?.getUsage?.() ?? {};
const citations = aibitat._pendingCitations ?? [];
const existingSources = options?.sources ?? [];
await WorkspaceChats.new({ await WorkspaceChats.new({
workspaceId: Number(invocation.workspace_id), workspaceId: Number(invocation.workspace_id),
prompt, prompt,
response: { response: {
sources: options?.sources ?? [], sources: [...existingSources, ...citations],
// when we have a _storeSpecial called the options param can include a storedResponse() function // when we have a _storeSpecial called the options param can include a storedResponse() function
// that will override the text property to store extra information in, depending on the special type of chat. // that will override the text property to store extra information in, depending on the special type of chat.
text: options.hasOwnProperty("storedResponse") text: options.hasOwnProperty("storedResponse")
@ -79,6 +83,7 @@ const chatHistory = {
user: { id: invocation?.user_id || null }, user: { id: invocation?.user_id || null },
threadId: invocation?.thread_id || null, threadId: invocation?.thread_id || null,
}); });
aibitat.clearCitations?.();
options?.postSave(); options?.postSave();
}, },
}; };

View File

@ -136,6 +136,15 @@ const docSummarizer = {
); );
} }
// Report citation for the document being summarized
this.super.addCitation?.({
id: docInfo.document_id,
title: document.title || filename,
text: document.content,
chunkSource: null,
score: null,
});
const { TokenManager } = require("../../../helpers/tiktoken"); const { TokenManager } = require("../../../helpers/tiktoken");
if ( if (
new TokenManager(this.super.model).countFromString( new TokenManager(this.super.model).countFromString(

View File

@ -111,6 +111,36 @@ const webBrowsing = {
return `${str.slice(0, length)}...${str.slice(-length)}`; return `${str.slice(0, length)}...${str.slice(-length)}`;
}, },
/**
* Report citations for an array of search results.
* Uses title, link, and snippet directly from result data.
* @param {Array<{title?: string, link?: string, snippet?: string}>} results - Search results to report as citations
*/
reportSearchResultsCitations: function (results) {
if (!Array.isArray(results)) return;
const citations = [];
for (const result of results) {
const fallbackUrl =
result.link ||
result.url ||
result.website ||
result.product_link ||
result.patent_link ||
result.link_clean;
citations.push({
id: result.link || fallbackUrl,
title: result.title || fallbackUrl,
text: result.snippet || result.description || result.text || "",
chunkSource: result.link
? `link://${result.link}`
: `link://${fallbackUrl}`,
score: null,
});
}
this.super.addCitation?.(citations);
},
/** /**
* Use SerpApi * Use SerpApi
* SerpApi supports dozens of search engines across the major platforms including Google, DuckDuckGo, Bing, eBay, Amazon, Baidu, Yandex, and more. * SerpApi supports dozens of search engines across the major platforms including Google, DuckDuckGo, Bing, eBay, Amazon, Baidu, Yandex, and more.
@ -362,6 +392,7 @@ const webBrowsing = {
if (data.length === 0) if (data.length === 0)
return `No information was found online for the search query.`; return `No information was found online for the search query.`;
this.reportSearchResultsCitations(data);
const result = JSON.stringify(data); const result = JSON.stringify(data);
this.super.introspect( this.super.introspect(
`${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)`
@ -436,6 +467,7 @@ const webBrowsing = {
if (data.length === 0) if (data.length === 0)
return `No information was found online for the search query.`; return `No information was found online for the search query.`;
this.reportSearchResultsCitations(data);
const result = JSON.stringify(data); const result = JSON.stringify(data);
this.super.introspect( this.super.introspect(
`${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)`
@ -504,6 +536,7 @@ const webBrowsing = {
if (data.length === 0) if (data.length === 0)
return `No information was found online for the search query.`; return `No information was found online for the search query.`;
this.reportSearchResultsCitations(data);
const result = JSON.stringify(data); const result = JSON.stringify(data);
this.super.introspect( this.super.introspect(
`${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)`
@ -559,6 +592,7 @@ const webBrowsing = {
if (searchResponse.length === 0) if (searchResponse.length === 0)
return `No information was found online for the search query.`; return `No information was found online for the search query.`;
this.reportSearchResultsCitations(searchResponse);
const result = JSON.stringify(searchResponse); const result = JSON.stringify(searchResponse);
this.super.introspect( this.super.introspect(
`${this.caller}: I found ${searchResponse.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` `${this.caller}: I found ${searchResponse.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)`
@ -643,6 +677,7 @@ const webBrowsing = {
if (data.length === 0) if (data.length === 0)
return `No information was found online for the search query.`; return `No information was found online for the search query.`;
this.reportSearchResultsCitations(data);
const result = JSON.stringify(data); const result = JSON.stringify(data);
this.super.introspect( this.super.introspect(
`${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)`
@ -715,6 +750,7 @@ const webBrowsing = {
if (data.length === 0) if (data.length === 0)
return `No information was found online for the search query.`; return `No information was found online for the search query.`;
this.reportSearchResultsCitations(data);
const result = JSON.stringify(data); const result = JSON.stringify(data);
this.super.introspect( this.super.introspect(
`${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)`
@ -778,6 +814,7 @@ const webBrowsing = {
if (data.length === 0) if (data.length === 0)
return `No information was found online for the search query.`; return `No information was found online for the search query.`;
this.reportSearchResultsCitations(data);
const result = JSON.stringify(data); const result = JSON.stringify(data);
this.super.introspect( this.super.introspect(
`${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)`
@ -785,6 +822,26 @@ const webBrowsing = {
return result; return result;
}, },
_duckDuckGoEngine: async function (query) { _duckDuckGoEngine: async function (query) {
/**
* Extract the actual destination URL from a DuckDuckGo redirect link.
* DDG links look like: //duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com&rut=...
* @param {string} ddgLink - The DuckDuckGo redirect link
* @returns {string} The actual destination URL
*/
function extractUrl(ddgLink) {
if (!ddgLink) return ddgLink;
try {
const fullUrl = ddgLink.startsWith("//")
? `https:${ddgLink}`
: ddgLink;
const url = new URL(fullUrl);
const actualUrl = url.searchParams.get("uddg");
return actualUrl ? decodeURIComponent(actualUrl) : ddgLink;
} catch {
return ddgLink;
}
}
this.super.introspect( this.super.introspect(
`${this.caller}: Using DuckDuckGo to search for "${ `${this.caller}: Using DuckDuckGo to search for "${
query.length > 100 ? `${query.slice(0, 100)}...` : query query.length > 100 ? `${query.slice(0, 100)}...` : query
@ -823,11 +880,11 @@ const webBrowsing = {
); );
const title = titleMatch ? titleMatch[1].trim() : ""; const title = titleMatch ? titleMatch[1].trim() : "";
// Extract URL // Extract URL and clean DDG redirect
const urlMatch = result.match( const urlMatch = result.match(
/<a[^>]*class="result__a"[^>]*href="([^"]*)">/ /<a[^>]*class="result__a"[^>]*href="([^"]*)">/
); );
const link = urlMatch ? urlMatch[1] : ""; const link = extractUrl(urlMatch ? urlMatch[1] : "");
// Extract snippet // Extract snippet
const snippetMatch = result.match( const snippetMatch = result.match(
@ -846,6 +903,7 @@ const webBrowsing = {
return `No information was found online for the search query.`; return `No information was found online for the search query.`;
} }
this.reportSearchResultsCitations(data);
const result = JSON.stringify(data); const result = JSON.stringify(data);
this.super.introspect( this.super.introspect(
`${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)`
@ -913,6 +971,7 @@ const webBrowsing = {
if (data.length === 0) if (data.length === 0)
return `No information was found online for the search query.`; return `No information was found online for the search query.`;
this.reportSearchResultsCitations(data);
const result = JSON.stringify(data); const result = JSON.stringify(data);
this.super.introspect( this.super.introspect(
`${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)`

View File

@ -55,6 +55,33 @@ const webScraping = {
} }
}, },
/**
* Report a URL citation to be displayed in the chat UI.
* @param {string} url - The URL that was accessed
* @param {string} content - The content retrieved from the URL
*/
reportUrlCitation: function (url, content) {
try {
const urlObj = new URL(url);
this.super.addCitation?.({
id: url,
title: urlObj.hostname + urlObj.pathname,
text: content,
chunkSource: `link://${url}`,
score: null,
});
} catch {
// URL parsing failed, still add citation without parsed title
this.super.addCitation?.({
id: url,
title: url,
text: content,
chunkSource: `link://${url}`,
score: null,
});
}
},
/** /**
* Scrape a website and summarize the content based on objective if the content is too large. * Scrape a website and summarize the content based on objective if the content is too large.
* Objective is the original objective & task that user give to the agent, url is the url of the website to be scraped. * Objective is the original objective & task that user give to the agent, url is the url of the website to be scraped.
@ -83,6 +110,7 @@ const webScraping = {
throw new Error("There was no content to be collected or read."); throw new Error("There was no content to be collected or read.");
} }
this.reportUrlCitation(url, content);
const { TokenManager } = require("../../../helpers/tiktoken"); const { TokenManager } = require("../../../helpers/tiktoken");
const tokenEstimate = new TokenManager( const tokenEstimate = new TokenManager(
this.super.model this.super.model