ai-progress-controls 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +823 -0
- package/dist/ai-progress-controls.es.js +7191 -0
- package/dist/ai-progress-controls.es.js.map +1 -0
- package/dist/ai-progress-controls.umd.js +2 -0
- package/dist/ai-progress-controls.umd.js.map +1 -0
- package/dist/index.d.ts +2212 -0
- package/package.json +105 -0
- package/src/__tests__/setup.ts +93 -0
- package/src/core/base/AIControl.ts +230 -0
- package/src/core/base/index.ts +3 -0
- package/src/core/base/types.ts +77 -0
- package/src/core/base/utils.ts +168 -0
- package/src/core/batch-progress/BatchProgress.test.ts +458 -0
- package/src/core/batch-progress/BatchProgress.ts +760 -0
- package/src/core/batch-progress/index.ts +14 -0
- package/src/core/batch-progress/styles.ts +480 -0
- package/src/core/batch-progress/types.ts +169 -0
- package/src/core/model-loader/ModelLoader.test.ts +311 -0
- package/src/core/model-loader/ModelLoader.ts +673 -0
- package/src/core/model-loader/index.ts +2 -0
- package/src/core/model-loader/styles.ts +496 -0
- package/src/core/model-loader/types.ts +127 -0
- package/src/core/parameter-panel/ParameterPanel.test.ts +856 -0
- package/src/core/parameter-panel/ParameterPanel.ts +877 -0
- package/src/core/parameter-panel/index.ts +14 -0
- package/src/core/parameter-panel/styles.ts +323 -0
- package/src/core/parameter-panel/types.ts +278 -0
- package/src/core/parameter-slider/ParameterSlider.test.ts +299 -0
- package/src/core/parameter-slider/ParameterSlider.ts +653 -0
- package/src/core/parameter-slider/index.ts +8 -0
- package/src/core/parameter-slider/styles.ts +493 -0
- package/src/core/parameter-slider/types.ts +107 -0
- package/src/core/queue-progress/QueueProgress.test.ts +344 -0
- package/src/core/queue-progress/QueueProgress.ts +563 -0
- package/src/core/queue-progress/index.ts +5 -0
- package/src/core/queue-progress/styles.ts +469 -0
- package/src/core/queue-progress/types.ts +130 -0
- package/src/core/retry-progress/RetryProgress.test.ts +397 -0
- package/src/core/retry-progress/RetryProgress.ts +957 -0
- package/src/core/retry-progress/index.ts +6 -0
- package/src/core/retry-progress/styles.ts +530 -0
- package/src/core/retry-progress/types.ts +176 -0
- package/src/core/stream-progress/StreamProgress.test.ts +531 -0
- package/src/core/stream-progress/StreamProgress.ts +517 -0
- package/src/core/stream-progress/index.ts +2 -0
- package/src/core/stream-progress/styles.ts +349 -0
- package/src/core/stream-progress/types.ts +82 -0
- package/src/index.ts +19 -0
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
import { AIControl } from '../base/AIControl';
|
|
2
|
+
import { ParameterSlider } from '../parameter-slider/ParameterSlider';
|
|
3
|
+
import type {
|
|
4
|
+
ParameterPanelConfig,
|
|
5
|
+
ParameterPanelState,
|
|
6
|
+
ParameterDefinition,
|
|
7
|
+
PanelChangeEvent,
|
|
8
|
+
PresetLoadEvent,
|
|
9
|
+
ConfigExportEvent,
|
|
10
|
+
ConfigImportEvent,
|
|
11
|
+
PanelResetEvent,
|
|
12
|
+
ValidationErrorEvent,
|
|
13
|
+
ExportedConfig,
|
|
14
|
+
} from './types';
|
|
15
|
+
import { styles } from './styles';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* ParameterPanel Component
|
|
19
|
+
*
|
|
20
|
+
* Manages multiple AI parameters as a coordinated group with presets, validation, and export/import.
|
|
21
|
+
* Automatically creates and manages child ParameterSlider instances.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```typescript
|
|
25
|
+
* // Create LLM configuration panel
|
|
26
|
+
* const panel = new ParameterPanel({
|
|
27
|
+
* title: 'Model Configuration',
|
|
28
|
+
* parameters: [
|
|
29
|
+
* { id: 'temperature', label: 'Temperature', min: 0, max: 2, value: 0.7, step: 0.1 },
|
|
30
|
+
* { id: 'topP', label: 'Top-P', min: 0, max: 1, value: 0.9, step: 0.05 },
|
|
31
|
+
* { id: 'maxTokens', label: 'Max Tokens', min: 100, max: 4000, value: 2000, step: 100 }
|
|
32
|
+
* ],
|
|
33
|
+
* presets: {
|
|
34
|
+
* chatgpt: {
|
|
35
|
+
* name: 'ChatGPT',
|
|
36
|
+
* description: 'Balanced configuration',
|
|
37
|
+
* values: { temperature: 0.7, topP: 0.9, maxTokens: 2000 }
|
|
38
|
+
* },
|
|
39
|
+
* code: {
|
|
40
|
+
* name: 'Code Generation',
|
|
41
|
+
* description: 'More focused for code',
|
|
42
|
+
* values: { temperature: 0.2, topP: 0.8, maxTokens: 1000 }
|
|
43
|
+
* }
|
|
44
|
+
* }
|
|
45
|
+
* });
|
|
46
|
+
*
|
|
47
|
+
* document.body.appendChild(panel);
|
|
48
|
+
*
|
|
49
|
+
* // Get all values
|
|
50
|
+
* const config = panel.getAllValues();
|
|
51
|
+
* // { temperature: 0.7, topP: 0.9, maxTokens: 2000 }
|
|
52
|
+
*
|
|
53
|
+
* // Load preset
|
|
54
|
+
* panel.loadPreset('code');
|
|
55
|
+
*
|
|
56
|
+
* // Listen to changes
|
|
57
|
+
* panel.addEventListener('panelchange', (e) => {
|
|
58
|
+
* console.log('Changed parameter:', e.detail.parameterId);
|
|
59
|
+
* console.log('All values:', e.detail.allValues);
|
|
60
|
+
* });
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* @fires panelchange - Fired when any parameter changes
|
|
64
|
+
* @fires presetload - Fired when preset is loaded
|
|
65
|
+
* @fires configexport - Fired when configuration is exported
|
|
66
|
+
* @fires configimport - Fired when configuration is imported
|
|
67
|
+
* @fires panelreset - Fired when panel is reset
|
|
68
|
+
* @fires validationerror - Fired when validation fails
|
|
69
|
+
*/
|
|
70
|
+
export class ParameterPanel extends AIControl {
|
|
71
|
+
private readonly state: ParameterPanelState;
|
|
72
|
+
private readonly parameters: Map<string, ParameterSlider> = new Map();
|
|
73
|
+
private readonly parameterDefinitions: Map<string, ParameterDefinition> = new Map();
|
|
74
|
+
private readonly presets: Map<
|
|
75
|
+
string,
|
|
76
|
+
{ name: string; description?: string; values: Record<string, number>; isBuiltIn: boolean }
|
|
77
|
+
>;
|
|
78
|
+
private readonly panelConfig: ParameterPanelConfig;
|
|
79
|
+
|
|
80
|
+
constructor(config: ParameterPanelConfig) {
|
|
81
|
+
super({ debug: config.debug, disabled: config.disabled });
|
|
82
|
+
|
|
83
|
+
this.panelConfig = config;
|
|
84
|
+
|
|
85
|
+
if (!config.parameters || config.parameters.length === 0) {
|
|
86
|
+
throw new Error('ParameterPanel requires at least one parameter');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Store parameter definitions
|
|
90
|
+
config.parameters.forEach((param) => {
|
|
91
|
+
this.parameterDefinitions.set(param.id, param);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Initialize presets
|
|
95
|
+
this.presets = new Map();
|
|
96
|
+
if (config.presets) {
|
|
97
|
+
Object.entries(config.presets).forEach(([id, preset]) => {
|
|
98
|
+
this.presets.set(id, { ...preset, isBuiltIn: preset.isBuiltIn ?? false });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Initialize state
|
|
103
|
+
const initialValues: Record<string, number> = {};
|
|
104
|
+
config.parameters.forEach((param) => {
|
|
105
|
+
initialValues[param.id] = param.value;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
this.state = {
|
|
109
|
+
values: initialValues,
|
|
110
|
+
activePreset: null,
|
|
111
|
+
isCollapsed: Boolean(this.panelConfig.collapsible && this.panelConfig.startCollapsed),
|
|
112
|
+
errors: {},
|
|
113
|
+
isDirty: false,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
this.attachShadow({ mode: 'open' });
|
|
117
|
+
|
|
118
|
+
// Load persisted values if enabled
|
|
119
|
+
if (this.panelConfig.persistValues) {
|
|
120
|
+
this.loadFromStorage();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Load persisted presets if enabled
|
|
124
|
+
if (this.panelConfig.persistPresets) {
|
|
125
|
+
this.loadPresetsFromStorage();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get all parameter values
|
|
131
|
+
*/
|
|
132
|
+
getAllValues(): Record<string, number> {
|
|
133
|
+
return { ...this.state.values };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get specific parameter value
|
|
138
|
+
*/
|
|
139
|
+
getValue(parameterId: string): number | undefined {
|
|
140
|
+
return this.state.values[parameterId];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Set specific parameter value
|
|
145
|
+
*/
|
|
146
|
+
setValue(
|
|
147
|
+
parameterId: string,
|
|
148
|
+
value: number,
|
|
149
|
+
source: 'slider' | 'input' | 'preset' | 'reset' | 'import' = 'slider'
|
|
150
|
+
): void {
|
|
151
|
+
const paramDef = this.parameterDefinitions.get(parameterId);
|
|
152
|
+
if (!paramDef) {
|
|
153
|
+
this.log(`Parameter ${parameterId} not found`, 'warn');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Clamp value to range
|
|
158
|
+
value = Math.max(paramDef.min, Math.min(paramDef.max, value));
|
|
159
|
+
|
|
160
|
+
// Validate if enabled (but not during reset/import)
|
|
161
|
+
if (
|
|
162
|
+
this.panelConfig.validateOnChange &&
|
|
163
|
+
paramDef.validate &&
|
|
164
|
+
source !== 'reset' &&
|
|
165
|
+
source !== 'import'
|
|
166
|
+
) {
|
|
167
|
+
const validationResult = paramDef.validate(value, this.state.values);
|
|
168
|
+
if (validationResult !== true) {
|
|
169
|
+
this.state.errors[parameterId] =
|
|
170
|
+
typeof validationResult === 'string' ? validationResult : 'Validation failed';
|
|
171
|
+
this.dispatchValidationError(parameterId, this.state.errors[parameterId]);
|
|
172
|
+
this.render();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Clear error for this parameter
|
|
178
|
+
delete this.state.errors[parameterId];
|
|
179
|
+
|
|
180
|
+
// Update value
|
|
181
|
+
const oldValue = this.state.values[parameterId];
|
|
182
|
+
this.state.values[parameterId] = value;
|
|
183
|
+
|
|
184
|
+
// Only mark dirty and clear preset for non-reset/import sources
|
|
185
|
+
if (source !== 'reset' && source !== 'import' && source !== 'preset') {
|
|
186
|
+
this.state.isDirty = true;
|
|
187
|
+
this.state.activePreset = null; // Clear active preset when manually changed
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Update child component if it exists
|
|
191
|
+
const slider = this.parameters.get(parameterId);
|
|
192
|
+
if (slider && oldValue !== value) {
|
|
193
|
+
// Map 'import' to 'reset' since ParameterSlider doesn't support 'import'
|
|
194
|
+
const sliderSource = source === 'import' ? 'reset' : source;
|
|
195
|
+
slider.setValue(value, sliderSource);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Persist if enabled
|
|
199
|
+
if (this.panelConfig.persistValues) {
|
|
200
|
+
this.saveToStorage();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Emit change event (only if called programmatically, not from child slider)
|
|
204
|
+
if (this.panelConfig.emitChangeEvents && oldValue !== value) {
|
|
205
|
+
this.dispatchPanelChange(parameterId, value, oldValue ?? paramDef.min, source);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Re-render to update UI
|
|
209
|
+
this.render();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Load preset values
|
|
214
|
+
*/
|
|
215
|
+
loadPreset(presetId: string): void {
|
|
216
|
+
const preset = this.presets.get(presetId);
|
|
217
|
+
if (!preset) {
|
|
218
|
+
this.log(`Preset ${presetId} not found`, 'warn');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const previousValues = { ...this.state.values };
|
|
223
|
+
|
|
224
|
+
// Apply all preset values
|
|
225
|
+
Object.entries(preset.values).forEach(([parameterId, value]) => {
|
|
226
|
+
if (this.parameterDefinitions.has(parameterId)) {
|
|
227
|
+
this.setValue(parameterId, value, 'preset');
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
this.state.activePreset = presetId;
|
|
232
|
+
this.state.isDirty = false;
|
|
233
|
+
|
|
234
|
+
// Emit preset load event
|
|
235
|
+
this.dispatchPresetLoad(presetId, preset, previousValues);
|
|
236
|
+
|
|
237
|
+
this.render();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Reset all parameters to default values
|
|
242
|
+
*/
|
|
243
|
+
resetAll(): void {
|
|
244
|
+
this.parameterDefinitions.forEach((paramDef, parameterId) => {
|
|
245
|
+
const defaultValue = paramDef.value;
|
|
246
|
+
this.setValue(parameterId, defaultValue, 'reset');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
this.state.activePreset = null;
|
|
250
|
+
this.state.isDirty = false;
|
|
251
|
+
this.state.errors = {};
|
|
252
|
+
|
|
253
|
+
// Emit reset event
|
|
254
|
+
this.dispatchPanelReset();
|
|
255
|
+
|
|
256
|
+
this.render();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Export configuration as JSON
|
|
261
|
+
*/
|
|
262
|
+
exportConfig(): ExportedConfig {
|
|
263
|
+
const parameters: Record<string, number> = {};
|
|
264
|
+
|
|
265
|
+
this.parameterDefinitions.forEach((_, parameterId) => {
|
|
266
|
+
parameters[parameterId] = this.state.values[parameterId] ?? 0;
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const config: ExportedConfig = {
|
|
270
|
+
version: '1.0',
|
|
271
|
+
parameters,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
if (this.state.activePreset) {
|
|
275
|
+
config.preset = this.state.activePreset;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
config.metadata = {
|
|
279
|
+
created: new Date().toISOString(),
|
|
280
|
+
name: (this.config as any).title || 'Parameters',
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// Emit export event
|
|
284
|
+
this.dispatchConfigExport(config);
|
|
285
|
+
|
|
286
|
+
return config;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Import configuration from JSON
|
|
291
|
+
*/
|
|
292
|
+
importConfig(config: ExportedConfig): void {
|
|
293
|
+
if (!config.parameters) {
|
|
294
|
+
this.log('Invalid config: missing parameters', 'warn');
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Apply imported values
|
|
299
|
+
Object.entries(config.parameters).forEach(([parameterId, value]) => {
|
|
300
|
+
if (this.parameterDefinitions.has(parameterId)) {
|
|
301
|
+
this.setValue(parameterId, value, 'import');
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Load active preset if specified
|
|
306
|
+
if (config.preset && this.presets.has(config.preset)) {
|
|
307
|
+
this.state.activePreset = config.preset;
|
|
308
|
+
} else {
|
|
309
|
+
this.state.activePreset = null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this.state.isDirty = false;
|
|
313
|
+
|
|
314
|
+
// Emit import event
|
|
315
|
+
this.dispatchConfigImport(config);
|
|
316
|
+
|
|
317
|
+
this.render();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Toggle collapsed state
|
|
322
|
+
*/
|
|
323
|
+
toggleCollapse(): void {
|
|
324
|
+
if (!this.panelConfig.collapsible) return;
|
|
325
|
+
|
|
326
|
+
this.state.isCollapsed = !this.state.isCollapsed;
|
|
327
|
+
this.render();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Validate all parameters
|
|
332
|
+
*/
|
|
333
|
+
validateAll(): boolean {
|
|
334
|
+
let isValid = true;
|
|
335
|
+
this.state.errors = {};
|
|
336
|
+
|
|
337
|
+
this.parameterDefinitions.forEach((paramDef, parameterId) => {
|
|
338
|
+
if (paramDef.validate) {
|
|
339
|
+
const value = this.state.values[parameterId] ?? paramDef.min;
|
|
340
|
+
const validationResult = paramDef.validate(value, this.state.values);
|
|
341
|
+
if (validationResult !== true) {
|
|
342
|
+
isValid = false;
|
|
343
|
+
this.state.errors[parameterId] =
|
|
344
|
+
typeof validationResult === 'string' ? validationResult : 'Validation failed';
|
|
345
|
+
this.dispatchValidationError(parameterId, this.state.errors[parameterId]);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
if (!isValid) {
|
|
351
|
+
this.render();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return isValid;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Add custom preset
|
|
359
|
+
*/
|
|
360
|
+
addPreset(id: string, name: string, values: Record<string, number>, description?: string): void {
|
|
361
|
+
this.presets.set(id, { name, description, values, isBuiltIn: false });
|
|
362
|
+
|
|
363
|
+
if (this.panelConfig.persistPresets) {
|
|
364
|
+
this.savePresetsToStorage();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
this.render();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Remove custom preset
|
|
372
|
+
*/
|
|
373
|
+
removePreset(id: string): void {
|
|
374
|
+
const preset = this.presets.get(id);
|
|
375
|
+
if (preset && !preset.isBuiltIn) {
|
|
376
|
+
this.presets.delete(id);
|
|
377
|
+
|
|
378
|
+
if (this.state.activePreset === id) {
|
|
379
|
+
this.state.activePreset = null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (this.panelConfig.persistPresets) {
|
|
383
|
+
this.savePresetsToStorage();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
this.render();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Event dispatchers
|
|
391
|
+
private dispatchPanelChange(
|
|
392
|
+
parameterId: string,
|
|
393
|
+
value: number,
|
|
394
|
+
oldValue: number,
|
|
395
|
+
source: 'slider' | 'input' | 'preset' | 'reset' | 'import'
|
|
396
|
+
): void {
|
|
397
|
+
const event = new CustomEvent<PanelChangeEvent>('panelchange', {
|
|
398
|
+
detail: {
|
|
399
|
+
parameterId,
|
|
400
|
+
value,
|
|
401
|
+
previousValue: oldValue,
|
|
402
|
+
allValues: this.getAllValues(),
|
|
403
|
+
source,
|
|
404
|
+
timestamp: Date.now(),
|
|
405
|
+
},
|
|
406
|
+
bubbles: true,
|
|
407
|
+
composed: true,
|
|
408
|
+
});
|
|
409
|
+
this.dispatchEvent(event);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private dispatchPresetLoad(
|
|
413
|
+
presetId: string,
|
|
414
|
+
preset: { name: string; description?: string; values: Record<string, number> },
|
|
415
|
+
previousValues: Record<string, number>
|
|
416
|
+
): void {
|
|
417
|
+
const event = new CustomEvent<PresetLoadEvent>('presetload', {
|
|
418
|
+
detail: {
|
|
419
|
+
presetId,
|
|
420
|
+
preset: {
|
|
421
|
+
name: preset.name,
|
|
422
|
+
description: preset.description,
|
|
423
|
+
values: preset.values,
|
|
424
|
+
},
|
|
425
|
+
previousValues,
|
|
426
|
+
timestamp: Date.now(),
|
|
427
|
+
},
|
|
428
|
+
bubbles: true,
|
|
429
|
+
composed: true,
|
|
430
|
+
});
|
|
431
|
+
this.dispatchEvent(event);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private dispatchConfigExport(config: ExportedConfig): void {
|
|
435
|
+
const event = new CustomEvent<ConfigExportEvent>('configexport', {
|
|
436
|
+
detail: {
|
|
437
|
+
config,
|
|
438
|
+
format: 'json',
|
|
439
|
+
timestamp: Date.now(),
|
|
440
|
+
},
|
|
441
|
+
bubbles: true,
|
|
442
|
+
composed: true,
|
|
443
|
+
});
|
|
444
|
+
this.dispatchEvent(event);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private dispatchConfigImport(config: ExportedConfig): void {
|
|
448
|
+
const previousValues = { ...this.state.values };
|
|
449
|
+
const event = new CustomEvent<ConfigImportEvent>('configimport', {
|
|
450
|
+
detail: {
|
|
451
|
+
config,
|
|
452
|
+
previousValues,
|
|
453
|
+
timestamp: Date.now(),
|
|
454
|
+
},
|
|
455
|
+
bubbles: true,
|
|
456
|
+
composed: true,
|
|
457
|
+
});
|
|
458
|
+
this.dispatchEvent(event);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private dispatchPanelReset(): void {
|
|
462
|
+
const previousValues = { ...this.state.values };
|
|
463
|
+
const newValues: Record<string, number> = {};
|
|
464
|
+
this.parameterDefinitions.forEach((param, id) => {
|
|
465
|
+
newValues[id] = param.value;
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const event = new CustomEvent<PanelResetEvent>('panelreset', {
|
|
469
|
+
detail: {
|
|
470
|
+
previousValues,
|
|
471
|
+
newValues,
|
|
472
|
+
timestamp: Date.now(),
|
|
473
|
+
},
|
|
474
|
+
bubbles: true,
|
|
475
|
+
composed: true,
|
|
476
|
+
});
|
|
477
|
+
this.dispatchEvent(event);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private dispatchValidationError(parameterId: string, message: string): void {
|
|
481
|
+
const event = new CustomEvent<ValidationErrorEvent>('validationerror', {
|
|
482
|
+
detail: {
|
|
483
|
+
parameterId,
|
|
484
|
+
error: message,
|
|
485
|
+
value: this.state.values[parameterId] || 0,
|
|
486
|
+
timestamp: Date.now(),
|
|
487
|
+
},
|
|
488
|
+
bubbles: true,
|
|
489
|
+
composed: true,
|
|
490
|
+
});
|
|
491
|
+
this.dispatchEvent(event);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Storage methods
|
|
495
|
+
private saveToStorage(): void {
|
|
496
|
+
try {
|
|
497
|
+
const data = {
|
|
498
|
+
values: this.state.values,
|
|
499
|
+
activePreset: this.state.activePreset,
|
|
500
|
+
};
|
|
501
|
+
localStorage.setItem(this.panelConfig.storageKey as string, JSON.stringify(data));
|
|
502
|
+
} catch (error) {
|
|
503
|
+
this.log('Failed to save to storage', 'error', error);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private loadFromStorage(): void {
|
|
508
|
+
try {
|
|
509
|
+
const data = localStorage.getItem(this.panelConfig.storageKey as string);
|
|
510
|
+
if (data) {
|
|
511
|
+
const parsed = JSON.parse(data);
|
|
512
|
+
if (parsed.values) {
|
|
513
|
+
Object.entries(parsed.values).forEach(([parameterId, value]) => {
|
|
514
|
+
if (this.parameterDefinitions.has(parameterId)) {
|
|
515
|
+
this.state.values[parameterId] = value as number;
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
if (parsed.activePreset && this.presets.has(parsed.activePreset)) {
|
|
520
|
+
this.state.activePreset = parsed.activePreset;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
} catch (error) {
|
|
524
|
+
this.log('Failed to load from storage', 'error', error);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private savePresetsToStorage(): void {
|
|
529
|
+
try {
|
|
530
|
+
const customPresets = Array.from(this.presets.entries())
|
|
531
|
+
.filter(([_, preset]) => !preset.isBuiltIn)
|
|
532
|
+
.reduce(
|
|
533
|
+
(acc, [id, preset]) => {
|
|
534
|
+
acc[id] = { name: preset.name, description: preset.description, values: preset.values };
|
|
535
|
+
return acc;
|
|
536
|
+
},
|
|
537
|
+
{} as Record<string, any>
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
localStorage.setItem(`${this.panelConfig.storageKey}-presets`, JSON.stringify(customPresets));
|
|
541
|
+
} catch (error) {
|
|
542
|
+
this.log('Failed to save presets', 'error', error);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private loadPresetsFromStorage(): void {
|
|
547
|
+
try {
|
|
548
|
+
const data = localStorage.getItem(`${this.panelConfig.storageKey}-presets`);
|
|
549
|
+
if (data) {
|
|
550
|
+
const customPresets = JSON.parse(data);
|
|
551
|
+
Object.entries(customPresets).forEach(([id, preset]: [string, any]) => {
|
|
552
|
+
this.presets.set(id, { ...preset, isBuiltIn: false });
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
} catch (error) {
|
|
556
|
+
this.log('Failed to load presets', 'error', error);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Rendering
|
|
561
|
+
protected render(): void {
|
|
562
|
+
if (!this.shadowRoot) return;
|
|
563
|
+
|
|
564
|
+
const hasErrors = Object.keys(this.state.errors).length > 0;
|
|
565
|
+
|
|
566
|
+
this.shadowRoot.innerHTML = `
|
|
567
|
+
<style>${styles}</style>
|
|
568
|
+
<div class="container" style="--grid-columns: ${this.panelConfig.columns}">
|
|
569
|
+
${this.renderHeader()}
|
|
570
|
+
<div class="content ${this.state.isCollapsed ? 'collapsed' : ''}">
|
|
571
|
+
${hasErrors ? this.renderErrors() : ''}
|
|
572
|
+
${this.panelConfig.showPresets && this.presets.size > 0 ? this.renderPresets() : ''}
|
|
573
|
+
${this.renderParameters()}
|
|
574
|
+
${this.panelConfig.showResetAll || this.panelConfig.showExportImport ? this.renderActions() : ''}
|
|
575
|
+
</div>
|
|
576
|
+
</div>
|
|
577
|
+
`;
|
|
578
|
+
|
|
579
|
+
// Attach event listeners
|
|
580
|
+
this.attachEventListeners();
|
|
581
|
+
|
|
582
|
+
// Create and attach parameter sliders
|
|
583
|
+
this.createParameterSliders();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private renderHeader(): string {
|
|
587
|
+
const collapsedClass = this.state.isCollapsed ? 'collapsed' : '';
|
|
588
|
+
const showTitle = this.panelConfig.title !== undefined && this.panelConfig.title !== null;
|
|
589
|
+
|
|
590
|
+
return `
|
|
591
|
+
<div class="header ${this.panelConfig.collapsible ? 'collapsible' : ''}" id="header">
|
|
592
|
+
${
|
|
593
|
+
showTitle
|
|
594
|
+
? `
|
|
595
|
+
<div class="title-section">
|
|
596
|
+
<h3 class="title">${this.panelConfig.title}</h3>
|
|
597
|
+
<span class="dirty-indicator ${this.state.isDirty ? 'show' : ''}"></span>
|
|
598
|
+
</div>
|
|
599
|
+
`
|
|
600
|
+
: `
|
|
601
|
+
<div class="title-section">
|
|
602
|
+
<span class="dirty-indicator ${this.state.isDirty ? 'show' : ''}"></span>
|
|
603
|
+
</div>
|
|
604
|
+
`
|
|
605
|
+
}
|
|
606
|
+
${
|
|
607
|
+
this.panelConfig.collapsible
|
|
608
|
+
? `
|
|
609
|
+
<span class="collapse-icon ${collapsedClass}">▼</span>
|
|
610
|
+
`
|
|
611
|
+
: ''
|
|
612
|
+
}
|
|
613
|
+
</div>
|
|
614
|
+
`;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private renderErrors(): string {
|
|
618
|
+
const errorEntries = Object.entries(this.state.errors);
|
|
619
|
+
if (errorEntries.length === 0) return '';
|
|
620
|
+
|
|
621
|
+
return `
|
|
622
|
+
<div class="validation-errors show">
|
|
623
|
+
${errorEntries
|
|
624
|
+
.map(
|
|
625
|
+
([parameterId, message]) => `
|
|
626
|
+
<div class="error-item">
|
|
627
|
+
<span class="error-icon">⚠</span>
|
|
628
|
+
<span>${this.parameterDefinitions.get(parameterId)?.label || parameterId}: ${message}</span>
|
|
629
|
+
</div>
|
|
630
|
+
`
|
|
631
|
+
)
|
|
632
|
+
.join('')}
|
|
633
|
+
</div>
|
|
634
|
+
`;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private renderPresets(): string {
|
|
638
|
+
return `
|
|
639
|
+
<div class="presets-section">
|
|
640
|
+
<div class="presets-label">Presets</div>
|
|
641
|
+
<div class="presets-buttons">
|
|
642
|
+
${Array.from(this.presets.entries())
|
|
643
|
+
.map(
|
|
644
|
+
([id, preset]) => `
|
|
645
|
+
<button
|
|
646
|
+
class="preset-btn ${this.state.activePreset === id ? 'active' : ''}"
|
|
647
|
+
data-preset-id="${id}"
|
|
648
|
+
title="${preset.description || preset.name}"
|
|
649
|
+
>
|
|
650
|
+
${preset.name}
|
|
651
|
+
</button>
|
|
652
|
+
`
|
|
653
|
+
)
|
|
654
|
+
.join('')}
|
|
655
|
+
</div>
|
|
656
|
+
</div>
|
|
657
|
+
`;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private renderParameters(): string {
|
|
661
|
+
return `
|
|
662
|
+
<div class="parameters-section">
|
|
663
|
+
<div class="parameters-grid layout-${this.panelConfig.layout}">
|
|
664
|
+
${Array.from(this.parameterDefinitions.keys())
|
|
665
|
+
.map(
|
|
666
|
+
(id) => `
|
|
667
|
+
<div class="parameter-wrapper" data-parameter-id="${id}"></div>
|
|
668
|
+
`
|
|
669
|
+
)
|
|
670
|
+
.join('')}
|
|
671
|
+
</div>
|
|
672
|
+
</div>
|
|
673
|
+
`;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private renderActions(): string {
|
|
677
|
+
return `
|
|
678
|
+
<div class="actions-section">
|
|
679
|
+
<div class="actions-left">
|
|
680
|
+
${
|
|
681
|
+
this.panelConfig.showResetAll
|
|
682
|
+
? `
|
|
683
|
+
<button class="action-btn danger" id="reset-btn">
|
|
684
|
+
<span>↻</span>
|
|
685
|
+
<span>Reset All</span>
|
|
686
|
+
</button>
|
|
687
|
+
`
|
|
688
|
+
: ''
|
|
689
|
+
}
|
|
690
|
+
</div>
|
|
691
|
+
<div class="actions-right">
|
|
692
|
+
${
|
|
693
|
+
this.panelConfig.showExportImport
|
|
694
|
+
? `
|
|
695
|
+
<button class="action-btn" id="import-btn">
|
|
696
|
+
<span>📥</span>
|
|
697
|
+
<span>Import</span>
|
|
698
|
+
</button>
|
|
699
|
+
<button class="action-btn primary" id="export-btn">
|
|
700
|
+
<span>📤</span>
|
|
701
|
+
<span>Export</span>
|
|
702
|
+
</button>
|
|
703
|
+
`
|
|
704
|
+
: ''
|
|
705
|
+
}
|
|
706
|
+
</div>
|
|
707
|
+
</div>
|
|
708
|
+
`;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private attachEventListeners(): void {
|
|
712
|
+
if (!this.shadowRoot) return;
|
|
713
|
+
|
|
714
|
+
// Header click for collapse
|
|
715
|
+
if (this.panelConfig.collapsible) {
|
|
716
|
+
const header = this.shadowRoot.getElementById('header');
|
|
717
|
+
header?.addEventListener('click', () => this.toggleCollapse());
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Preset buttons
|
|
721
|
+
const presetButtons = this.shadowRoot.querySelectorAll('.preset-btn');
|
|
722
|
+
presetButtons.forEach((btn) => {
|
|
723
|
+
btn.addEventListener('click', () => {
|
|
724
|
+
const presetId = (btn as HTMLElement).dataset.presetId;
|
|
725
|
+
if (presetId) this.loadPreset(presetId);
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
// Reset button
|
|
730
|
+
const resetBtn = this.shadowRoot.getElementById('reset-btn');
|
|
731
|
+
resetBtn?.addEventListener('click', () => this.resetAll());
|
|
732
|
+
|
|
733
|
+
// Export button
|
|
734
|
+
const exportBtn = this.shadowRoot.getElementById('export-btn');
|
|
735
|
+
exportBtn?.addEventListener('click', () => this.handleExport());
|
|
736
|
+
|
|
737
|
+
// Import button
|
|
738
|
+
const importBtn = this.shadowRoot.getElementById('import-btn');
|
|
739
|
+
importBtn?.addEventListener('click', () => this.handleImport());
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private createParameterSliders(): void {
|
|
743
|
+
if (!this.shadowRoot) return;
|
|
744
|
+
|
|
745
|
+
// Clear existing sliders
|
|
746
|
+
this.parameters.clear();
|
|
747
|
+
|
|
748
|
+
// Create slider for each parameter
|
|
749
|
+
this.parameterDefinitions.forEach((paramDef, parameterId) => {
|
|
750
|
+
const wrapper = this.shadowRoot!.querySelector(`[data-parameter-id="${parameterId}"]`);
|
|
751
|
+
if (!wrapper) return;
|
|
752
|
+
|
|
753
|
+
// Create ParameterSlider instance
|
|
754
|
+
const slider = new ParameterSlider({
|
|
755
|
+
label: paramDef.label,
|
|
756
|
+
min: paramDef.min,
|
|
757
|
+
max: paramDef.max,
|
|
758
|
+
value: this.state.values[parameterId],
|
|
759
|
+
step: paramDef.step,
|
|
760
|
+
unit: paramDef.unit,
|
|
761
|
+
description: paramDef.description,
|
|
762
|
+
presets: paramDef.presets,
|
|
763
|
+
showInput: paramDef.showInput,
|
|
764
|
+
disabled: paramDef.disabled || this._disabled,
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
// Listen to value changes from child slider (user interaction only)
|
|
768
|
+
slider.addEventListener('valuechange', ((e: CustomEvent) => {
|
|
769
|
+
// Stop propagation to prevent event bubbling
|
|
770
|
+
e.stopPropagation();
|
|
771
|
+
|
|
772
|
+
// Update our state and emit our own event
|
|
773
|
+
const paramDef = this.parameterDefinitions.get(parameterId);
|
|
774
|
+
if (!paramDef) return;
|
|
775
|
+
|
|
776
|
+
const value = e.detail.value;
|
|
777
|
+
const source = e.detail.source;
|
|
778
|
+
|
|
779
|
+
// Clamp value to range
|
|
780
|
+
const clampedValue = Math.max(paramDef.min, Math.min(paramDef.max, value));
|
|
781
|
+
|
|
782
|
+
// Validate if enabled
|
|
783
|
+
if (this.panelConfig.validateOnChange && paramDef.validate) {
|
|
784
|
+
const validationResult = paramDef.validate(clampedValue, this.state.values);
|
|
785
|
+
if (validationResult !== true) {
|
|
786
|
+
this.state.errors[parameterId] =
|
|
787
|
+
typeof validationResult === 'string' ? validationResult : 'Validation failed';
|
|
788
|
+
this.dispatchValidationError(parameterId, this.state.errors[parameterId]);
|
|
789
|
+
this.render();
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Clear error
|
|
795
|
+
delete this.state.errors[parameterId];
|
|
796
|
+
|
|
797
|
+
// Update value
|
|
798
|
+
const oldValue = this.state.values[parameterId];
|
|
799
|
+
this.state.values[parameterId] = clampedValue;
|
|
800
|
+
|
|
801
|
+
// Mark dirty and clear preset for user changes
|
|
802
|
+
if (source !== 'reset' && source !== 'import' && source !== 'preset') {
|
|
803
|
+
this.state.isDirty = true;
|
|
804
|
+
this.state.activePreset = null;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Persist if enabled
|
|
808
|
+
if (this.panelConfig.persistValues) {
|
|
809
|
+
this.saveToStorage();
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Emit change event
|
|
813
|
+
if (this.panelConfig.emitChangeEvents && oldValue !== clampedValue) {
|
|
814
|
+
this.dispatchPanelChange(parameterId, clampedValue, oldValue ?? paramDef.min, source);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Re-render to update UI
|
|
818
|
+
this.render();
|
|
819
|
+
}) as EventListener);
|
|
820
|
+
|
|
821
|
+
// Append to wrapper
|
|
822
|
+
wrapper.appendChild(slider);
|
|
823
|
+
|
|
824
|
+
// Store reference
|
|
825
|
+
this.parameters.set(parameterId, slider);
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
private handleExport(): void {
|
|
830
|
+
const config = this.exportConfig();
|
|
831
|
+
const json = JSON.stringify(config, null, 2);
|
|
832
|
+
const blob = new Blob([json], { type: 'application/json' });
|
|
833
|
+
const url = URL.createObjectURL(blob);
|
|
834
|
+
const a = document.createElement('a');
|
|
835
|
+
a.href = url;
|
|
836
|
+
a.download = `${this.panelConfig.title}-config.json`;
|
|
837
|
+
a.click();
|
|
838
|
+
URL.revokeObjectURL(url);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
private handleImport(): void {
|
|
842
|
+
const input = document.createElement('input');
|
|
843
|
+
input.type = 'file';
|
|
844
|
+
input.accept = '.json';
|
|
845
|
+
input.onchange = (e) => {
|
|
846
|
+
const file = (e.target as HTMLInputElement).files?.[0];
|
|
847
|
+
if (!file) return;
|
|
848
|
+
|
|
849
|
+
file
|
|
850
|
+
.text()
|
|
851
|
+
.then((text) => {
|
|
852
|
+
try {
|
|
853
|
+
const config = JSON.parse(text);
|
|
854
|
+
this.importConfig(config);
|
|
855
|
+
} catch (error) {
|
|
856
|
+
this.log('Failed to parse config file', 'error', error);
|
|
857
|
+
}
|
|
858
|
+
})
|
|
859
|
+
.catch((error) => {
|
|
860
|
+
this.log('Failed to read config file', 'error', error);
|
|
861
|
+
});
|
|
862
|
+
};
|
|
863
|
+
input.click();
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Clean up when component is removed
|
|
868
|
+
*/
|
|
869
|
+
override disconnectedCallback(): void {
|
|
870
|
+
this.parameters.clear();
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Register custom element
|
|
875
|
+
if (!customElements.get('ai-parameter-panel')) {
|
|
876
|
+
customElements.define('ai-parameter-panel', ParameterPanel);
|
|
877
|
+
}
|