luxlabs 1.0.14 → 1.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.
package/commands/ab-tests.js
CHANGED
|
@@ -20,7 +20,7 @@ const chalk = require('chalk');
|
|
|
20
20
|
const fs = require('fs');
|
|
21
21
|
const path = require('path');
|
|
22
22
|
const ora = require('ora');
|
|
23
|
-
const { loadConfig, getProjectId, getStudioApiUrl } = require('../lib/config');
|
|
23
|
+
const { loadConfig, getProjectId, getStudioApiUrl, getInterfaceRepoDir, getInterfacesDir } = require('../lib/config');
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Show help for ab-tests commands
|
|
@@ -52,65 +52,58 @@ function showHelp() {
|
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* Get the path to ab-tests.json for an interface
|
|
55
|
-
*
|
|
55
|
+
* Uses ~/.lux-studio/{orgId}/projects/{projectId}/interfaces/{interfaceId}/repo/.lux/ab-tests.json
|
|
56
56
|
*/
|
|
57
57
|
function getABTestsPath(interfaceIdentifier) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
// First try the config-based path (preferred - used by Electron app)
|
|
59
|
+
if (interfaceIdentifier) {
|
|
60
|
+
const repoDir = getInterfaceRepoDir(interfaceIdentifier);
|
|
61
|
+
if (repoDir) {
|
|
62
|
+
const configPath = path.join(repoDir, '.lux', 'ab-tests.json');
|
|
63
|
+
if (fs.existsSync(configPath)) {
|
|
64
|
+
return configPath;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
//
|
|
65
|
-
|
|
69
|
+
// Try to find from interfaces dir if no identifier given
|
|
70
|
+
const interfacesDir = getInterfacesDir();
|
|
71
|
+
if (interfacesDir && fs.existsSync(interfacesDir)) {
|
|
66
72
|
const entries = fs.readdirSync(interfacesDir, { withFileTypes: true });
|
|
67
73
|
const dirs = entries.filter(e => e.isDirectory());
|
|
68
74
|
|
|
69
|
-
if (dirs.length ===
|
|
70
|
-
|
|
75
|
+
if (!interfaceIdentifier && dirs.length === 1) {
|
|
76
|
+
// Auto-select the only interface
|
|
77
|
+
const repoDir = path.join(interfacesDir, dirs[0].name, 'repo');
|
|
78
|
+
const configPath = path.join(repoDir, '.lux', 'ab-tests.json');
|
|
79
|
+
if (fs.existsSync(configPath)) {
|
|
80
|
+
return configPath;
|
|
81
|
+
}
|
|
71
82
|
}
|
|
72
83
|
|
|
73
|
-
if
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
for (const dir of dirs) {
|
|
80
|
-
const metaPath = path.join(interfacesDir, dir.name, 'metadata.json');
|
|
81
|
-
let name = dir.name;
|
|
84
|
+
// Search by name if identifier doesn't match directly
|
|
85
|
+
if (interfaceIdentifier) {
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
if (!entry.isDirectory()) continue;
|
|
88
|
+
|
|
89
|
+
const metaPath = path.join(interfacesDir, entry.name, 'metadata.json');
|
|
82
90
|
if (fs.existsSync(metaPath)) {
|
|
83
91
|
try {
|
|
84
92
|
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
85
|
-
name
|
|
93
|
+
if (meta.name && meta.name.toLowerCase() === interfaceIdentifier.toLowerCase()) {
|
|
94
|
+
const repoDir = path.join(interfacesDir, entry.name, 'repo');
|
|
95
|
+
return path.join(repoDir, '.lux', 'ab-tests.json');
|
|
96
|
+
}
|
|
86
97
|
} catch (e) { /* ignore */ }
|
|
87
98
|
}
|
|
88
|
-
console.log(chalk.dim(` - ${name} (${dir.name})`));
|
|
89
99
|
}
|
|
90
|
-
return null;
|
|
91
100
|
}
|
|
92
101
|
}
|
|
93
102
|
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
if (fs.existsSync(
|
|
97
|
-
return
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Try to find by name
|
|
101
|
-
const entries = fs.readdirSync(interfacesDir, { withFileTypes: true });
|
|
102
|
-
for (const entry of entries) {
|
|
103
|
-
if (!entry.isDirectory()) continue;
|
|
104
|
-
|
|
105
|
-
const metaPath = path.join(interfacesDir, entry.name, 'metadata.json');
|
|
106
|
-
if (fs.existsSync(metaPath)) {
|
|
107
|
-
try {
|
|
108
|
-
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
109
|
-
if (meta.name && meta.name.toLowerCase() === interfaceIdentifier.toLowerCase()) {
|
|
110
|
-
return path.join(interfacesDir, entry.name, 'repo', '.lux', 'ab-tests.json');
|
|
111
|
-
}
|
|
112
|
-
} catch (e) { /* ignore */ }
|
|
113
|
-
}
|
|
103
|
+
// Fallback: try cwd-based path (for running from interface repo directory)
|
|
104
|
+
const cwdPath = path.join(process.cwd(), '.lux', 'ab-tests.json');
|
|
105
|
+
if (fs.existsSync(cwdPath)) {
|
|
106
|
+
return cwdPath;
|
|
114
107
|
}
|
|
115
108
|
|
|
116
109
|
return null;
|
|
@@ -503,7 +496,8 @@ async function callLifecycleApi(endpoint, testId, interfaceId, creds, testData =
|
|
|
503
496
|
const data = await response.json();
|
|
504
497
|
|
|
505
498
|
if (!response.ok) {
|
|
506
|
-
|
|
499
|
+
const errorMsg = data.details || data.error || `API returned ${response.status}`;
|
|
500
|
+
throw new Error(errorMsg);
|
|
507
501
|
}
|
|
508
502
|
|
|
509
503
|
return data;
|
package/commands/data.js
CHANGED
|
@@ -182,6 +182,12 @@ ${chalk.bold('Table Commands:')}
|
|
|
182
182
|
tables export <table-name> [file] Export table to CSV
|
|
183
183
|
tables import <table-name> <csv-file> Import CSV to table
|
|
184
184
|
|
|
185
|
+
${chalk.bold('Column Commands:')}
|
|
186
|
+
tables add-column <table> <name> <type> [--not-null] [--default <val>] Add column
|
|
187
|
+
tables drop-column <table> <column> Delete column
|
|
188
|
+
tables rename-column <table> <old> <new> Rename column
|
|
189
|
+
tables change-type <table> <column> <type> Change column type (if empty)
|
|
190
|
+
|
|
185
191
|
${chalk.bold('KV Commands:')}
|
|
186
192
|
kv list List all KV namespaces
|
|
187
193
|
kv init <name> [description] Create new KV namespace
|
|
@@ -585,6 +591,121 @@ ${chalk.bold('Examples:')}
|
|
|
585
591
|
break;
|
|
586
592
|
}
|
|
587
593
|
|
|
594
|
+
// ============ COLUMN OPERATIONS ============
|
|
595
|
+
|
|
596
|
+
case 'add-column': {
|
|
597
|
+
requireArgs(args.slice(2), 3, 'lux data tables add-column <table-name> <column-name> <type> [--not-null] [--default <value>]');
|
|
598
|
+
const tableName = args[2];
|
|
599
|
+
const columnName = args[3];
|
|
600
|
+
const columnType = args[4].toUpperCase();
|
|
601
|
+
|
|
602
|
+
// Parse flags
|
|
603
|
+
const notNull = args.includes('--not-null');
|
|
604
|
+
const defaultIndex = args.indexOf('--default');
|
|
605
|
+
const defaultValue = defaultIndex !== -1 ? args[defaultIndex + 1] : undefined;
|
|
606
|
+
|
|
607
|
+
info(`Adding column '${columnName}' to table '${tableName}'...`);
|
|
608
|
+
const tablesApiUrl = getTablesApiUrl();
|
|
609
|
+
|
|
610
|
+
const columnDef = {
|
|
611
|
+
name: columnName,
|
|
612
|
+
type: columnType,
|
|
613
|
+
notNull,
|
|
614
|
+
};
|
|
615
|
+
if (defaultValue !== undefined) {
|
|
616
|
+
// Try to parse as number or keep as string
|
|
617
|
+
const parsed = Number(defaultValue);
|
|
618
|
+
columnDef.defaultValue = isNaN(parsed) ? defaultValue : parsed;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const { data } = await axios.post(
|
|
622
|
+
`${tablesApiUrl}/${encodeURIComponent(tableName)}/columns`,
|
|
623
|
+
{ column: columnDef },
|
|
624
|
+
{ headers: getStudioAuthHeaders() }
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
success(`Column '${columnName}' added!`);
|
|
628
|
+
console.log(` Type: ${data.column.type}`);
|
|
629
|
+
if (data.column.notNull) console.log(` NOT NULL: Yes`);
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
case 'drop-column': {
|
|
634
|
+
requireArgs(args.slice(2), 2, 'lux data tables drop-column <table-name> <column-name>');
|
|
635
|
+
const tableName = args[2];
|
|
636
|
+
const columnName = args[3];
|
|
637
|
+
|
|
638
|
+
info(`Dropping column '${columnName}' from table '${tableName}'...`);
|
|
639
|
+
const tablesApiUrl = getTablesApiUrl();
|
|
640
|
+
|
|
641
|
+
await axios.delete(
|
|
642
|
+
`${tablesApiUrl}/${encodeURIComponent(tableName)}/columns/${encodeURIComponent(columnName)}`,
|
|
643
|
+
{ headers: getStudioAuthHeaders() }
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
success(`Column '${columnName}' deleted!`);
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
case 'rename-column': {
|
|
651
|
+
requireArgs(args.slice(2), 3, 'lux data tables rename-column <table-name> <old-name> <new-name>');
|
|
652
|
+
const tableName = args[2];
|
|
653
|
+
const oldName = args[3];
|
|
654
|
+
const newName = args[4];
|
|
655
|
+
|
|
656
|
+
info(`Renaming column '${oldName}' to '${newName}'...`);
|
|
657
|
+
const tablesApiUrl = getTablesApiUrl();
|
|
658
|
+
|
|
659
|
+
await axios.patch(
|
|
660
|
+
`${tablesApiUrl}/${encodeURIComponent(tableName)}/columns/${encodeURIComponent(oldName)}`,
|
|
661
|
+
{ newName },
|
|
662
|
+
{ headers: getStudioAuthHeaders() }
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
success(`Column renamed from '${oldName}' to '${newName}'!`);
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
case 'change-type': {
|
|
670
|
+
requireArgs(args.slice(2), 3, 'lux data tables change-type <table-name> <column-name> <new-type>');
|
|
671
|
+
const tableName = args[2];
|
|
672
|
+
const columnName = args[3];
|
|
673
|
+
const newType = args[4].toUpperCase();
|
|
674
|
+
|
|
675
|
+
// Validate type
|
|
676
|
+
const validTypes = ['TEXT', 'INTEGER', 'REAL'];
|
|
677
|
+
if (!validTypes.includes(newType)) {
|
|
678
|
+
error(`Invalid type '${newType}'. Must be one of: ${validTypes.join(', ')}`);
|
|
679
|
+
process.exit(1);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// First check if column has data
|
|
683
|
+
info(`Checking if column '${columnName}' has data...`);
|
|
684
|
+
const tablesApiUrl = getTablesApiUrl();
|
|
685
|
+
|
|
686
|
+
const { data: countData } = await axios.get(
|
|
687
|
+
`${tablesApiUrl}/${encodeURIComponent(tableName)}/columns/${encodeURIComponent(columnName)}/count`,
|
|
688
|
+
{ headers: getStudioAuthHeaders() }
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
if (countData.hasData) {
|
|
692
|
+
error(`Cannot change type: column '${columnName}' has ${countData.recordCount} non-null values.`);
|
|
693
|
+
console.log(chalk.yellow(' Clear the column data first, or drop and recreate the column.'));
|
|
694
|
+
process.exit(1);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
info(`Changing column type to ${newType}...`);
|
|
698
|
+
|
|
699
|
+
await axios.put(
|
|
700
|
+
`${tablesApiUrl}/${encodeURIComponent(tableName)}/columns/${encodeURIComponent(columnName)}/type`,
|
|
701
|
+
{ newType },
|
|
702
|
+
{ headers: getStudioAuthHeaders() }
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
success(`Column '${columnName}' type changed to ${newType}!`);
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
|
|
588
709
|
default:
|
|
589
710
|
error(`Unknown table subcommand: ${subCommand}`);
|
|
590
711
|
}
|
package/package.json
CHANGED
|
@@ -11,9 +11,22 @@ const LUX_INTERFACE_ID = process.env.NEXT_PUBLIC_LUX_INTERFACE_ID
|
|
|
11
11
|
const LUX_ORG_ID = process.env.NEXT_PUBLIC_LUX_ORG_ID
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
14
|
+
* LUX STUDIO A/B TEST PREVIEW MODE
|
|
15
|
+
*
|
|
15
16
|
* When previewing A/B test variants in Lux Studio, the preview URL includes
|
|
16
17
|
* ?__lux_flag_overrides={"flag-key":"variant-key"} to force specific variants.
|
|
18
|
+
*
|
|
19
|
+
* If no real PostHog API key is configured (NEXT_PUBLIC_POSTHOG_KEY), we use a
|
|
20
|
+
* fake/dummy key ('phc_lux_preview_mode') to initialize PostHog just enough for
|
|
21
|
+
* the feature flag override system to work. This allows A/B test previews to
|
|
22
|
+
* function during local development without a real PostHog account.
|
|
23
|
+
*
|
|
24
|
+
* The dummy key disables all analytics (autocapture, session recording, etc.)
|
|
25
|
+
* so no data is sent to PostHog - it's purely for local flag overrides.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse Lux Studio flag overrides from URL query params.
|
|
17
30
|
*/
|
|
18
31
|
function getLuxFlagOverrides(): Record<string, string> | undefined {
|
|
19
32
|
if (typeof window === 'undefined') return undefined
|
|
@@ -32,24 +45,37 @@ function getLuxFlagOverrides(): Record<string, string> | undefined {
|
|
|
32
45
|
return undefined
|
|
33
46
|
}
|
|
34
47
|
|
|
48
|
+
/** Check if we're in Lux Studio preview mode (have flag overrides in URL) */
|
|
49
|
+
function isLuxPreviewMode(): boolean {
|
|
50
|
+
return getLuxFlagOverrides() !== undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
|
36
54
|
const initialized = useRef(false)
|
|
37
55
|
const searchParams = useSearchParams()
|
|
38
56
|
|
|
39
|
-
// Initialize PostHog
|
|
57
|
+
// Initialize PostHog - use a dummy key for preview mode if no real key
|
|
40
58
|
useEffect(() => {
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
59
|
+
if (initialized.current) return
|
|
60
|
+
|
|
61
|
+
const hasOverrides = isLuxPreviewMode()
|
|
62
|
+
// Use real key if available, otherwise use dummy key for preview mode only
|
|
63
|
+
// The dummy key 'phc_lux_preview_mode' is NOT a real PostHog key - it just
|
|
64
|
+
// allows the PostHog SDK to initialize so flag overrides work locally
|
|
65
|
+
const keyToUse = POSTHOG_KEY || (hasOverrides ? 'phc_lux_preview_mode' : null)
|
|
66
|
+
|
|
67
|
+
if (!keyToUse) {
|
|
68
|
+
console.log('[PostHog] No API key configured and not in preview mode, skipping initialization')
|
|
45
69
|
return
|
|
46
70
|
}
|
|
47
71
|
|
|
48
|
-
posthog.init(
|
|
72
|
+
posthog.init(keyToUse, {
|
|
49
73
|
api_host: POSTHOG_HOST,
|
|
50
74
|
persistence: 'localStorage',
|
|
51
75
|
capture_pageview: false, // We capture manually for better control
|
|
52
|
-
capture_pageleave:
|
|
76
|
+
capture_pageleave: !hasOverrides, // Don't capture in preview mode without real key
|
|
77
|
+
autocapture: POSTHOG_KEY ? true : false, // Disable autocapture without real key
|
|
78
|
+
disable_session_recording: !POSTHOG_KEY, // Disable recording without real key
|
|
53
79
|
loaded: (ph) => {
|
|
54
80
|
// Register lux properties with all events for filtering
|
|
55
81
|
const props: Record<string, string> = {}
|
|
@@ -65,25 +91,32 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
|
|
65
91
|
}, [])
|
|
66
92
|
|
|
67
93
|
// Apply Lux Studio flag overrides whenever URL changes
|
|
68
|
-
//
|
|
94
|
+
// Uses posthog.featureFlags.overrideFeatureFlags() - the correct API
|
|
69
95
|
useEffect(() => {
|
|
70
|
-
if
|
|
96
|
+
// Only skip if PostHog not initialized at all
|
|
97
|
+
if (!initialized.current && !POSTHOG_KEY && !isLuxPreviewMode()) return
|
|
71
98
|
|
|
72
99
|
const flagOverrides = getLuxFlagOverrides()
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
100
|
+
|
|
101
|
+
if (flagOverrides && Object.keys(flagOverrides).length > 0) {
|
|
102
|
+
// IMPORTANT: overrideFeatureFlags expects { flags: { [key]: value } }
|
|
103
|
+
posthog.featureFlags.overrideFeatureFlags({
|
|
104
|
+
flags: flagOverrides,
|
|
105
|
+
})
|
|
76
106
|
console.log('[PostHog] Applied Lux Studio flag overrides:', flagOverrides)
|
|
77
107
|
|
|
78
108
|
// Force reload to make React hooks aware of the change
|
|
79
109
|
posthog.reloadFeatureFlags()
|
|
80
|
-
} else {
|
|
110
|
+
} else if (initialized.current) {
|
|
81
111
|
// Clear any previous overrides when not in preview mode
|
|
82
|
-
posthog.featureFlags.
|
|
112
|
+
posthog.featureFlags.overrideFeatureFlags({ flags: {} })
|
|
83
113
|
}
|
|
84
114
|
}, [searchParams])
|
|
85
115
|
|
|
86
|
-
if (
|
|
116
|
+
// Always use PHProvider if we're initialized (either with real key or preview mode)
|
|
117
|
+
const shouldUsePHProvider = POSTHOG_KEY || isLuxPreviewMode()
|
|
118
|
+
|
|
119
|
+
if (!shouldUsePHProvider) {
|
|
87
120
|
return <>{children}</>
|
|
88
121
|
}
|
|
89
122
|
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lux Knowledge Upload Library
|
|
3
|
+
*
|
|
4
|
+
* UI-agnostic utilities for uploading files to your project's knowledge bucket.
|
|
5
|
+
* Uses presigned URLs for efficient large file support without base64 encoding.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* // Basic upload
|
|
9
|
+
* import { uploadToKnowledge } from '@/lib/knowledge';
|
|
10
|
+
*
|
|
11
|
+
* const result = await uploadToKnowledge(file);
|
|
12
|
+
* console.log('Uploaded to:', result.path);
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // Upload to specific folder
|
|
16
|
+
* const result = await uploadToKnowledge(file, {
|
|
17
|
+
* folder: 'documents/invoices',
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* // Upload with landing zone (files go to 'uploads/' first)
|
|
22
|
+
* const knowledge = createKnowledgeUploader({
|
|
23
|
+
* landingZone: 'uploads',
|
|
24
|
+
* });
|
|
25
|
+
* const result = await knowledge.upload(file);
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* // Upload with progress tracking
|
|
29
|
+
* const result = await uploadToKnowledge(file, {
|
|
30
|
+
* onProgress: (percent) => setProgress(percent),
|
|
31
|
+
* });
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
// ============================================
|
|
35
|
+
// Types
|
|
36
|
+
// ============================================
|
|
37
|
+
|
|
38
|
+
export interface UploadOptions {
|
|
39
|
+
/** Subfolder path within knowledge (e.g., 'documents/invoices') */
|
|
40
|
+
folder?: string;
|
|
41
|
+
/** Custom filename (defaults to original file name) */
|
|
42
|
+
filename?: string;
|
|
43
|
+
/** Override content type (defaults to file.type or 'application/octet-stream') */
|
|
44
|
+
contentType?: string;
|
|
45
|
+
/** Progress callback (0-100) */
|
|
46
|
+
onProgress?: (percent: number) => void;
|
|
47
|
+
/** Device ID for sync tracking */
|
|
48
|
+
deviceId?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface UploadResult {
|
|
52
|
+
/** Whether the upload succeeded */
|
|
53
|
+
success: boolean;
|
|
54
|
+
/** Full path within knowledge bucket */
|
|
55
|
+
path: string;
|
|
56
|
+
/** File size in bytes */
|
|
57
|
+
size: number;
|
|
58
|
+
/** Any error message if failed */
|
|
59
|
+
error?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface KnowledgeConfig {
|
|
63
|
+
/** API base URL (defaults to env or window config) */
|
|
64
|
+
apiUrl?: string;
|
|
65
|
+
/** API key for authentication */
|
|
66
|
+
apiKey?: string;
|
|
67
|
+
/** Project ID */
|
|
68
|
+
projectId?: string;
|
|
69
|
+
/** Default folder for all uploads */
|
|
70
|
+
landingZone?: string;
|
|
71
|
+
/** Device ID for sync tracking */
|
|
72
|
+
deviceId?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ============================================
|
|
76
|
+
// Configuration
|
|
77
|
+
// ============================================
|
|
78
|
+
|
|
79
|
+
let globalConfig: KnowledgeConfig = {};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Configure the knowledge uploader globally
|
|
83
|
+
*/
|
|
84
|
+
export function configureKnowledge(config: KnowledgeConfig): void {
|
|
85
|
+
globalConfig = { ...globalConfig, ...config };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get API URL from config, window, or environment
|
|
90
|
+
*/
|
|
91
|
+
function getApiUrl(): string {
|
|
92
|
+
if (globalConfig.apiUrl) return globalConfig.apiUrl;
|
|
93
|
+
|
|
94
|
+
if (typeof window !== 'undefined') {
|
|
95
|
+
if ((window as any).__LUX_API_URL__) {
|
|
96
|
+
return (window as any).__LUX_API_URL__;
|
|
97
|
+
}
|
|
98
|
+
if ((window as any).__LUX_LOCAL_MODE__) {
|
|
99
|
+
return 'http://localhost:3001';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (typeof process !== 'undefined' && process.env) {
|
|
104
|
+
return process.env.NEXT_PUBLIC_LUX_API_URL || process.env.LUX_API_URL || 'https://v2.uselux.ai';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return 'https://v2.uselux.ai';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get API key from config, window, or environment
|
|
112
|
+
*/
|
|
113
|
+
function getApiKey(): string {
|
|
114
|
+
if (globalConfig.apiKey) return globalConfig.apiKey;
|
|
115
|
+
|
|
116
|
+
if (typeof window !== 'undefined' && (window as any).__LUX_API_KEY__) {
|
|
117
|
+
return (window as any).__LUX_API_KEY__;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (typeof process !== 'undefined' && process.env) {
|
|
121
|
+
return process.env.NEXT_PUBLIC_LUX_API_KEY || process.env.LUX_API_KEY || '';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get project ID from config, window, or environment
|
|
129
|
+
*/
|
|
130
|
+
function getProjectId(): string {
|
|
131
|
+
if (globalConfig.projectId) return globalConfig.projectId;
|
|
132
|
+
|
|
133
|
+
if (typeof window !== 'undefined' && (window as any).__LUX_PROJECT_ID__) {
|
|
134
|
+
return (window as any).__LUX_PROJECT_ID__;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (typeof process !== 'undefined' && process.env) {
|
|
138
|
+
return process.env.NEXT_PUBLIC_LUX_PROJECT_ID || process.env.LUX_PROJECT_ID || '';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return '';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ============================================
|
|
145
|
+
// Core Upload Function
|
|
146
|
+
// ============================================
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Upload a file to the knowledge bucket
|
|
150
|
+
*
|
|
151
|
+
* @param file - File or Blob to upload
|
|
152
|
+
* @param options - Upload options (folder, filename, progress callback)
|
|
153
|
+
* @returns Upload result with path and size
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* const input = document.querySelector('input[type="file"]');
|
|
157
|
+
* const file = input.files[0];
|
|
158
|
+
* const result = await uploadToKnowledge(file, {
|
|
159
|
+
* folder: 'user-uploads',
|
|
160
|
+
* onProgress: (p) => console.log(`${p}% uploaded`),
|
|
161
|
+
* });
|
|
162
|
+
*/
|
|
163
|
+
export async function uploadToKnowledge(
|
|
164
|
+
file: File | Blob,
|
|
165
|
+
options: UploadOptions = {}
|
|
166
|
+
): Promise<UploadResult> {
|
|
167
|
+
const apiUrl = getApiUrl();
|
|
168
|
+
const apiKey = getApiKey();
|
|
169
|
+
const projectId = getProjectId();
|
|
170
|
+
|
|
171
|
+
if (!projectId) {
|
|
172
|
+
return {
|
|
173
|
+
success: false,
|
|
174
|
+
path: '',
|
|
175
|
+
size: 0,
|
|
176
|
+
error: 'Project ID not configured. Set LUX_PROJECT_ID or configure globally.',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const filename = options.filename || (file instanceof File ? file.name : 'upload');
|
|
181
|
+
const contentType = options.contentType || file.type || 'application/octet-stream';
|
|
182
|
+
const folder = options.folder || globalConfig.landingZone;
|
|
183
|
+
const deviceId = options.deviceId || globalConfig.deviceId || 'web-upload';
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
// Step 1: Get presigned upload URL
|
|
187
|
+
const urlResponse = await fetch(`${apiUrl}/api/projects/${projectId}/knowledge/upload-url`, {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: {
|
|
190
|
+
'Content-Type': 'application/json',
|
|
191
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
192
|
+
},
|
|
193
|
+
body: JSON.stringify({
|
|
194
|
+
path: filename,
|
|
195
|
+
contentType,
|
|
196
|
+
folder,
|
|
197
|
+
}),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (!urlResponse.ok) {
|
|
201
|
+
const error = await urlResponse.json().catch(() => ({ error: 'Failed to get upload URL' }));
|
|
202
|
+
return {
|
|
203
|
+
success: false,
|
|
204
|
+
path: '',
|
|
205
|
+
size: 0,
|
|
206
|
+
error: error.error || `Failed to get upload URL: ${urlResponse.status}`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const { uploadUrl, path } = await urlResponse.json();
|
|
211
|
+
|
|
212
|
+
// Step 2: Upload directly to R2
|
|
213
|
+
if (options.onProgress) {
|
|
214
|
+
// Use XMLHttpRequest for progress tracking
|
|
215
|
+
await uploadWithProgress(uploadUrl, file, contentType, options.onProgress);
|
|
216
|
+
} else {
|
|
217
|
+
// Use fetch for simpler uploads
|
|
218
|
+
const uploadResponse = await fetch(uploadUrl, {
|
|
219
|
+
method: 'PUT',
|
|
220
|
+
headers: {
|
|
221
|
+
'Content-Type': contentType,
|
|
222
|
+
},
|
|
223
|
+
body: file,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
if (!uploadResponse.ok) {
|
|
227
|
+
return {
|
|
228
|
+
success: false,
|
|
229
|
+
path: '',
|
|
230
|
+
size: 0,
|
|
231
|
+
error: `Upload failed: ${uploadResponse.status}`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Step 3: Confirm upload and trigger sync broadcast
|
|
237
|
+
const confirmResponse = await fetch(`${apiUrl}/api/projects/${projectId}/knowledge/confirm-upload`, {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
headers: {
|
|
240
|
+
'Content-Type': 'application/json',
|
|
241
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
242
|
+
'X-Device-Id': deviceId,
|
|
243
|
+
},
|
|
244
|
+
body: JSON.stringify({ path }),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
if (!confirmResponse.ok) {
|
|
248
|
+
// Upload succeeded but confirmation failed - file is still there
|
|
249
|
+
console.warn('[knowledge] Upload succeeded but confirmation failed');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const result = await confirmResponse.json();
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
success: true,
|
|
256
|
+
path,
|
|
257
|
+
size: result.size || file.size,
|
|
258
|
+
};
|
|
259
|
+
} catch (error: any) {
|
|
260
|
+
return {
|
|
261
|
+
success: false,
|
|
262
|
+
path: '',
|
|
263
|
+
size: 0,
|
|
264
|
+
error: error.message || 'Upload failed',
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Upload with XMLHttpRequest for progress tracking
|
|
271
|
+
*/
|
|
272
|
+
function uploadWithProgress(
|
|
273
|
+
url: string,
|
|
274
|
+
file: File | Blob,
|
|
275
|
+
contentType: string,
|
|
276
|
+
onProgress: (percent: number) => void
|
|
277
|
+
): Promise<void> {
|
|
278
|
+
return new Promise((resolve, reject) => {
|
|
279
|
+
const xhr = new XMLHttpRequest();
|
|
280
|
+
|
|
281
|
+
xhr.upload.addEventListener('progress', (event) => {
|
|
282
|
+
if (event.lengthComputable) {
|
|
283
|
+
const percent = Math.round((event.loaded / event.total) * 100);
|
|
284
|
+
onProgress(percent);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
xhr.addEventListener('load', () => {
|
|
289
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
290
|
+
onProgress(100);
|
|
291
|
+
resolve();
|
|
292
|
+
} else {
|
|
293
|
+
reject(new Error(`Upload failed: ${xhr.status}`));
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
xhr.addEventListener('error', () => {
|
|
298
|
+
reject(new Error('Upload failed: Network error'));
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
xhr.open('PUT', url);
|
|
302
|
+
xhr.setRequestHeader('Content-Type', contentType);
|
|
303
|
+
xhr.send(file);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ============================================
|
|
308
|
+
// Batch Upload
|
|
309
|
+
// ============================================
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Upload multiple files to knowledge
|
|
313
|
+
*
|
|
314
|
+
* @param files - Array of files to upload
|
|
315
|
+
* @param options - Upload options applied to all files
|
|
316
|
+
* @returns Array of upload results
|
|
317
|
+
*
|
|
318
|
+
* @example
|
|
319
|
+
* const input = document.querySelector('input[type="file"][multiple]');
|
|
320
|
+
* const results = await uploadMultiple(Array.from(input.files), {
|
|
321
|
+
* folder: 'batch-uploads',
|
|
322
|
+
* });
|
|
323
|
+
*/
|
|
324
|
+
export async function uploadMultiple(
|
|
325
|
+
files: (File | Blob)[],
|
|
326
|
+
options: UploadOptions = {}
|
|
327
|
+
): Promise<UploadResult[]> {
|
|
328
|
+
const results: UploadResult[] = [];
|
|
329
|
+
|
|
330
|
+
for (const file of files) {
|
|
331
|
+
const result = await uploadToKnowledge(file, options);
|
|
332
|
+
results.push(result);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return results;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ============================================
|
|
339
|
+
// Factory Function
|
|
340
|
+
// ============================================
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Create a configured knowledge uploader instance
|
|
344
|
+
*
|
|
345
|
+
* @param config - Configuration options
|
|
346
|
+
* @returns Uploader instance with bound config
|
|
347
|
+
*
|
|
348
|
+
* @example
|
|
349
|
+
* const knowledge = createKnowledgeUploader({
|
|
350
|
+
* landingZone: 'user-uploads',
|
|
351
|
+
* projectId: 'my-project-id',
|
|
352
|
+
* });
|
|
353
|
+
*
|
|
354
|
+
* // All uploads go to 'user-uploads/' folder
|
|
355
|
+
* await knowledge.upload(file);
|
|
356
|
+
* await knowledge.uploadMultiple(files);
|
|
357
|
+
*/
|
|
358
|
+
export function createKnowledgeUploader(config: KnowledgeConfig = {}) {
|
|
359
|
+
const mergedConfig = { ...globalConfig, ...config };
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
/**
|
|
363
|
+
* Upload a single file
|
|
364
|
+
*/
|
|
365
|
+
upload: (file: File | Blob, options: UploadOptions = {}) => {
|
|
366
|
+
const prevConfig = globalConfig;
|
|
367
|
+
globalConfig = mergedConfig;
|
|
368
|
+
const result = uploadToKnowledge(file, options);
|
|
369
|
+
globalConfig = prevConfig;
|
|
370
|
+
return result;
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Upload multiple files
|
|
375
|
+
*/
|
|
376
|
+
uploadMultiple: (files: (File | Blob)[], options: UploadOptions = {}) => {
|
|
377
|
+
const prevConfig = globalConfig;
|
|
378
|
+
globalConfig = mergedConfig;
|
|
379
|
+
const result = uploadMultiple(files, options);
|
|
380
|
+
globalConfig = prevConfig;
|
|
381
|
+
return result;
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Update configuration
|
|
386
|
+
*/
|
|
387
|
+
configure: (newConfig: Partial<KnowledgeConfig>) => {
|
|
388
|
+
Object.assign(mergedConfig, newConfig);
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ============================================
|
|
394
|
+
// Convenience Exports
|
|
395
|
+
// ============================================
|
|
396
|
+
|
|
397
|
+
export const knowledge = {
|
|
398
|
+
upload: uploadToKnowledge,
|
|
399
|
+
uploadMultiple,
|
|
400
|
+
configure: configureKnowledge,
|
|
401
|
+
createUploader: createKnowledgeUploader,
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
export default knowledge;
|