Files
qwen-code/packages/web-shell/vite.config.ts
T
ytahdnand钉萁 df5470e430 feat(web-shell): Add workspace file browser and previews (#13878)
* feat(web-shell): Add workspace file browser and previews

* fix(web-shell): Restore source preview CI fixtures and notices

* fix(web-shell): Address file pane review blockers

* fix(web-shell): Resolve review regressions and strengthen file checks

---------

Co-authored-by: 钉萁 <[email protected]>
2026-10-13 09:38:47 +00:00

286 lines
12 KiB
TypeScript

import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import type { PreviewServer, ProxyOptions, ViteDevServer } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import pkg from './package.json' with { type: 'json' };
import { getAllowedDaemonOrigin } from './client/config/daemon';
const daemonProxy: ProxyOptions = {
target: process.env['QWEN_DAEMON_URL'] ?? 'http://127.0.0.1:4170',
changeOrigin: true,
bypass: (req) => {
if (req.url?.startsWith('/api/')) return undefined;
// These paths overlap daemon route prefixes and client source directories.
if (
req.method === 'GET' &&
(req.url?.startsWith('/extensions/') ||
req.url?.startsWith('/session-catalog/') ||
req.url?.startsWith('/live/')) &&
/\.(?:[cm]?[jt]sx?|css|map)(?:\?|$)/.test(req.url)
) {
return req.url;
}
const fetchMode = req.headers['sec-fetch-mode'];
const fetchDest = req.headers['sec-fetch-dest'];
const accept = req.headers.accept ?? '';
const isDocumentNavigation =
fetchMode === 'navigate' ||
fetchDest === 'document' ||
accept.trim().toLowerCase().startsWith('text/html');
if (isDocumentNavigation) {
return '/index.html';
}
return undefined;
},
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
proxyReq.removeHeader('origin');
proxyReq.removeHeader('referer');
});
proxy.on('proxyReqWs', (proxyReq) => {
proxyReq.removeHeader('origin');
proxyReq.removeHeader('referer');
});
},
};
const managedAgentJavaProxy: ProxyOptions = {
target: process.env['QWEN_MANAGED_AGENT_JAVA_URL'] ?? 'http://127.0.0.1:8080',
changeOrigin: true,
};
export const QUALIFIED_VOICE_STREAM_PROXY =
'^/workspaces/[^/]+/voice/stream/?$';
export const MANAGED_AGENT_JAVA_ROUTE_PROXY = '/api/agent/web-shell/v1';
export const MANAGED_AGENT_PUBLIC_ROUTE_PROXY = '/v1/agents';
// Exact-path on purpose. A bare `/brand` prefix would also match
// `/brandContext.ts` — the client source module `main.tsx` and `App.tsx` import
// for a value — and proxy it to the daemon, so the module graph never loads and
// the dev page blanks. Same hazard the `/voice` and `/live` entries document.
export const BRAND_ROUTE_PROXY = '^/brand/?$';
// The local-files bridge upgrades here for secondary-workspace sessions;
// without a ws-enabled entry the upgrade is never forwarded in dev and the
// bridge hangs in `connecting`.
export const QUALIFIED_ACP_WS_PROXY = '^/workspaces/[^/]+/acp/?$';
// Shared with vite.lib.config.ts so the app and lib builds can never drift
// onto different syntax floors: esbuild miscompiles xterm's logical
// assignments below ES2021 (#11643), and the lib build bundles the same
// xterm for npm hosts.
export const WEB_SHELL_BUILD_TARGET = 'es2021';
// Development permits same-origin ancestors; production denies them by default.
function developmentCsp(requestUrl: string): string {
const queryStart = requestUrl.indexOf('?');
const raw = new URLSearchParams(
queryStart === -1 ? '' : requestUrl.slice(queryStart + 1),
);
const connectOrigins: string[] = [];
for (const value of [raw.get('daemon'), ...raw.getAll('fanout')]) {
const origin = getAllowedDaemonOrigin(value || '');
if (!origin || connectOrigins.includes(origin)) continue;
const websocket = new URL(origin);
websocket.protocol = websocket.protocol === 'https:' ? 'wss:' : 'ws:';
connectOrigins.push(origin, websocket.origin);
}
const clientMcpOverWs = process.env['QWEN_SERVE_CLIENT_MCP_OVER_WS'];
if (
clientMcpOverWs !== undefined &&
!['0', 'false'].includes(clientMcpOverWs.trim().toLowerCase())
) {
connectOrigins.push('http://127.0.0.1:47821');
}
return [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
"img-src 'self' data: blob:",
"media-src 'self' data:",
`connect-src 'self' ${connectOrigins.join(' ')}`.trim(),
"worker-src 'self' blob:",
"base-uri 'none'",
'frame-src http: https: blob:',
"frame-ancestors 'self'",
].join('; ');
}
function configureDevelopmentMiddleware(
server: ViteDevServer | PreviewServer,
): void {
server.middlewares.use((req, res, next) => {
res.setHeader('Content-Security-Policy', developmentCsp(req.url || '/'));
// Vite resolves the extensionless settings page to settings.ts before SPA fallback.
if (
req.method === 'GET' &&
/^\/settings\/?(?:\?|$)/.test(req.url || '') &&
req.headers.accept?.includes('text/html')
) {
req.url = '/index.html';
}
next();
});
}
export default defineConfig(({ command }) => ({
root: 'client',
// Worker-only imports otherwise trigger a dev-page reload on first preview.
optimizeDeps: { include: ['exceljs', 'ssf'] },
plugins: [
react(),
tailwindcss(),
{
name: 'web-shell-development-middleware',
configureServer: configureDevelopmentMiddleware,
configurePreviewServer: configureDevelopmentMiddleware,
},
],
resolve: {
alias: {
'@qwen-code/web-shell/daemon-react-sdk': resolve(
__dirname,
'./client/daemon-react-sdk.ts',
),
'@qwen-code/web-shell/transcript': resolve(
__dirname,
'./client/transcript.ts',
),
'@': resolve(__dirname, './client'),
...(command === 'serve'
? {
'@qwen-code/sdk/daemon': resolve(
__dirname,
'../sdk-typescript/src/daemon/index.ts',
),
'@qwen-code/sdk': resolve(
__dirname,
'../sdk-typescript/src/index.ts',
),
}
: {}),
},
dedupe: ['react', 'react-dom', '@qwen-code/sdk'],
},
build: {
// Avoid esbuild lowering xterm's logical assignments into invalid code.
target: WEB_SHELL_BUILD_TARGET,
outDir: '../dist',
emptyOutDir: true,
// The Live Voice capture worklet is loaded with audioWorklet.addModule(),
// which the Web Shell CSP (`script-src 'self'`, no `data:`) only allows
// from a same-origin URL. At ~2 KB it is under Vite's default inline
// limit and would be turned into a `data:` URL — silently, because the
// client then falls back to the main-thread capture node. Keep it a file.
assetsInlineLimit: (filePath) =>
/[\\/]live[\\/]capture-worklet\.js$/.test(filePath) ? false : undefined,
rollupOptions: {
input: {
index: resolve(__dirname, 'client/index.html'),
// This import-free worker must remain at the origin root so it can
// control all Web Shell navigation.
sw: resolve(__dirname, 'client/sw.js'),
},
output: {
entryFileNames: (chunk) =>
chunk.name === 'sw' ? '[name].js' : 'assets/[name]-[hash].js',
format: 'es',
},
},
},
define: {
__WEB_SHELL_VERSION__: JSON.stringify(pkg.version),
},
server: {
cors: false,
headers: {
'Referrer-Policy': 'no-referrer',
},
port: 5173,
// Dev launchers (scripts/managed-agent-dev.js) hand the token-bearing
// open path through the environment instead of argv: npm echoes expanded
// argv to its inherited stdio, and argv is world-readable in
// /proc/<pid>/cmdline. Caveat: server.open spawns the browser launcher
// with the full URL as an argv element, so on POSIX the token still
// reaches one world-readable argv before landing in the page — this
// transport removes only the npm hop's echo. Unset means no browser is
// opened, as before.
open: process.env['QWEN_WEB_SHELL_OPEN_PATH'],
proxy: {
[MANAGED_AGENT_JAVA_ROUTE_PROXY]: managedAgentJavaProxy,
[MANAGED_AGENT_PUBLIC_ROUTE_PROXY]: managedAgentJavaProxy,
'/health': daemonProxy,
'/capabilities': daemonProxy,
// Web Shell brand (`GET /brand`). Without it the SPA fallback answers with
// index.html in dev; the client swallows the parse failure and silently
// keeps the built-in name and logo, so a locally configured `ui.brand`
// would appear to do nothing.
[BRAND_ROUTE_PROXY]: daemonProxy,
'/mcp-app-sandbox': { ...daemonProxy, bypass: undefined },
// Daemon status report; scoped to the exact route the dashboard uses (a
// bare `/daemon` prefix would proxy unrelated `/daemon/*` paths). Without
// it the SPA fallback answers with index.html and the dialog fails JSON
// parsing in dev.
'/daemon/status': daemonProxy,
'/standalone/sessions': daemonProxy,
'/session': daemonProxy,
'/permission': daemonProxy,
'^/workspaces/[^/]+/agent(?:/|$)': daemonProxy,
'/agent-hosts': daemonProxy,
[QUALIFIED_VOICE_STREAM_PROXY]: { ...daemonProxy, ws: true },
[QUALIFIED_ACP_WS_PROXY]: { ...daemonProxy, ws: true },
'/workspace': daemonProxy,
// Remote-daemon browse/register proxies. Keys are path-prefix matches,
// so the `/workspace` entry above cannot reach these; without them the
// SPA fallback returns index.html in dev and the Add-workspace dialog
// fails JSON parsing on the browse leg.
'/remote-workspace-path-suggestions': daemonProxy,
'/remote-workspaces': daemonProxy,
'/extensions': daemonProxy,
'/file': daemonProxy,
'/stat': daemonProxy,
'/list': daemonProxy,
'/glob': daemonProxy,
// Scheduled-tasks CRUD (the Scheduled Tasks dialog). Prefix-matches
// `/scheduled-tasks` and `/scheduled-tasks/:id`. Like the routes above,
// without it the SPA fallback returns index.html in dev and the dialog
// fails JSON parsing / reports an HTTP error on open.
'/scheduled-tasks': daemonProxy,
// Goals page (`GET /goals`). Without it the SPA fallback returns
// index.html in dev and the page fails JSON parsing on open.
'/goals': daemonProxy,
// Token-usage dashboard (Daemon Status "统计" tab). Same reason as the
// routes above — without it the SPA fallback returns index.html in dev and
// the tab fails JSON parsing on `GET /usage/dashboard`.
'/usage': daemonProxy,
// Standalone-session CRUD (`/standalone/sessions*`) — the sidebar's
// standalone sessions list/load/create. Without it the SPA fallback
// returns index.html in dev and clicking or creating a standalone
// session fails JSON parsing.
'/standalone': daemonProxy,
// Live voice routes (`/live/status`, `/live/setup`, ...). The prefix
// overlaps `client/live/*` source modules; the bypass above exempts
// those source files from proxying.
'/live': daemonProxy,
// Voice dictation is a WebSocket (`/voice/stream`); `ws: true` makes the
// dev proxy forward the HTTP upgrade to the daemon. Scope it to the exact
// path — a bare `/voice` prefix would shadow the client's own
// `client/voice/*` source modules (e.g. `/voice/voiceModels.ts`), which
// vite must serve, and blanks the page.
'/voice/stream': { ...daemonProxy, ws: true },
// Interactive terminal WebSocket (`/terminal`); `ws: true` forwards the
// HTTP upgrade to the daemon, same as `/voice/stream`.
'/terminal': { ...daemonProxy, ws: true },
// ACP WebSocket (`/acp`): the local-files bridge upgrades here to host
// its client-side MCP server. Exact-path regex, so the prefix cannot
// shadow a client source module (same reasoning as `/voice/stream`).
// Without it the dev server answers the upgrade itself and the bridge
// hangs in `connecting`; production needs no proxy because the daemon
// serves the page and `/acp` is then same-origin.
'^/acp/?$': { ...daemonProxy, ws: true },
},
},
}));