← back to Open Seo

src/client/features/ai-search/components/PromptExplorerResults.tsx

246 lines

import { useState } from "react";
import {
  AlertCircle,
  CheckCircle2,
  ExternalLink,
  Globe,
  XCircle,
} from "lucide-react";
import { MarkdownAnswer } from "@/client/features/ai-search/components/MarkdownAnswer";
import {
  formatModelLabel,
  getModelAccent,
} from "@/client/features/ai-search/platformLabels";
import { formatUrlForDisplay } from "@/client/components/table/url";
import type {
  PromptExplorerCitation,
  PromptExplorerModelResult,
  PromptExplorerResult,
} from "@/types/schemas/ai-search";

type Props = {
  result: PromptExplorerResult;
};

export function PromptExplorerResults({ result }: Props) {
  return (
    <div className="space-y-5">
      {result.results.map((modelResult) => (
        <ModelResultCard
          key={modelResult.model}
          modelResult={modelResult}
          highlightBrand={result.highlightBrand}
        />
      ))}
    </div>
  );
}

function ModelResultCard({
  modelResult,
  highlightBrand,
}: {
  modelResult: PromptExplorerModelResult;
  highlightBrand: string | null;
}) {
  const accent = getModelAccent(modelResult.model);

  if (modelResult.status === "error") {
    return (
      <article
        className={`overflow-hidden rounded-r-lg border border-base-300 border-l-4 ${accent.border} bg-base-100`}
      >
        <ModelHeader
          model={modelResult.model}
          modelName={null}
          tokens={null}
          webSearch={false}
          brandMentioned={null}
          highlightBrand={null}
          status="error"
        />
        <div className="flex items-start gap-2 px-5 py-4 text-sm text-error">
          <AlertCircle className="mt-0.5 size-4 shrink-0" />
          <span>{modelResult.message}</span>
        </div>
      </article>
    );
  }

  return (
    <article
      className={`overflow-hidden rounded-r-lg border border-base-300 border-l-4 ${accent.border} bg-base-100`}
    >
      <ModelHeader
        model={modelResult.model}
        modelName={modelResult.modelName}
        tokens={modelResult.outputTokens}
        webSearch={modelResult.webSearch}
        brandMentioned={modelResult.brandMentioned}
        highlightBrand={highlightBrand}
        status="success"
      />

      <div className="px-5 py-5">
        <MarkdownAnswer text={modelResult.text} />
      </div>

      {modelResult.citations.length > 0 ? (
        <CitationsList
          citations={modelResult.citations}
          highlightBrand={highlightBrand}
        />
      ) : null}

      {modelResult.fanOutQueries.length > 0 ? (
        <div className="border-t border-base-200 px-5 py-3">
          <p className="mb-2 text-xs font-medium uppercase tracking-wider text-base-content/50">
            Related queries the model considered
          </p>
          <div className="flex flex-wrap gap-1.5">
            {modelResult.fanOutQueries.map((query, index) => (
              <span
                key={`${query}-${index}`}
                className="rounded-full border border-base-300 px-2.5 py-0.5 text-xs text-base-content/70"
              >
                {query}
              </span>
            ))}
          </div>
        </div>
      ) : null}
    </article>
  );
}

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 (
    <div className="border-t border-base-200 bg-base-200/30 px-5 py-3">
      <p className="mb-2 text-xs font-medium uppercase tracking-wider text-base-content/50">
        Cited sources ({citations.length})
      </p>
      <ul className="space-y-1.5">
        {visible.map((citation, index) => (
          <li
            key={`${citation.url}-${index}`}
            className="flex items-start gap-2 text-sm"
          >
            <span className="mt-1 size-1 shrink-0 rounded-full bg-base-content/30" />
            <a
              href={citation.url}
              target="_blank"
              rel="noreferrer"
              className={`link inline-flex items-start gap-1 ${
                citation.matchedBrand ? "link-primary font-medium" : ""
              }`}
            >
              <span className="break-all">
                {citation.title || formatUrlForDisplay(citation.url)}
              </span>
              <ExternalLink className="mt-1 size-3 shrink-0" />
            </a>
            {citation.matchedBrand && highlightBrand ? (
              <span className="badge badge-primary badge-xs">
                {highlightBrand}
              </span>
            ) : null}
          </li>
        ))}
      </ul>
      {citations.length > 3 ? (
        <button
          type="button"
          onClick={() => setExpanded((current) => !current)}
          className="mt-1.5 text-xs text-base-content/50 hover:text-base-content"
        >
          {expanded ? "Show less" : `+${remaining} more`}
        </button>
      ) : null}
    </div>
  );
}

function ModelHeader({
  model,
  modelName,
  tokens,
  webSearch,
  brandMentioned,
  highlightBrand,
  status,
}: {
  model: PromptExplorerModelResult["model"];
  modelName: string | null;
  tokens: number | null;
  webSearch: boolean;
  brandMentioned: boolean | null;
  highlightBrand: string | null;
  status: "success" | "error";
}) {
  const accent = getModelAccent(model);
  return (
    <header className="flex flex-wrap items-center justify-between gap-2 border-b border-base-200 bg-base-200/40 px-5 py-3">
      <div className="flex flex-wrap items-center gap-2">
        <span className={`size-2 rounded-full ${accent.dot}`} />
        <h3 className="text-sm font-semibold">{formatModelLabel(model)}</h3>
        {modelName ? (
          <code className="text-xs text-base-content/50">{modelName}</code>
        ) : null}
        {status === "error" ? (
          <span className="badge badge-error badge-sm">Error</span>
        ) : null}
        <BrandMentionBadge
          mentioned={brandMentioned}
          highlightBrand={highlightBrand}
        />
        {webSearch ? (
          <span className="inline-flex items-center gap-1 text-xs text-base-content/60">
            <Globe className="size-3" />
            web search
          </span>
        ) : null}
      </div>
      {tokens != null ? (
        <span className="text-xs tabular-nums text-base-content/50">
          {tokens.toLocaleString()} tokens
        </span>
      ) : null}
    </header>
  );
}

function BrandMentionBadge({
  mentioned,
  highlightBrand,
}: {
  mentioned: boolean | null;
  highlightBrand: string | null;
}) {
  if (mentioned == null || !highlightBrand) return null;
  if (mentioned) {
    return (
      <span className="inline-flex items-center gap-1 rounded-full bg-success/15 px-2 py-0.5 text-xs font-medium text-success">
        <CheckCircle2 className="size-3" />
        {highlightBrand}
      </span>
    );
  }
  return (
    <span className="inline-flex items-center gap-1 rounded-full bg-base-200 px-2 py-0.5 text-xs text-base-content/60">
      <XCircle className="size-3" />
      no {highlightBrand}
    </span>
  );
}