6bb128c804
Point OMNIGENT_URL at a bare Databricks workspace origin and npm run dev auto-fills the /api/2.0/omnigent api-proxy mount and emits the host_id slice key on host-scoped traffic (build-time VITE_DATABRICKS_WORKSPACE flag + the unified isDatabricksWorkspace() gate), so the standalone dev bundle shards like the embedded UI. An explicit mount or a local server is unaffected. Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com> Co-authored-by: Isaac <isaac@example.com>
310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import tailwindcss from "@tailwindcss/vite";
|
|
import react from "@vitejs/plugin-react";
|
|
import type { Plugin, ProxyOptions } from "vite";
|
|
import { defineConfig } from "vitest/config";
|
|
|
|
// Databricks workspace-hosted omnigent is mounted behind the api-proxy at this
|
|
// path; a local / self-hosted server mounts at the root. Mirrors the Python
|
|
// WORKSPACE_API_PATH (omnigent/cli_auth.py) so both sides agree on the shape of
|
|
// a workspace URL.
|
|
const WORKSPACE_API_PATH = "/api/2.0/omnigent";
|
|
|
|
function isWorkspaceHost(hostname: string): boolean {
|
|
return hostname.endsWith(".databricks.com") || hostname.endsWith(".azuredatabricks.net");
|
|
}
|
|
|
|
// Resolve the proxy target. Point OMNIGENT_URL at a bare workspace origin
|
|
// (https://<ws>.databricks.com) and the api-proxy mount is filled in
|
|
// automatically, so the browser's relative /v1/... paths reach the
|
|
// workspace-hosted server without hand-typing the /api/2.0/omnigent prefix. An
|
|
// explicit non-root path is respected (a custom mount, or a local server).
|
|
function resolveTarget(raw: string): string {
|
|
const url = new URL(raw);
|
|
if (isWorkspaceHost(url.hostname) && (url.pathname === "" || url.pathname === "/")) {
|
|
url.pathname = WORKSPACE_API_PATH;
|
|
}
|
|
return url.toString().replace(/\/$/, "");
|
|
}
|
|
|
|
const OMNIGENT_URL = resolveTarget(process.env.OMNIGENT_URL ?? "http://localhost:6767");
|
|
|
|
let cachedToken: string | null | undefined;
|
|
|
|
function resolveToken(host: string): string | null {
|
|
if (cachedToken !== undefined) return cachedToken;
|
|
|
|
if (process.env.OMNIGENT_AUTH_TOKEN) {
|
|
cachedToken = process.env.OMNIGENT_AUTH_TOKEN;
|
|
return cachedToken;
|
|
}
|
|
|
|
try {
|
|
const output = execFileSync(
|
|
"databricks",
|
|
["auth", "token", "--host", host, "--output", "json"],
|
|
{
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
const tokenResponse = JSON.parse(output) as { access_token?: string };
|
|
cachedToken = tokenResponse.access_token ?? null;
|
|
} catch {
|
|
cachedToken = null;
|
|
}
|
|
|
|
return cachedToken;
|
|
}
|
|
|
|
function configureProxy(target: string, useAuth: boolean): NonNullable<ProxyOptions["configure"]> {
|
|
const parsed = new URL(target);
|
|
const host = parsed.origin;
|
|
// The URL pathname becomes a prefix prepended to every proxied request.
|
|
// e.g. OMNIGENT_URL=https://host.com/api/2.0/omnigent means the browser's
|
|
// /v1/sessions is rewritten to /api/2.0/omnigent/v1/sessions before forwarding.
|
|
const basePath = parsed.pathname.replace(/\/$/, "");
|
|
|
|
return (proxy) => {
|
|
proxy.on("proxyReq", (proxyReq) => {
|
|
if (basePath) proxyReq.path = `${basePath}${proxyReq.path}`;
|
|
if (useAuth) {
|
|
const token = resolveToken(host);
|
|
if (token) proxyReq.setHeader("Authorization", `Bearer ${token}`);
|
|
}
|
|
});
|
|
|
|
proxy.on("proxyReqWs", (proxyReq) => {
|
|
if (basePath) proxyReq.path = `${basePath}${proxyReq.path}`;
|
|
if (useAuth) {
|
|
const token = resolveToken(host);
|
|
if (token) proxyReq.setHeader("Authorization", `Bearer ${token}`);
|
|
}
|
|
});
|
|
|
|
proxy.on("proxyRes", (proxyRes, _req, res) => {
|
|
const contentType = proxyRes.headers["content-type"] ?? "";
|
|
if (typeof contentType === "string" && contentType.includes("text/event-stream")) {
|
|
// http-proxy applies upstream headers after its own proxyRes listener
|
|
// runs. Defer flushing until after those headers have been copied.
|
|
setImmediate(() => res.flushHeaders());
|
|
}
|
|
});
|
|
};
|
|
}
|
|
|
|
function createProxyConfig(target: string, useAuth: boolean): Record<string, ProxyOptions> {
|
|
const origin = new URL(target).origin;
|
|
const configure = configureProxy(target, useAuth);
|
|
|
|
return {
|
|
"/v1": {
|
|
target: origin,
|
|
changeOrigin: true,
|
|
ws: true,
|
|
configure,
|
|
},
|
|
"/api": {
|
|
target: origin,
|
|
changeOrigin: true,
|
|
configure,
|
|
},
|
|
"/auth": {
|
|
target: origin,
|
|
changeOrigin: true,
|
|
configure,
|
|
},
|
|
"/health": {
|
|
target: origin,
|
|
changeOrigin: true,
|
|
configure,
|
|
},
|
|
// The server's version manifest (/.well-known/omnigent.json), which the
|
|
// desktop shell reads to learn what it's talking to. Without this the dev
|
|
// server answers with the SPA's index.html, and the shell — which rightly
|
|
// refuses to parse HTML as a manifest — sees every dev server as
|
|
// "pre-manifest", making the capability invisible in local development.
|
|
"/.well-known": {
|
|
target: origin,
|
|
changeOrigin: true,
|
|
configure,
|
|
},
|
|
};
|
|
}
|
|
|
|
const parsed = new URL(OMNIGENT_URL);
|
|
const useAuth = !!process.env.OMNIGENT_AUTH_TOKEN || isWorkspaceHost(parsed.hostname);
|
|
|
|
// A Databricks workspace-hosted server sits behind the multi-replica sharding
|
|
// layer, so the dev bundle must emit the host_id slice key on host-scoped
|
|
// traffic — same as the embedded (managed) UI — or requests scatter across
|
|
// replicas and a host's runner tunnel becomes unreachable ("host is offline").
|
|
// Surface it to the browser as a build-time flag (inlined into import.meta.env);
|
|
// the embed path keys off its host fetcher instead (see isDatabricksWorkspace in
|
|
// host.ts). Keyed off the resolved mount so pointing at a bare workspace origin
|
|
// turns it on.
|
|
const isDatabricksWorkspaceEnv = parsed.pathname.replace(/\/$/, "") === WORKSPACE_API_PATH;
|
|
if (isDatabricksWorkspaceEnv) process.env.VITE_DATABRICKS_WORKSPACE = "true";
|
|
|
|
if (useAuth) {
|
|
const token = resolveToken(parsed.origin);
|
|
if (token) {
|
|
console.log(
|
|
`[dev-proxy] target=${OMNIGENT_URL} (authenticated${isDatabricksWorkspaceEnv ? ", databricks-workspace" : ""})`,
|
|
);
|
|
} else {
|
|
console.error(
|
|
`\n[dev-proxy] ERROR: No auth token for ${parsed.origin}.\n` +
|
|
` Set OMNIGENT_AUTH_TOKEN or run: databricks auth login --host ${parsed.origin}\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
} else {
|
|
console.log(`[dev-proxy] target=${OMNIGENT_URL}`);
|
|
}
|
|
|
|
const proxyConfig = createProxyConfig(OMNIGENT_URL, useAuth);
|
|
|
|
/**
|
|
* Emit the tombstone `sw.js` for the standalone build (see `sw-src/sw.js`): the
|
|
* retired PWA service worker's self-unregistering replacement. Registered ONLY
|
|
* here (not in `vite.embed.config.ts`), so the embed island still ships no
|
|
* service worker.
|
|
*
|
|
* @deprecated Delete this plugin together with `sw-src/sw.js` in 0.11.0, once
|
|
* existing registrations have had time to unregister themselves.
|
|
*/
|
|
function emitServiceWorkerTombstone(): Plugin {
|
|
return {
|
|
name: "emit-service-worker-tombstone",
|
|
generateBundle() {
|
|
this.emitFile({
|
|
type: "asset",
|
|
fileName: "sw.js",
|
|
source: readFileSync(path.resolve(__dirname, "sw-src/sw.js"), "utf8"),
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
// Safari < 16.4 cannot parse regex lookbehind; these dependency regexes would
|
|
// otherwise throw there, at module scope during boot or on the first rendered
|
|
// markdown message (#1978):
|
|
// - marked probes lookbehind support with `new RegExp("(?<=1)(?<!1)")` inside
|
|
// try/catch, but rolldown constant-folds the probe to `true`; route the
|
|
// constructor through `globalThis` so it stays a runtime check.
|
|
// - remend builds its single-tilde repair regex at module scope with no
|
|
// guard; fall back to a never-matching regex so the repair no-ops.
|
|
// - mdast-util-gfm-autolink-literal's email regex is constructed when a GFM
|
|
// message renders; fall back to a never-matching regex (plain-text emails
|
|
// just don't autolink).
|
|
const LOOKBEHIND_REWRITES: [string, string][] = [
|
|
['new RegExp("(?<=1)(?<!1)")', 'new globalThis.RegExp("(?<=1)(?<!1)")'],
|
|
[
|
|
'new RegExp("(?<=[\\\\p{L}\\\\p{N}_])~(?!~)(?=[\\\\p{L}\\\\p{N}_])","gu")',
|
|
'(() => { try { return new globalThis.RegExp("(?<=[\\\\p{L}\\\\p{N}_])~(?!~)(?=[\\\\p{L}\\\\p{N}_])", "gu"); } catch { return /(?!)/gu; } })()',
|
|
],
|
|
[
|
|
"/(?<=^|\\s|\\p{P}|\\p{S})([-.\\w+]+)@([-\\w]+(?:\\.[-\\w]+)+)/gu",
|
|
'(() => { try { return new globalThis.RegExp("(?<=^|\\\\s|\\\\p{P}|\\\\p{S})([-.\\\\w+]+)@([-\\\\w]+(?:\\\\.[-\\\\w]+)+)", "gu"); } catch { return /(?!)/gu; } })()',
|
|
],
|
|
];
|
|
const LOOKBEHIND_REWRITE_MODULES = [
|
|
"/node_modules/marked/",
|
|
"/node_modules/remend/",
|
|
"/node_modules/mdast-util-gfm-autolink-literal/",
|
|
];
|
|
|
|
function isLookbehindRewriteModule(id: string): boolean {
|
|
const normalizedId = id.replaceAll("\\", "/");
|
|
return LOOKBEHIND_REWRITE_MODULES.some((modulePath) => normalizedId.includes(modulePath));
|
|
}
|
|
|
|
function safariLookbehindWorkarounds(): Plugin {
|
|
return {
|
|
name: "safari-lookbehind-workarounds",
|
|
transform(code, id) {
|
|
if (!isLookbehindRewriteModule(id)) return;
|
|
|
|
let out = code;
|
|
for (const [from, to] of LOOKBEHIND_REWRITES) {
|
|
out = out.replaceAll(from, to);
|
|
}
|
|
if (out === code) return;
|
|
return { code: out, map: null };
|
|
},
|
|
};
|
|
}
|
|
|
|
export default defineConfig({
|
|
plugins: [emitServiceWorkerTombstone(), safariLookbehindWorkarounds(), react(), tailwindcss()],
|
|
resolve: {
|
|
alias: {
|
|
"@": path.resolve(__dirname, "./src"),
|
|
},
|
|
},
|
|
test: {
|
|
globals: true,
|
|
environment: "jsdom",
|
|
setupFiles: ["./src/test-setup.ts"],
|
|
// Scope discovery to src/ — the web suite lives there. Without this,
|
|
// vitest's default glob descends into the nested electron package and
|
|
// tries to run its node:test files (which aren't vitest suites).
|
|
include: ["src/**/*.{test,spec}.?(c|m)[jt]s?(x)"],
|
|
coverage: {
|
|
provider: "v8",
|
|
// With `include` set, vitest counts every matching source file (untested
|
|
// ones as 0%), so the total reflects the whole frontend — parity with the
|
|
// backend's --cov=omnigent, not just files a test happened to import.
|
|
include: ["src/**/*.{ts,tsx}"],
|
|
exclude: [
|
|
"src/**/*.test.{ts,tsx}",
|
|
"src/**/*.d.ts",
|
|
"src/test-setup.ts",
|
|
// Vendored UI kit, not product code (see tests/e2e_ui/COVERAGE_GAPS.md).
|
|
"src/components/ai-elements/**",
|
|
],
|
|
reportsDirectory: "./coverage",
|
|
// text-summary: human-readable console line; json-summary: machine-
|
|
// readable coverage/coverage-summary.json that CI distills to total.txt.
|
|
reporter: ["text-summary", "json-summary"],
|
|
},
|
|
},
|
|
server: {
|
|
proxy: proxyConfig,
|
|
},
|
|
build: {
|
|
// default baseline is Safari 16.4+; iPadOS 15 can't parse dep regex lookbehinds (#1978)
|
|
target: ["chrome111", "edge111", "firefox114", "safari15", "ios15"],
|
|
outDir: path.resolve(__dirname, "../omnigent/server/static/web-ui"),
|
|
emptyOutDir: true,
|
|
rollupOptions: {
|
|
output: {
|
|
manualChunks(id) {
|
|
const normalized = id.replaceAll("\\", "/");
|
|
// Shiki lazily imports each language grammar (`@shikijs/langs/<lang>`)
|
|
// via dynamic import; leave those as their own on-demand chunks
|
|
// instead of folding ~200 grammars into the eagerly-loaded core.
|
|
if (normalized.includes("/@shikijs/langs/")) {
|
|
return;
|
|
}
|
|
// Keep Shiki's core, engines, and bundle glue (incl. the language
|
|
// index + alias map) in one chunk. pnpm's symlinks + Vite's default
|
|
// split otherwise expose a top-level cyclic import between the
|
|
// language bundle and the alias-map chunk that executes before its
|
|
// data dependency is initialized, producing "Cannot read properties
|
|
// of undefined (reading 'flatMap')" and a blank Monaco/file-viewer
|
|
// screen. The engines must stay here too: excluding them splits the
|
|
// cyclic core across chunks and reintroduces the bug.
|
|
if (normalized.includes("/shiki") || normalized.includes("/@shikijs/")) {
|
|
return "shiki";
|
|
}
|
|
return undefined;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|