agent-orchestrator/frontend/src/renderer/lib/report-problem.ts

254 lines
8.1 KiB
TypeScript

import { aoBridge } from "./bridge";
import { routeSurface } from "./telemetry";
export type ReportProblemType = "bug" | "feature" | "feedback" | "question";
export type ReportProblemOutput = "github" | "discord" | "email";
export type ReportProblemInput = {
type: ReportProblemType;
summary: string;
details: string;
expected: string;
includeDiagnostics: boolean;
};
export type ReportProblemFieldCopy = {
summaryLabel: string;
summaryPlaceholder: string;
detailsLabel: string;
detailsPlaceholder: string;
expectedLabel: string;
expectedPlaceholder: string;
};
export type ReportProblemDiagnostics = {
appVersion: string;
buildMode: string;
daemonMessage?: string;
daemonState: string;
generatedAt: string;
platform: string;
routeSurface: string;
};
const REDACTED_LOCAL_PATH = "[redacted-local-path]";
const REDACTED_LOCAL_URL = "[redacted-local-url]";
const REDACTED_SECRET = "[redacted-secret]";
const DISCORD_INVITE_URL = "https://discord.com/invite/UZv7JjxbwG";
const GITHUB_NEW_ISSUE_URL = "https://github.com/AgentWrapper/agent-orchestrator/issues/new";
const REPORT_TYPE_LABELS: Record<ReportProblemType, string> = {
bug: "Bug report",
feature: "Feature request",
feedback: "Feedback",
question: "Setup question",
};
const REPORT_FIELD_COPY: Record<ReportProblemType, ReportProblemFieldCopy> = {
bug: {
summaryLabel: "Bug summary",
summaryPlaceholder: "Short description of the bug",
detailsLabel: "Steps to reproduce",
detailsPlaceholder: "1. Open...\n2. Click...\n3. See...",
expectedLabel: "Expected behavior",
expectedPlaceholder: "What should have happened instead?",
},
feature: {
summaryLabel: "Feature summary",
summaryPlaceholder: "Short description of the idea",
detailsLabel: "Problem / use case",
detailsPlaceholder: "What problem are you trying to solve?",
expectedLabel: "Requested behavior",
expectedPlaceholder: "What should AO do?",
},
feedback: {
summaryLabel: "Feedback summary",
summaryPlaceholder: "Short summary",
detailsLabel: "Message",
detailsPlaceholder: "What should the AO team know?",
expectedLabel: "Optional context",
expectedPlaceholder: "Anything else that would help?",
},
question: {
summaryLabel: "Question summary",
summaryPlaceholder: "Short description of the setup question",
detailsLabel: "What are you trying to do?",
detailsPlaceholder: "Describe the setup or workflow you are attempting.",
expectedLabel: "What did you try?",
expectedPlaceholder: "Commands, docs, or fixes you already tried.",
},
};
const LOCAL_URL_PATTERN =
/(?:\bfile:\/\/\/\S+|\bapp:\/\/renderer\/\S+|\bhttps?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?\S*)/gi;
const LOCAL_PATH_PATTERN = /(?:\/Users\/|\/home\/|\/tmp\/|\/private\/var\/|\/var\/folders\/)\S+|\b[A-Za-z]:\\[^\s)]+/g;
const QUERY_SECRET_PATTERN =
/([?&](?:api[_-]?key|token|secret|password|access[_-]?token|refresh[_-]?token|auth)=)[^&\s)]+/gi;
const ASSIGNMENT_SECRET_PATTERN =
/(\b[A-Z0-9_]*(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|ACCESS[_-]?TOKEN|REFRESH[_-]?TOKEN|AUTH)[A-Z0-9_]*\s*[:=]\s*)(["']?)[^\s"',)]+/gi;
const BEARER_SECRET_PATTERN = /\b(Bearer\s+)[A-Za-z0-9._~+/-]+/gi;
const OPENAI_KEY_PATTERN = /\bsk-[A-Za-z0-9_-]+/g;
export function sanitizeReportText(value: string): string {
if (!value) return "";
return value
.replace(LOCAL_URL_PATTERN, REDACTED_LOCAL_URL)
.replace(LOCAL_PATH_PATTERN, REDACTED_LOCAL_PATH)
.replace(QUERY_SECRET_PATTERN, `$1${REDACTED_SECRET}`)
.replace(ASSIGNMENT_SECRET_PATTERN, `$1$2${REDACTED_SECRET}`)
.replace(BEARER_SECRET_PATTERN, `$1${REDACTED_SECRET}`)
.replace(OPENAI_KEY_PATTERN, REDACTED_SECRET);
}
export async function collectReportProblemDiagnostics(now = new Date()): Promise<ReportProblemDiagnostics> {
const [versionResult, daemonResult] = await Promise.allSettled([
aoBridge.app.getVersion(),
aoBridge.daemon.getStatus(),
]);
const daemonStatus = daemonResult.status === "fulfilled" ? daemonResult.value : undefined;
return {
appVersion: versionResult.status === "fulfilled" ? versionResult.value : "unknown",
buildMode: import.meta.env.DEV ? "dev" : "packaged",
daemonMessage: daemonStatus?.message,
daemonState: daemonStatus?.state ?? "unknown",
generatedAt: now.toISOString(),
platform: typeof navigator === "undefined" ? "unknown" : navigator.platform || "unknown",
routeSurface: typeof window === "undefined" ? "unknown" : routeSurface(currentRoutePath()),
};
}
export function reportProblemFieldCopy(type: ReportProblemType): ReportProblemFieldCopy {
return REPORT_FIELD_COPY[type];
}
export function formatReportProblemDraft(
input: ReportProblemInput,
diagnostics: ReportProblemDiagnostics,
output: ReportProblemOutput,
): string {
const fields = normalizeInput(input);
const diagnosticsBlock = input.includeDiagnostics ? formatDiagnostics(diagnostics) : "No diagnostics included.";
if (output === "discord") {
return [
`**AO ${fields.typeLabel}**`,
`Summary: ${fields.summary}`,
`${fields.detailsLabel}: ${fields.details}`,
`${fields.expectedLabel}: ${fields.expected}`,
"",
"Safe diagnostics:",
diagnosticsBlock,
"",
exclusionNotice(),
].join("\n");
}
if (output === "email") {
return [
`Subject: AO feedback: ${fields.summary}`,
"",
"AO feedback",
"",
`Type: ${fields.typeLabel}`,
`Summary: ${fields.summary}`,
"",
`${fields.detailsLabel}:`,
fields.details,
"",
`${fields.expectedLabel}:`,
fields.expected,
"",
"Safe diagnostics:",
diagnosticsBlock,
"",
exclusionNotice(),
].join("\n");
}
return [
`# ${fields.summary === "Not provided" ? fields.typeLabel : fields.summary}`,
"",
"## Type",
fields.typeLabel,
"",
"## Summary",
fields.summary,
"",
`## ${fields.detailsLabel}`,
fields.details,
"",
`## ${fields.expectedLabel}`,
fields.expected,
"",
"## Safe diagnostics",
diagnosticsBlock,
"",
`_${exclusionNotice()}_`,
].join("\n");
}
export function reportProblemDestinationUrl(
input: ReportProblemInput,
diagnostics: ReportProblemDiagnostics,
output: ReportProblemOutput,
): string | null {
if (output === "discord") return DISCORD_INVITE_URL;
if (output === "email") return null;
const title = reportTitle(input);
const draft = formatReportProblemDraft(input, diagnostics, output);
const url = new URL(GITHUB_NEW_ISSUE_URL);
url.searchParams.set("title", title);
url.searchParams.set("body", draft);
return url.toString();
}
function normalizeInput(input: ReportProblemInput) {
const fieldCopy = REPORT_FIELD_COPY[input.type];
return {
typeLabel: REPORT_TYPE_LABELS[input.type],
detailsLabel: fieldCopy.detailsLabel,
expectedLabel: fieldCopy.expectedLabel,
summary: valueOrPlaceholder(input.summary),
details: valueOrPlaceholder(input.details),
expected: valueOrPlaceholder(input.expected),
};
}
function reportTitle(input: ReportProblemInput): string {
const summary = valueOrPlaceholder(input.summary);
return summary === "Not provided" ? REPORT_TYPE_LABELS[input.type] : summary;
}
function valueOrPlaceholder(value: string): string {
const safe = sanitizeReportText(value.trim());
return safe || "Not provided";
}
function formatDiagnostics(diagnostics: ReportProblemDiagnostics): string {
const lines = [
`AO version: ${sanitizeReportText(diagnostics.appVersion) || "unknown"}`,
`Build mode: ${sanitizeReportText(diagnostics.buildMode) || "unknown"}`,
`Platform: ${sanitizeReportText(diagnostics.platform) || "unknown"}`,
`Route surface: ${sanitizeReportText(diagnostics.routeSurface) || "unknown"}`,
`Daemon: ${sanitizeReportText(diagnostics.daemonState) || "unknown"}`,
`Generated: ${sanitizeReportText(diagnostics.generatedAt) || "unknown"}`,
];
if (diagnostics.daemonMessage?.trim()) {
lines.push(`Daemon message: ${sanitizeReportText(diagnostics.daemonMessage.trim())}`);
}
return lines.map((line) => `- ${line}`).join("\n");
}
function currentRoutePath(): string {
const hashPath = window.location.hash.replace(/^#/, "").split("?")[0];
if (hashPath?.startsWith("/")) return hashPath;
return window.location.pathname;
}
function exclusionNotice(): string {
return "Generated locally by AO. No logs, repo contents, prompts, terminal transcript, issue/PR bodies, raw crash dumps, env vars, or SQLite data are included.";
}