astro-tractstack 2.0.15 → 2.0.16

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,512 +0,0 @@
1
- import { useState, useCallback } from 'react';
2
- import prompts from '@/constants/prompts.json';
3
- import ColorPickerCombo from '@/components/fields/ColorPickerCombo';
4
- import { AiPanePreview } from './AiPanePreview';
5
- import { CopyInputStep } from './steps/CopyInputStep';
6
- import { parseAiPane } from '@/utils/compositor/aiPaneParser';
7
- import { classNames } from '@/utils/helpers';
8
- import type { TemplatePane } from '@/types/compositorTypes';
9
- import type { BrandConfig } from '@/types/tractstack';
10
-
11
- interface AiPaneGeneratorProps {
12
- ownerId: string;
13
- onComplete: (pane: TemplatePane) => void;
14
- onCancel: () => void;
15
- config?: BrandConfig;
16
- }
17
-
18
- type GenerationStep = 'input' | 'preview' | 'loading';
19
- type CopyMode = 'prompt' | 'raw';
20
-
21
- interface GenerationResponse {
22
- success: boolean;
23
- data?: {
24
- response: string | object;
25
- };
26
- error?: string;
27
- }
28
-
29
- const harmonyOptions = [
30
- 'Analogous',
31
- 'Monochromatic',
32
- 'Complementary',
33
- 'Triadic',
34
- ];
35
- const themeOptions = ['Light', 'Dark', 'Bright', 'Muted', 'Pastel', 'Earthy'];
36
-
37
- export function AiPaneGenerator({
38
- ownerId,
39
- onComplete,
40
- onCancel,
41
- config,
42
- }: AiPaneGeneratorProps) {
43
- const [currentStep, setCurrentStep] = useState<GenerationStep>('input');
44
- const [selectedLayout] = useState<string>('Text Only');
45
- const [copyMode, setCopyMode] = useState<CopyMode>('prompt');
46
- const [copyPrompt, setCopyPrompt] = useState('');
47
- const [rawCopy, setRawCopy] = useState('');
48
- const [generatedShell, setGeneratedShell] = useState<string | null>(null);
49
- const [generatedCopy, setGeneratedCopy] = useState<string | null>(null);
50
- const [error, setError] = useState<string | null>(null);
51
-
52
- const [selectedHarmony, setSelectedHarmony] = useState<string>(
53
- harmonyOptions[0]
54
- );
55
- const [baseColor, setBaseColor] = useState<string>('');
56
- const [accentColor, setAccentColor] = useState<string>('');
57
- const [selectedTheme, setSelectedTheme] = useState<string>(themeOptions[0]);
58
- const [additionalNotes, setAdditionalNotes] = useState<string>('');
59
-
60
- const [isInjectMode, setIsInjectMode] = useState(false);
61
- const [injectShell, setInjectShell] = useState('');
62
- const [injectCopy, setInjectCopy] = useState('');
63
-
64
- const callAskLemurAPI = useCallback(
65
- async (
66
- prompt: string,
67
- context: string,
68
- expectJson: boolean
69
- ): Promise<string> => {
70
- const goBackend =
71
- import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
72
- const tenantId = import.meta.env.PUBLIC_TENANTID || 'default';
73
-
74
- const requestBody = {
75
- prompt: prompt,
76
- input_text: context,
77
- final_model: '',
78
- temperature: 0.5,
79
- max_tokens: 2000,
80
- };
81
-
82
- const response = await fetch(`${goBackend}/api/v1/aai/askLemur`, {
83
- method: 'POST',
84
- headers: {
85
- 'Content-Type': 'application/json',
86
- 'X-Tenant-ID': tenantId,
87
- },
88
- credentials: 'include',
89
- body: JSON.stringify(requestBody),
90
- });
91
-
92
- if (!response.ok) {
93
- const errorText = await response.text();
94
- console.error('AskLemur API Error Response:', errorText);
95
- let backendError = `API call failed: ${response.status} ${response.statusText}`;
96
- try {
97
- const errorJson = JSON.parse(errorText);
98
- if (errorJson && errorJson.error) {
99
- backendError = errorJson.error;
100
- }
101
- } catch (e) {
102
- /* Ignore */
103
- }
104
- throw new Error(backendError);
105
- }
106
-
107
- const result = (await response.json()) as GenerationResponse;
108
-
109
- if (!result.success || !result.data?.response) {
110
- throw new Error(
111
- result.error || 'Generation failed to return valid response.'
112
- );
113
- }
114
-
115
- let rawResponseData = result.data.response;
116
-
117
- if (expectJson && typeof rawResponseData === 'object') {
118
- return JSON.stringify(rawResponseData);
119
- }
120
-
121
- if (typeof rawResponseData === 'string') {
122
- let responseString = rawResponseData;
123
- try {
124
- if (
125
- responseString.startsWith('```json') &&
126
- responseString.endsWith('```')
127
- ) {
128
- responseString = responseString.slice(7, -3).trim();
129
- } else if (
130
- responseString.startsWith('```html') &&
131
- responseString.endsWith('```')
132
- ) {
133
- responseString = responseString.slice(7, -3).trim();
134
- }
135
- } catch (e) {
136
- /* Ignore stripping errors */
137
- }
138
- return responseString;
139
- }
140
-
141
- throw new Error('Unexpected response format received from API.');
142
- },
143
- []
144
- );
145
-
146
- const handleGenerate = useCallback(async () => {
147
- setError(null);
148
- setCurrentStep('loading');
149
- setGeneratedShell(null);
150
- setGeneratedCopy(null);
151
-
152
- let designInput = `Generate a design using a **${selectedHarmony.toLowerCase()}** color scheme with a **${selectedTheme.toLowerCase()}** theme.`;
153
- if (baseColor) {
154
- designInput += ` Base the colors around **${baseColor}**.`;
155
- }
156
- if (accentColor) {
157
- designInput += ` Use **${accentColor}** as an accent color.`;
158
- }
159
- if (additionalNotes) {
160
- designInput += ` Refine the design with these additional notes: "${additionalNotes}"`;
161
- }
162
-
163
- try {
164
- const shellPromptDetails = prompts.aiPaneShellPrompt;
165
- const copyPromptDetails = prompts.aiPaneCopyPrompt;
166
-
167
- if (
168
- !shellPromptDetails?.user_template ||
169
- !copyPromptDetails?.user_template
170
- ) {
171
- throw new Error('AI prompts not found or incomplete in prompts.json');
172
- }
173
-
174
- const formattedShellPrompt = shellPromptDetails.user_template
175
- .replace('{{DESIGN_INPUT}}', designInput)
176
- .replace('{{LAYOUT_TYPE}}', selectedLayout);
177
-
178
- const shellResult = await callAskLemurAPI(
179
- formattedShellPrompt,
180
- shellPromptDetails.system || '',
181
- true
182
- );
183
- setGeneratedShell(shellResult);
184
-
185
- const copyInputContent = copyMode === 'prompt' ? copyPrompt : rawCopy;
186
- const formattedCopyPrompt = copyPromptDetails.user_template
187
- .replace('{{COPY_INPUT}}', copyInputContent)
188
- .replace('{{DESIGN_INPUT}}', designInput)
189
- .replace('{{LAYOUT_TYPE}}', selectedLayout)
190
- .replace('{{SHELL_JSON}}', shellResult);
191
-
192
- const copyResult = await callAskLemurAPI(
193
- formattedCopyPrompt,
194
- copyPromptDetails.system || '',
195
- false
196
- );
197
- setGeneratedCopy(copyResult);
198
-
199
- setCurrentStep('preview');
200
- } catch (err: any) {
201
- console.error('AI Pane Generation Error:', err);
202
- setError(err.message || 'Failed to generate AI pane.');
203
- setCurrentStep('input');
204
- }
205
- }, [
206
- selectedHarmony,
207
- baseColor,
208
- accentColor,
209
- selectedTheme,
210
- additionalNotes,
211
- selectedLayout,
212
- copyMode,
213
- copyPrompt,
214
- rawCopy,
215
- callAskLemurAPI,
216
- ]);
217
-
218
- const handleInject = useCallback(() => {
219
- setError(null);
220
- if (!injectShell || !injectCopy) {
221
- setError('Both Shell JSON and Copy HTML must be provided.');
222
- return;
223
- }
224
- try {
225
- const shellResponse = JSON.parse(injectShell);
226
- const copyResponse = JSON.parse(injectCopy);
227
-
228
- const shellPayloadString = JSON.stringify(shellResponse?.data?.response);
229
- const copyPayloadString = copyResponse?.data?.response;
230
-
231
- if (
232
- !shellPayloadString ||
233
- shellPayloadString === 'null' ||
234
- typeof copyPayloadString !== 'string'
235
- ) {
236
- throw new Error(
237
- 'Payloads are in an unexpected format. Could not find "data.response".'
238
- );
239
- }
240
-
241
- const pane = parseAiPane(
242
- shellPayloadString,
243
- copyPayloadString,
244
- selectedLayout
245
- );
246
- onComplete(pane);
247
- } catch (err: any) {
248
- console.error('Payload Injection Error:', err);
249
- setError(err.message || 'Failed to parse payloads. Check JSON format.');
250
- }
251
- }, [injectShell, injectCopy, selectedLayout, onComplete]);
252
-
253
- const handleBack = () => {
254
- setError(null);
255
- if (currentStep === 'preview') {
256
- setCurrentStep('input');
257
- } else if (currentStep === 'input') {
258
- if (isInjectMode) {
259
- setIsInjectMode(false);
260
- } else {
261
- onCancel();
262
- }
263
- } else if (currentStep === 'loading') {
264
- setCurrentStep('input');
265
- }
266
- };
267
-
268
- if (currentStep === 'loading') {
269
- return (
270
- <div className="flex min-h-[200px] flex-col items-center justify-center space-y-4 p-6">
271
- <div className="h-8 w-8 animate-spin rounded-full border-b-2 border-gray-400"></div>
272
- <p className="text-sm text-gray-500">Generating AI Pane...</p>
273
- <button
274
- type="button"
275
- onClick={handleBack}
276
- className="mt-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-bold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2"
277
- >
278
- Cancel
279
- </button>
280
- </div>
281
- );
282
- }
283
-
284
- if (currentStep === 'preview' && generatedShell && generatedCopy) {
285
- return (
286
- <AiPanePreview
287
- shellJson={generatedShell}
288
- copyHtml={generatedCopy}
289
- layout={selectedLayout}
290
- ownerId={ownerId}
291
- onComplete={onComplete}
292
- onBack={handleBack}
293
- />
294
- );
295
- }
296
-
297
- if (currentStep === 'input') {
298
- if (isInjectMode) {
299
- return (
300
- <div className="space-y-6 p-4">
301
- <div>
302
- <label
303
- htmlFor="shell-json"
304
- className="block text-lg font-bold text-gray-800"
305
- >
306
- Shell JSON Payload
307
- </label>
308
- <textarea
309
- id="shell-json"
310
- value={injectShell}
311
- onChange={(e) => setInjectShell(e.target.value)}
312
- placeholder="Paste raw API response for ShellJson here..."
313
- rows={8}
314
- className="mt-2 block w-full rounded-md border-gray-300 p-2 font-mono text-sm shadow-sm focus:border-cyan-500 focus:ring-cyan-500"
315
- />
316
- </div>
317
-
318
- <div>
319
- <label
320
- htmlFor="copy-html"
321
- className="block text-lg font-bold text-gray-800"
322
- >
323
- Copy HTML Payload
324
- </label>
325
- <textarea
326
- id="copy-html"
327
- value={injectCopy}
328
- onChange={(e) => setInjectCopy(e.target.value)}
329
- placeholder="Paste raw API response for copyHtml here..."
330
- rows={8}
331
- className="mt-2 block w-full rounded-md border-gray-300 p-2 font-mono text-sm shadow-sm focus:border-cyan-500 focus:ring-cyan-500"
332
- />
333
- </div>
334
-
335
- {error && <p className="text-sm text-red-600">{error}</p>}
336
-
337
- <div className="flex justify-between pt-4">
338
- <button
339
- type="button"
340
- onClick={handleBack}
341
- className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-bold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2"
342
- >
343
- Back to Generator
344
- </button>
345
- <button
346
- type="button"
347
- onClick={handleInject}
348
- disabled={!injectShell || !injectCopy}
349
- className={`rounded-md border border-transparent px-4 py-2 text-sm font-bold text-white shadow-sm ${
350
- !injectShell || !injectCopy
351
- ? 'cursor-not-allowed bg-gray-400'
352
- : 'bg-cyan-600 hover:bg-cyan-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2'
353
- }`}
354
- >
355
- Create from Payloads
356
- </button>
357
- </div>
358
- </div>
359
- );
360
- }
361
-
362
- return (
363
- <div className="space-y-6 p-4">
364
- <div>
365
- <label className="block text-lg font-bold text-gray-800">
366
- Color Harmony
367
- </label>
368
- <div className="mt-2 flex flex-wrap gap-x-4 gap-y-2">
369
- {harmonyOptions.map((option) => (
370
- <div key={option} className="flex items-center space-x-2">
371
- <input
372
- type="radio"
373
- id={`harmony-${option}`}
374
- name="harmonyOptions"
375
- value={option}
376
- checked={selectedHarmony === option}
377
- onChange={(e) => setSelectedHarmony(e.target.value)}
378
- className="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500"
379
- />
380
- <label
381
- htmlFor={`harmony-${option}`}
382
- className="text-sm font-bold text-gray-700"
383
- >
384
- {option}
385
- </label>
386
- </div>
387
- ))}
388
- </div>
389
- </div>
390
-
391
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
392
- <div>
393
- <ColorPickerCombo
394
- title="Base Color (Optional)"
395
- config={config!}
396
- defaultColor={baseColor}
397
- onColorChange={setBaseColor}
398
- allowNull={true}
399
- />
400
- </div>
401
- <div>
402
- <ColorPickerCombo
403
- title="Accent Color (Optional)"
404
- config={config!}
405
- defaultColor={accentColor}
406
- onColorChange={setAccentColor}
407
- allowNull={true}
408
- />
409
- </div>
410
- </div>
411
-
412
- <div>
413
- <label className="block text-lg font-bold text-gray-800">
414
- Theme / Mood
415
- </label>
416
- <div className="mt-2 flex flex-wrap gap-x-4 gap-y-2">
417
- {themeOptions.map((option) => (
418
- <div key={option} className="flex items-center space-x-2">
419
- <input
420
- type="radio"
421
- id={`theme-${option}`}
422
- name="themeOptions"
423
- value={option}
424
- checked={selectedTheme === option}
425
- onChange={(e) => setSelectedTheme(e.target.value)}
426
- className="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500"
427
- />
428
- <label
429
- htmlFor={`theme-${option}`}
430
- className="text-sm font-bold text-gray-700"
431
- >
432
- {option}
433
- </label>
434
- </div>
435
- ))}
436
- </div>
437
- </div>
438
-
439
- <div>
440
- <label
441
- htmlFor="additional-notes"
442
- className="block text-lg font-bold text-gray-800"
443
- >
444
- Additional Design Notes (Optional)
445
- </label>
446
- <p className="mb-2 mt-1 text-sm text-gray-500">
447
- Add specific requests like "use rounded corners", "add subtle
448
- texture".
449
- </p>
450
- <textarea
451
- id="additional-notes"
452
- value={additionalNotes}
453
- onChange={(e) => setAdditionalNotes(e.target.value)}
454
- placeholder="Enter additional notes..."
455
- rows={3}
456
- className="block w-full rounded-md border-gray-300 p-2 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 sm:text-sm"
457
- />
458
- </div>
459
-
460
- <CopyInputStep
461
- copyMode={copyMode}
462
- onCopyModeChange={setCopyMode}
463
- promptValue={copyPrompt}
464
- onPromptValueChange={setCopyPrompt}
465
- copyValue={rawCopy}
466
- onCopyValueChange={setRawCopy}
467
- />
468
-
469
- {error && <p className="text-sm text-red-600">{error}</p>}
470
-
471
- <div className="flex justify-between pt-4">
472
- <button
473
- type="button"
474
- onClick={handleBack}
475
- className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-bold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2"
476
- >
477
- Cancel
478
- </button>
479
- <button
480
- type="button"
481
- onClick={handleGenerate}
482
- disabled={copyMode === 'prompt' ? !copyPrompt : !rawCopy}
483
- className={classNames(
484
- `rounded-md border border-transparent px-4 py-2 text-sm font-bold shadow-sm transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2`,
485
- (copyMode === 'prompt' && !copyPrompt) ||
486
- (copyMode === `raw` && !rawCopy)
487
- ? 'cursor-not-allowed bg-gray-300 text-gray-500'
488
- : 'bg-cyan-600 text-white hover:bg-cyan-700'
489
- )}
490
- >
491
- Generate Pane
492
- </button>
493
- </div>
494
-
495
- <div className="border-t border-gray-200 pt-4 text-center">
496
- <button
497
- type="button"
498
- onClick={() => {
499
- setError(null);
500
- setIsInjectMode(true);
501
- }}
502
- className="text-sm text-cyan-600 hover:text-cyan-800 hover:underline"
503
- >
504
- Direct Inject Payload
505
- </button>
506
- </div>
507
- </div>
508
- );
509
- }
510
-
511
- return null;
512
- }
@@ -1,107 +0,0 @@
1
- import { useState, useEffect, useMemo } from 'react';
2
- import type { TemplatePane } from '@/types/compositorTypes';
3
- import { parseAiPane } from '@/utils/compositor/aiPaneParser';
4
-
5
- interface AiPanePreviewProps {
6
- shellJson: string;
7
- copyHtml: string;
8
- layout: string;
9
- ownerId: string;
10
- onComplete: (pane: TemplatePane) => void;
11
- onBack: () => void;
12
- }
13
-
14
- export function AiPanePreview({
15
- shellJson,
16
- copyHtml,
17
- layout,
18
- onComplete,
19
- onBack,
20
- }: AiPanePreviewProps) {
21
- const [error, setError] = useState<string | null>(null);
22
- const [hasCompleted, setHasCompleted] = useState<boolean>(false);
23
- const [isLoading, setIsLoading] = useState<boolean>(true);
24
-
25
- useEffect(() => {
26
- setError(null);
27
- setHasCompleted(false);
28
- setIsLoading(true);
29
- let isActive = true;
30
-
31
- if (shellJson && copyHtml) {
32
- try {
33
- const pane = parseAiPane(shellJson, copyHtml, layout);
34
- if (isActive && !hasCompleted) {
35
- onComplete(pane);
36
- setHasCompleted(true);
37
- setIsLoading(false);
38
- }
39
- } catch (err: any) {
40
- console.error('Error parsing AI Pane:', err);
41
- if (isActive) {
42
- setError(err.message || 'Failed to parse generated content.');
43
- setIsLoading(false);
44
- }
45
- }
46
- } else {
47
- // Handle case where inputs might be initially empty
48
- setIsLoading(false);
49
- }
50
-
51
- return () => {
52
- isActive = false;
53
- };
54
- }, [shellJson, copyHtml, layout, onComplete, hasCompleted]);
55
-
56
- const displayContent = useMemo(() => {
57
- if (isLoading) {
58
- return (
59
- <div className="p-4 text-center text-gray-500">
60
- <div className="mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-b-2 border-gray-400"></div>
61
- <p className="text-sm">Processing...</p>
62
- </div>
63
- );
64
- }
65
- if (error) {
66
- return (
67
- <div className="p-4 text-center text-red-600">
68
- <p className="font-semibold">Error:</p>
69
- <p className="mt-1 text-sm">{error}</p>
70
- </div>
71
- );
72
- }
73
- if (hasCompleted) {
74
- return (
75
- <div className="p-4 text-center text-green-700">
76
- <p className="font-semibold">Pane Applied Successfully!</p>
77
- <p className="mt-1 text-sm">
78
- You can now go back or continue editing.
79
- </p>
80
- </div>
81
- );
82
- }
83
- // Fallback/initial state before useEffect runs if needed
84
- return (
85
- <div className="p-4 text-center text-gray-500">
86
- <p className="text-sm">Preparing...</p>
87
- </div>
88
- );
89
- }, [isLoading, error, hasCompleted]);
90
-
91
- return (
92
- <div className="flex h-full flex-col p-4">
93
- <div className="relative mb-4 flex min-h-[200px] flex-grow items-center justify-center overflow-auto rounded border bg-gray-50">
94
- {displayContent}
95
- </div>
96
- <div className="flex flex-shrink-0 justify-start">
97
- <button
98
- onClick={onBack}
99
- className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
100
- type="button"
101
- >
102
- Back
103
- </button>
104
- </div>
105
- </div>
106
- );
107
- }
@@ -1,72 +0,0 @@
1
- import { findUniqueSlug } from '@/utils/helpers';
2
-
3
- interface TitleSlugResponse {
4
- title: string;
5
- slug: string;
6
- }
7
-
8
- export async function getTitleSlug(
9
- markdownContent: string,
10
- existingSlugs: string[]
11
- ): Promise<TitleSlugResponse | null> {
12
- if (
13
- !markdownContent ||
14
- markdownContent.trim() === '...' ||
15
- markdownContent.trim().length === 0
16
- ) {
17
- return null;
18
- }
19
-
20
- try {
21
- const backendUrl =
22
- import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
23
- const tenantId = import.meta.env.PUBLIC_TENANTID || 'default';
24
-
25
- const response = await fetch(`${backendUrl}/api/v1/aai/askLemur`, {
26
- method: 'POST',
27
- headers: {
28
- 'Content-Type': 'application/json',
29
- 'X-Tenant-ID': tenantId,
30
- },
31
- credentials: 'include',
32
- body: JSON.stringify({
33
- prompt: `Generate a concise title (maximum 40-50 characters) and a URL-friendly slug (lowercase, only letters, numbers, and dashes, no spaces) that captures the essence of this markdown content. Return only a JSON object with "title" and "slug" keys.
34
-
35
- Example response format:
36
- {
37
- "title": "Short Descriptive Title",
38
- "slug": "short-descriptive-title"
39
- }`,
40
- input_text: markdownContent,
41
- final_model: '',
42
- }),
43
- });
44
-
45
- if (response.ok) {
46
- const data = await response.json();
47
- if (data.success && data.data?.response) {
48
- let titleData;
49
- try {
50
- if (typeof data.data.response === 'string') {
51
- titleData = JSON.parse(data.data.response);
52
- } else {
53
- titleData = data.data.response;
54
- }
55
-
56
- if (titleData.title && titleData.slug) {
57
- return {
58
- title: findUniqueSlug(titleData.title, existingSlugs),
59
- slug: findUniqueSlug(titleData.slug, existingSlugs),
60
- };
61
- }
62
- } catch (parseError) {
63
- console.error('Error parsing title data:', parseError);
64
- }
65
- }
66
- }
67
- } catch (error) {
68
- console.error('Error generating title:', error);
69
- }
70
-
71
- return null;
72
- }