Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { Output } from "ai";
import { z } from "zod";
import { Ai } from "webiny/api";
import { Logger } from "webiny/api";
import { ResolveAiCapabilityUseCase } from "webiny/api/ai-powerups";
import { withAdditionalInstructions } from "webiny/api/ai-powerups";
import { IssueDrafter } from "webiny/api/bug-reporter";
import type { IBugReportPayload } from "webiny/api/bug-reporter";
import { BUG_REPORT_DRAFT_CAPABILITY } from "./capability.js";

/*
* Only the first few images go to the model. They are the expensive part of the request, and a
* report with more than a handful is attaching context rather than showing the failure.
*/
const MAX_IMAGES_SENT = 3;

/*
* Exported as the bare zod schema rather than a ready-made `Output.object(...)`: the AI SDK's
* `Output` type can't be named in emitted declarations (TS4023), so the call site wraps it.
*/
const issueDraftSchema = z.object({
title: z.string(),
summary: z.string(),
stepsToReproduce: z.array(z.string()),
expected: z.string(),
actual: z.string()
});

/* Capped because a provider error message can itself be a response body. */
const MAX_REASON_LENGTH = 200;

function describeFailure(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return message.slice(0, MAX_REASON_LENGTH);
}

interface IFilePart {
type: "file";
data: string;
mediaType: string;
}

interface ITextPart {
type: "text";
text: string;
}

function buildPrompt(payload: IBugReportPayload, timeline: string): string {
const said = payload.description.trim();
const lines: string[] = ["## What the reporter said"];

if (said === "") {
lines.push("_Nothing. They attached screenshots only — read them._");
} else {
lines.push(said);
}

lines.push(
"",
"## Where they were",
`Page: ${payload.environment.page}`,
`URL: ${payload.environment.url}`,
`Viewport: ${payload.environment.viewport}`,
`Browser: ${payload.environment.userAgent}`,
"",
"## What they did (most recent last)",
timeline
);

return lines.join("\n");
}

/*
* Screenshots go in as base64 file parts, the same way AI image enrichment sends images, and
* ahead of the text so the model has looked at them before it reads the prompt.
*/
function buildContent(payload: IBugReportPayload, prompt: string): Array<IFilePart | ITextPart> {
const content: Array<IFilePart | ITextPart> = [];

for (const screenshot of payload.screenshots.slice(0, MAX_IMAGES_SENT)) {
content.push({
type: "file",
data: screenshot.base64,
mediaType: screenshot.mediaType
});
}

content.push({ type: "text", text: prompt });

return content;
}

/*
* Turns a filed report into a searchable title plus steps to reproduce.
*
* A decorator rather than a replacement because the base drafter is the fallback, and it is used on
* two of the three paths through here: no model role configured, and the model call failing. Only
* a successful draft discards it. Losing a report because drafting broke would be the worst
* possible trade — the reporter has already spent the effort.
*/
class BugReportDrafterDecoratorImpl implements IssueDrafter.Interface {
constructor(
private resolveCapability: ResolveAiCapabilityUseCase.Interface,
private ai: Ai.Interface,
private logger: Logger.Interface,
private decoratee: IssueDrafter.Interface
) {}

async execute(payload: IBugReportPayload, timeline: string): Promise<IssueDrafter.Draft> {
const verbatim = await this.decoratee.execute(payload, timeline);

const resolved = await this.resolveCapability.execute(BUG_REPORT_DRAFT_CAPABILITY);
if (resolved.isFail()) {
return verbatim;
}

const capability = resolved.value;
const prompt = buildPrompt(payload, timeline);

const output = Output.object({ schema: issueDraftSchema });
const system = withAdditionalInstructions(capability);
const content = buildContent(payload, prompt);

try {
const result = await this.ai.generateText({
model: capability.model,
connection: capability.connection,
output,
system,
messages: [{ role: "user", content }]
});

return {
title: result.output.title,
summary: result.output.summary,
stepsToReproduce: result.output.stepsToReproduce,
expected: result.output.expected,
actual: result.output.actual
};
} catch (error) {
/*
* The message only, never the error object. An AI SDK error carries
* `requestBodyValues`, and the request body here is the reporter's description plus
* the whole timeline, so logging the object would copy the report into the API logs.
* It also carries response headers, and the logger configures no redaction.
*/
this.logger.error(
{ code: "BUG_REPORT_DRAFTING_FAILED", reason: describeFailure(error) },
"Bug report drafting failed; filing verbatim."
);
return verbatim;
}
}
}

export const BugReportDrafterDecorator = IssueDrafter.createDecorator({
decorator: BugReportDrafterDecoratorImpl,
dependencies: [ResolveAiCapabilityUseCase, Ai, Logger]
});
21 changes: 21 additions & 0 deletions extensions/bug-report-ai/6.6.x/extensions/bugReportAi/Extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createFeature } from "webiny/api";
import { BugReportDraftCapability } from "./capability.js";
import { BugReportDrafterDecorator } from "./BugReportDrafterDecorator.js";

