diff --git a/src/client/features/ai-search/components/PromptExplorerResults.tsx b/src/client/features/ai-search/components/PromptExplorerResults.tsx
index 171769e..c796da4 100644
--- a/src/client/features/ai-search/components/PromptExplorerResults.tsx
+++ b/src/client/features/ai-search/components/PromptExplorerResults.tsx
@@ -1,3 +1,4 @@
+import { useState } from "react";
import {
AlertCircle,
CheckCircle2,
@@ -12,6 +13,7 @@ import {
} from "@/client/features/ai-search/platformLabels";
import { formatUrlForDisplay } from "@/client/components/table/url";
import type {
+ PromptExplorerCitation,
PromptExplorerModelResult,
PromptExplorerResult,
} from "@/types/schemas/ai-search";
@@ -84,39 +86,10 @@ function ModelResultCard({
{modelResult.citations.length > 0 ? (
-
-
- Cited sources ({modelResult.citations.length})
-
-
-
+
) : null}
{modelResult.fanOutQueries.length > 0 ? (
@@ -140,6 +113,64 @@ function ModelResultCard({
);
}
+function CitationsList({
+ citations,
+ highlightBrand,
+}: {
+ citations: PromptExplorerCitation[];
+ highlightBrand: string | null;
+}) {
+ const [expanded, setExpanded] = useState(false);
+
+ const visible = expanded ? citations : citations.slice(0, 3);
+ const remaining = citations.length - visible.length;
+
+ return (
+
+
+ Cited sources ({citations.length})
+
+
+ {citations.length > 3 ? (
+
+ ) : null}
+
+ );
+}
+
function ModelHeader({
model,
modelName,
diff --git a/src/server/features/ai-search/services/promptExplorer.test.ts b/src/server/features/ai-search/services/promptExplorer.test.ts
new file mode 100644
index 0000000..7d89122
--- /dev/null
+++ b/src/server/features/ai-search/services/promptExplorer.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it, vi } from "vitest";
+import type { LlmResponseResult } from "@/server/lib/dataforseoLlmSchemas";
+
+vi.mock("cloudflare:workers", () => ({ waitUntil: vi.fn() }));
+
+const { extractCitations } = await import("./promptExplorer");
+
+// DataForSEO's LLM Responses payload nests references as untyped
+// `{ title, url }` objects under items[].sections[].annotations — mirroring the
+// SDK's AnnotationInfo, which has no citation-type discriminator.
+function response(
+ annotations: Array<{ title?: string; url?: string }>,
+): LlmResponseResult {
+ return {
+ model_name: "gpt-5",
+ web_search: true,
+ items: [
+ {
+ type: "reasoning",
+ sections: [{ type: "summary_text", text: "thinking" }],
+ },
+ {
+ type: "message",
+ sections: [{ type: "text", text: "answer", annotations }],
+ },
+ ],
+ };
+}
+
+describe("extractCitations", () => {
+ it("keeps untyped annotations (no citation-type discriminator exists)", () => {
+ const citations = extractCitations(
+ response([
+ { title: "Town & Country", url: "https://www.townandcountrymag.com/x" },
+ { title: "Stylevana", url: "https://www.stylevana.com/y" },
+ ]),
+ );
+ expect(citations.map((c) => c.url)).toEqual([
+ "https://www.townandcountrymag.com/x",
+ "https://www.stylevana.com/y",
+ ]);
+ expect(citations[0]?.domain).toBe("townandcountrymag.com");
+ expect(citations[0]?.title).toBe("Town & Country");
+ });
+
+ it("dedupes repeated URLs and drops unsafe schemes", () => {
+ const citations = extractCitations(
+ response([
+ { title: "A", url: "https://example.com/a" },
+ { title: "A dup", url: "https://example.com/a" },
+ { title: "evil", url: "javascript:alert(1)" },
+ { title: "no url" },
+ ]),
+ );
+ expect(citations).toHaveLength(1);
+ expect(citations[0]?.url).toBe("https://example.com/a");
+ });
+
+ it("ignores annotations outside message items and returns [] when absent", () => {
+ expect(extractCitations({ items: [] })).toEqual([]);
+ expect(
+ extractCitations({
+ items: [
+ {
+ type: "reasoning",
+ sections: [
+ {
+ type: "summary_text",
+ text: "t",
+ annotations: [{ title: "x", url: "https://x.test/1" }],
+ },
+ ],
+ },
+ ],
+ }),
+ ).toEqual([]);
+ });
+});
diff --git a/src/server/features/ai-search/services/promptExplorer.ts b/src/server/features/ai-search/services/promptExplorer.ts
index 6312f9e..2151687 100644
--- a/src/server/features/ai-search/services/promptExplorer.ts
+++ b/src/server/features/ai-search/services/promptExplorer.ts
@@ -207,7 +207,7 @@ function extractText(response: LlmResponseResult): string {
return textParts.join("\n\n").trim();
}
-function extractCitations(
+export function extractCitations(
response: LlmResponseResult,
): PromptExplorerCitation[] {
const seen = new Set();
@@ -217,10 +217,10 @@ function extractCitations(
if (item.type !== "message") continue;
for (const section of item.sections ?? []) {
for (const annotation of section.annotations ?? []) {
- if (annotation.type !== "citation") continue;
- // Drop non-http(s) URLs — LLMs can be coaxed into emitting
- // `javascript:` payloads as "citations" and we render these as
- // in the UI.
+ // DataForSEO annotations are untyped `{ title, url }` reference
+ // objects (AnnotationInfo) — there is no citation-type discriminator
+ // to filter on. Guard on URL safety only: LLMs can be coaxed into
+ // emitting `javascript:` payloads, and we render these as .
const safeUrl = safeHttpUrl(annotation.url);
if (!safeUrl || seen.has(safeUrl)) continue;
seen.add(safeUrl);