Newer
Older
bugtrail / packages / web / src / lib / aiPrompt.ts
@Eugene Sukhodolskiy Eugene Sukhodolskiy 9 hours ago 5 KB AI prompt generator on the report page
import type { RecordingStep, ReportDetail } from "@ltt/shared";

/** vue-i18n translate function (named interpolation only, which is all we need). */
type Translate = (key: string, named?: Record<string, unknown>) => string;

export interface PromptLinks {
  /** public share URL of the report itself */
  reportUrl: string;
  /** public (token-scoped) download URL for an attachment file */
  fileUrl: (fileId: string) => string;
}

/**
 * Builds a ready-to-paste bug-fix prompt for an AI coding agent from a report:
 * the tester's comment, page URL, element context, environment, recorded steps
 * and attachment links. Section labels follow the panel language (i18n); the
 * developer edits the result in the dialog before handing it to the agent.
 */
export function buildAiPrompt(report: ReportDetail, t: Translate, links: PromptLinks): string {
  const lines: string[] = [];
  const add = (line = "") => lines.push(line);
  const field = (label: string, value: unknown) => {
    if (value === undefined || value === null || value === "") return;
    add(`- ${label}: ${value}`);
  };

  add(t("report.ai.intro"));
  add();

  add(`## ${t("report.ai.bugSection")}`);
  add(`- ${t("report.ai.title")}: ${report.title}`);
  add(`- ${t("report.status")}: ${t(`reports.status.${report.status}`)}`);
  if (report.description) add(`- ${t("report.description")}: ${report.description}`);
  add();

  if (report.page_url || report.page_title) {
    add(`## ${t("report.ai.pageSection")}`);
    field(t("report.ai.url"), report.page_url);
    field(t("report.ai.pageTitle"), report.page_title);
    add();
  }

  const element = report.element;
  if (element) {
    add(`## ${t("report.ai.elementSection")}`);
    field(t("report.ai.tag"), element.tag);
    field(t("report.ai.id"), element.id);
    field(t("report.ai.classes"), element.classes?.join(" "));
    field(t("report.ai.selector"), element.selector);
    // same as the plain selector — not worth a second line
    field(t("report.ai.uniqueSelector"), element.unique_selector !== element.selector ? element.unique_selector : null);
    field(t("report.ai.text"), element.text_snippet ? `"${element.text_snippet}"` : null);
    field(t("report.ai.ariaLabel"), element.aria_label);
    if (element.rect) {
      const r = element.rect;
      field(
        t("report.ai.rect"),
        `x=${Math.round(r.x)} y=${Math.round(r.y)}, ${Math.round(r.w)}×${Math.round(r.h)}px`
      );
    }
    if (element.test_attributes && Object.keys(element.test_attributes).length) {
      field(t("report.ai.testAttributes"), JSON.stringify(element.test_attributes));
    }
    add();
  }

  const environment = report.environment ?? {};
  if (Object.keys(environment).length) {
    add(`## ${t("report.ai.envSection")}`);
    field(t("report.ai.browser"), [environment.browser, environment.browser_version].filter(Boolean).join(" ") || null);
    field(t("report.ai.os"), environment.os);
    const viewport = environment.viewport as { w?: number; h?: number } | undefined;
    if (viewport?.w && viewport?.h) field(t("report.ai.viewport"), `${viewport.w}×${viewport.h}`);
    field(t("report.ai.dpr"), environment.dpr);
    field(t("report.ai.language"), environment.language);
    field(t("report.ai.userAgent"), environment.user_agent);
    add();
  }

  if (report.steps?.length) {
    add(`## ${t("report.ai.stepsSection")}`);
    report.steps.forEach((step, index) => add(`${index + 1}. ${stepLine(step, t)}`));
    add();
  }

  if (report.attachments.length) {
    add(`## ${t("report.ai.attachmentsSection")}`);
    // absolute URLs: the agent follows them from outside the panel, and the
    // token in the path is the authorization
    report.attachments.forEach((attachment) => {
      const label = attachment.mime.startsWith("video/")
        ? t("report.screenRecording")
        : attachment.mime.startsWith("image/")
          ? t("report.ai.screenshot")
          : attachment.filename;
      add(`- ${label}: ${new URL(links.fileUrl(attachment.file_id), window.location.origin)}`);
    });
    add();
  }

  add(`## ${t("report.ai.reportLink")}`);
  add(links.reportUrl);
  add();

  add(`## ${t("report.ai.taskSection")}`);
  add(t("report.ai.taskText"));
  return lines.join("\n");
}

/** One human-readable step line, e.g. `1.2s — Click: <button> "Send"`. */
function stepLine(step: RecordingStep, t: Translate): string {
  const data = step.data ?? {};
  const parts: string[] = [];
  const element = data.element as Record<string, unknown> | undefined;
  if (element && typeof element === "object") {
    const tag = element.tag as string | undefined;
    const text = element.text_snippet as string | undefined;
    if (tag) parts.push(`<${tag}>`);
    if (text) parts.push(`"${text}"`);
    const selector = element.unique_selector ?? element.selector;
    if (typeof selector === "string" && selector) parts.push(selector);
  }
  if (typeof data.value === "string" && data.value) parts.push(`"${data.value}"`);
  if (typeof data.value_length === "number") parts.push(`(${data.value_length} chars)`);
  if (typeof data.from_url === "string" && typeof data.to_url === "string") {
    parts.push(`${data.from_url} → ${data.to_url}`);
  } else if (typeof data.to_url === "string") {
    parts.push(data.to_url);
  }
  if (typeof data.text === "string" && data.text) parts.push(data.text);
  const label = t(`report.stepTypes.${step.type}`);
  const time = `${(step.offset_ms / 1000).toFixed(1)}s`;
  return `${time} — ${label}${parts.length ? `: ${parts.join(" ")}` : ""}`;
}