mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* 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]>
286 lines
12 KiB
TypeScript
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 },
|
|
},
|
|
},
|
|
}));
|