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(" ")}` : ""}`;
}