@oxy-hq/sdk 0.3.0 → 2.0.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/README.md +64 -435
- package/dist/index.cjs +1585 -997
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +464 -870
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +464 -870
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1571 -967
- package/dist/index.mjs.map +1 -1
- package/package.json +73 -78
- package/dist/postMessage-Cb5PCtcE.cjs +0 -233
- package/dist/postMessage-Cb5PCtcE.cjs.map +0 -1
- package/dist/postMessage-Gnhr_wnw.mjs +0 -207
- package/dist/postMessage-Gnhr_wnw.mjs.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -7,19 +7,6 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
7
7
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
8
8
|
var __getProtoOf = Object.getPrototypeOf;
|
|
9
9
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
10
|
-
var __exportAll = (all, no_symbols) => {
|
|
11
|
-
let target = {};
|
|
12
|
-
for (var name in all) {
|
|
13
|
-
__defProp(target, name, {
|
|
14
|
-
get: all[name],
|
|
15
|
-
enumerable: true
|
|
16
|
-
});
|
|
17
|
-
}
|
|
18
|
-
if (!no_symbols) {
|
|
19
|
-
__defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
20
|
-
}
|
|
21
|
-
return target;
|
|
22
|
-
};
|
|
23
10
|
var __copyProps = (to, from, except, desc) => {
|
|
24
11
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
25
12
|
for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
@@ -40,1068 +27,1669 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
40
27
|
}) : target, mod));
|
|
41
28
|
|
|
42
29
|
//#endregion
|
|
43
|
-
const require_postMessage = require('./postMessage-Cb5PCtcE.cjs');
|
|
44
|
-
let _duckdb_duckdb_wasm = require("@duckdb/duckdb-wasm");
|
|
45
|
-
_duckdb_duckdb_wasm = __toESM(_duckdb_duckdb_wasm);
|
|
46
30
|
let react = require("react");
|
|
47
31
|
react = __toESM(react);
|
|
48
32
|
|
|
49
|
-
//#region src/
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (typeof process !== "undefined" && process.env) return process.env[name];
|
|
55
|
-
if (typeof {} !== "undefined" && {}.env) {
|
|
56
|
-
const viteValue = {}.env[`VITE_${name}`];
|
|
57
|
-
if (viteValue !== void 0) return viteValue;
|
|
58
|
-
return {}.env[name];
|
|
59
|
-
}
|
|
33
|
+
//#region src/customer-app/logger.ts
|
|
34
|
+
let activeLogger = createConsoleLogger();
|
|
35
|
+
/** Replace the global logger. Pass `null` to silence everything. */
|
|
36
|
+
function setOxyAppLogger(logger) {
|
|
37
|
+
activeLogger = logger ?? silentLogger();
|
|
60
38
|
}
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
* Environment variables:
|
|
65
|
-
* - OXY_URL: Base URL of the Oxy API
|
|
66
|
-
* - OXY_API_KEY: API key for authentication
|
|
67
|
-
* - OXY_PROJECT_ID: Project ID (UUID)
|
|
68
|
-
* - OXY_BRANCH: (Optional) Branch name
|
|
69
|
-
*
|
|
70
|
-
* @param overrides - Optional configuration overrides
|
|
71
|
-
* @returns OxyConfig object
|
|
72
|
-
* @throws Error if required environment variables are missing
|
|
73
|
-
*/
|
|
74
|
-
function createConfig(overrides) {
|
|
75
|
-
const baseUrl = overrides?.baseUrl || getEnvVar("OXY_URL");
|
|
76
|
-
const apiKey = overrides?.apiKey || getEnvVar("OXY_API_KEY");
|
|
77
|
-
const projectId = overrides?.projectId || getEnvVar("OXY_PROJECT_ID");
|
|
78
|
-
if (!baseUrl) throw new Error("OXY_URL environment variable or baseUrl config is required");
|
|
79
|
-
if (!projectId) throw new Error("OXY_PROJECT_ID environment variable or projectId config is required");
|
|
80
|
-
return {
|
|
81
|
-
baseUrl: baseUrl.replace(/\/$/, ""),
|
|
82
|
-
apiKey,
|
|
83
|
-
projectId,
|
|
84
|
-
branch: overrides?.branch || getEnvVar("OXY_BRANCH"),
|
|
85
|
-
timeout: overrides?.timeout || 3e4,
|
|
86
|
-
parentOrigin: overrides?.parentOrigin,
|
|
87
|
-
disableAutoAuth: overrides?.disableAutoAuth
|
|
88
|
-
};
|
|
39
|
+
/** Used by the SDK internals; not part of the public surface. */
|
|
40
|
+
function getOxyAppLogger() {
|
|
41
|
+
return activeLogger;
|
|
89
42
|
}
|
|
43
|
+
function createConsoleLogger() {
|
|
44
|
+
return { log(level, msg, ctx) {
|
|
45
|
+
if (typeof console === "undefined") return;
|
|
46
|
+
const prefix = "[oxy-app]";
|
|
47
|
+
const args = ctx ? [
|
|
48
|
+
prefix,
|
|
49
|
+
msg,
|
|
50
|
+
ctx
|
|
51
|
+
] : [prefix, msg];
|
|
52
|
+
switch (level) {
|
|
53
|
+
case "debug":
|
|
54
|
+
console.debug(...args);
|
|
55
|
+
break;
|
|
56
|
+
case "info":
|
|
57
|
+
console.info(...args);
|
|
58
|
+
break;
|
|
59
|
+
case "warn":
|
|
60
|
+
console.warn(...args);
|
|
61
|
+
break;
|
|
62
|
+
case "error":
|
|
63
|
+
console.error(...args);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
} };
|
|
67
|
+
}
|
|
68
|
+
function silentLogger() {
|
|
69
|
+
return { log() {} };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/customer-app/debug.ts
|
|
90
74
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* When running in an iframe without an API key, this function will:
|
|
97
|
-
* 1. Detect the iframe context
|
|
98
|
-
* 2. Send an authentication request to the parent window
|
|
99
|
-
* 3. Wait for the parent to respond with credentials
|
|
100
|
-
* 4. Return the configured client
|
|
101
|
-
*
|
|
102
|
-
* Environment variables (fallback):
|
|
103
|
-
* - OXY_URL: Base URL of the Oxy API
|
|
104
|
-
* - OXY_API_KEY: API key for authentication
|
|
105
|
-
* - OXY_PROJECT_ID: Project ID (UUID)
|
|
106
|
-
* - OXY_BRANCH: (Optional) Branch name
|
|
107
|
-
*
|
|
108
|
-
* @param overrides - Optional configuration overrides
|
|
109
|
-
* @returns Promise resolving to OxyConfig object
|
|
110
|
-
* @throws Error if required configuration is missing
|
|
111
|
-
* @throws PostMessageAuthTimeoutError if parent doesn't respond
|
|
112
|
-
*
|
|
113
|
-
* @example
|
|
114
|
-
* ```typescript
|
|
115
|
-
* // Automatic iframe detection and authentication
|
|
116
|
-
* const config = await createConfigAsync({
|
|
117
|
-
* parentOrigin: 'https://app.example.com',
|
|
118
|
-
* projectId: 'my-project-id',
|
|
119
|
-
* baseUrl: 'https://api.oxy.tech'
|
|
120
|
-
* });
|
|
121
|
-
* ```
|
|
75
|
+
* Fetch the server-side diagnostic snapshot for this bundle. Pair with
|
|
76
|
+
* `loadCustomerAppManifest()` — pass its result here. Logs the
|
|
77
|
+
* snapshot through the SDK logger so it appears in the bundle's
|
|
78
|
+
* console at info level.
|
|
122
79
|
*/
|
|
123
|
-
async function
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
console.error("[Oxy SDK] Failed to authenticate via postMessage:", error.message);
|
|
137
|
-
return apiKey;
|
|
138
|
-
});
|
|
139
|
-
return createFinalConfig(baseUrl, apiKey, projectId, overrides);
|
|
140
|
-
}
|
|
141
|
-
function logWarningAboutMissingParentOrigin() {
|
|
142
|
-
console.warn("[Oxy SDK] Running in iframe without API key and no parentOrigin specified. PostMessage authentication will be skipped. Provide parentOrigin config to enable automatic authentication.");
|
|
80
|
+
async function getCustomerAppDebug(resolved) {
|
|
81
|
+
const log = getOxyAppLogger();
|
|
82
|
+
const { apiBaseUrl, orgSlug, appSlug } = resolved;
|
|
83
|
+
const url = `${apiBaseUrl}/api/customer-apps/${encodeURIComponent(orgSlug)}/${encodeURIComponent(appSlug)}/debug`;
|
|
84
|
+
log.log("debug", "fetching debug snapshot", { url });
|
|
85
|
+
const res = await fetch(url, { credentials: "same-origin" });
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
const detail = await res.text().catch(() => "");
|
|
88
|
+
throw new Error(`Failed to fetch debug snapshot (HTTP ${res.status}): ${detail || res.statusText}`);
|
|
89
|
+
}
|
|
90
|
+
const snapshot = await res.json();
|
|
91
|
+
log.log("info", "debug snapshot", snapshot);
|
|
92
|
+
return snapshot;
|
|
143
93
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
94
|
+
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/customer-app/errors.ts
|
|
97
|
+
const ARCH_DOC = "internal-docs/customer-apps.md";
|
|
98
|
+
/** Interpret a thrown error as a structured report for UI display. */
|
|
99
|
+
function interpretCustomerAppError(err) {
|
|
100
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
101
|
+
if (/Failed to load oxy-app\.json.*HTTP 404/.test(message)) return {
|
|
102
|
+
title: "Manifest not found",
|
|
103
|
+
message,
|
|
104
|
+
hint: "The bundle is being served, but oxy-app.json was not. Check that public/oxy-app.json is committed in the customer-app repo and that the build copied it into the static output. If you're using Next.js, anything under public/ is auto-copied to out/.",
|
|
105
|
+
docs: ARCH_DOC
|
|
106
|
+
};
|
|
107
|
+
if (/Failed to load oxy-app\.json/.test(message)) return {
|
|
108
|
+
title: "Manifest could not be loaded",
|
|
109
|
+
message,
|
|
110
|
+
hint: "Network error fetching the manifest. Confirm the bundle is being served from a path that matches OXY_APP_BASE_PATH at build time — a mismatch causes assets and the manifest to 404.",
|
|
111
|
+
docs: ARCH_DOC
|
|
112
|
+
};
|
|
113
|
+
if (/schemaVersion/i.test(message)) return {
|
|
114
|
+
title: "Manifest schema mismatch",
|
|
115
|
+
message,
|
|
116
|
+
hint: "This bundle was built against a different version of the data-product contract than the SDK it ships. Rebuild the bundle with a compatible @oxy-hq/sdk version.",
|
|
117
|
+
docs: ARCH_DOC
|
|
118
|
+
};
|
|
119
|
+
if (/^401:/m.test(message)) return {
|
|
120
|
+
title: "Session expired",
|
|
121
|
+
message,
|
|
122
|
+
hint: "Reload the page to re-authenticate via oxy's session cookie.",
|
|
123
|
+
docs: ARCH_DOC
|
|
124
|
+
};
|
|
125
|
+
if (/^403:.*origin not allowed/im.test(message)) return {
|
|
126
|
+
title: "Request origin not allowed",
|
|
127
|
+
message,
|
|
128
|
+
hint: "The bundle's host isn't in oxy's OXY_ALLOWED_ORIGINS. Production: add the bundle's serving origin to the env var. Local dev: oxy auto-allows http://localhost:5173 and :5174.",
|
|
129
|
+
docs: ARCH_DOC
|
|
130
|
+
};
|
|
131
|
+
if (/^403:.*not a member/im.test(message)) return {
|
|
132
|
+
title: "Access denied",
|
|
133
|
+
message,
|
|
134
|
+
hint: "Your account isn't a member of the org that owns this project. Ask an org owner to add you.",
|
|
135
|
+
docs: ARCH_DOC
|
|
136
|
+
};
|
|
137
|
+
if (/^403:.*SELECT.*WITH/im.test(message)) return {
|
|
138
|
+
title: "Query rejected — read-only endpoint",
|
|
139
|
+
message,
|
|
140
|
+
hint: "This proxy only runs SELECT or WITH queries. Mutations (INSERT/UPDATE/DELETE/DROP) are not allowed from customer-app bundles.",
|
|
141
|
+
docs: ARCH_DOC
|
|
142
|
+
};
|
|
143
|
+
if (/^403:/m.test(message)) return {
|
|
144
|
+
title: "Access denied",
|
|
145
|
+
message,
|
|
146
|
+
hint: "The request was rejected by the server. Check the oxy server logs for details.",
|
|
147
|
+
docs: ARCH_DOC
|
|
148
|
+
};
|
|
149
|
+
if (/^404:/m.test(message) && /project/i.test(message)) return {
|
|
150
|
+
title: "Project not found",
|
|
151
|
+
message,
|
|
152
|
+
hint: "The projectId in oxy-app.json doesn't match any registered project. Confirm the manifest's projectId is a real UUID for this deployment.",
|
|
153
|
+
docs: ARCH_DOC
|
|
154
|
+
};
|
|
155
|
+
if (/^400:.*sql.*must be non-empty/im.test(message)) return {
|
|
156
|
+
title: "Empty SQL",
|
|
157
|
+
message,
|
|
158
|
+
hint: "useQuery was called with an empty or whitespace-only `sql`. Pass a real query, or set `enabled: false` to skip the call.",
|
|
159
|
+
docs: ARCH_DOC
|
|
160
|
+
};
|
|
161
|
+
if (/^400:/m.test(message) && /query failed/i.test(message)) return {
|
|
162
|
+
title: "Query failed",
|
|
163
|
+
message,
|
|
164
|
+
hint: "The SQL ran but the warehouse rejected it. Full error in the oxy server logs (look for the projects::query span).",
|
|
165
|
+
docs: ARCH_DOC
|
|
166
|
+
};
|
|
167
|
+
if (/^502:/m.test(message)) return {
|
|
168
|
+
title: "Warehouse unreachable",
|
|
169
|
+
message,
|
|
170
|
+
hint: "Oxy couldn't reach the configured database. Check connector config + warehouse health.",
|
|
171
|
+
docs: ARCH_DOC
|
|
172
|
+
};
|
|
173
|
+
if (/Unexpected token '<'|<!doctype/i.test(message)) return {
|
|
174
|
+
title: "Fetched HTML where JSON was expected",
|
|
175
|
+
message,
|
|
176
|
+
hint: "Most likely the built bundle is stale — built against an old SDK whose endpoints no longer exist on the server. Rebuild the bundle (vite build) with @oxy-hq/sdk@^2.0.0 and reload. If the bundle is current, check that OXY_APP_BASE_PATH matches the path the customer-app row is served at.",
|
|
177
|
+
docs: ARCH_DOC
|
|
155
178
|
};
|
|
156
|
-
}
|
|
157
|
-
function createFinalConfig(baseUrl, apiKey, projectId, overrides) {
|
|
158
|
-
if (!baseUrl) throw new Error("OXY_URL environment variable or baseUrl config is required");
|
|
159
|
-
if (!projectId) throw new Error("OXY_PROJECT_ID environment variable or projectId config is required");
|
|
160
179
|
return {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
timeout: overrides?.timeout || 3e4,
|
|
166
|
-
parentOrigin: overrides?.parentOrigin,
|
|
167
|
-
disableAutoAuth: overrides?.disableAutoAuth
|
|
180
|
+
title: "Unexpected error loading the dashboard",
|
|
181
|
+
message,
|
|
182
|
+
hint: "Check the browser console for the full stack trace, and the oxy server logs for the corresponding request.",
|
|
183
|
+
docs: ARCH_DOC
|
|
168
184
|
};
|
|
169
185
|
}
|
|
170
186
|
|
|
171
187
|
//#endregion
|
|
172
|
-
//#region src/
|
|
173
|
-
let dbInstance = null;
|
|
174
|
-
let connection = null;
|
|
175
|
-
let operationQueue = Promise.resolve();
|
|
188
|
+
//#region src/customer-app/inject.ts
|
|
176
189
|
/**
|
|
177
|
-
*
|
|
190
|
+
* Read the runtime app-config oxy injected at serve time. Returns
|
|
191
|
+
* `undefined` outside the browser or when the global isn't set
|
|
192
|
+
* (`pnpm dev` against a non-oxy server, etc. — manifest hints are
|
|
193
|
+
* the fallback).
|
|
178
194
|
*/
|
|
179
|
-
function
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
return currentOperation;
|
|
195
|
+
function readInjectedAppConfig() {
|
|
196
|
+
if (typeof window === "undefined") return void 0;
|
|
197
|
+
return window.__OXY_APP__;
|
|
183
198
|
}
|
|
199
|
+
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/customer-app/manifest.ts
|
|
202
|
+
let cached = null;
|
|
184
203
|
/**
|
|
185
|
-
*
|
|
204
|
+
* Load + validate the manifest. Cached after the first call so callers
|
|
205
|
+
* can invoke this from every component without coordinating.
|
|
186
206
|
*/
|
|
187
|
-
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
const bundle = await _duckdb_duckdb_wasm.selectBundle(JSDELIVR_BUNDLES);
|
|
191
|
-
const worker_url = URL.createObjectURL(new Blob([`importScripts("${bundle.mainWorker}");`], { type: "text/javascript" }));
|
|
192
|
-
const worker = new Worker(worker_url);
|
|
193
|
-
const logger = new _duckdb_duckdb_wasm.ConsoleLogger();
|
|
194
|
-
dbInstance = new _duckdb_duckdb_wasm.AsyncDuckDB(logger, worker);
|
|
195
|
-
await dbInstance.instantiate(bundle.mainModule, bundle.pthreadWorker);
|
|
196
|
-
URL.revokeObjectURL(worker_url);
|
|
197
|
-
return dbInstance;
|
|
207
|
+
function loadCustomerAppManifest(options = {}) {
|
|
208
|
+
if (!cached) cached = fetchAndValidate(options);
|
|
209
|
+
return cached;
|
|
198
210
|
}
|
|
199
|
-
/**
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
async function getConnection() {
|
|
203
|
-
if (connection) return connection;
|
|
204
|
-
connection = await (await initializeDuckDB()).connect();
|
|
205
|
-
return connection;
|
|
211
|
+
/** For tests: reset the cache between runs. */
|
|
212
|
+
function _resetCustomerAppManifestCacheForTest() {
|
|
213
|
+
cached = null;
|
|
206
214
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
* @param tableName - Name to use for the table in queries (required)
|
|
226
|
-
*
|
|
227
|
-
* @example
|
|
228
|
-
* ```typescript
|
|
229
|
-
* const blob = await client.getFile('data/sales.parquet');
|
|
230
|
-
* const reader = new ParquetReader();
|
|
231
|
-
* await reader.registerParquet(blob, 'sales');
|
|
232
|
-
* ```
|
|
233
|
-
*
|
|
234
|
-
* @example
|
|
235
|
-
* ```typescript
|
|
236
|
-
* // Register multiple files
|
|
237
|
-
* const reader = new ParquetReader();
|
|
238
|
-
* await reader.registerParquet(salesBlob, 'sales');
|
|
239
|
-
* await reader.registerParquet(customersBlob, 'customers');
|
|
240
|
-
* const result = await reader.query('SELECT * FROM sales JOIN customers ON sales.customer_id = customers.id');
|
|
241
|
-
* ```
|
|
242
|
-
*/
|
|
243
|
-
async registerParquet(blob, tableName) {
|
|
244
|
-
const internalTableName = this.generateInternalTableName(tableName);
|
|
245
|
-
await enqueueOperation(async () => {
|
|
246
|
-
const conn = await getConnection();
|
|
247
|
-
const db = await initializeDuckDB();
|
|
248
|
-
const arrayBuffer = await blob.arrayBuffer();
|
|
249
|
-
const uint8Array = new Uint8Array(arrayBuffer);
|
|
250
|
-
await db.registerFileBuffer(`${internalTableName}.parquet`, uint8Array);
|
|
251
|
-
try {
|
|
252
|
-
await conn.query(`DROP TABLE IF EXISTS ${internalTableName}`);
|
|
253
|
-
} catch {}
|
|
254
|
-
await conn.query(`CREATE TABLE ${internalTableName} AS SELECT * FROM '${internalTableName}.parquet'`);
|
|
255
|
-
this.tableMap.set(tableName, internalTableName);
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
/**
|
|
259
|
-
* Register multiple Parquet files at once
|
|
260
|
-
*
|
|
261
|
-
* @param files - Array of objects containing blob and tableName
|
|
262
|
-
*
|
|
263
|
-
* @example
|
|
264
|
-
* ```typescript
|
|
265
|
-
* const reader = new ParquetReader();
|
|
266
|
-
* await reader.registerMultipleParquet([
|
|
267
|
-
* { blob: salesBlob, tableName: 'sales' },
|
|
268
|
-
* { blob: customersBlob, tableName: 'customers' },
|
|
269
|
-
* { blob: productsBlob, tableName: 'products' }
|
|
270
|
-
* ]);
|
|
271
|
-
* const result = await reader.query('SELECT * FROM sales JOIN customers ON sales.customer_id = customers.id');
|
|
272
|
-
* ```
|
|
273
|
-
*/
|
|
274
|
-
async registerMultipleParquet(files) {
|
|
275
|
-
for (const file of files) await this.registerParquet(file.blob, file.tableName);
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* Execute a SQL query against the registered Parquet data
|
|
279
|
-
*
|
|
280
|
-
* @param sql - SQL query string
|
|
281
|
-
* @returns Query result with columns and rows
|
|
282
|
-
*
|
|
283
|
-
* @example
|
|
284
|
-
* ```typescript
|
|
285
|
-
* const result = await reader.query('SELECT * FROM sales LIMIT 10');
|
|
286
|
-
* console.log(result.columns);
|
|
287
|
-
* console.log(result.rows);
|
|
288
|
-
* ```
|
|
289
|
-
*
|
|
290
|
-
* @example
|
|
291
|
-
* ```typescript
|
|
292
|
-
* // Query multiple tables
|
|
293
|
-
* await reader.registerParquet(salesBlob, 'sales');
|
|
294
|
-
* await reader.registerParquet(customersBlob, 'customers');
|
|
295
|
-
* const result = await reader.query(`
|
|
296
|
-
* SELECT s.*, c.name
|
|
297
|
-
* FROM sales s
|
|
298
|
-
* JOIN customers c ON s.customer_id = c.id
|
|
299
|
-
* `);
|
|
300
|
-
* ```
|
|
301
|
-
*/
|
|
302
|
-
async query(sql) {
|
|
303
|
-
if (this.tableMap.size === 0) throw new Error("No Parquet files registered. Call registerParquet() first.");
|
|
304
|
-
return enqueueOperation(async () => {
|
|
305
|
-
const conn = await getConnection();
|
|
306
|
-
let rewrittenSql = sql;
|
|
307
|
-
for (const [userTableName, internalTableName] of this.tableMap.entries()) rewrittenSql = rewrittenSql.replace(new RegExp(`\\b${userTableName}\\b`, "g"), internalTableName);
|
|
308
|
-
const result = await conn.query(rewrittenSql);
|
|
309
|
-
const columns = result.schema.fields.map((field) => field.name);
|
|
310
|
-
const rows = [];
|
|
311
|
-
for (let i = 0; i < result.numRows; i++) {
|
|
312
|
-
const row = [];
|
|
313
|
-
for (let j = 0; j < result.numCols; j++) {
|
|
314
|
-
const col = result.getChildAt(j);
|
|
315
|
-
row.push(col?.get(i));
|
|
316
|
-
}
|
|
317
|
-
rows.push(row);
|
|
318
|
-
}
|
|
319
|
-
return {
|
|
320
|
-
columns,
|
|
321
|
-
rows,
|
|
322
|
-
rowCount: result.numRows
|
|
323
|
-
};
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
/**
|
|
327
|
-
* Get all data from a registered table
|
|
328
|
-
*
|
|
329
|
-
* @param tableName - Name of the table to query
|
|
330
|
-
* @param limit - Maximum number of rows to return (default: all)
|
|
331
|
-
* @returns Query result
|
|
332
|
-
*
|
|
333
|
-
* @example
|
|
334
|
-
* ```typescript
|
|
335
|
-
* const allData = await reader.getAll('sales');
|
|
336
|
-
* const first100 = await reader.getAll('sales', 100);
|
|
337
|
-
* ```
|
|
338
|
-
*/
|
|
339
|
-
async getAll(tableName, limit) {
|
|
340
|
-
const limitClause = limit ? ` LIMIT ${limit}` : "";
|
|
341
|
-
return this.query(`SELECT * FROM ${tableName}${limitClause}`);
|
|
342
|
-
}
|
|
343
|
-
/**
|
|
344
|
-
* Get table schema information
|
|
345
|
-
*
|
|
346
|
-
* @param tableName - Name of the table to describe
|
|
347
|
-
* @returns Schema information
|
|
348
|
-
*
|
|
349
|
-
* @example
|
|
350
|
-
* ```typescript
|
|
351
|
-
* const schema = await reader.getSchema('sales');
|
|
352
|
-
* console.log(schema.columns); // ['id', 'name', 'sales']
|
|
353
|
-
* console.log(schema.rows); // [['id', 'INTEGER'], ['name', 'VARCHAR'], ...]
|
|
354
|
-
* ```
|
|
355
|
-
*/
|
|
356
|
-
async getSchema(tableName) {
|
|
357
|
-
return this.query(`DESCRIBE ${tableName}`);
|
|
358
|
-
}
|
|
359
|
-
/**
|
|
360
|
-
* Get row count for a table
|
|
361
|
-
*
|
|
362
|
-
* @param tableName - Name of the table to count
|
|
363
|
-
* @returns Number of rows in the table
|
|
364
|
-
*
|
|
365
|
-
* @example
|
|
366
|
-
* ```typescript
|
|
367
|
-
* const count = await reader.count('sales');
|
|
368
|
-
* console.log(`Total rows: ${count}`);
|
|
369
|
-
* ```
|
|
370
|
-
*/
|
|
371
|
-
async count(tableName) {
|
|
372
|
-
return (await this.query(`SELECT COUNT(*) as count FROM ${tableName}`)).rows[0][0];
|
|
373
|
-
}
|
|
374
|
-
/**
|
|
375
|
-
* Close and cleanup all registered resources
|
|
376
|
-
*/
|
|
377
|
-
async close() {
|
|
378
|
-
if (this.tableMap.size > 0) await enqueueOperation(async () => {
|
|
379
|
-
const conn = await getConnection();
|
|
380
|
-
const db = await initializeDuckDB();
|
|
381
|
-
for (const [, internalTableName] of this.tableMap.entries()) {
|
|
382
|
-
try {
|
|
383
|
-
await conn.query(`DROP TABLE IF EXISTS ${internalTableName}`);
|
|
384
|
-
} catch {}
|
|
385
|
-
try {
|
|
386
|
-
await db.dropFile(`${internalTableName}.parquet`);
|
|
387
|
-
} catch {}
|
|
388
|
-
}
|
|
389
|
-
this.tableMap.clear();
|
|
215
|
+
async function fetchAndValidate(options) {
|
|
216
|
+
const log = getOxyAppLogger();
|
|
217
|
+
const injected = readInjectedAppConfig();
|
|
218
|
+
const manifestUrl = options.manifestUrl ?? defaultManifestUrl(injected);
|
|
219
|
+
log.log("info", "loading manifest", {
|
|
220
|
+
manifestUrl,
|
|
221
|
+
injectionPresent: !!injected,
|
|
222
|
+
orgSlug: injected?.orgSlug,
|
|
223
|
+
appSlug: injected?.slug,
|
|
224
|
+
appId: injected?.appId
|
|
225
|
+
});
|
|
226
|
+
const startedAt = Date.now();
|
|
227
|
+
const res = await fetch(manifestUrl, { credentials: "same-origin" });
|
|
228
|
+
if (!res.ok) {
|
|
229
|
+
log.log("error", "manifest fetch failed", {
|
|
230
|
+
manifestUrl,
|
|
231
|
+
status: res.status,
|
|
232
|
+
statusText: res.statusText
|
|
390
233
|
});
|
|
234
|
+
throw new Error(`Failed to load oxy-app.json from ${manifestUrl} (HTTP ${res.status}). The customer-app repo must commit this file alongside the bundle.`);
|
|
391
235
|
}
|
|
392
|
-
|
|
236
|
+
const manifest = validateManifest(await res.json(), manifestUrl);
|
|
237
|
+
const resolved = {
|
|
238
|
+
manifest,
|
|
239
|
+
productNames: [],
|
|
240
|
+
orgSlug: injected?.orgSlug ?? "",
|
|
241
|
+
appSlug: injected?.slug ?? "",
|
|
242
|
+
apiBaseUrl: injected?.apiBaseUrl || "",
|
|
243
|
+
appId: injected?.appId,
|
|
244
|
+
projectId: injected?.projectId ?? manifest.projectId
|
|
245
|
+
};
|
|
246
|
+
log.log("info", "manifest ready", {
|
|
247
|
+
durationMs: Date.now() - startedAt,
|
|
248
|
+
schemaVersion: manifest.schemaVersion,
|
|
249
|
+
slug: manifest.slug
|
|
250
|
+
});
|
|
251
|
+
return resolved;
|
|
252
|
+
}
|
|
393
253
|
/**
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
* @param blob - Parquet file as Blob
|
|
397
|
-
* @param tableName - Name to use for the table in queries (default: 'data')
|
|
398
|
-
* @param sql - SQL query to execute (optional, defaults to SELECT * FROM tableName)
|
|
399
|
-
* @returns Query result
|
|
254
|
+
* Default manifest URL.
|
|
400
255
|
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
256
|
+
* Resolution order (bundler-agnostic):
|
|
257
|
+
* 1. `window.__OXY_APP__.orgSlug`/`slug` injection → the canonical
|
|
258
|
+
* `/customer-apps/<org>/<app>/oxy-app.json`. Works for every
|
|
259
|
+
* bundle oxy serves regardless of how it was built.
|
|
260
|
+
* 2. `NEXT_PUBLIC_APP_BASE_PATH` env var — kept for backward compat
|
|
261
|
+
* with Next.js bundles that bake basePath at build time.
|
|
262
|
+
* 3. Empty basePath → `/oxy-app.json` (only matches when running in
|
|
263
|
+
* a `vite dev` / `next dev` root mount; will 404 under oxy).
|
|
407
264
|
*/
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const query = sql || `SELECT * FROM ${tableName}`;
|
|
412
|
-
const result = await reader.query(query);
|
|
413
|
-
await reader.close();
|
|
414
|
-
return result;
|
|
265
|
+
function defaultManifestUrl(injected) {
|
|
266
|
+
if (injected?.orgSlug && injected?.slug) return `/customer-apps/${encodeURIComponent(injected.orgSlug)}/${encodeURIComponent(injected.slug)}/oxy-app.json`;
|
|
267
|
+
return "/oxy-app.json";
|
|
415
268
|
}
|
|
416
269
|
/**
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
* @param tableName - Name to use for the table (default: 'data')
|
|
421
|
-
* @param limit - Maximum number of rows (optional)
|
|
422
|
-
* @returns Query result
|
|
270
|
+
* Validate a v2 manifest. Required: schemaVersion === 2, slug (non-empty).
|
|
271
|
+
* Optional: name (display), orgSlug (dev-time hint for the admin dialog),
|
|
272
|
+
* projectId (dev-time hint when there's no server-side injection).
|
|
423
273
|
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
* const blob = await client.getFile('data/sales.parquet');
|
|
427
|
-
* const data = await readParquet(blob, 'sales', 1000);
|
|
428
|
-
* console.log(`Loaded ${data.rowCount} rows`);
|
|
429
|
-
* ```
|
|
274
|
+
* At serve time, oxy's identity injection (window.__OXY_APP__) overrides
|
|
275
|
+
* the manifest's orgSlug/projectId — the manifest fields are advisory.
|
|
430
276
|
*/
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
return
|
|
277
|
+
function validateManifest(raw, url) {
|
|
278
|
+
if (!isRecord(raw)) throw new Error(`Manifest at ${url} is not a JSON object`);
|
|
279
|
+
if (raw.schemaVersion !== 2) throw new Error(`oxy-app.json: schemaVersion must be 2 (got ${JSON.stringify(raw.schemaVersion)}). v1 manifests are no longer supported — upgrade to the identity-only shape.`);
|
|
280
|
+
if (raw.products !== void 0 || raw.writers !== void 0) throw new Error(`oxy-app.json is schemaVersion 2 (identity-only); \`products\` and \`writers\` are no longer supported`);
|
|
281
|
+
if (typeof raw.slug !== "string" || !raw.slug.trim()) throw new Error("oxy-app.json: `slug` is required and must be a non-empty string");
|
|
282
|
+
return {
|
|
283
|
+
schemaVersion: 2,
|
|
284
|
+
name: typeof raw.name === "string" ? raw.name : void 0,
|
|
285
|
+
slug: raw.slug,
|
|
286
|
+
orgSlug: typeof raw.orgSlug === "string" ? raw.orgSlug : void 0,
|
|
287
|
+
projectId: typeof raw.projectId === "string" ? raw.projectId : void 0
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function isRecord(v) {
|
|
291
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
437
292
|
}
|
|
438
293
|
|
|
439
294
|
//#endregion
|
|
440
|
-
//#region src/
|
|
295
|
+
//#region src/customer-app/interpolate.ts
|
|
441
296
|
/**
|
|
442
|
-
*
|
|
297
|
+
* Interpolate `{{ params.X }}` and `{{ params.X | sqlquote }}` placeholders
|
|
298
|
+
* in a SQL template.
|
|
299
|
+
*
|
|
300
|
+
* - `{{ params.X | sqlquote }}` — quote strings ('foo'), pass numbers and
|
|
301
|
+
* booleans raw, nullish becomes NULL. Mirrors the server's Jinja sqlquote
|
|
302
|
+
* filter.
|
|
303
|
+
* - `{{ params.X }}` — raw pass-through. Used for already-trusted values
|
|
304
|
+
* (numbers, identifiers the caller has validated). Caller is responsible
|
|
305
|
+
* for safety.
|
|
306
|
+
*
|
|
307
|
+
* Not a security boundary. The server still gates SQL execution by
|
|
308
|
+
* project membership. Bundles that accept untrusted user input should
|
|
309
|
+
* use `| sqlquote` or validate/coerce before passing.
|
|
443
310
|
*/
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
* This is the recommended method when using the SDK in an iframe that needs to
|
|
452
|
-
* obtain authentication from the parent window via postMessage.
|
|
453
|
-
*
|
|
454
|
-
* @param config - Optional configuration overrides
|
|
455
|
-
* @returns Promise resolving to OxyClient instance
|
|
456
|
-
* @throws Error if required configuration is missing
|
|
457
|
-
* @throws PostMessageAuthTimeoutError if parent doesn't respond
|
|
458
|
-
*
|
|
459
|
-
* @example
|
|
460
|
-
* ```typescript
|
|
461
|
-
* // In an iframe - automatic postMessage auth
|
|
462
|
-
* const client = await OxyClient.create({
|
|
463
|
-
* parentOrigin: 'https://app.example.com',
|
|
464
|
-
* projectId: 'my-project-id',
|
|
465
|
-
* baseUrl: 'https://api.oxy.tech'
|
|
466
|
-
* });
|
|
467
|
-
*
|
|
468
|
-
* // Use the client normally
|
|
469
|
-
* const apps = await client.listApps();
|
|
470
|
-
* ```
|
|
471
|
-
*/
|
|
472
|
-
static async create(config) {
|
|
473
|
-
return new OxyClient(await createConfigAsync(config));
|
|
474
|
-
}
|
|
475
|
-
/**
|
|
476
|
-
* Encodes a file path to base64 for use in API URLs.
|
|
477
|
-
* Handles Unicode characters (e.g., emojis) properly in both Node.js and browser.
|
|
478
|
-
*/
|
|
479
|
-
encodePathBase64(path) {
|
|
480
|
-
if (typeof Buffer !== "undefined") return Buffer.from(path).toString("base64");
|
|
481
|
-
else return btoa(encodeURIComponent(path).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16))));
|
|
482
|
-
}
|
|
483
|
-
/**
|
|
484
|
-
* Makes an authenticated HTTP request to the Oxy API
|
|
485
|
-
*/
|
|
486
|
-
async request(endpoint, options = {}) {
|
|
487
|
-
const url = `${this.config.baseUrl}${endpoint}`;
|
|
488
|
-
const headers = {
|
|
489
|
-
"Content-Type": "application/json",
|
|
490
|
-
...options.headers || {}
|
|
491
|
-
};
|
|
492
|
-
if (this.config.apiKey) headers.Authorization = `Bearer ${this.config.apiKey}`;
|
|
493
|
-
const controller = new AbortController();
|
|
494
|
-
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout || 3e4);
|
|
495
|
-
try {
|
|
496
|
-
const response = await fetch(url, {
|
|
497
|
-
...options,
|
|
498
|
-
headers,
|
|
499
|
-
signal: controller.signal
|
|
500
|
-
});
|
|
501
|
-
clearTimeout(timeoutId);
|
|
502
|
-
if (!response.ok) {
|
|
503
|
-
const errorText = await response.text().catch(() => "Unknown error");
|
|
504
|
-
throw {
|
|
505
|
-
message: `API request failed: ${response.statusText}`,
|
|
506
|
-
status: response.status,
|
|
507
|
-
details: errorText
|
|
508
|
-
};
|
|
509
|
-
}
|
|
510
|
-
if ((typeof options.headers === "object" && options.headers !== null ? options.headers.Accept : void 0) === "application/octet-stream") return response.blob();
|
|
511
|
-
return response.json();
|
|
512
|
-
} catch (error) {
|
|
513
|
-
clearTimeout(timeoutId);
|
|
514
|
-
if (error instanceof Error && error.name === "AbortError") throw new Error(`Request timeout after ${this.config.timeout || 3e4}ms`);
|
|
515
|
-
throw error;
|
|
311
|
+
function interpolateSqlParams(sql, params) {
|
|
312
|
+
return sql.replace(/\{\{\s*params\.([a-zA-Z0-9_]+)(\s*\|\s*sqlquote)?\s*\}\}/g, (_match, key, sqlquote) => {
|
|
313
|
+
const v = params[key];
|
|
314
|
+
if (v === null || v === void 0) return "NULL";
|
|
315
|
+
if (sqlquote) {
|
|
316
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
317
|
+
return `'${String(v).replace(/'/g, "''")}'`;
|
|
516
318
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
*/
|
|
521
|
-
buildQueryParams(additionalParams = {}) {
|
|
522
|
-
const params = { ...additionalParams };
|
|
523
|
-
if (this.config.branch) params.branch = this.config.branch;
|
|
524
|
-
const queryString = new URLSearchParams(params).toString();
|
|
525
|
-
return queryString ? `?${queryString}` : "";
|
|
526
|
-
}
|
|
527
|
-
/**
|
|
528
|
-
* Lists all apps in the project
|
|
529
|
-
*
|
|
530
|
-
* @returns Array of app items
|
|
531
|
-
*
|
|
532
|
-
* @example
|
|
533
|
-
* ```typescript
|
|
534
|
-
* const apps = await client.listApps();
|
|
535
|
-
* console.log('Available apps:', apps);
|
|
536
|
-
* ```
|
|
537
|
-
*/
|
|
538
|
-
async listApps() {
|
|
539
|
-
const query = this.buildQueryParams();
|
|
540
|
-
return this.request(`/${this.config.projectId}/apps${query}`);
|
|
541
|
-
}
|
|
542
|
-
/**
|
|
543
|
-
* Gets data for a specific app
|
|
544
|
-
*
|
|
545
|
-
* @param appPath - Relative path to the app file (e.g., 'my-app.app.yml')
|
|
546
|
-
* @returns App data response
|
|
547
|
-
*
|
|
548
|
-
* @example
|
|
549
|
-
* ```typescript
|
|
550
|
-
* const data = await client.getAppData('dashboard.app.yml');
|
|
551
|
-
* if (data.error) {
|
|
552
|
-
* console.error('Error:', data.error);
|
|
553
|
-
* } else {
|
|
554
|
-
* console.log('App data:', data.data);
|
|
555
|
-
* }
|
|
556
|
-
* ```
|
|
557
|
-
*/
|
|
558
|
-
async getAppData(appPath) {
|
|
559
|
-
const pathb64 = this.encodePathBase64(appPath);
|
|
560
|
-
const query = this.buildQueryParams();
|
|
561
|
-
return this.request(`/${this.config.projectId}/apps/${pathb64}${query}`);
|
|
562
|
-
}
|
|
563
|
-
/**
|
|
564
|
-
* Runs an app and returns fresh data (bypasses cache)
|
|
565
|
-
*
|
|
566
|
-
* @param appPath - Relative path to the app file
|
|
567
|
-
* @returns App data response
|
|
568
|
-
*
|
|
569
|
-
* @example
|
|
570
|
-
* ```typescript
|
|
571
|
-
* const data = await client.runApp('dashboard.app.yml');
|
|
572
|
-
* console.log('Fresh app data:', data.data);
|
|
573
|
-
* ```
|
|
574
|
-
*/
|
|
575
|
-
async runApp(appPath) {
|
|
576
|
-
const pathb64 = this.encodePathBase64(appPath);
|
|
577
|
-
const query = this.buildQueryParams();
|
|
578
|
-
return this.request(`/${this.config.projectId}/apps/${pathb64}/run${query}`, { method: "POST" });
|
|
579
|
-
}
|
|
580
|
-
/**
|
|
581
|
-
* Gets display configurations for an app
|
|
582
|
-
*
|
|
583
|
-
* @param appPath - Relative path to the app file
|
|
584
|
-
* @returns Display configurations with potential errors
|
|
585
|
-
*
|
|
586
|
-
* @example
|
|
587
|
-
* ```typescript
|
|
588
|
-
* const displays = await client.getDisplays('dashboard.app.yml');
|
|
589
|
-
* displays.displays.forEach(d => {
|
|
590
|
-
* if (d.error) {
|
|
591
|
-
* console.error('Display error:', d.error);
|
|
592
|
-
* } else {
|
|
593
|
-
* console.log('Display:', d.display);
|
|
594
|
-
* }
|
|
595
|
-
* });
|
|
596
|
-
* ```
|
|
597
|
-
*/
|
|
598
|
-
async getDisplays(appPath) {
|
|
599
|
-
const pathb64 = this.encodePathBase64(appPath);
|
|
600
|
-
const query = this.buildQueryParams();
|
|
601
|
-
return this.request(`/${this.config.projectId}/apps/${pathb64}/displays${query}`);
|
|
602
|
-
}
|
|
603
|
-
/**
|
|
604
|
-
* Gets a file from the app state directory (e.g., generated charts, images)
|
|
605
|
-
*
|
|
606
|
-
* This is useful for retrieving generated assets like charts, images, or other
|
|
607
|
-
* files produced by app workflows and stored in the state directory.
|
|
608
|
-
*
|
|
609
|
-
* @param filePath - Relative path to the file in state directory
|
|
610
|
-
* @returns Blob containing the file data
|
|
611
|
-
*
|
|
612
|
-
* @example
|
|
613
|
-
* ```typescript
|
|
614
|
-
* // Get a generated chart image
|
|
615
|
-
* const blob = await client.getFile('charts/sales-chart.png');
|
|
616
|
-
* const imageUrl = URL.createObjectURL(blob);
|
|
617
|
-
*
|
|
618
|
-
* // Use in an img tag
|
|
619
|
-
* document.querySelector('img').src = imageUrl;
|
|
620
|
-
* ```
|
|
621
|
-
*
|
|
622
|
-
* @example
|
|
623
|
-
* ```typescript
|
|
624
|
-
* // Download a file
|
|
625
|
-
* const blob = await client.getFile('exports/data.csv');
|
|
626
|
-
* const a = document.createElement('a');
|
|
627
|
-
* a.href = URL.createObjectURL(blob);
|
|
628
|
-
* a.download = 'data.csv';
|
|
629
|
-
* a.click();
|
|
630
|
-
* ```
|
|
631
|
-
*/
|
|
632
|
-
async getFile(filePath) {
|
|
633
|
-
const pathb64 = this.encodePathBase64(filePath);
|
|
634
|
-
const query = this.buildQueryParams();
|
|
635
|
-
return this.request(`/${this.config.projectId}/apps/file/${pathb64}${query}`, { headers: { Accept: "application/octet-stream" } });
|
|
636
|
-
}
|
|
637
|
-
/**
|
|
638
|
-
* Gets a file URL for direct browser access
|
|
639
|
-
*
|
|
640
|
-
* This returns a URL that can be used directly in img tags, fetch calls, etc.
|
|
641
|
-
* The URL includes authentication via query parameters.
|
|
642
|
-
*
|
|
643
|
-
* @param filePath - Relative path to the file in state directory
|
|
644
|
-
* @returns Full URL to the file
|
|
645
|
-
*
|
|
646
|
-
* @example
|
|
647
|
-
* ```typescript
|
|
648
|
-
* const imageUrl = client.getFileUrl('charts/sales-chart.png');
|
|
649
|
-
*
|
|
650
|
-
* // Use directly in img tag (in environments where query-based auth is supported)
|
|
651
|
-
* document.querySelector('img').src = imageUrl;
|
|
652
|
-
* ```
|
|
653
|
-
*/
|
|
654
|
-
getFileUrl(filePath) {
|
|
655
|
-
const pathb64 = this.encodePathBase64(filePath);
|
|
656
|
-
const query = this.buildQueryParams();
|
|
657
|
-
return `${this.config.baseUrl}/${this.config.projectId}/apps/file/${pathb64}${query}`;
|
|
658
|
-
}
|
|
659
|
-
/**
|
|
660
|
-
* Fetches a parquet file and parses it into table data
|
|
661
|
-
*
|
|
662
|
-
* @param filePath - Relative path to the parquet file
|
|
663
|
-
* @param limit - Maximum number of rows to return (default: 100)
|
|
664
|
-
* @returns TableData with columns and rows
|
|
665
|
-
*
|
|
666
|
-
* @example
|
|
667
|
-
* ```typescript
|
|
668
|
-
* const tableData = await client.getTableData('data/sales.parquet', 50);
|
|
669
|
-
* console.log(tableData.columns);
|
|
670
|
-
* console.log(tableData.rows);
|
|
671
|
-
* console.log(`Total rows: ${tableData.total_rows}`);
|
|
672
|
-
* ```
|
|
673
|
-
*/
|
|
674
|
-
async getTableData(filePath, limit = 100) {
|
|
675
|
-
const result = await readParquet(await this.getFile(filePath), "data", limit);
|
|
676
|
-
return {
|
|
677
|
-
columns: result.columns,
|
|
678
|
-
rows: result.rows,
|
|
679
|
-
total_rows: result.rowCount
|
|
680
|
-
};
|
|
681
|
-
}
|
|
682
|
-
};
|
|
319
|
+
return String(v);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
683
322
|
|
|
684
323
|
//#endregion
|
|
685
|
-
//#region src/
|
|
324
|
+
//#region src/customer-app/markdown.ts
|
|
686
325
|
/**
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
* @example
|
|
692
|
-
* ```typescript
|
|
693
|
-
* // Create SDK instance
|
|
694
|
-
* const sdk = new OxySDK({ apiKey: 'your-key', projectId: 'your-project' });
|
|
326
|
+
* Allowlist for `[text](url)` href values in agent-emitted markdown.
|
|
327
|
+
* Markdown comes from an LLM, which sits across an external trust
|
|
328
|
+
* boundary — without this filter, a `javascript:` URL produced by
|
|
329
|
+
* the model would render as a clickable XSS in the bundle's origin.
|
|
695
330
|
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
331
|
+
* Accepts:
|
|
332
|
+
* - http(s):// absolute URLs
|
|
333
|
+
* - mailto: addresses
|
|
334
|
+
* - root-relative paths (`/foo`)
|
|
335
|
+
* - same-page fragments (`#section`)
|
|
700
336
|
*
|
|
701
|
-
*
|
|
702
|
-
*
|
|
703
|
-
*
|
|
337
|
+
* Rejects everything else, including `javascript:`, `data:`,
|
|
338
|
+
* protocol-relative `//evil.com`, and any other scheme. Comparison
|
|
339
|
+
* is case-insensitive after stripping leading whitespace + ASCII
|
|
340
|
+
* control bytes (browsers strip these before scheme resolution, so
|
|
341
|
+
* `java\tscript:` would otherwise slip past a naive prefix check).
|
|
704
342
|
*/
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
/**
|
|
711
|
-
* Creates an OxySDK instance asynchronously with support for postMessage authentication
|
|
712
|
-
*
|
|
713
|
-
* @param config - Optional configuration overrides
|
|
714
|
-
* @returns Promise resolving to OxySDK instance
|
|
715
|
-
*
|
|
716
|
-
* @example
|
|
717
|
-
* ```typescript
|
|
718
|
-
* // In an iframe - automatic postMessage auth
|
|
719
|
-
* const sdk = await OxySDK.create({
|
|
720
|
-
* parentOrigin: 'https://app.example.com',
|
|
721
|
-
* projectId: 'my-project-id'
|
|
722
|
-
* });
|
|
723
|
-
* ```
|
|
724
|
-
*/
|
|
725
|
-
static async create(config) {
|
|
726
|
-
return new OxySDK(await createConfigAsync(config));
|
|
343
|
+
function isSafeLinkHref(raw) {
|
|
344
|
+
let cleaned = "";
|
|
345
|
+
for (let i = 0; i < raw.length; i++) {
|
|
346
|
+
const cc = raw.charCodeAt(i);
|
|
347
|
+
if (cc > 32 && cc !== 127) cleaned += raw[i];
|
|
727
348
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
* @param tableName - Name to use for the table in SQL queries
|
|
733
|
-
*
|
|
734
|
-
* @example
|
|
735
|
-
* ```typescript
|
|
736
|
-
* await sdk.loadFile('data/sales.parquet', 'sales');
|
|
737
|
-
* await sdk.loadFile('data/customers.parquet', 'customers');
|
|
738
|
-
*
|
|
739
|
-
* const result = await sdk.query(`
|
|
740
|
-
* SELECT s.*, c.name
|
|
741
|
-
* FROM sales s
|
|
742
|
-
* JOIN customers c ON s.customer_id = c.id
|
|
743
|
-
* `);
|
|
744
|
-
* ```
|
|
745
|
-
*/
|
|
746
|
-
async loadFile(filePath, tableName) {
|
|
747
|
-
const blob = await this.client.getFile(filePath);
|
|
748
|
-
await this.reader.registerParquet(blob, tableName);
|
|
349
|
+
if (cleaned === "") return false;
|
|
350
|
+
if (cleaned.startsWith("#") || cleaned.startsWith("/")) {
|
|
351
|
+
if (cleaned.startsWith("//")) return false;
|
|
352
|
+
return true;
|
|
749
353
|
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
if (!appDataResponse.data) return null;
|
|
789
|
-
const loadPromises = Object.entries(appDataResponse.data).map(async ([tableName, fileRef]) => {
|
|
790
|
-
await this.loadFile(fileRef.file_path, tableName);
|
|
354
|
+
const lower = cleaned.toLowerCase();
|
|
355
|
+
return lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("mailto:");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
//#endregion
|
|
359
|
+
//#region src/customer-app/react.tsx
|
|
360
|
+
function defaultFetcher(input, init) {
|
|
361
|
+
return fetch(input, {
|
|
362
|
+
credentials: "include",
|
|
363
|
+
...init
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
const OxyAppContext = react.createContext(void 0);
|
|
367
|
+
/**
|
|
368
|
+
* Top-level provider. Loads the manifest once on mount; children only
|
|
369
|
+
* render after the manifest is ready (or the error fallback fires).
|
|
370
|
+
*/
|
|
371
|
+
function OxyAppProvider(props) {
|
|
372
|
+
const { manifestOptions, fallback, errorFallback, fetcher: fetcherProp, children } = props;
|
|
373
|
+
const fetcher = fetcherProp ?? defaultFetcher;
|
|
374
|
+
const [state, setState] = react.useState({
|
|
375
|
+
status: "loading",
|
|
376
|
+
fetcher
|
|
377
|
+
});
|
|
378
|
+
react.useEffect(() => {
|
|
379
|
+
let cancelled = false;
|
|
380
|
+
loadCustomerAppManifest(manifestOptions).then((resolved) => {
|
|
381
|
+
if (!cancelled) setState({
|
|
382
|
+
status: "ready",
|
|
383
|
+
resolved,
|
|
384
|
+
fetcher
|
|
385
|
+
});
|
|
386
|
+
}).catch((e) => {
|
|
387
|
+
if (!cancelled) setState({
|
|
388
|
+
status: "error",
|
|
389
|
+
error: interpretCustomerAppError(e),
|
|
390
|
+
fetcher
|
|
391
|
+
});
|
|
791
392
|
});
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
* @returns Query result with columns and rows
|
|
800
|
-
*
|
|
801
|
-
* @example
|
|
802
|
-
* ```typescript
|
|
803
|
-
* await sdk.loadFile('data/sales.parquet', 'sales');
|
|
804
|
-
*
|
|
805
|
-
* const result = await sdk.query('SELECT product, SUM(amount) as total FROM sales GROUP BY product');
|
|
806
|
-
* console.log(result.columns); // ['product', 'total']
|
|
807
|
-
* console.log(result.rows); // [['Product A', 1000], ['Product B', 2000]]
|
|
808
|
-
* console.log(result.rowCount); // 2
|
|
809
|
-
* ```
|
|
810
|
-
*/
|
|
811
|
-
async query(sql) {
|
|
812
|
-
return this.reader.query(sql);
|
|
813
|
-
}
|
|
814
|
-
/**
|
|
815
|
-
* Get all data from a loaded table
|
|
816
|
-
*
|
|
817
|
-
* @param tableName - Name of the table
|
|
818
|
-
* @param limit - Maximum number of rows (optional)
|
|
819
|
-
* @returns Query result
|
|
820
|
-
*
|
|
821
|
-
* @example
|
|
822
|
-
* ```typescript
|
|
823
|
-
* await sdk.loadFile('data/sales.parquet', 'sales');
|
|
824
|
-
* const allData = await sdk.getAll('sales');
|
|
825
|
-
* const first100 = await sdk.getAll('sales', 100);
|
|
826
|
-
* ```
|
|
827
|
-
*/
|
|
828
|
-
async getAll(tableName, limit) {
|
|
829
|
-
return this.reader.getAll(tableName, limit);
|
|
830
|
-
}
|
|
831
|
-
/**
|
|
832
|
-
* Get schema information for a loaded table
|
|
833
|
-
*
|
|
834
|
-
* @param tableName - Name of the table
|
|
835
|
-
* @returns Schema information
|
|
836
|
-
*
|
|
837
|
-
* @example
|
|
838
|
-
* ```typescript
|
|
839
|
-
* await sdk.loadFile('data/sales.parquet', 'sales');
|
|
840
|
-
* const schema = await sdk.getSchema('sales');
|
|
841
|
-
* console.log(schema.columns); // ['column_name', 'column_type', ...]
|
|
842
|
-
* console.log(schema.rows); // [['id', 'INTEGER'], ['name', 'VARCHAR'], ...]
|
|
843
|
-
* ```
|
|
844
|
-
*/
|
|
845
|
-
async getSchema(tableName) {
|
|
846
|
-
return this.reader.getSchema(tableName);
|
|
847
|
-
}
|
|
848
|
-
/**
|
|
849
|
-
* Get row count for a loaded table
|
|
850
|
-
*
|
|
851
|
-
* @param tableName - Name of the table
|
|
852
|
-
* @returns Number of rows
|
|
853
|
-
*
|
|
854
|
-
* @example
|
|
855
|
-
* ```typescript
|
|
856
|
-
* await sdk.loadFile('data/sales.parquet', 'sales');
|
|
857
|
-
* const count = await sdk.count('sales');
|
|
858
|
-
* console.log(`Total rows: ${count}`);
|
|
859
|
-
* ```
|
|
860
|
-
*/
|
|
861
|
-
async count(tableName) {
|
|
862
|
-
return this.reader.count(tableName);
|
|
863
|
-
}
|
|
864
|
-
/**
|
|
865
|
-
* Get direct access to the underlying OxyClient
|
|
866
|
-
*
|
|
867
|
-
* Useful for advanced operations like listing apps, getting displays, etc.
|
|
868
|
-
*
|
|
869
|
-
* @returns The OxyClient instance
|
|
870
|
-
*
|
|
871
|
-
* @example
|
|
872
|
-
* ```typescript
|
|
873
|
-
* const apps = await sdk.getClient().listApps();
|
|
874
|
-
* const displays = await sdk.getClient().getDisplays('my-app.app.yml');
|
|
875
|
-
* ```
|
|
876
|
-
*/
|
|
877
|
-
getClient() {
|
|
878
|
-
return this.client;
|
|
879
|
-
}
|
|
880
|
-
/**
|
|
881
|
-
* Get direct access to the underlying ParquetReader
|
|
882
|
-
*
|
|
883
|
-
* Useful for advanced operations like registering blobs directly.
|
|
884
|
-
*
|
|
885
|
-
* @returns The ParquetReader instance
|
|
886
|
-
*
|
|
887
|
-
* @example
|
|
888
|
-
* ```typescript
|
|
889
|
-
* const myBlob = new Blob([parquetData]);
|
|
890
|
-
* await sdk.getReader().registerParquet(myBlob, 'mydata');
|
|
891
|
-
* ```
|
|
892
|
-
*/
|
|
893
|
-
getReader() {
|
|
894
|
-
return this.reader;
|
|
393
|
+
return () => {
|
|
394
|
+
cancelled = true;
|
|
395
|
+
};
|
|
396
|
+
}, [manifestOptions, fetcher]);
|
|
397
|
+
if (state.status === "error" && state.error) {
|
|
398
|
+
const err = state.error;
|
|
399
|
+
return /* @__PURE__ */ react.createElement(OxyAppContext.Provider, { value: state }, errorFallback ? errorFallback(err) : defaultErrorFallback(err));
|
|
895
400
|
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
401
|
+
if (state.status === "loading") return /* @__PURE__ */ react.createElement(OxyAppContext.Provider, { value: state }, fallback ?? null);
|
|
402
|
+
return /* @__PURE__ */ react.createElement(OxyAppContext.Provider, { value: state }, children);
|
|
403
|
+
}
|
|
404
|
+
function defaultErrorFallback(err) {
|
|
405
|
+
return /* @__PURE__ */ react.createElement("div", { style: {
|
|
406
|
+
margin: "2rem auto",
|
|
407
|
+
maxWidth: "640px",
|
|
408
|
+
padding: "1rem",
|
|
409
|
+
border: "1px solid #fca5a5",
|
|
410
|
+
background: "#fee2e2",
|
|
411
|
+
color: "#991b1b",
|
|
412
|
+
borderRadius: "8px",
|
|
413
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
414
|
+
fontSize: "14px"
|
|
415
|
+
} }, /* @__PURE__ */ react.createElement("div", { style: { fontWeight: 600 } }, err.title), /* @__PURE__ */ react.createElement("pre", { style: {
|
|
416
|
+
fontSize: "12px",
|
|
417
|
+
marginTop: "4px"
|
|
418
|
+
} }, err.message), /* @__PURE__ */ react.createElement("div", { style: { marginTop: "12px" } }, /* @__PURE__ */ react.createElement("strong", null, "What to try:"), " ", err.hint));
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Emit a one-time `console.warn` the first time a beta hook is
|
|
422
|
+
* used in a given page load. Bundles upgrading from a future GA
|
|
423
|
+
* release won't see the warning; the message lets us flag rough
|
|
424
|
+
* edges without breaking the build.
|
|
425
|
+
*/
|
|
426
|
+
const _warnedBeta = /* @__PURE__ */ new Set();
|
|
427
|
+
function warnBetaOnce(name) {
|
|
428
|
+
if (_warnedBeta.has(name)) return;
|
|
429
|
+
_warnedBeta.add(name);
|
|
430
|
+
if (typeof console !== "undefined" && typeof console.warn === "function") console.warn(`[@oxy-hq/sdk] \`${name}\` is in beta — interface and behavior may change. See https://github.com/oxy-hq/customer-apps for caveats and the migration guide.`);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Error thrown by all customer-app hooks when an API call returns a
|
|
434
|
+
* non-2xx response. Carries the structured `code` + `hint` the server
|
|
435
|
+
* emits so bundle UIs can render an actionable message instead of
|
|
436
|
+
* "404: { ...json... }".
|
|
437
|
+
*
|
|
438
|
+
* The server contract is documented in
|
|
439
|
+
* `crates/app/src/server/api/projects/agent_ask.rs` and
|
|
440
|
+
* `procedure_run.rs` — both emit `{ message, code?, hint? }` as JSON.
|
|
441
|
+
* Hooks that previously wrapped the raw text in `new Error()` now
|
|
442
|
+
* throw this type instead.
|
|
443
|
+
*/
|
|
444
|
+
var OxyApiError = class extends Error {
|
|
445
|
+
constructor(opts) {
|
|
446
|
+
const base = opts.message || `HTTP ${opts.status}`;
|
|
447
|
+
const code = opts.code ? ` [${opts.code}]` : "";
|
|
448
|
+
const hint = opts.hint ? `\n\n${opts.hint}` : "";
|
|
449
|
+
super(`${base}${code}${hint}`);
|
|
450
|
+
this.name = "OxyApiError";
|
|
451
|
+
this.status = opts.status;
|
|
452
|
+
this.code = opts.code ?? null;
|
|
453
|
+
this.hint = opts.hint ?? null;
|
|
911
454
|
}
|
|
912
455
|
};
|
|
913
|
-
|
|
914
|
-
//#endregion
|
|
915
|
-
//#region src/react.tsx
|
|
916
456
|
/**
|
|
917
|
-
*
|
|
457
|
+
* Read a non-2xx response from oxy and return an `OxyApiError`.
|
|
458
|
+
* Parses the JSON envelope when present; falls back to raw text
|
|
459
|
+
* (truncated to 240 chars so a runaway HTML error page doesn't
|
|
460
|
+
* dominate the bundle UI).
|
|
461
|
+
*/
|
|
462
|
+
async function apiErrorFromResponse(resp) {
|
|
463
|
+
let body;
|
|
464
|
+
let raw = "";
|
|
465
|
+
try {
|
|
466
|
+
raw = await resp.text();
|
|
467
|
+
body = raw ? JSON.parse(raw) : void 0;
|
|
468
|
+
} catch {}
|
|
469
|
+
if (body && typeof body === "object") {
|
|
470
|
+
const b = body;
|
|
471
|
+
return new OxyApiError({
|
|
472
|
+
status: resp.status,
|
|
473
|
+
message: typeof b.message === "string" ? b.message : `HTTP ${resp.status}`,
|
|
474
|
+
code: typeof b.code === "string" ? b.code : null,
|
|
475
|
+
hint: typeof b.hint === "string" ? b.hint : null
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
const snippet = raw.length > 240 ? `${raw.slice(0, 237)}…` : raw;
|
|
479
|
+
return new OxyApiError({
|
|
480
|
+
status: resp.status,
|
|
481
|
+
message: snippet || `HTTP ${resp.status}`
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Read the resolved manifest from context. Throws if called outside
|
|
486
|
+
* `<OxyAppProvider>` — that's a programmer error worth surfacing
|
|
487
|
+
* loudly, not silently swallowing.
|
|
918
488
|
*/
|
|
919
|
-
|
|
489
|
+
function useResolvedManifest() {
|
|
490
|
+
const ctx = react.useContext(OxyAppContext);
|
|
491
|
+
if (!ctx) throw new Error("useResolvedManifest must be called inside <OxyAppProvider>");
|
|
492
|
+
if (ctx.status !== "ready" || !ctx.resolved) throw new Error("useResolvedManifest called before manifest finished loading. Use the provider's `fallback` prop to render while loading.");
|
|
493
|
+
return ctx.resolved;
|
|
494
|
+
}
|
|
920
495
|
/**
|
|
921
|
-
*
|
|
496
|
+
* Low-level hook that returns the raw context value (including the
|
|
497
|
+
* fetcher). Prefer `useResolvedManifest` for manifest access; use
|
|
498
|
+
* this only when you need the fetcher or projectId without requiring
|
|
499
|
+
* the manifest to be ready (e.g. inside `useQuery`).
|
|
500
|
+
*/
|
|
501
|
+
function useOxyApp() {
|
|
502
|
+
const ctx = react.useContext(OxyAppContext);
|
|
503
|
+
if (!ctx) throw new Error("useOxyApp must be called inside <OxyAppProvider>");
|
|
504
|
+
return {
|
|
505
|
+
projectId: ctx.resolved?.projectId,
|
|
506
|
+
fetcher: ctx.fetcher
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Execute an ad-hoc SQL query against the project linked to this
|
|
511
|
+
* customer app. The query is specified inline by the caller; no
|
|
512
|
+
* manifest declaration is involved.
|
|
922
513
|
*
|
|
923
|
-
*
|
|
924
|
-
*
|
|
925
|
-
*
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
514
|
+
* Re-runs whenever `input` or enabled `params` change. Use the
|
|
515
|
+
* `enabled` option to defer the first fetch until required data is
|
|
516
|
+
* available (e.g. a user-supplied filter value).
|
|
517
|
+
*/
|
|
518
|
+
function useQuery(input, opts = {}) {
|
|
519
|
+
const { projectId, fetcher } = useOxyApp();
|
|
520
|
+
const enabled = opts.enabled !== false;
|
|
521
|
+
const paramsKey = JSON.stringify(opts.params);
|
|
522
|
+
const sqlWithParams = react.useMemo(() => interpolateSqlParams(input.sql, opts.params ?? {}), [input.sql, paramsKey]);
|
|
523
|
+
const [state, setState] = react.useState({
|
|
524
|
+
rows: [],
|
|
525
|
+
columns: [],
|
|
526
|
+
loading: enabled && !!projectId,
|
|
527
|
+
error: null
|
|
528
|
+
});
|
|
529
|
+
const [nonce, setNonce] = react.useState(0);
|
|
530
|
+
react.useEffect(() => {
|
|
531
|
+
if (!enabled || !projectId) {
|
|
532
|
+
setState((s) => s.loading ? {
|
|
533
|
+
...s,
|
|
534
|
+
loading: false
|
|
535
|
+
} : s);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const ctrl = new AbortController();
|
|
539
|
+
let cancelled = false;
|
|
540
|
+
setState((s) => ({
|
|
541
|
+
...s,
|
|
542
|
+
loading: true,
|
|
543
|
+
error: null
|
|
544
|
+
}));
|
|
545
|
+
const body = JSON.stringify({
|
|
546
|
+
sql: sqlWithParams,
|
|
547
|
+
...input.database ? { database: input.database } : {}
|
|
548
|
+
});
|
|
549
|
+
fetcher(`/api/projects/${projectId}/query`, {
|
|
550
|
+
method: "POST",
|
|
551
|
+
headers: { "content-type": "application/json" },
|
|
552
|
+
body,
|
|
553
|
+
signal: ctrl.signal
|
|
554
|
+
}).then(async (resp) => {
|
|
555
|
+
if (!resp.ok) throw await apiErrorFromResponse(resp);
|
|
556
|
+
return resp.json();
|
|
557
|
+
}).then(({ columns, rows }) => {
|
|
558
|
+
if (cancelled) return;
|
|
559
|
+
setState({
|
|
560
|
+
rows: rows.map((r) => Object.fromEntries(columns.map((c, i) => [c, r[i]]))),
|
|
561
|
+
columns,
|
|
562
|
+
loading: false,
|
|
563
|
+
error: null
|
|
564
|
+
});
|
|
565
|
+
}).catch((err) => {
|
|
566
|
+
if (cancelled) return;
|
|
567
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
568
|
+
setState((s) => ({
|
|
569
|
+
...s,
|
|
570
|
+
loading: false,
|
|
571
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
572
|
+
}));
|
|
573
|
+
});
|
|
574
|
+
return () => {
|
|
575
|
+
cancelled = true;
|
|
576
|
+
ctrl.abort();
|
|
577
|
+
};
|
|
578
|
+
}, [
|
|
579
|
+
enabled,
|
|
580
|
+
projectId,
|
|
581
|
+
sqlWithParams,
|
|
582
|
+
input.database,
|
|
583
|
+
nonce,
|
|
584
|
+
fetcher
|
|
585
|
+
]);
|
|
586
|
+
return {
|
|
587
|
+
rows: state.rows,
|
|
588
|
+
columns: state.columns,
|
|
589
|
+
loading: state.loading,
|
|
590
|
+
error: state.error,
|
|
591
|
+
refetch: () => setNonce((n) => n + 1)
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Run a semantic-layer query against the project's `.view.yml` /
|
|
596
|
+
* `.topic.yml` definitions. The server compiles to SQL and executes
|
|
597
|
+
* through the same connector path as `useQuery`, so result shape
|
|
598
|
+
* matches.
|
|
938
599
|
*
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
600
|
+
* Re-runs whenever the input shape changes (deep-compared via JSON).
|
|
601
|
+
* Use `opts.enabled = false` to defer the first fetch until required
|
|
602
|
+
* inputs (e.g. a user-picked filter value) are available.
|
|
603
|
+
*/
|
|
604
|
+
function useSemanticQuery(input, opts = {}) {
|
|
605
|
+
const { projectId, fetcher } = useOxyApp();
|
|
606
|
+
const enabled = opts.enabled !== false;
|
|
607
|
+
const debug = opts.debug === true;
|
|
608
|
+
const inputKey = react.useMemo(() => JSON.stringify(input), [input]);
|
|
609
|
+
const [state, setState] = react.useState({
|
|
610
|
+
rows: [],
|
|
611
|
+
columns: [],
|
|
612
|
+
truncated: false,
|
|
613
|
+
sql: null,
|
|
614
|
+
loading: enabled && !!projectId,
|
|
615
|
+
error: null
|
|
616
|
+
});
|
|
617
|
+
const [nonce, setNonce] = react.useState(0);
|
|
618
|
+
react.useEffect(() => {
|
|
619
|
+
if (!enabled || !projectId) {
|
|
620
|
+
setState((s) => s.loading ? {
|
|
621
|
+
...s,
|
|
622
|
+
loading: false
|
|
623
|
+
} : s);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const ctrl = new AbortController();
|
|
627
|
+
let cancelled = false;
|
|
628
|
+
setState((s) => ({
|
|
629
|
+
...s,
|
|
630
|
+
loading: true,
|
|
631
|
+
error: null
|
|
632
|
+
}));
|
|
633
|
+
const body = JSON.stringify({
|
|
634
|
+
v: 1,
|
|
635
|
+
topic: input.topic,
|
|
636
|
+
dimensions: input.dimensions ?? [],
|
|
637
|
+
measures: input.measures ?? [],
|
|
638
|
+
time_dimensions: input.time_dimensions ?? [],
|
|
639
|
+
filters: input.filters ?? [],
|
|
640
|
+
...input.limit != null ? { limit: input.limit } : {}
|
|
641
|
+
});
|
|
642
|
+
fetcher(`/api/projects/${projectId}/semantic-query${debug ? "?debug=1" : ""}`, {
|
|
643
|
+
method: "POST",
|
|
644
|
+
headers: { "content-type": "application/json" },
|
|
645
|
+
body,
|
|
646
|
+
signal: ctrl.signal
|
|
647
|
+
}).then(async (resp) => {
|
|
648
|
+
if (!resp.ok) throw await apiErrorFromResponse(resp);
|
|
649
|
+
return resp.json();
|
|
650
|
+
}).then(({ columns, rows, truncated, sql }) => {
|
|
651
|
+
if (cancelled) return;
|
|
652
|
+
setState({
|
|
653
|
+
rows: rows.map((r) => Object.fromEntries(columns.map((c, i) => [c, r[i]]))),
|
|
654
|
+
columns,
|
|
655
|
+
truncated,
|
|
656
|
+
sql: sql ?? null,
|
|
657
|
+
loading: false,
|
|
658
|
+
error: null
|
|
659
|
+
});
|
|
660
|
+
}).catch((err) => {
|
|
661
|
+
if (cancelled) return;
|
|
662
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
663
|
+
setState((s) => ({
|
|
664
|
+
...s,
|
|
665
|
+
loading: false,
|
|
666
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
667
|
+
}));
|
|
668
|
+
});
|
|
669
|
+
return () => {
|
|
670
|
+
cancelled = true;
|
|
671
|
+
ctrl.abort();
|
|
672
|
+
};
|
|
673
|
+
}, [
|
|
674
|
+
enabled,
|
|
675
|
+
projectId,
|
|
676
|
+
inputKey,
|
|
677
|
+
debug,
|
|
678
|
+
nonce,
|
|
679
|
+
fetcher
|
|
680
|
+
]);
|
|
681
|
+
return {
|
|
682
|
+
rows: state.rows,
|
|
683
|
+
columns: state.columns,
|
|
684
|
+
truncated: state.truncated,
|
|
685
|
+
sql: state.sql,
|
|
686
|
+
loading: state.loading,
|
|
687
|
+
error: state.error,
|
|
688
|
+
refetch: () => setNonce((n) => n + 1)
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
const PROCEDURE_POLL_MS = 2e3;
|
|
692
|
+
const PROCEDURE_BACKOFF_MS = 5e3;
|
|
693
|
+
const PROCEDURE_MAX_WAIT_MS = 3600 * 1e3;
|
|
694
|
+
/**
|
|
695
|
+
* @beta Long-running procedure runner. The wire shape works end-to-end
|
|
696
|
+
* (start → poll → cancel; runs survive server restarts via the
|
|
697
|
+
* `customer_app_procedure_runs` table) but a few rough edges remain
|
|
698
|
+
* before this is GA-ready:
|
|
953
699
|
*
|
|
954
|
-
*
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
700
|
+
* - Hint surfaces for `procedure_not_found` are correct but the
|
|
701
|
+
* procedure-discovery rules (which directories the server scans,
|
|
702
|
+
* case-sensitivity, branch awareness) aren't documented yet.
|
|
703
|
+
* - Cancellation across multi-instance deployments leans on a
|
|
704
|
+
* periodic sweep — fine for now, but expect occasional latency
|
|
705
|
+
* between `cancel()` and the run actually stopping.
|
|
706
|
+
* - Progress reporting requires the procedure to emit named
|
|
707
|
+
* steps; bundles get `progress: null` until that lands.
|
|
958
708
|
*
|
|
959
|
-
*
|
|
960
|
-
*
|
|
961
|
-
* <OxyProvider config={createConfig()}>
|
|
962
|
-
* <Dashboard />
|
|
963
|
-
* </OxyProvider>
|
|
964
|
-
* );
|
|
965
|
-
* }
|
|
966
|
-
* ```
|
|
709
|
+
* The API surface is stable; expect breaking changes only if the
|
|
710
|
+
* server-side `customer_app_procedure_runs` schema changes.
|
|
967
711
|
*/
|
|
968
|
-
function
|
|
969
|
-
|
|
970
|
-
const
|
|
971
|
-
const
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
712
|
+
function useProcedureRun(input, opts = {}) {
|
|
713
|
+
warnBetaOnce("useProcedureRun");
|
|
714
|
+
const { projectId, fetcher } = useOxyApp();
|
|
715
|
+
const pollMs = opts.pollIntervalMs ?? PROCEDURE_POLL_MS;
|
|
716
|
+
const backoffMs = opts.pollIntervalBackoffMs ?? PROCEDURE_BACKOFF_MS;
|
|
717
|
+
const maxWaitMs = opts.maxWaitMs ?? PROCEDURE_MAX_WAIT_MS;
|
|
718
|
+
const [state, setState] = react.useState({
|
|
719
|
+
state: "idle",
|
|
720
|
+
progress: null,
|
|
721
|
+
result: null,
|
|
722
|
+
error: null
|
|
723
|
+
});
|
|
724
|
+
const inflight = react.useRef({});
|
|
725
|
+
const cancel = react.useCallback(() => {
|
|
726
|
+
const runId = inflight.current.runId;
|
|
727
|
+
if (!projectId || !runId) return;
|
|
728
|
+
inflight.current.abort?.abort();
|
|
729
|
+
inflight.current.runId = void 0;
|
|
730
|
+
fetcher(`/api/projects/${projectId}/procedures/runs/${encodeURIComponent(runId)}/cancel`, { method: "POST" }).catch(() => {});
|
|
731
|
+
setState({
|
|
732
|
+
state: "failed",
|
|
733
|
+
progress: null,
|
|
734
|
+
result: null,
|
|
735
|
+
error: /* @__PURE__ */ new Error("procedure cancelled by user")
|
|
736
|
+
});
|
|
737
|
+
}, [projectId, fetcher]);
|
|
738
|
+
const run = react.useCallback((params) => {
|
|
739
|
+
if (!projectId) {
|
|
740
|
+
setState((s) => ({
|
|
741
|
+
...s,
|
|
742
|
+
state: "failed",
|
|
743
|
+
error: /* @__PURE__ */ new Error("project not configured")
|
|
744
|
+
}));
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
inflight.current.abort?.abort();
|
|
748
|
+
const ctrl = new AbortController();
|
|
749
|
+
inflight.current = { abort: ctrl };
|
|
750
|
+
setState({
|
|
751
|
+
state: "running",
|
|
752
|
+
progress: null,
|
|
753
|
+
result: null,
|
|
754
|
+
error: null
|
|
755
|
+
});
|
|
756
|
+
(async () => {
|
|
976
757
|
try {
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
758
|
+
const body = JSON.stringify({
|
|
759
|
+
v: 1,
|
|
760
|
+
...params ? { params } : {}
|
|
761
|
+
});
|
|
762
|
+
const startResp = await fetcher(`/api/projects/${projectId}/procedures/${encodeURIComponent(input.procedureId)}/runs`, {
|
|
763
|
+
method: "POST",
|
|
764
|
+
headers: { "content-type": "application/json" },
|
|
765
|
+
body,
|
|
766
|
+
signal: ctrl.signal
|
|
767
|
+
});
|
|
768
|
+
if (!startResp.ok) throw await apiErrorFromResponse(startResp);
|
|
769
|
+
const { run_id } = await startResp.json();
|
|
770
|
+
inflight.current.runId = run_id;
|
|
771
|
+
const startedAt = Date.now();
|
|
772
|
+
let pollCount = 0;
|
|
773
|
+
while (true) {
|
|
774
|
+
if (ctrl.signal.aborted) return;
|
|
775
|
+
if (Date.now() - startedAt > maxWaitMs) throw new Error("procedure run timed out client-side");
|
|
776
|
+
await sleep(pollCount < 6 ? pollMs : backoffMs, ctrl.signal);
|
|
777
|
+
if (ctrl.signal.aborted) return;
|
|
778
|
+
pollCount += 1;
|
|
779
|
+
const pollResp = await fetcher(`/api/projects/${projectId}/procedures/runs/${encodeURIComponent(run_id)}`, {
|
|
780
|
+
method: "GET",
|
|
781
|
+
signal: ctrl.signal
|
|
782
|
+
});
|
|
783
|
+
if (!pollResp.ok) throw await apiErrorFromResponse(pollResp);
|
|
784
|
+
const poll = await pollResp.json();
|
|
785
|
+
if (poll.status === "running") {
|
|
786
|
+
if (poll.progress) setState((s) => ({
|
|
787
|
+
...s,
|
|
788
|
+
progress: poll.progress ?? null
|
|
986
789
|
}));
|
|
987
|
-
|
|
790
|
+
continue;
|
|
988
791
|
}
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
792
|
+
if (poll.status === "done") {
|
|
793
|
+
inflight.current.runId = void 0;
|
|
794
|
+
setState({
|
|
795
|
+
state: "done",
|
|
796
|
+
progress: null,
|
|
797
|
+
result: poll.result,
|
|
798
|
+
error: null
|
|
799
|
+
});
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (poll.status === "cancelled") {
|
|
803
|
+
inflight.current.runId = void 0;
|
|
804
|
+
setState({
|
|
805
|
+
state: "failed",
|
|
806
|
+
progress: null,
|
|
807
|
+
result: null,
|
|
808
|
+
error: /* @__PURE__ */ new Error("procedure cancelled")
|
|
809
|
+
});
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
inflight.current.runId = void 0;
|
|
813
|
+
setState({
|
|
814
|
+
state: "failed",
|
|
815
|
+
progress: null,
|
|
816
|
+
result: null,
|
|
817
|
+
error: new Error(poll.error.message)
|
|
818
|
+
});
|
|
819
|
+
return;
|
|
1004
820
|
}
|
|
821
|
+
} catch (e) {
|
|
822
|
+
if (e instanceof DOMException && e.name === "AbortError") return;
|
|
823
|
+
inflight.current.runId = void 0;
|
|
824
|
+
setState({
|
|
825
|
+
state: "failed",
|
|
826
|
+
progress: null,
|
|
827
|
+
result: null,
|
|
828
|
+
error: e instanceof Error ? e : new Error(String(e))
|
|
829
|
+
});
|
|
1005
830
|
}
|
|
1006
|
-
}
|
|
1007
|
-
|
|
831
|
+
})();
|
|
832
|
+
}, [
|
|
833
|
+
projectId,
|
|
834
|
+
fetcher,
|
|
835
|
+
input.procedureId,
|
|
836
|
+
pollMs,
|
|
837
|
+
backoffMs,
|
|
838
|
+
maxWaitMs
|
|
839
|
+
]);
|
|
840
|
+
react.useEffect(() => {
|
|
1008
841
|
return () => {
|
|
1009
|
-
|
|
1010
|
-
if (sdkInstance) sdkInstance.close().catch(console.error);
|
|
842
|
+
inflight.current.abort?.abort();
|
|
1011
843
|
};
|
|
844
|
+
}, []);
|
|
845
|
+
return {
|
|
846
|
+
state: state.state,
|
|
847
|
+
run,
|
|
848
|
+
cancel,
|
|
849
|
+
progress: state.progress,
|
|
850
|
+
result: state.result,
|
|
851
|
+
error: state.error
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
function useAgentRun(input) {
|
|
855
|
+
const { projectId, fetcher } = useOxyApp();
|
|
856
|
+
const [state, setState] = react.useState({
|
|
857
|
+
state: "idle",
|
|
858
|
+
events: [],
|
|
859
|
+
artifacts: [],
|
|
860
|
+
answer: null,
|
|
861
|
+
clarification: null,
|
|
862
|
+
threadId: null,
|
|
863
|
+
threadUrl: null,
|
|
864
|
+
error: null
|
|
865
|
+
});
|
|
866
|
+
const inflight = react.useRef({});
|
|
867
|
+
const cancel = react.useCallback(() => {
|
|
868
|
+
const runId = inflight.current.runId;
|
|
869
|
+
if (!projectId || !runId) return;
|
|
870
|
+
inflight.current.abort?.abort();
|
|
871
|
+
inflight.current.runId = void 0;
|
|
872
|
+
fetcher(`/api/projects/${projectId}/agents/asks/${encodeURIComponent(runId)}/cancel`, { method: "POST" }).catch(() => {});
|
|
873
|
+
setState((s) => ({
|
|
874
|
+
...s,
|
|
875
|
+
state: "failed",
|
|
876
|
+
error: /* @__PURE__ */ new Error("agent run cancelled by user")
|
|
877
|
+
}));
|
|
878
|
+
}, [projectId, fetcher]);
|
|
879
|
+
const ask = react.useCallback((question, opts = {}) => {
|
|
880
|
+
if (!projectId) {
|
|
881
|
+
setState((s) => ({
|
|
882
|
+
...s,
|
|
883
|
+
state: "failed",
|
|
884
|
+
error: /* @__PURE__ */ new Error("project not configured")
|
|
885
|
+
}));
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
inflight.current.abort?.abort();
|
|
889
|
+
const ctrl = new AbortController();
|
|
890
|
+
inflight.current = { abort: ctrl };
|
|
891
|
+
setState({
|
|
892
|
+
state: "running",
|
|
893
|
+
events: [],
|
|
894
|
+
artifacts: [],
|
|
895
|
+
answer: null,
|
|
896
|
+
clarification: null,
|
|
897
|
+
threadId: opts.threadId ?? null,
|
|
898
|
+
threadUrl: opts.threadId ? `/threads/${opts.threadId}` : null,
|
|
899
|
+
error: null
|
|
900
|
+
});
|
|
901
|
+
(async () => {
|
|
902
|
+
try {
|
|
903
|
+
const body = JSON.stringify({
|
|
904
|
+
v: 1,
|
|
905
|
+
question,
|
|
906
|
+
...opts.threadId ? { thread_id: opts.threadId } : {}
|
|
907
|
+
});
|
|
908
|
+
const startResp = await fetcher(`/api/projects/${projectId}/agents/${encodeURIComponent(input.agentId)}/asks`, {
|
|
909
|
+
method: "POST",
|
|
910
|
+
headers: { "content-type": "application/json" },
|
|
911
|
+
body,
|
|
912
|
+
signal: ctrl.signal
|
|
913
|
+
});
|
|
914
|
+
if (!startResp.ok) throw await apiErrorFromResponse(startResp);
|
|
915
|
+
const { run_id, thread_id, thread_url } = await startResp.json();
|
|
916
|
+
inflight.current.runId = run_id;
|
|
917
|
+
setState((s) => ({
|
|
918
|
+
...s,
|
|
919
|
+
threadId: thread_id,
|
|
920
|
+
threadUrl: thread_url ?? `/threads/${thread_id}`
|
|
921
|
+
}));
|
|
922
|
+
let lastEventId = "";
|
|
923
|
+
let attempts = 0;
|
|
924
|
+
let terminated = false;
|
|
925
|
+
while (true) {
|
|
926
|
+
if (ctrl.signal.aborted) return;
|
|
927
|
+
if (terminated) return;
|
|
928
|
+
attempts += 1;
|
|
929
|
+
try {
|
|
930
|
+
await consumeSseStream({
|
|
931
|
+
url: `/api/projects/${projectId}/agents/runs/${encodeURIComponent(run_id)}/events`,
|
|
932
|
+
fetcher,
|
|
933
|
+
signal: ctrl.signal,
|
|
934
|
+
lastEventId,
|
|
935
|
+
onEvent: (ev) => {
|
|
936
|
+
if (ev.id) lastEventId = ev.id;
|
|
937
|
+
const data = parseSseData(ev.data);
|
|
938
|
+
const eventType = ev.event || "message";
|
|
939
|
+
const artifact = extractSqlArtifact(eventType, ev.id, data);
|
|
940
|
+
const token = eventType === "text_delta" && typeof data === "object" && data !== null && "token" in data ? String(data.token) : null;
|
|
941
|
+
setState((s) => ({
|
|
942
|
+
...s,
|
|
943
|
+
events: [...s.events, {
|
|
944
|
+
type: eventType,
|
|
945
|
+
data
|
|
946
|
+
}],
|
|
947
|
+
artifacts: artifact ? [...s.artifacts, artifact] : s.artifacts,
|
|
948
|
+
answer: token !== null ? (s.answer ?? "") + token : s.answer
|
|
949
|
+
}));
|
|
950
|
+
if (ev.event === "done") {
|
|
951
|
+
terminated = true;
|
|
952
|
+
inflight.current.runId = void 0;
|
|
953
|
+
setState((s) => ({
|
|
954
|
+
...s,
|
|
955
|
+
state: "done"
|
|
956
|
+
}));
|
|
957
|
+
} else if (ev.event === "failed" || ev.event === "error" || ev.event === "cancelled") {
|
|
958
|
+
terminated = true;
|
|
959
|
+
inflight.current.runId = void 0;
|
|
960
|
+
const message = typeof data === "object" && data !== null && "message" in data ? String(data.message) : `agent run ${ev.event}`;
|
|
961
|
+
setState((s) => ({
|
|
962
|
+
...s,
|
|
963
|
+
state: "failed",
|
|
964
|
+
error: new Error(message)
|
|
965
|
+
}));
|
|
966
|
+
} else if (ev.event === "ask_user") {
|
|
967
|
+
terminated = true;
|
|
968
|
+
const clarification = typeof data === "object" && data !== null && "question" in data ? String(data.question) : "Agent needs clarification.";
|
|
969
|
+
setState((s) => ({
|
|
970
|
+
...s,
|
|
971
|
+
state: "needs_clarification",
|
|
972
|
+
clarification
|
|
973
|
+
}));
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
} catch (err) {
|
|
978
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
979
|
+
if (attempts >= 5) {
|
|
980
|
+
inflight.current.runId = void 0;
|
|
981
|
+
setState((s) => ({
|
|
982
|
+
...s,
|
|
983
|
+
state: "failed",
|
|
984
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
985
|
+
}));
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
if (terminated) return;
|
|
990
|
+
await sleep(1e3, ctrl.signal);
|
|
991
|
+
}
|
|
992
|
+
} catch (e) {
|
|
993
|
+
if (e instanceof DOMException && e.name === "AbortError") return;
|
|
994
|
+
inflight.current.runId = void 0;
|
|
995
|
+
setState((s) => ({
|
|
996
|
+
...s,
|
|
997
|
+
state: "failed",
|
|
998
|
+
error: e instanceof Error ? e : new Error(String(e))
|
|
999
|
+
}));
|
|
1000
|
+
}
|
|
1001
|
+
})();
|
|
1012
1002
|
}, [
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
onError
|
|
1003
|
+
projectId,
|
|
1004
|
+
fetcher,
|
|
1005
|
+
input.agentId
|
|
1017
1006
|
]);
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
}
|
|
1007
|
+
react.useEffect(() => {
|
|
1008
|
+
return () => {
|
|
1009
|
+
inflight.current.abort?.abort();
|
|
1010
|
+
};
|
|
1011
|
+
}, []);
|
|
1012
|
+
return {
|
|
1013
|
+
state: state.state,
|
|
1014
|
+
ask,
|
|
1015
|
+
cancel,
|
|
1016
|
+
events: state.events,
|
|
1017
|
+
artifacts: state.artifacts,
|
|
1018
|
+
answer: state.answer,
|
|
1019
|
+
clarification: state.clarification,
|
|
1020
|
+
threadId: state.threadId,
|
|
1021
|
+
threadUrl: state.threadUrl,
|
|
1022
|
+
error: state.error
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
/** Parsed JSON payload of an SSE `data:` line, falling back to raw
|
|
1026
|
+
* string on parse failure. */
|
|
1027
|
+
function parseSseData(raw) {
|
|
1028
|
+
try {
|
|
1029
|
+
return JSON.parse(raw);
|
|
1030
|
+
} catch {
|
|
1031
|
+
return raw;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
/** UI event types in the analytics taxonomy that carry SQL the bundle
|
|
1035
|
+
* may want to render alongside the answer. Each carries the same
|
|
1036
|
+
* shape (`query` / `columns` / `rows` / `success`) so we can parse
|
|
1037
|
+
* uniformly. New types added upstream don't surface as artifacts
|
|
1038
|
+
* until added here — that's intentional, the renderer needs to
|
|
1039
|
+
* know how to display each. */
|
|
1040
|
+
const SQL_EVENT_TYPES = new Set([
|
|
1041
|
+
"query_executed",
|
|
1042
|
+
"query_generated",
|
|
1043
|
+
"verified_sql",
|
|
1044
|
+
"semantic_query",
|
|
1045
|
+
"omni_query"
|
|
1046
|
+
]);
|
|
1047
|
+
/** Extract a SQL artifact from a single SSE event when the type +
|
|
1048
|
+
* payload shape match. Returns `null` for events that aren't SQL
|
|
1049
|
+
* carriers or whose payload doesn't include the expected fields. */
|
|
1050
|
+
function extractSqlArtifact(eventType, eventId, data) {
|
|
1051
|
+
if (!SQL_EVENT_TYPES.has(eventType)) return null;
|
|
1052
|
+
if (typeof data !== "object" || data === null) return null;
|
|
1053
|
+
const obj = data;
|
|
1054
|
+
const sql = typeof obj.query === "string" ? obj.query : typeof obj.sql === "string" ? obj.sql : null;
|
|
1055
|
+
if (!sql) return null;
|
|
1056
|
+
const columns = Array.isArray(obj.columns) ? obj.columns.map(String) : void 0;
|
|
1057
|
+
const rows = Array.isArray(obj.rows) ? obj.rows : void 0;
|
|
1058
|
+
const rowCount = typeof obj.row_count === "number" ? obj.row_count : typeof obj.rowCount === "number" ? obj.rowCount : rows?.length;
|
|
1059
|
+
const artifact = {
|
|
1060
|
+
type: "sql",
|
|
1061
|
+
id: eventId || `${eventType}-${sql.slice(0, 32)}`,
|
|
1062
|
+
source: eventType,
|
|
1063
|
+
sql
|
|
1064
|
+
};
|
|
1065
|
+
if (columns && rows) artifact.results = {
|
|
1066
|
+
columns,
|
|
1067
|
+
rows,
|
|
1068
|
+
rowCount: rowCount ?? rows.length
|
|
1069
|
+
};
|
|
1070
|
+
const errMsg = typeof obj.error === "string" ? obj.error : obj.success === false && typeof obj.message === "string" ? obj.message : void 0;
|
|
1071
|
+
if (errMsg) artifact.error = errMsg;
|
|
1072
|
+
return artifact;
|
|
1023
1073
|
}
|
|
1024
1074
|
/**
|
|
1025
|
-
*
|
|
1075
|
+
* Hand-rolled SSE consumer using `fetch` + `ReadableStream`. We use
|
|
1076
|
+
* this in place of `EventSource` because:
|
|
1077
|
+
* 1. EventSource doesn't expose the connection's `Last-Event-ID`
|
|
1078
|
+
* header in a way you can control. The browser tracks it
|
|
1079
|
+
* internally but you can't pass a starting value, so a hook
|
|
1080
|
+
* that wants to resume after a tab switch / network blip has
|
|
1081
|
+
* no way to ask the server to replay from a known point.
|
|
1082
|
+
* 2. EventSource can't pass `Authorization` / other custom
|
|
1083
|
+
* headers — only `withCredentials` for cookies. Fine today,
|
|
1084
|
+
* but couples us to cookie auth forever.
|
|
1026
1085
|
*
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1086
|
+
* The parser handles the message-block model from the SSE spec
|
|
1087
|
+
* verbatim: lines split by `\n` (or `\r\n` / `\r`), event blocks
|
|
1088
|
+
* separated by blank lines, `id:` / `event:` / `data:` fields
|
|
1089
|
+
* accumulated per block. Multiple `data:` lines concatenate with
|
|
1090
|
+
* `\n` (per spec) — we honor that even though the server emits
|
|
1091
|
+
* single-line data today.
|
|
1029
1092
|
*
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1032
|
-
* function Dashboard() {
|
|
1033
|
-
* const { sdk, isLoading, error } = useOxy();
|
|
1034
|
-
*
|
|
1035
|
-
* useEffect(() => {
|
|
1036
|
-
* if (sdk) {
|
|
1037
|
-
* sdk.loadAppData('dashboard.app.yml')
|
|
1038
|
-
* .then(() => sdk.query('SELECT * FROM my_table'))
|
|
1039
|
-
* .then(result => console.log(result));
|
|
1040
|
-
* }
|
|
1041
|
-
* }, [sdk]);
|
|
1042
|
-
*
|
|
1043
|
-
* if (isLoading) return <div>Loading SDK...</div>;
|
|
1044
|
-
* if (error) return <div>Error: {error.message}</div>;
|
|
1045
|
-
* if (!sdk) return null;
|
|
1046
|
-
*
|
|
1047
|
-
* return <div>Dashboard</div>;
|
|
1048
|
-
* }
|
|
1049
|
-
* ```
|
|
1093
|
+
* Throws on network error or non-2xx. Returns when the stream ends
|
|
1094
|
+
* normally (server closed connection cleanly).
|
|
1050
1095
|
*/
|
|
1051
|
-
function
|
|
1052
|
-
const
|
|
1053
|
-
|
|
1054
|
-
|
|
1096
|
+
async function consumeSseStream(opts) {
|
|
1097
|
+
const headers = {
|
|
1098
|
+
accept: "text/event-stream",
|
|
1099
|
+
"cache-control": "no-cache"
|
|
1100
|
+
};
|
|
1101
|
+
if (opts.lastEventId) headers["Last-Event-ID"] = opts.lastEventId;
|
|
1102
|
+
const resp = await opts.fetcher(opts.url, {
|
|
1103
|
+
method: "GET",
|
|
1104
|
+
headers,
|
|
1105
|
+
signal: opts.signal
|
|
1106
|
+
});
|
|
1107
|
+
if (!resp.ok) throw await apiErrorFromResponse(resp);
|
|
1108
|
+
if (!resp.body) throw new Error("SSE response has no body");
|
|
1109
|
+
const reader = resp.body.getReader();
|
|
1110
|
+
const decoder = new TextDecoder();
|
|
1111
|
+
let buffer = "";
|
|
1112
|
+
let currentId = "";
|
|
1113
|
+
let currentEvent = "message";
|
|
1114
|
+
let currentData = [];
|
|
1115
|
+
const dispatch = () => {
|
|
1116
|
+
if (currentData.length === 0 && currentEvent === "message" && !currentId) return;
|
|
1117
|
+
opts.onEvent({
|
|
1118
|
+
id: currentId,
|
|
1119
|
+
event: currentEvent,
|
|
1120
|
+
data: currentData.join("\n")
|
|
1121
|
+
});
|
|
1122
|
+
currentEvent = "message";
|
|
1123
|
+
currentData = [];
|
|
1124
|
+
};
|
|
1125
|
+
while (true) {
|
|
1126
|
+
const { value, done } = await reader.read();
|
|
1127
|
+
if (done) break;
|
|
1128
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1129
|
+
let nlIndex;
|
|
1130
|
+
while ((nlIndex = buffer.search(/\r\n|\r|\n/)) !== -1) {
|
|
1131
|
+
if (nlIndex === buffer.length - 1 && buffer[nlIndex] === "\r") break;
|
|
1132
|
+
const line = buffer.slice(0, nlIndex);
|
|
1133
|
+
const sep = buffer.slice(nlIndex, nlIndex + 2);
|
|
1134
|
+
buffer = buffer.slice(nlIndex + (sep === "\r\n" ? 2 : 1));
|
|
1135
|
+
if (line === "") {
|
|
1136
|
+
dispatch();
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
if (line.startsWith(":")) continue;
|
|
1140
|
+
const colonAt = line.indexOf(":");
|
|
1141
|
+
const field = colonAt === -1 ? line : line.slice(0, colonAt);
|
|
1142
|
+
let value = colonAt === -1 ? "" : line.slice(colonAt + 1);
|
|
1143
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
1144
|
+
switch (field) {
|
|
1145
|
+
case "id":
|
|
1146
|
+
currentId = value;
|
|
1147
|
+
break;
|
|
1148
|
+
case "event":
|
|
1149
|
+
currentEvent = value;
|
|
1150
|
+
break;
|
|
1151
|
+
case "data":
|
|
1152
|
+
currentData.push(value);
|
|
1153
|
+
break;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
if (currentData.length > 0) dispatch();
|
|
1158
|
+
}
|
|
1159
|
+
function sleep(ms, signal) {
|
|
1160
|
+
return new Promise((resolve, reject) => {
|
|
1161
|
+
const t = setTimeout(resolve, ms);
|
|
1162
|
+
signal?.addEventListener("abort", () => {
|
|
1163
|
+
clearTimeout(t);
|
|
1164
|
+
reject(new DOMException("aborted", "AbortError"));
|
|
1165
|
+
}, { once: true });
|
|
1166
|
+
});
|
|
1055
1167
|
}
|
|
1056
1168
|
/**
|
|
1057
|
-
*
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
1060
|
-
* Use this when you know the SDK should be ready.
|
|
1169
|
+
* Renders an agent run's answer + artifacts + thread link as a
|
|
1170
|
+
* single block. The default styling is intentionally neutral
|
|
1171
|
+
* (system fonts, gray surfaces) so it blends into any bundle.
|
|
1061
1172
|
*
|
|
1062
|
-
*
|
|
1063
|
-
* @returns {OxySDK} The SDK instance
|
|
1173
|
+
* Designed to be paired with `useAgentRun`:
|
|
1064
1174
|
*
|
|
1065
|
-
* @example
|
|
1066
1175
|
* ```tsx
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
1071
|
-
*
|
|
1072
|
-
*
|
|
1073
|
-
*
|
|
1074
|
-
* .then(setData);
|
|
1075
|
-
* }, [sdk]);
|
|
1076
|
-
*
|
|
1077
|
-
* return <table>...</table>;
|
|
1078
|
-
* }
|
|
1176
|
+
* const run = useAgentRun({ agentId: "analyst" });
|
|
1177
|
+
* return (
|
|
1178
|
+
* <>
|
|
1179
|
+
* <button onClick={() => run.ask("how many users last week?")}>Ask</button>
|
|
1180
|
+
* <OxyAnswer {...run} />
|
|
1181
|
+
* </>
|
|
1182
|
+
* );
|
|
1079
1183
|
* ```
|
|
1080
1184
|
*/
|
|
1081
|
-
function
|
|
1082
|
-
const {
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1185
|
+
function OxyAnswer(props) {
|
|
1186
|
+
const { answer, artifacts = [], state, clarification, error, threadUrl, threadLinkLabel = "Continue this thread in Oxy", maxArtifactRows = 10, className } = props;
|
|
1187
|
+
const isRunning = state === "running";
|
|
1188
|
+
const isFailed = state === "failed";
|
|
1189
|
+
const needsClarification = state === "needs_clarification";
|
|
1190
|
+
return /* @__PURE__ */ react.createElement("div", {
|
|
1191
|
+
className,
|
|
1192
|
+
style: styles.answerWrap
|
|
1193
|
+
}, isRunning && answer === null ? /* @__PURE__ */ react.createElement("div", { style: styles.statusRow }, /* @__PURE__ */ react.createElement("span", {
|
|
1194
|
+
style: styles.spinner,
|
|
1195
|
+
"aria-hidden": "true"
|
|
1196
|
+
}), /* @__PURE__ */ react.createElement("span", { style: styles.statusText }, "Thinking…")) : null, artifacts.length > 0 ? /* @__PURE__ */ react.createElement("div", { style: styles.artifactList }, artifacts.map((a) => /* @__PURE__ */ react.createElement(SqlArtifactBlock, {
|
|
1197
|
+
key: a.id,
|
|
1198
|
+
artifact: a,
|
|
1199
|
+
maxRows: maxArtifactRows
|
|
1200
|
+
}))) : null, answer ? /* @__PURE__ */ react.createElement("div", { style: styles.markdown }, /* @__PURE__ */ react.createElement(MarkdownText, { text: answer })) : null, needsClarification && clarification ? /* @__PURE__ */ react.createElement("div", { style: styles.clarification }, /* @__PURE__ */ react.createElement("strong", null, "Agent needs clarification:"), /* @__PURE__ */ react.createElement("div", { style: { marginTop: 4 } }, clarification)) : null, isFailed && error ? /* @__PURE__ */ react.createElement(ErrorBlock, { error }) : null, threadUrl && (answer || artifacts.length > 0) ? /* @__PURE__ */ react.createElement("div", { style: styles.threadLinkRow }, /* @__PURE__ */ react.createElement("a", {
|
|
1201
|
+
href: threadUrl,
|
|
1202
|
+
target: "_blank",
|
|
1203
|
+
rel: "noreferrer noopener",
|
|
1204
|
+
style: styles.threadLink
|
|
1205
|
+
}, threadLinkLabel, " →"), /* @__PURE__ */ react.createElement("span", {
|
|
1206
|
+
style: styles.betaBadge,
|
|
1207
|
+
title: "Thread linking is in beta — see docs"
|
|
1208
|
+
}, "beta")) : null);
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Complete drop-in chat surface. One agent, one input, one answer
|
|
1212
|
+
* view. The chat is single-turn by default — each new question
|
|
1213
|
+
* cancels the previous run and clears the answer. Bundles that
|
|
1214
|
+
* want a multi-turn conversation history compose their own UI
|
|
1215
|
+
* using `useAgentRun` directly.
|
|
1216
|
+
*
|
|
1217
|
+
* Single-turn keeps the surface dead simple: bundles use this for
|
|
1218
|
+
* the "ask anything about your data" widget that sits next to
|
|
1219
|
+
* structured panels. Multi-turn is rare in those contexts and
|
|
1220
|
+
* better expressed by the bundle.
|
|
1221
|
+
*/
|
|
1222
|
+
function OxyChat(props) {
|
|
1223
|
+
const { agentId, placeholder = "Ask a question about your data…", submitLabel = "Ask", emptyState, maxArtifactRows, className } = props;
|
|
1224
|
+
const run = useAgentRun({ agentId });
|
|
1225
|
+
const [question, setQuestion] = react.useState("");
|
|
1226
|
+
const submit = react.useCallback((e) => {
|
|
1227
|
+
e?.preventDefault();
|
|
1228
|
+
const q = question.trim();
|
|
1229
|
+
if (!q || run.state === "running") return;
|
|
1230
|
+
run.ask(q);
|
|
1231
|
+
}, [question, run]);
|
|
1232
|
+
return /* @__PURE__ */ react.createElement("div", {
|
|
1233
|
+
className,
|
|
1234
|
+
style: styles.chatWrap
|
|
1235
|
+
}, /* @__PURE__ */ react.createElement("form", {
|
|
1236
|
+
onSubmit: submit,
|
|
1237
|
+
style: styles.chatForm
|
|
1238
|
+
}, /* @__PURE__ */ react.createElement("input", {
|
|
1239
|
+
type: "text",
|
|
1240
|
+
value: question,
|
|
1241
|
+
onChange: (e) => setQuestion(e.target.value),
|
|
1242
|
+
placeholder,
|
|
1243
|
+
disabled: run.state === "running",
|
|
1244
|
+
style: styles.chatInput,
|
|
1245
|
+
"aria-label": "Question"
|
|
1246
|
+
}), /* @__PURE__ */ react.createElement("button", {
|
|
1247
|
+
type: "submit",
|
|
1248
|
+
disabled: run.state === "running" || question.trim() === "",
|
|
1249
|
+
style: styles.chatSubmit
|
|
1250
|
+
}, run.state === "running" ? "…" : submitLabel), run.state === "running" ? /* @__PURE__ */ react.createElement("button", {
|
|
1251
|
+
type: "button",
|
|
1252
|
+
onClick: run.cancel,
|
|
1253
|
+
style: styles.chatCancel
|
|
1254
|
+
}, "Stop") : null), run.state === "idle" ? emptyState ?? /* @__PURE__ */ react.createElement("div", { style: styles.emptyState }, "Ask a question to get started.") : /* @__PURE__ */ react.createElement(OxyAnswer, {
|
|
1255
|
+
answer: run.answer,
|
|
1256
|
+
artifacts: run.artifacts,
|
|
1257
|
+
state: run.state,
|
|
1258
|
+
clarification: run.clarification,
|
|
1259
|
+
error: run.error,
|
|
1260
|
+
threadUrl: run.threadUrl,
|
|
1261
|
+
maxArtifactRows
|
|
1262
|
+
}));
|
|
1086
1263
|
}
|
|
1264
|
+
function SqlArtifactBlock(props) {
|
|
1265
|
+
const { artifact, maxRows } = props;
|
|
1266
|
+
const [open, setOpen] = react.useState(false);
|
|
1267
|
+
const results = artifact.results;
|
|
1268
|
+
const truncated = results ? results.rows.length > maxRows : false;
|
|
1269
|
+
const visibleRows = results ? results.rows.slice(0, maxRows) : [];
|
|
1270
|
+
const sourceLabel = artifact.source === "verified_sql" ? "Verified query" : artifact.source === "semantic_query" ? "Semantic query" : artifact.source === "omni_query" ? "Omni query" : "Query";
|
|
1271
|
+
return /* @__PURE__ */ react.createElement("div", { style: styles.artifact }, /* @__PURE__ */ react.createElement("button", {
|
|
1272
|
+
type: "button",
|
|
1273
|
+
onClick: () => setOpen((o) => !o),
|
|
1274
|
+
style: styles.artifactHeader
|
|
1275
|
+
}, /* @__PURE__ */ react.createElement("span", { style: styles.artifactBadge }, sourceLabel), /* @__PURE__ */ react.createElement("span", { style: styles.artifactSummary }, results ? `${results.rowCount} row${results.rowCount === 1 ? "" : "s"}` : artifact.error ? "execution failed" : "SQL only"), /* @__PURE__ */ react.createElement("span", { style: styles.artifactToggle }, open ? "Hide" : "Show")), open ? /* @__PURE__ */ react.createElement("div", null, /* @__PURE__ */ react.createElement("pre", { style: styles.sqlBlock }, artifact.sql), artifact.error ? /* @__PURE__ */ react.createElement("div", { style: styles.error }, artifact.error) : results ? /* @__PURE__ */ react.createElement("div", { style: styles.resultsWrap }, /* @__PURE__ */ react.createElement("table", { style: styles.resultsTable }, /* @__PURE__ */ react.createElement("thead", null, /* @__PURE__ */ react.createElement("tr", null, results.columns.map((c) => /* @__PURE__ */ react.createElement("th", {
|
|
1276
|
+
key: c,
|
|
1277
|
+
style: styles.resultsTh
|
|
1278
|
+
}, c)))), /* @__PURE__ */ react.createElement("tbody", null, visibleRows.map((row, i) => /* @__PURE__ */ react.createElement("tr", { key: i }, row.map((cell, j) => /* @__PURE__ */ react.createElement("td", {
|
|
1279
|
+
key: j,
|
|
1280
|
+
style: styles.resultsTd
|
|
1281
|
+
}, formatCell(cell))))))), truncated ? /* @__PURE__ */ react.createElement("div", { style: styles.truncatedNote }, "+", results.rows.length - maxRows, " more rows. Open the thread in Oxy to see all.") : null) : null) : null);
|
|
1282
|
+
}
|
|
1283
|
+
function formatCell(value) {
|
|
1284
|
+
if (value === null || value === void 0) return "—";
|
|
1285
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
1286
|
+
return String(value);
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Renders a thrown Error with the server's `hint` line broken out
|
|
1290
|
+
* if the error is an `OxyApiError`. Falls back to `error.message`
|
|
1291
|
+
* for plain Errors. Whitespace-preserving so multi-line hints from
|
|
1292
|
+
* the server land readably.
|
|
1293
|
+
*/
|
|
1294
|
+
function ErrorBlock(props) {
|
|
1295
|
+
const { error } = props;
|
|
1296
|
+
if (error instanceof OxyApiError) return /* @__PURE__ */ react.createElement("div", { style: styles.error }, /* @__PURE__ */ react.createElement("div", null, /* @__PURE__ */ react.createElement("strong", null, "Run failed:"), " ", error.message.split("\n\n")[0]), error.hint ? /* @__PURE__ */ react.createElement("div", { style: {
|
|
1297
|
+
marginTop: 6,
|
|
1298
|
+
fontWeight: 400,
|
|
1299
|
+
whiteSpace: "pre-wrap"
|
|
1300
|
+
} }, /* @__PURE__ */ react.createElement("strong", null, "Hint:"), " ", error.hint) : null);
|
|
1301
|
+
return /* @__PURE__ */ react.createElement("div", { style: styles.error }, /* @__PURE__ */ react.createElement("strong", null, "Run failed:"), " ", error.message);
|
|
1302
|
+
}
|
|
1303
|
+
function MarkdownText(props) {
|
|
1304
|
+
const blocks = react.useMemo(() => parseMarkdown(props.text), [props.text]);
|
|
1305
|
+
return /* @__PURE__ */ react.createElement(react.Fragment, null, blocks);
|
|
1306
|
+
}
|
|
1307
|
+
function parseMarkdown(text) {
|
|
1308
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
1309
|
+
const blocks = [];
|
|
1310
|
+
let i = 0;
|
|
1311
|
+
while (i < lines.length) {
|
|
1312
|
+
const line = lines[i];
|
|
1313
|
+
if (line === void 0) {
|
|
1314
|
+
i++;
|
|
1315
|
+
continue;
|
|
1316
|
+
}
|
|
1317
|
+
const fence = line.match(/^```(\w*)\s*$/);
|
|
1318
|
+
if (fence) {
|
|
1319
|
+
const lang = fence[1] ?? "";
|
|
1320
|
+
const buf = [];
|
|
1321
|
+
i++;
|
|
1322
|
+
while (i < lines.length && !/^```\s*$/.test(lines[i] ?? "")) {
|
|
1323
|
+
buf.push(lines[i] ?? "");
|
|
1324
|
+
i++;
|
|
1325
|
+
}
|
|
1326
|
+
i++;
|
|
1327
|
+
blocks.push({
|
|
1328
|
+
kind: "code",
|
|
1329
|
+
lang,
|
|
1330
|
+
code: buf.join("\n")
|
|
1331
|
+
});
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
const h = line.match(/^(#{1,3})\s+(.+)$/);
|
|
1335
|
+
if (h) {
|
|
1336
|
+
blocks.push({
|
|
1337
|
+
kind: "h",
|
|
1338
|
+
level: h[1]?.length,
|
|
1339
|
+
text: h[2]
|
|
1340
|
+
});
|
|
1341
|
+
i++;
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
if (/^\s*[-*]\s+/.test(line)) {
|
|
1345
|
+
const items = [];
|
|
1346
|
+
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i] ?? "")) {
|
|
1347
|
+
items.push((lines[i] ?? "").replace(/^\s*[-*]\s+/, ""));
|
|
1348
|
+
i++;
|
|
1349
|
+
}
|
|
1350
|
+
blocks.push({
|
|
1351
|
+
kind: "list",
|
|
1352
|
+
items
|
|
1353
|
+
});
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1356
|
+
if (line.trim() === "") {
|
|
1357
|
+
i++;
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
const buf = [line];
|
|
1361
|
+
i++;
|
|
1362
|
+
while (i < lines.length) {
|
|
1363
|
+
const next = lines[i] ?? "";
|
|
1364
|
+
if (next.trim() === "" || /^#{1,3}\s+/.test(next) || /^```/.test(next) || /^\s*[-*]\s+/.test(next)) break;
|
|
1365
|
+
buf.push(next);
|
|
1366
|
+
i++;
|
|
1367
|
+
}
|
|
1368
|
+
blocks.push({
|
|
1369
|
+
kind: "p",
|
|
1370
|
+
text: buf.join(" ")
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
return blocks.map((b, idx) => {
|
|
1374
|
+
switch (b.kind) {
|
|
1375
|
+
case "h": {
|
|
1376
|
+
const Tag = `h${b.level}`;
|
|
1377
|
+
const headingStyle = b.level === 1 ? styles.h1 : b.level === 2 ? styles.h2 : styles.h3;
|
|
1378
|
+
return /* @__PURE__ */ react.createElement(Tag, {
|
|
1379
|
+
key: idx,
|
|
1380
|
+
style: headingStyle
|
|
1381
|
+
}, renderInline(b.text));
|
|
1382
|
+
}
|
|
1383
|
+
case "code": return /* @__PURE__ */ react.createElement("pre", {
|
|
1384
|
+
key: idx,
|
|
1385
|
+
style: styles.codeBlock,
|
|
1386
|
+
"data-lang": b.lang || void 0
|
|
1387
|
+
}, /* @__PURE__ */ react.createElement("code", null, b.code));
|
|
1388
|
+
case "list": return /* @__PURE__ */ react.createElement("ul", {
|
|
1389
|
+
key: idx,
|
|
1390
|
+
style: styles.list
|
|
1391
|
+
}, b.items.map((item, i) => /* @__PURE__ */ react.createElement("li", { key: i }, renderInline(item))));
|
|
1392
|
+
case "p": return /* @__PURE__ */ react.createElement("p", {
|
|
1393
|
+
key: idx,
|
|
1394
|
+
style: styles.paragraph
|
|
1395
|
+
}, renderInline(b.text));
|
|
1396
|
+
}
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Inline tokenizer for **bold**, *italic*, `code`, and [text](url).
|
|
1401
|
+
* Lazy: scan once, split into segments. The patterns are matched in
|
|
1402
|
+
* priority order (code first so backticks don't get eaten by bold).
|
|
1403
|
+
*/
|
|
1404
|
+
function renderInline(text) {
|
|
1405
|
+
const segments = [];
|
|
1406
|
+
let remaining = text;
|
|
1407
|
+
let key = 0;
|
|
1408
|
+
const patterns = [
|
|
1409
|
+
{
|
|
1410
|
+
re: /`([^`]+)`/,
|
|
1411
|
+
render: (m) => /* @__PURE__ */ react.createElement("code", { style: styles.inlineCode }, m[1])
|
|
1412
|
+
},
|
|
1413
|
+
{
|
|
1414
|
+
re: /\[([^\]]+)\]\(([^)]+)\)/,
|
|
1415
|
+
render: (m) => {
|
|
1416
|
+
if (isSafeLinkHref(m[2])) return /* @__PURE__ */ react.createElement("a", {
|
|
1417
|
+
href: m[2],
|
|
1418
|
+
target: "_blank",
|
|
1419
|
+
rel: "noreferrer noopener",
|
|
1420
|
+
style: styles.link
|
|
1421
|
+
}, m[1]);
|
|
1422
|
+
return /* @__PURE__ */ react.createElement(react.Fragment, null, m[1]);
|
|
1423
|
+
}
|
|
1424
|
+
},
|
|
1425
|
+
{
|
|
1426
|
+
re: /\*\*([^*]+)\*\*/,
|
|
1427
|
+
render: (m) => /* @__PURE__ */ react.createElement("strong", null, m[1])
|
|
1428
|
+
},
|
|
1429
|
+
{
|
|
1430
|
+
re: /\*([^*]+)\*/,
|
|
1431
|
+
render: (m) => /* @__PURE__ */ react.createElement("em", null, m[1])
|
|
1432
|
+
}
|
|
1433
|
+
];
|
|
1434
|
+
while (remaining.length > 0) {
|
|
1435
|
+
let earliest = null;
|
|
1436
|
+
for (const { re, render } of patterns) {
|
|
1437
|
+
const m = re.exec(remaining);
|
|
1438
|
+
if (m && (earliest === null || m.index < earliest.idx)) earliest = {
|
|
1439
|
+
idx: m.index,
|
|
1440
|
+
len: m[0].length,
|
|
1441
|
+
node: render(m)
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
if (earliest === null) {
|
|
1445
|
+
segments.push(remaining);
|
|
1446
|
+
break;
|
|
1447
|
+
}
|
|
1448
|
+
if (earliest.idx > 0) segments.push(remaining.slice(0, earliest.idx));
|
|
1449
|
+
segments.push(/* @__PURE__ */ react.createElement(react.Fragment, { key: key++ }, earliest.node));
|
|
1450
|
+
remaining = remaining.slice(earliest.idx + earliest.len);
|
|
1451
|
+
}
|
|
1452
|
+
return segments;
|
|
1453
|
+
}
|
|
1454
|
+
const SANS = "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif";
|
|
1455
|
+
const MONO = "ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace";
|
|
1456
|
+
const styles = {
|
|
1457
|
+
answerWrap: {
|
|
1458
|
+
fontFamily: SANS,
|
|
1459
|
+
fontSize: 14,
|
|
1460
|
+
lineHeight: 1.5,
|
|
1461
|
+
color: "#1f2937"
|
|
1462
|
+
},
|
|
1463
|
+
statusRow: {
|
|
1464
|
+
display: "flex",
|
|
1465
|
+
alignItems: "center",
|
|
1466
|
+
gap: 8,
|
|
1467
|
+
padding: "8px 0"
|
|
1468
|
+
},
|
|
1469
|
+
spinner: {
|
|
1470
|
+
display: "inline-block",
|
|
1471
|
+
width: 12,
|
|
1472
|
+
height: 12,
|
|
1473
|
+
borderRadius: "50%",
|
|
1474
|
+
border: "2px solid #d1d5db",
|
|
1475
|
+
borderTopColor: "#6b7280",
|
|
1476
|
+
animation: "oxy-spin 0.8s linear infinite"
|
|
1477
|
+
},
|
|
1478
|
+
statusText: { color: "#6b7280" },
|
|
1479
|
+
markdown: { marginTop: 8 },
|
|
1480
|
+
h1: {
|
|
1481
|
+
fontSize: 20,
|
|
1482
|
+
fontWeight: 600,
|
|
1483
|
+
margin: "16px 0 8px"
|
|
1484
|
+
},
|
|
1485
|
+
h2: {
|
|
1486
|
+
fontSize: 17,
|
|
1487
|
+
fontWeight: 600,
|
|
1488
|
+
margin: "14px 0 6px"
|
|
1489
|
+
},
|
|
1490
|
+
h3: {
|
|
1491
|
+
fontSize: 15,
|
|
1492
|
+
fontWeight: 600,
|
|
1493
|
+
margin: "12px 0 4px"
|
|
1494
|
+
},
|
|
1495
|
+
paragraph: { margin: "0 0 8px" },
|
|
1496
|
+
list: {
|
|
1497
|
+
margin: "0 0 8px",
|
|
1498
|
+
paddingLeft: 20
|
|
1499
|
+
},
|
|
1500
|
+
codeBlock: {
|
|
1501
|
+
fontFamily: MONO,
|
|
1502
|
+
fontSize: 12,
|
|
1503
|
+
background: "#f3f4f6",
|
|
1504
|
+
border: "1px solid #e5e7eb",
|
|
1505
|
+
borderRadius: 6,
|
|
1506
|
+
padding: "8px 10px",
|
|
1507
|
+
overflowX: "auto",
|
|
1508
|
+
margin: "8px 0"
|
|
1509
|
+
},
|
|
1510
|
+
inlineCode: {
|
|
1511
|
+
fontFamily: MONO,
|
|
1512
|
+
fontSize: "0.92em",
|
|
1513
|
+
background: "#f3f4f6",
|
|
1514
|
+
padding: "1px 4px",
|
|
1515
|
+
borderRadius: 3
|
|
1516
|
+
},
|
|
1517
|
+
link: {
|
|
1518
|
+
color: "#2563eb",
|
|
1519
|
+
textDecoration: "underline"
|
|
1520
|
+
},
|
|
1521
|
+
clarification: {
|
|
1522
|
+
marginTop: 12,
|
|
1523
|
+
padding: "10px 12px",
|
|
1524
|
+
background: "#fef3c7",
|
|
1525
|
+
border: "1px solid #fcd34d",
|
|
1526
|
+
borderRadius: 6,
|
|
1527
|
+
color: "#78350f"
|
|
1528
|
+
},
|
|
1529
|
+
error: {
|
|
1530
|
+
marginTop: 12,
|
|
1531
|
+
padding: "10px 12px",
|
|
1532
|
+
background: "#fee2e2",
|
|
1533
|
+
border: "1px solid #fca5a5",
|
|
1534
|
+
borderRadius: 6,
|
|
1535
|
+
color: "#991b1b"
|
|
1536
|
+
},
|
|
1537
|
+
threadLinkRow: {
|
|
1538
|
+
marginTop: 12,
|
|
1539
|
+
textAlign: "right",
|
|
1540
|
+
display: "flex",
|
|
1541
|
+
justifyContent: "flex-end",
|
|
1542
|
+
alignItems: "center",
|
|
1543
|
+
gap: 6
|
|
1544
|
+
},
|
|
1545
|
+
threadLink: {
|
|
1546
|
+
fontSize: 12,
|
|
1547
|
+
color: "#6b7280",
|
|
1548
|
+
textDecoration: "none"
|
|
1549
|
+
},
|
|
1550
|
+
betaBadge: {
|
|
1551
|
+
fontSize: 9,
|
|
1552
|
+
fontWeight: 600,
|
|
1553
|
+
letterSpacing: .5,
|
|
1554
|
+
textTransform: "uppercase",
|
|
1555
|
+
padding: "1px 5px",
|
|
1556
|
+
borderRadius: 3,
|
|
1557
|
+
background: "#fef3c7",
|
|
1558
|
+
color: "#92400e",
|
|
1559
|
+
border: "1px solid #fcd34d"
|
|
1560
|
+
},
|
|
1561
|
+
artifactList: {
|
|
1562
|
+
display: "flex",
|
|
1563
|
+
flexDirection: "column",
|
|
1564
|
+
gap: 8,
|
|
1565
|
+
marginBottom: 8
|
|
1566
|
+
},
|
|
1567
|
+
artifact: {
|
|
1568
|
+
border: "1px solid #e5e7eb",
|
|
1569
|
+
borderRadius: 6,
|
|
1570
|
+
background: "#fafafa",
|
|
1571
|
+
overflow: "hidden"
|
|
1572
|
+
},
|
|
1573
|
+
artifactHeader: {
|
|
1574
|
+
display: "flex",
|
|
1575
|
+
alignItems: "center",
|
|
1576
|
+
gap: 10,
|
|
1577
|
+
width: "100%",
|
|
1578
|
+
padding: "6px 10px",
|
|
1579
|
+
background: "transparent",
|
|
1580
|
+
border: "none",
|
|
1581
|
+
borderBottom: "1px solid transparent",
|
|
1582
|
+
cursor: "pointer",
|
|
1583
|
+
fontFamily: SANS,
|
|
1584
|
+
fontSize: 12,
|
|
1585
|
+
color: "#374151"
|
|
1586
|
+
},
|
|
1587
|
+
artifactBadge: {
|
|
1588
|
+
fontWeight: 600,
|
|
1589
|
+
fontSize: 11,
|
|
1590
|
+
textTransform: "uppercase",
|
|
1591
|
+
letterSpacing: .4,
|
|
1592
|
+
color: "#4b5563"
|
|
1593
|
+
},
|
|
1594
|
+
artifactSummary: {
|
|
1595
|
+
color: "#6b7280",
|
|
1596
|
+
flex: 1
|
|
1597
|
+
},
|
|
1598
|
+
artifactToggle: { color: "#2563eb" },
|
|
1599
|
+
sqlBlock: {
|
|
1600
|
+
fontFamily: MONO,
|
|
1601
|
+
fontSize: 12,
|
|
1602
|
+
margin: 0,
|
|
1603
|
+
padding: "8px 10px",
|
|
1604
|
+
background: "#0f172a",
|
|
1605
|
+
color: "#e2e8f0",
|
|
1606
|
+
overflowX: "auto"
|
|
1607
|
+
},
|
|
1608
|
+
resultsWrap: {
|
|
1609
|
+
padding: 8,
|
|
1610
|
+
overflowX: "auto"
|
|
1611
|
+
},
|
|
1612
|
+
resultsTable: {
|
|
1613
|
+
width: "100%",
|
|
1614
|
+
borderCollapse: "collapse",
|
|
1615
|
+
fontSize: 12
|
|
1616
|
+
},
|
|
1617
|
+
resultsTh: {
|
|
1618
|
+
textAlign: "left",
|
|
1619
|
+
padding: "4px 8px",
|
|
1620
|
+
borderBottom: "1px solid #e5e7eb",
|
|
1621
|
+
fontWeight: 600,
|
|
1622
|
+
color: "#374151"
|
|
1623
|
+
},
|
|
1624
|
+
resultsTd: {
|
|
1625
|
+
padding: "4px 8px",
|
|
1626
|
+
borderBottom: "1px solid #f3f4f6",
|
|
1627
|
+
color: "#1f2937"
|
|
1628
|
+
},
|
|
1629
|
+
truncatedNote: {
|
|
1630
|
+
fontSize: 11,
|
|
1631
|
+
color: "#6b7280",
|
|
1632
|
+
padding: "6px 8px"
|
|
1633
|
+
},
|
|
1634
|
+
chatWrap: {
|
|
1635
|
+
fontFamily: SANS,
|
|
1636
|
+
fontSize: 14,
|
|
1637
|
+
color: "#1f2937"
|
|
1638
|
+
},
|
|
1639
|
+
chatForm: {
|
|
1640
|
+
display: "flex",
|
|
1641
|
+
gap: 8,
|
|
1642
|
+
marginBottom: 12
|
|
1643
|
+
},
|
|
1644
|
+
chatInput: {
|
|
1645
|
+
flex: 1,
|
|
1646
|
+
padding: "8px 12px",
|
|
1647
|
+
border: "1px solid #d1d5db",
|
|
1648
|
+
borderRadius: 6,
|
|
1649
|
+
fontSize: 14,
|
|
1650
|
+
fontFamily: SANS
|
|
1651
|
+
},
|
|
1652
|
+
chatSubmit: {
|
|
1653
|
+
padding: "8px 16px",
|
|
1654
|
+
border: "none",
|
|
1655
|
+
borderRadius: 6,
|
|
1656
|
+
background: "#2563eb",
|
|
1657
|
+
color: "#ffffff",
|
|
1658
|
+
fontSize: 14,
|
|
1659
|
+
fontWeight: 500,
|
|
1660
|
+
cursor: "pointer"
|
|
1661
|
+
},
|
|
1662
|
+
chatCancel: {
|
|
1663
|
+
padding: "8px 12px",
|
|
1664
|
+
border: "1px solid #d1d5db",
|
|
1665
|
+
borderRadius: 6,
|
|
1666
|
+
background: "#ffffff",
|
|
1667
|
+
color: "#374151",
|
|
1668
|
+
fontSize: 14,
|
|
1669
|
+
cursor: "pointer"
|
|
1670
|
+
},
|
|
1671
|
+
emptyState: {
|
|
1672
|
+
padding: "12px 0",
|
|
1673
|
+
color: "#9ca3af",
|
|
1674
|
+
fontStyle: "italic"
|
|
1675
|
+
}
|
|
1676
|
+
};
|
|
1087
1677
|
|
|
1088
1678
|
//#endregion
|
|
1089
|
-
exports.
|
|
1090
|
-
exports.
|
|
1091
|
-
exports.
|
|
1092
|
-
exports.
|
|
1093
|
-
exports.
|
|
1094
|
-
exports.
|
|
1095
|
-
exports.
|
|
1096
|
-
exports.
|
|
1097
|
-
exports.
|
|
1098
|
-
exports.
|
|
1099
|
-
exports.
|
|
1100
|
-
exports.
|
|
1101
|
-
exports.
|
|
1102
|
-
exports.
|
|
1103
|
-
exports.
|
|
1104
|
-
exports.
|
|
1105
|
-
exports.useOxy = useOxy;
|
|
1106
|
-
exports.useOxySDK = useOxySDK;
|
|
1679
|
+
exports.OxyAnswer = OxyAnswer;
|
|
1680
|
+
exports.OxyApiError = OxyApiError;
|
|
1681
|
+
exports.OxyAppProvider = OxyAppProvider;
|
|
1682
|
+
exports.OxyChat = OxyChat;
|
|
1683
|
+
exports._resetCustomerAppManifestCacheForTest = _resetCustomerAppManifestCacheForTest;
|
|
1684
|
+
exports.getCustomerAppDebug = getCustomerAppDebug;
|
|
1685
|
+
exports.getOxyAppLogger = getOxyAppLogger;
|
|
1686
|
+
exports.interpretCustomerAppError = interpretCustomerAppError;
|
|
1687
|
+
exports.loadCustomerAppManifest = loadCustomerAppManifest;
|
|
1688
|
+
exports.readInjectedAppConfig = readInjectedAppConfig;
|
|
1689
|
+
exports.setOxyAppLogger = setOxyAppLogger;
|
|
1690
|
+
exports.useAgentRun = useAgentRun;
|
|
1691
|
+
exports.useProcedureRun = useProcedureRun;
|
|
1692
|
+
exports.useQuery = useQuery;
|
|
1693
|
+
exports.useResolvedManifest = useResolvedManifest;
|
|
1694
|
+
exports.useSemanticQuery = useSemanticQuery;
|
|
1107
1695
|
//# sourceMappingURL=index.cjs.map
|