/**
* AI drafting for the bug reporter.
*
* An extension rather than part of the bug reporter itself: the built-in reporter files the
* reporter's own words and knows nothing about AI, so it carries no `ai`, `zod` or AI Power-Ups
* dependency. This decorates it, and deleting the extension puts the reporter back as it was.
*
* Registering the capability also puts a row in the AI Power-Ups settings screen, so the model and
* any extra instructions are configurable per project like any built-in AI feature.
*/
export default createFeature({
name: "BugReporter/Ai",
register(container) {
container.register(BugReportDraftCapability);
container.registerDecorator(BugReportDrafterDecorator);
}
});
51 changes: 51 additions & 0 deletions extensions/bug-report-ai/6.6.x/extensions/bugReportAi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Bug report AI drafting

Turns a bug report filed from the Admin app into a searchable issue title, a summary and steps to
reproduce, instead of the reporter's raw words.

Webiny ships a bug reporter in every project: `cmd+shift+b` in the Admin app, describe what broke,
paste a screenshot, and a GitHub issue appears with the environment and a timeline of the last few
minutes attached. On its own it files what the reporter typed, verbatim. This extension decorates
that with a model call.

## Requirements

Webiny 6.6.0 or newer, and AI Power-Ups configured with at least one provider.

## What it does

`capability.ts` registers `bugReporter.draftIssue` as an AI capability, which gives it a row in the
AI Power-Ups settings screen. A project can point it at a different model or append its own
instructions there, the same as any built-in AI feature. It asks for the `standard` model role
rather than `vision`, even though it reads screenshots, because `vision` falls back to `standard`
when unset and a project that has only configured `standard` should still get drafting.

`BugReportDrafterDecorator.ts` decorates `IssueDrafter`. It sends the reporter's words, the first
three screenshots and the recorded timeline to the model, and asks for a title, a summary, steps to
reproduce, expected and actual.

## Why a decorator

The built-in drafter is the fallback, and it is used on two of the three paths through here: no
model role configured, and the model call failing. Only a successful draft discards it.

Losing a report because drafting broke would be the worst possible trade, since the reporter has
already spent the effort. Everything factual in the issue, the screenshots, the environment table
and the timeline, is assembled by the bug reporter regardless of whether a model ran, so a report
that falls back is still complete. It just reads as prose plus evidence rather than as a filled-in
form.

Deleting this extension puts the reporter back exactly as it was.

## Prompt

The instructions live in `capability.ts` and are fixed. A project appends to them through the AI
Power-Ups settings screen rather than replacing them, because the output contract is part of the
implementation: the decorator parses what comes back.

The prompt is told to work only from the evidence and never to invent a cause, a stack trace or a
step that is not in it, and to say so in the summary when the reporter was vague. The timeline is
named as the most reliable part of the input, since a failed GraphQL operation or a console error
in it usually is the bug. Reporters often paste a screenshot and type nothing, because the error
text is in the image, so it is also told to read the screenshots and quote error messages from them
exactly.
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { AiCapability } from "webiny/api/ai-powerups";

export const BUG_REPORT_DRAFT_CAPABILITY = "bugReporter.draftIssue";

/*
* Fixed instructions a project can append to, not replace. The output contract lives here too:
* the decorator parses what comes back, so the shape is implementation rather than settings.
*/
const guidance = [
"You turn a spoken or typed bug report from a Webiny team member into a GitHub issue.",
"You are given their words verbatim, any screenshots they attached, the page they were on,",
"and a timeline of what they did in the minutes before reporting. Work only from that",
"evidence.",
"",
"Write the way an engineer on the team writes: plain, specific, no filler. Never invent a",
"cause, a stack trace, or a step that is not in the evidence. If the reporter was vague and",
"nothing else fills the gap, say so in the summary rather than guessing.",
"The timeline is the most reliable part of the input — a failed GraphQL operation or a",
"console error in it usually IS the bug, so lead with it.",
"",
"Reporters often paste a screenshot and type nothing, because the error text is in the",
"image. Read the screenshots: quote error messages from them exactly, and treat what they",
"show as the reporter's description when there is no text.",
"",
"The title must be one line under 80 characters, specific enough to search for.",
"Leave stepsToReproduce empty when the evidence does not show them."
].join("\n");

class BugReportDraftCapabilityImpl implements AiCapability.Interface {
readonly id = BUG_REPORT_DRAFT_CAPABILITY;
readonly label = "Bug report drafting";
readonly description =
"Turns a reported bug into an issue title, summary and steps to reproduce.";
/*
* Reads screenshots, so `vision` would fit — but `vision` falls back to `standard` when unset,
* and a project that has only configured `standard` should still get drafting.
*/
readonly defaultRole = "standard" as const;
readonly guidance = guidance;
}

export const BugReportDraftCapability = AiCapability.createImplementation({
implementation: BugReportDraftCapabilityImpl,
dependencies: []
});
36 changes: 36 additions & 0 deletions extensions/bug-report-ai/extension.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"$schema": "../../schemas/extension.schema.json",
"name": "bug-report-ai",
"webinyConfigTsx": {
"imports": [
{
"specifier": "Api",
"path": "webiny/extensions"
}
],
"component": {
"name": "Api.Extension",
"props": {
"src": "/extensions/bugReportAi/Extension.ts"
}
}
},
"packageJson": {
"dependencies": {
"ai": "^7.0.73",
"zod": "4.4.3"
}
},
"additionalNotes": {
"messages": [
{
"text": "Configure a model for %s in AI Power-Ups settings. Without one, reports are still filed, just in the reporter's own words.",
"variables": ["Bug report drafting"]
},
{
"text": "Learn more about the extension in %s.",
"variables": ["extensions/bugReportAi/README.md"]
}
]
}
}