@adia-ai/a2ui-retrieval 0.6.6 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,250 +0,0 @@
1
- /**
2
- * Feedback Analyzer
3
- *
4
- * Reads JSONL feedback files, aggregates by intent category,
5
- * and surfaces weak intents, promotion candidates, and pattern gaps.
6
- *
7
- * Usage:
8
- * import { FeedbackAnalyzer } from './feedback-analyzer.js';
9
- * const analyzer = new FeedbackAnalyzer();
10
- * const entries = await analyzer.readRange(30);
11
- * const aggregated = analyzer.aggregateByIntent(entries);
12
- * const weak = analyzer.findWeakIntents(aggregated);
13
- */
14
-
15
- import { feedbackStore } from './feedback-store.js';
16
- import { categorizeIntent } from '../intent/intent-categorizer.js';
17
-
18
- // `process` is not in scope under "types": []
19
- declare const process: { versions?: { node?: string } } | undefined;
20
-
21
- // Lazy top-level import pattern for Node-only modules
22
- let fs: typeof import('node:fs/promises') | null = null;
23
- let path: typeof import('node:path') | null = null;
24
- const IS_NODE = typeof process !== 'undefined' && process.versions?.node;
25
- if (IS_NODE) {
26
- try {
27
- fs = await import(/* @vite-ignore */ 'node:fs/promises');
28
- path = await import(/* @vite-ignore */ 'node:path');
29
- } catch {
30
- // Node builtins unavailable
31
- }
32
- }
33
-
34
- // packages/a2ui/retrieval/feedback → up 3 → packages/a2ui → corpus/feedback
35
- const FEEDBACK_DIR: string | null = path
36
- ? path.join(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..', 'a2ui/corpus', 'feedback')
37
- : null;
38
-
39
- type FeedbackEntry = Record<string, unknown> & { type?: string; executionId?: string; intent?: string; patternMatch?: boolean; score?: number; rating?: number; _file?: string };
40
-
41
- type AggregatedBucket = {
42
- count: number;
43
- avgScore: number;
44
- avgRating: number;
45
- patternMatchRate: number;
46
- sampleIntents: string[];
47
- entries: FeedbackEntry[];
48
- };
49
-
50
- type InternalBucket = {
51
- count: number;
52
- totalScore: number;
53
- totalRating: number;
54
- ratingCount: number;
55
- patternMatchCount: number;
56
- entries: FeedbackEntry[];
57
- sampleIntents: string[];
58
- };
59
-
60
- export class FeedbackAnalyzer {
61
- /**
62
- * Read JSONL feedback files for the last N days.
63
- */
64
- async readRange(days = 30): Promise<FeedbackEntry[]> {
65
- if (!fs || !FEEDBACK_DIR) return [];
66
-
67
- const entries: FeedbackEntry[] = [];
68
- const now = new Date();
69
-
70
- // Build set of date strings we want
71
- const dateStrings = new Set<string>();
72
- for (let i = 0; i < days; i++) {
73
- const d = new Date(now);
74
- d.setDate(d.getDate() - i);
75
- dateStrings.add(d.toISOString().slice(0, 10));
76
- }
77
-
78
- try {
79
- const files = await fs.readdir(FEEDBACK_DIR);
80
- const jsonlFiles = files
81
- .filter((f: string) => f.endsWith('.jsonl'))
82
- .filter((f: string) => {
83
- const dateStr = f.replace('.jsonl', '');
84
- return dateStrings.has(dateStr);
85
- })
86
- .sort();
87
-
88
- for (const file of jsonlFiles) {
89
- try {
90
- const content = await fs.readFile(path!.join(FEEDBACK_DIR, file), 'utf8');
91
- const lines = content.trim().split('\n').filter(Boolean);
92
- for (const line of lines) {
93
- try {
94
- const entry = JSON.parse(line) as FeedbackEntry;
95
- entry['_file'] = file;
96
- entries.push(entry);
97
- } catch {
98
- // Skip malformed lines
99
- }
100
- }
101
- } catch {
102
- // Skip unreadable files
103
- }
104
- }
105
- } catch {
106
- // Feedback dir doesn't exist yet
107
- }
108
-
109
- return entries;
110
- }
111
-
112
- /**
113
- * Aggregate feedback entries by intent category.
114
- */
115
- aggregateByIntent(entries: FeedbackEntry[]): Map<string, AggregatedBucket> {
116
- const buckets = new Map<string, InternalBucket>();
117
-
118
- // First pass: group executions by category
119
- const executions = entries.filter(e => e.type === 'execution');
120
- const ratings = entries.filter(e => e.type === 'rating');
121
-
122
- // Index ratings by executionId for fast lookup
123
- const ratingsByExecId = new Map<string, FeedbackEntry[]>();
124
- for (const r of ratings) {
125
- if (!r.executionId) continue;
126
- if (!ratingsByExecId.has(r.executionId)) {
127
- ratingsByExecId.set(r.executionId, []);
128
- }
129
- ratingsByExecId.get(r.executionId)!.push(r);
130
- }
131
-
132
- for (const exec of executions) {
133
- const { category } = categorizeIntent(exec.intent ?? '');
134
-
135
- if (!buckets.has(category)) {
136
- buckets.set(category, {
137
- count: 0,
138
- totalScore: 0,
139
- totalRating: 0,
140
- ratingCount: 0,
141
- patternMatchCount: 0,
142
- entries: [],
143
- sampleIntents: [],
144
- });
145
- }
146
-
147
- const bucket = buckets.get(category)!;
148
- bucket.count++;
149
- bucket.totalScore += exec.score ?? 0;
150
- bucket.entries.push(exec);
151
-
152
- if (exec.patternMatch) {
153
- bucket.patternMatchCount++;
154
- }
155
-
156
- // Collect unique sample intents (up to 5)
157
- if (bucket.sampleIntents.length < 5 && exec.intent) {
158
- const intentLower = exec.intent.toLowerCase();
159
- if (!bucket.sampleIntents.some(s => s.toLowerCase() === intentLower)) {
160
- bucket.sampleIntents.push(exec.intent);
161
- }
162
- }
163
-
164
- // Attach ratings
165
- const execRatings = ratingsByExecId.get(exec.executionId ?? '') ?? [];
166
- for (const r of execRatings) {
167
- bucket.totalRating += (r.rating ?? 0);
168
- bucket.ratingCount++;
169
- }
170
- }
171
-
172
- // Compute averages
173
- const result = new Map<string, AggregatedBucket>();
174
- for (const [category, bucket] of buckets) {
175
- result.set(category, {
176
- count: bucket.count,
177
- avgScore: bucket.count > 0 ? Math.round(bucket.totalScore / bucket.count) : 0,
178
- avgRating: bucket.ratingCount > 0 ? Math.round((bucket.totalRating / bucket.ratingCount) * 10) / 10 : 0,
179
- patternMatchRate: bucket.count > 0 ? Math.round((bucket.patternMatchCount / bucket.count) * 100) : 0,
180
- sampleIntents: bucket.sampleIntents,
181
- entries: bucket.entries,
182
- });
183
- }
184
-
185
- return result;
186
- }
187
-
188
- /**
189
- * Find intent categories with weak performance.
190
- */
191
- findWeakIntents(aggregated: Map<string, AggregatedBucket>, threshold = 60): Array<{ category: string; count: number; avgScore: number; avgRating: number; sampleIntents: string[] }> {
192
- const weak = [];
193
- for (const [category, data] of aggregated) {
194
- if (data.avgScore < threshold) {
195
- weak.push({
196
- category,
197
- count: data.count,
198
- avgScore: data.avgScore,
199
- avgRating: data.avgRating,
200
- sampleIntents: data.sampleIntents,
201
- });
202
- }
203
- }
204
- return weak.sort((a, b) => a.avgScore - b.avgScore);
205
- }
206
-
207
- /**
208
- * Find intent categories ready for pattern promotion.
209
- * Criteria: avgScore >= 95, avgRating >= 4, count >= 3
210
- */
211
- findPromotionCandidates(aggregated: Map<string, AggregatedBucket>): Array<{ category: string; count: number; avgScore: number; avgRating: number; patternMatchRate: number; sampleIntents: string[] }> {
212
- const candidates = [];
213
- for (const [category, data] of aggregated) {
214
- if (data.avgScore >= 95 && data.avgRating >= 4 && data.count >= 3) {
215
- candidates.push({
216
- category,
217
- count: data.count,
218
- avgScore: data.avgScore,
219
- avgRating: data.avgRating,
220
- patternMatchRate: data.patternMatchRate,
221
- sampleIntents: data.sampleIntents,
222
- });
223
- }
224
- }
225
- return candidates.sort((a, b) => b.avgScore - a.avgScore);
226
- }
227
-
228
- /**
229
- * Find intent categories with no pattern match AND low scores — gaps in pattern coverage.
230
- */
231
- findPatternGaps(aggregated: Map<string, AggregatedBucket>): Array<{ category: string; count: number; avgScore: number; avgRating: number; patternMatchRate: number; sampleIntents: string[] }> {
232
- const gaps = [];
233
- for (const [category, data] of aggregated) {
234
- if (data.patternMatchRate === 0 && data.avgScore < 70) {
235
- gaps.push({
236
- category,
237
- count: data.count,
238
- avgScore: data.avgScore,
239
- avgRating: data.avgRating,
240
- patternMatchRate: data.patternMatchRate,
241
- sampleIntents: data.sampleIntents,
242
- });
243
- }
244
- }
245
- return gaps.sort((a, b) => a.avgScore - b.avgScore);
246
- }
247
- }
248
-
249
- // Export feedbackStore re-export so the module stays importable
250
- export { feedbackStore };
@@ -1,229 +0,0 @@
1
- /**
2
- * Persistent Feedback Store
3
- *
4
- * Writes execution metadata, ratings, LLM self-critique, and gap signals
5
- * to JSONL files on disk. One file per day. Browser-safe (no-ops if no fs).
6
- *
7
- * Usage:
8
- * import { feedbackStore } from './feedback-store.js';
9
- * feedbackStore.logExecution({ executionId, intent, model, domain, ... });
10
- * feedbackStore.logRating({ executionId, rating, ... });
11
- * feedbackStore.logGap({ type: 'pattern', description: '...' });
12
- * const recent = await feedbackStore.readRecent(50);
13
- */
14
-
15
- // `process` is not in scope under "types": []
16
- declare const process: { versions?: { node?: string } } | undefined;
17
-
18
- // Lazy top-level import pattern for Node-only modules
19
- let fs: typeof import('node:fs/promises') | null = null;
20
- let path: typeof import('node:path') | null = null;
21
- const IS_NODE = typeof process !== 'undefined' && process.versions?.node;
22
- if (IS_NODE) {
23
- try {
24
- fs = await import(/* @vite-ignore */ 'node:fs/promises');
25
- path = await import(/* @vite-ignore */ 'node:path');
26
- } catch {
27
- // Node builtins unavailable
28
- }
29
- }
30
-
31
- // packages/a2ui/retrieval/feedback → up 3 → packages/a2ui → corpus/feedback
32
- const FEEDBACK_DIR: string | null = path
33
- ? path.join(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..', 'a2ui/corpus', 'feedback')
34
- : null;
35
-
36
- function todayFile(): string | null {
37
- const d = new Date().toISOString().slice(0, 10);
38
- return path ? path.join(FEEDBACK_DIR!, `${d}.jsonl`) : null;
39
- }
40
-
41
- async function append(entry: unknown): Promise<void> {
42
- if (!fs || !FEEDBACK_DIR) return;
43
- try {
44
- await fs.mkdir(FEEDBACK_DIR, { recursive: true });
45
- await fs.appendFile(todayFile()!, JSON.stringify(entry) + '\n');
46
- } catch (e) {
47
- console.warn('FeedbackStore: write failed', (e as Error).message);
48
- }
49
- }
50
-
51
- type ExecutionEntry = {
52
- type: 'execution';
53
- timestamp: string;
54
- executionId: string;
55
- intent: string;
56
- model: string;
57
- domain: string;
58
- mode: string;
59
- patternMatch: boolean | undefined;
60
- patternConfidence: number | undefined;
61
- score: number | undefined;
62
- componentCount: number | undefined;
63
- tokenCount: number | undefined;
64
- meta: unknown;
65
- };
66
-
67
- type RatingEntry = {
68
- type: 'rating';
69
- timestamp: string;
70
- executionId: string;
71
- rating: number;
72
- intent: string;
73
- };
74
-
75
- type PatternSaveEntry = {
76
- type: 'pattern_save';
77
- timestamp: string;
78
- executionId: string;
79
- patternName: string;
80
- intent: string;
81
- };
82
-
83
- type GapEntry = {
84
- type: 'gap';
85
- gapType: string;
86
- timestamp: string;
87
- description: string | undefined;
88
- source: string | undefined;
89
- executionId: string | undefined;
90
- };
91
-
92
- type AnyEntry = ExecutionEntry | RatingEntry | PatternSaveEntry | GapEntry | Record<string, unknown>;
93
-
94
- export const feedbackStore = {
95
- /** Log a completed generation execution. */
96
- async logExecution({
97
- executionId, intent, model, domain, mode,
98
- patternMatch, patternConfidence,
99
- score, componentCount, tokenCount,
100
- meta,
101
- }: {
102
- executionId: string;
103
- intent: string;
104
- model: string;
105
- domain: string;
106
- mode: string;
107
- patternMatch?: boolean;
108
- patternConfidence?: number;
109
- score?: number;
110
- componentCount?: number;
111
- tokenCount?: number;
112
- meta?: unknown;
113
- messages?: unknown; // optional, not stored
114
- }): Promise<void> {
115
- await append({
116
- type: 'execution',
117
- timestamp: new Date().toISOString(),
118
- executionId, intent, model, domain, mode,
119
- patternMatch, patternConfidence,
120
- score, componentCount, tokenCount,
121
- meta: meta ?? null,
122
- });
123
- },
124
-
125
- /** Log a user rating (👍/👎). */
126
- async logRating({ executionId, rating, intent }: { executionId: string; rating: number; intent: string }): Promise<void> {
127
- await append({
128
- type: 'rating',
129
- timestamp: new Date().toISOString(),
130
- executionId, rating, intent,
131
- });
132
- },
133
-
134
- /** Log a pattern save action. */
135
- async logPatternSave({ executionId, patternName, intent }: { executionId: string; patternName: string; intent: string }): Promise<void> {
136
- await append({
137
- type: 'pattern_save',
138
- timestamp: new Date().toISOString(),
139
- executionId, patternName, intent,
140
- });
141
- },
142
-
143
- /** Log a training gap identified by LLM meta or heuristics. */
144
- async logGap({ type, description, source, executionId }: { type: string; description?: string; source?: string; executionId?: string }): Promise<void> {
145
- await append({
146
- type: 'gap',
147
- gapType: type, // 'pattern' | 'domain' | 'component' | 'prompt'
148
- timestamp: new Date().toISOString(),
149
- description, source, executionId,
150
- });
151
- },
152
-
153
- /** Read recent feedback entries (Node only). */
154
- async readRecent(limit = 100): Promise<AnyEntry[]> {
155
- if (!fs || !FEEDBACK_DIR) return [];
156
- try {
157
- const files = (await fs.readdir(FEEDBACK_DIR))
158
- .filter((f: string) => f.endsWith('.jsonl'))
159
- .sort()
160
- .reverse();
161
-
162
- const entries: AnyEntry[] = [];
163
- for (const file of files) {
164
- if (entries.length >= limit) break;
165
- const content = await fs.readFile(path!.join(FEEDBACK_DIR, file), 'utf8');
166
- const lines = content.trim().split('\n').filter(Boolean).reverse();
167
- for (const line of lines) {
168
- if (entries.length >= limit) break;
169
- try { entries.push(JSON.parse(line) as AnyEntry); } catch {}
170
- }
171
- }
172
- return entries;
173
- } catch { return []; }
174
- },
175
-
176
- /** Get gap summary — aggregate gap signals for training improvement. */
177
- async getGapSummary(): Promise<Record<string, string[]>> {
178
- const entries = await this.readRecent(500);
179
- const gaps = entries.filter(e => (e as Record<string, unknown>)['type'] === 'gap') as GapEntry[];
180
- const byType: Record<string, string[]> = {};
181
- for (const g of gaps) {
182
- if (!byType[g.gapType]) byType[g.gapType] = [];
183
- byType[g.gapType]!.push(g.description ?? '');
184
- }
185
- return byType;
186
- },
187
-
188
- /** Get quality metrics — aggregate from recent executions. */
189
- async getQualityMetrics(): Promise<{
190
- executions: number;
191
- avgScore: number;
192
- avgTokens: number;
193
- thumbUpRate: number;
194
- byDomain?: Record<string, { count: number; avgScore: number }>;
195
- gaps?: Record<string, string[]>;
196
- }> {
197
- const entries = await this.readRecent(500);
198
- const executions = entries.filter(e => (e as Record<string, unknown>)['type'] === 'execution') as ExecutionEntry[];
199
- const ratings = entries.filter(e => (e as Record<string, unknown>)['type'] === 'rating') as RatingEntry[];
200
-
201
- if (executions.length === 0) return { executions: 0, avgScore: 0, avgTokens: 0, thumbUpRate: 0 };
202
-
203
- const avgScore = executions.reduce((s, e) => s + (e.score ?? 0), 0) / executions.length;
204
- const avgTokens = executions.reduce((s, e) => s + (e.tokenCount ?? 0), 0) / executions.length;
205
- const thumbsUp = ratings.filter(r => r.rating >= 4).length;
206
- const thumbsDown = ratings.filter(r => r.rating < 4).length;
207
- const thumbUpRate = (thumbsUp + thumbsDown) > 0 ? thumbsUp / (thumbsUp + thumbsDown) : 0;
208
-
209
- // Per-domain breakdown
210
- const byDomain: Record<string, { count: number; totalScore: number }> = {};
211
- for (const e of executions) {
212
- const d = e.domain || 'unknown';
213
- if (!byDomain[d]) byDomain[d] = { count: 0, totalScore: 0 };
214
- byDomain[d]!.count++;
215
- byDomain[d]!.totalScore += e.score ?? 0;
216
- }
217
-
218
- return {
219
- executions: executions.length,
220
- avgScore: Math.round(avgScore),
221
- avgTokens: Math.round(avgTokens),
222
- thumbUpRate: Math.round(thumbUpRate * 100),
223
- byDomain: Object.fromEntries(
224
- Object.entries(byDomain).map(([d, v]) => [d, { count: v.count, avgScore: Math.round(v.totalScore / v.count) }])
225
- ),
226
- gaps: await this.getGapSummary(),
227
- };
228
- },
229
- };
@@ -1,201 +0,0 @@
1
- /**
2
- * FeedbackCollector — Structured feedback for the evolution engine.
3
- *
4
- * Captures per-generation feedback across multiple dimensions:
5
- * - Overall rating (1-5)
6
- * - Intent alignment, visual quality, component choice (1-5 each)
7
- * - Whether the user edited the output
8
- * - Pattern promotion signals ("this should become a pattern")
9
- *
10
- * Exports as a structured JSON log for the training cycle.
11
- */
12
-
13
- export type QualityDimensions = {
14
- structural: number;
15
- completeness: number;
16
- idiomatic: number;
17
- minimal: number;
18
- };
19
-
20
- export type ValidationCheck = { name: string; passed: boolean };
21
-
22
- export type GenerationData = {
23
- componentCount: number;
24
- componentTypes: string[];
25
- score: number;
26
- validationChecks: ValidationCheck[];
27
- qualityDimensions: QualityDimensions;
28
- };
29
-
30
- export type FeedbackData = {
31
- rating?: number;
32
- intentAlignment?: number;
33
- visualQuality?: number;
34
- componentChoice?: number;
35
- userEdited?: boolean;
36
- editSummary?: string;
37
- notes?: string;
38
- };
39
-
40
- export type PatternData = {
41
- patternUsed?: string;
42
- shouldBePattern?: boolean;
43
- suggestedName?: string;
44
- };
45
-
46
- export type FeedbackEntry = {
47
- executionId: string;
48
- intent: string;
49
- domain: string;
50
- mode: string;
51
- timestamp: number;
52
- generation: GenerationData;
53
- feedback: FeedbackData;
54
- patterns: PatternData;
55
- };
56
-
57
- type ComponentLike = {
58
- component?: string;
59
- [key: string]: unknown;
60
- };
61
-
62
- type ValidationResult = {
63
- score?: number;
64
- checks?: Array<{ name: string; passed: boolean }>;
65
- };
66
-
67
- export class FeedbackCollector {
68
- readonly #entries = new Map<string, FeedbackEntry>();
69
-
70
- /**
71
- * Initialize a feedback entry from generation results.
72
- * Called automatically after each generation completes.
73
- */
74
- initFromGeneration(executionId: string, { intent, domain, mode, messages, validation }: {
75
- intent?: string;
76
- domain?: string;
77
- mode?: string;
78
- messages?: Array<{ components?: ComponentLike[] }>;
79
- validation?: ValidationResult;
80
- }): void {
81
- const components: ComponentLike[] = messages?.[0]?.components ?? [];
82
- const checks = validation?.checks ?? [];
83
-
84
- // Compute quality dimensions
85
- const failedChecks = checks.filter(c => !c.passed);
86
- const structural = failedChecks.some(c =>
87
- ['hasRootComponent', 'noOrphanedChildren', 'flatAdjacency'].includes(c.name)
88
- ) ? 0.5 : 1;
89
- const completeness = Math.max(0, 1 - (
90
- failedChecks.filter(c => ['textContentSet', 'allTypesRegistered'].includes(c.name)).length * 0.1
91
- ));
92
- const idiomatic = failedChecks.some(c =>
93
- ['noBareDivs', 'noBareInputs', 'cardStructure'].includes(c.name)
94
- ) ? 0.5 : 1;
95
- const minimal = failedChecks.some(c =>
96
- ['noHardcodedColors', 'noInlineLayout'].includes(c.name)
97
- ) ? 0.5 : 1;
98
-
99
- this.#entries.set(executionId, {
100
- executionId,
101
- intent: intent ?? '',
102
- domain: domain ?? '',
103
- mode: mode ?? 'instant',
104
- timestamp: Date.now(),
105
- generation: {
106
- componentCount: components.length,
107
- componentTypes: [...new Set(components.map(c => c.component).filter((x): x is string => Boolean(x)))],
108
- score: validation?.score ?? 0,
109
- validationChecks: checks.map(c => ({ name: c.name, passed: c.passed })),
110
- qualityDimensions: { structural, completeness, idiomatic, minimal },
111
- },
112
- feedback: {},
113
- patterns: {},
114
- });
115
- }
116
-
117
- /**
118
- * Collect user feedback for an execution.
119
- */
120
- collectFeedback(executionId: string, feedback: FeedbackData): void {
121
- const entry = this.#entries.get(executionId);
122
- if (!entry) {
123
- // Create a minimal entry if init wasn't called
124
- this.#entries.set(executionId, {
125
- executionId,
126
- intent: '', domain: '', mode: '', timestamp: Date.now(),
127
- generation: { componentCount: 0, componentTypes: [], score: 0, validationChecks: [], qualityDimensions: { structural: 0, completeness: 0, idiomatic: 0, minimal: 0 } },
128
- feedback: {},
129
- patterns: {},
130
- });
131
- }
132
- const e = this.#entries.get(executionId)!;
133
- e.feedback = { ...e.feedback, ...feedback };
134
- }
135
-
136
- /**
137
- * Collect pattern-related feedback.
138
- */
139
- collectPatternFeedback(executionId: string, patternFeedback: PatternData): void {
140
- const entry = this.#entries.get(executionId);
141
- if (!entry) return;
142
- entry.patterns = { ...entry.patterns, ...patternFeedback };
143
- }
144
-
145
- /**
146
- * Get a single feedback entry.
147
- */
148
- get(executionId: string): FeedbackEntry | null {
149
- return this.#entries.get(executionId) ?? null;
150
- }
151
-
152
- /**
153
- * Get all feedback entries.
154
- */
155
- getAll(): FeedbackEntry[] {
156
- return [...this.#entries.values()];
157
- }
158
-
159
- /** Number of entries. */
160
- get size(): number {
161
- return this.#entries.size;
162
- }
163
-
164
- /**
165
- * Export all feedback as structured JSON.
166
- * In browser: triggers a file download.
167
- * In Node: returns the JSON string.
168
- */
169
- exportFeedback(): string {
170
- const data = {
171
- exportedAt: new Date().toISOString(),
172
- entryCount: this.#entries.size,
173
- entries: this.getAll(),
174
- };
175
- const json = JSON.stringify(data, null, 2);
176
-
177
- // Browser download
178
- if (typeof document !== 'undefined') {
179
- const date = new Date().toISOString().slice(0, 10);
180
- const blob = new Blob([json], { type: 'application/json' });
181
- const url = URL.createObjectURL(blob);
182
- const a = document.createElement('a');
183
- a.href = url;
184
- a.download = `gen-ui-feedback-${date}.json`;
185
- a.style.display = 'none';
186
- // Prevent SPA router from intercepting the blob URL click
187
- a.addEventListener('click', (e) => e.stopPropagation());
188
- document.body.appendChild(a);
189
- a.click();
190
- document.body.removeChild(a);
191
- URL.revokeObjectURL(url);
192
- }
193
-
194
- return json;
195
- }
196
-
197
- /** Clear all entries. */
198
- clear(): void {
199
- this.#entries.clear();
200
- }
201
- }