import { defineConfig, loadEnv } from "vite"; // import { darkTheme } from "./src/theme.js"; import vue from "@vitejs/plugin-vue"; import Components from "unplugin-vue-components/vite"; import { AntDesignVueResolver } from "unplugin-vue-components/resolvers"; import { visualizer } from "rollup-plugin-visualizer"; import svgLoader from "vite-svg-loader"; import path from "path"; import basicSsl from "@vitejs/plugin-basic-ssl"; // DMS token 的读取口径收在 harness/tools/dms-token.mjs(与本仓库的 DMS 脚本共用一份): // **显式参数 → 环境变量 → 本机文件 `.dms-token`**。 // 关键是「每个请求现读」而不是启动时读一次 —— token 约 24 小时过期,以前换一次就要重启 dev server。 import { DMS_TOKEN_FILE, readDmsToken, parseJwtPayload, describeDmsToken } from "./harness/tools/dms-token.mjs"; // NLS(阿里云智能语音交互)token 的签名口径收在 harness/tools/nls-token.mjs。 // **为什么由 dev server 换 token 而不是前端自己签**:签 token 要 AccessKeySecret, // 而前端 bundle 是公开的(谁都能拿 secret 签 token、烧账号额度)—— secret 只能待在 node 侧。 // 生产环境要由后端/网关提供同样的端点,**不能把这段 dev 端点当生产方案**。 import { createNlsTokenProvider, maskCredential, readNlsCredentials } from "./harness/tools/nls-token.mjs"; /** 同一个状态只提醒一次,避免每个请求都刷屏 */ let dmsTokenWarnedFor = ""; /** * token 有问题时给出**明确**提示 —— 以前 token 过期是**静默失败**(DMS 写入不生效、 * 只有一条埋在别处控制台里的 warn),排查「为什么数据没存上」要花很久。 */ const warnDmsTokenIfNeeded = (token: string): void => { if (!token) { if (dmsTokenWarnedFor === "missing") return; dmsTokenWarnedFor = "missing"; console.warn( `\n[dms] ⚠️ 没有 DMS token,DMS 读写(会话/问答记录/反馈)会失败,聊天本身不受影响。\n` + `[dms] 最省事:在浏览器里复制 token,然后跑 \`npm run dms:token\`(从剪贴板读)。\n` + `[dms] 文件位置:${DMS_TOKEN_FILE}\n` ); return; } const exp = Number(parseJwtPayload(token)?.exp); if (!Number.isFinite(exp)) return; const leftMs = exp * 1000 - Date.now(); const at = new Date(exp * 1000).toLocaleString("zh-CN"); if (leftMs <= 0) { if (dmsTokenWarnedFor === `expired:${exp}`) return; dmsTokenWarnedFor = `expired:${exp}`; console.warn( `\n[dms] ⚠️ DMS token 已于 ${at} 过期(DMS 读写会失败)。复制新 token 后跑 \`npm run dms:token\`。\n` ); return; } if (leftMs < 2 * 3600 * 1000) { const key = `soon:${exp}`; if (dmsTokenWarnedFor === key) return; dmsTokenWarnedFor = key; console.warn(`\n[dms] ⏳ DMS token 将于 ${at} 过期(还剩 ${(leftMs / 3600000).toFixed(1)} 小时)。\n`); } }; /** NLS 缺凭据只提醒一次,别每个请求刷屏 */ let nlsWarnedFor = ""; /** * 开发期端点:`GET /nls-token/` → `{ token, expireAt }`。 * * 前端只拿 token,**拿不到 AK/SK**(凭据只在 node 侧用于签名)。 * token 24 小时过期,提供者内部按 5 分钟提前刷新并做并发去重 —— * 所以前端可以放心每次要用就问一次。 * * ⚠️ 仅 dev(`apply: 'serve'`)。生产要后端/网关实现同一形态: * 前端仍是同源 `GET /nls-token/`,secret 仍在服务端。 */ const nlsTokenEndpoint = (env: Record) => { const credentials = readNlsCredentials(env); const provider = createNlsTokenProvider({ ak: credentials.ak, sk: credentials.sk, onRefresh: ({ expireAt }) => console.log( `[nls] token 已刷新,有效期至 ${new Date(expireAt).toLocaleString("zh-CN")}(AK=${maskCredential(credentials.ak)})` ), }); return { name: "nls-token-endpoint", apply: "serve" as const, configureServer(server: any) { server.middlewares.use("/nls-token", async (_req: any, res: any) => { if (!credentials.ak || !credentials.sk) { if (nlsWarnedFor !== "missing") { nlsWarnedFor = "missing"; console.warn( `\n[nls] ⚠️ 没有配置 NLS 凭据,语音识别拿不到 token。\n` + `[nls] 在 .env.development.local(按 .gitignore 不入库)里加:\n` + `[nls] ALIYUN_NLS_AK=… ALIYUN_NLS_SK=… ALIYUN_NLS_APPKEY=…\n` + `[nls] 改完重启 dev server(.env 只在启动时读)。\n` ); } res.statusCode = 503; res.setHeader("Content-Type", "application/json; charset=utf-8"); res.end(JSON.stringify({ error: "未配置 NLS 凭据(ALIYUN_NLS_AK / ALIYUN_NLS_SK)" })); return; } try { const { token, expireAt } = await provider.get(); res.setHeader("Content-Type", "application/json; charset=utf-8"); // 明确禁止缓存:token 轮换后前端再来问,别被浏览器缓存住旧的 res.setHeader("Cache-Control", "no-store"); // appkey 一起下发:它要出现在识别请求里,但**没必要打进前端产物** —— // 从端点拿,前端就零内置凭据,将来换 AppKey 也不用重新打包 res.end(JSON.stringify({ token, expireAt, appkey: credentials.appkey })); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn("[nls] 换 token 失败:", message); res.statusCode = 502; res.setHeader("Content-Type", "application/json; charset=utf-8"); res.end(JSON.stringify({ error: message })); } }); }, }; }; // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { // 第三个参数传空串:不加前缀过滤,才能读到 DMS_TOKEN 这类非 VITE_ 前缀的变量 // (DMS_TOKEN 只写在 .env.development.local,该文件按 .gitignore 不入库) const env = loadEnv(mode, process.cwd(), ""); return { plugins: [ vue(), Components({ resolvers: [AntDesignVueResolver()], }), visualizer({ open: false, gzipSize: true, file: "./dist/stats.html", brotliSize: true, }), svgLoader(), basicSsl(), nlsTokenEndpoint(env), ], server: { proxy: { //配置本地代理 "/api/": { //匹配的路径 target: "http://aixq.shqp.gov.cn", //目标url changeOrigin: true, //跨域 rewrite: (path) => path.replace(/^\/api/, ""), //重写路径 }, // 新版聊天后端代理(POST /api/chat,SSE) // 开发服务器是 HTTPS 而后端是 HTTP,浏览器会拦截直连(混合内容), // 且后端未实现 CORS,因此开发环境统一走同源代理。 // 对应 .env.development 中 VITE_CHAT_API="/chat-api" // target 取 VITE_CHAT_TARGET(地址收在 env 里,不再写死在配置文件中) "/chat-api/": { target: env.VITE_CHAT_TARGET || "http://192.168.2.23:8000", changeOrigin: true, rewrite: (path) => path.replace(/^\/chat-api/, ""), }, // DMS 数据管理服务(会话/问答记录/反馈) // 两件事都在代理侧做,浏览器拿不到: // ① 补回 /dms 前缀(DMS 的真实路由都在 /dms/ 下) // ② 注入 token 头(DMS 的鉴权头名就叫 token,不是 Authorization) // token 来源:.env.development.local 的 DMS_TOKEN(不入库) "/dms-api/": { target: env.VITE_DMS_TARGET || "http://121.43.55.7:10081", changeOrigin: true, rewrite: (path) => path.replace(/^\/dms-api/, "/dms"), // token 在**每个请求**现读(见 readDmsToken 的说明):换 token 不用重启 dev server。 // 用 configure 而不是静态 headers —— 后者的值在启动时就定死了。 configure: (proxy) => { proxy.on("proxyReq", (proxyReq) => { // 现读:换 token 后不需要重启 dev server const token = readDmsToken(env.DMS_TOKEN); if (token) { // DMS 的鉴权头名就叫 token(不是 Authorization) proxyReq.setHeader("token", token); } warnDmsTokenIfNeeded(token); }); }, }, // 取本机 IP 的接口(DMS 访客归属用,见 src/utils/client-ip.ts) // 走同源代理的原因与聊天后端一样:页面是 HTTPS,而它是 HTTP —— 直连会被混合内容拦掉 // (接口本身带 Access-Control-Allow-Origin: *,跨域不是问题,混合内容才是) "/ip-api/": { target: env.VITE_IP_TARGET || "http://121.43.55.7:10011", changeOrigin: true, rewrite: (path) => path.replace(/^\/ip-api/, ""), }, "/asr/": { //匹配的路径 target: "https://human-screen-v3.metamaker.cn", //目标url changeOrigin: true, //跨域 rewrite: (path) => path.replace(/^\/asr/, ""), //重写路径 ws: true, }, // [已移除] "/stream/" 代理(数字人视频流):src/index.html 里没有任何代码往它发请求, // 相关 env(VITE_STREAM_SERVER / VITE_STREAM_TOKEN / VITE_HUMAN_IDLE_MP4_URL)也已删除。 // 注意 "/asr/" 代理**保留** —— 它是切到讯飞 ASR 分支(VITE_XF_ASR="/asr")的退路。 }, }, build: { rollupOptions: { input: { main: path.resolve(__dirname, "index.html"), redirect: path.resolve(__dirname, "redirect.html"), }, output: { manualChunks: (id) => { console.log(id); if (id.includes("node_modules/three")) { return "three"; } else if (id.includes("node_modules")) { return "vendor"; } }, }, }, assetsInlineLimit: 20480, }, resolve: { alias: { "@": path.resolve("./src/"), three$: path.resolve(__dirname, "./node_modules/three/build/three.cjs"), }, }, base: "./", css: { preprocessorOptions: { scss: { api: "modern-compiler", }, }, }, }; });