luxlabs 1.0.15 → 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.
@@ -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
- * Supports both interface ID and interface name
55
+ * Uses ~/.lux-studio/{orgId}/projects/{projectId}/interfaces/{interfaceId}/repo/.lux/ab-tests.json
56
56
  */
57
57
  function getABTestsPath(interfaceIdentifier) {
58
- const interfacesDir = path.join(process.cwd(), 'interfaces');
59
-
60
- if (!fs.existsSync(interfacesDir)) {
61
- return null;
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
- // If no identifier provided, try to find the only interface
65
- if (!interfaceIdentifier) {
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 === 0) {
70
- return null;
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 (dirs.length === 1) {
74
- // Auto-select the only interface
75
- interfaceIdentifier = dirs[0].name;
76
- } else {
77
- // Multiple interfaces - need to specify
78
- console.log(chalk.yellow('Multiple interfaces found. Please specify which one:'));
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 = meta.name || dir.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
- // Check if it's a direct interface ID (directory exists)
95
- let interfaceDir = path.join(interfacesDir, interfaceIdentifier, 'repo');
96
- if (fs.existsSync(interfaceDir)) {
97
- return path.join(interfaceDir, '.lux', 'ab-tests.json');
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;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "luxlabs",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "description": "CLI tool for Lux - Upload and deploy interfaces from your terminal",
5
5
  "author": "Jason Henkel <jason@uselux.ai>",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -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
- * Parse Lux Studio flag overrides from URL query params.
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 once
57
+ // Initialize PostHog - use a dummy key for preview mode if no real key
40
58
  useEffect(() => {
41
- if (!POSTHOG_KEY || initialized.current) {
42
- if (!POSTHOG_KEY) {
43
- console.log('[PostHog] No API key configured, skipping initialization')
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(POSTHOG_KEY, {
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: true,
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
- // This uses posthog.featureFlags.override() which works even after init
94
+ // Uses posthog.featureFlags.overrideFeatureFlags() - the correct API
69
95
  useEffect(() => {
70
- if (!POSTHOG_KEY) return
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
- if (flagOverrides) {
74
- // Override feature flags for Lux Studio preview
75
- posthog.featureFlags.override(flagOverrides)
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.override(false)
112
+ posthog.featureFlags.overrideFeatureFlags({ flags: {} })
83
113
  }
84
114
  }, [searchParams])
85
115
 
86
- if (!POSTHOG_KEY) {
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;