vite.config.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import { defineConfig, loadEnv } from "vite";
  2. // import { darkTheme } from "./src/theme.js";
  3. import vue from "@vitejs/plugin-vue";
  4. import Components from "unplugin-vue-components/vite";
  5. import { AntDesignVueResolver } from "unplugin-vue-components/resolvers";
  6. import { visualizer } from "rollup-plugin-visualizer";
  7. import svgLoader from "vite-svg-loader";
  8. import path from "path";
  9. import basicSsl from "@vitejs/plugin-basic-ssl";
  10. // DMS token 的读取口径收在 harness/tools/dms-token.mjs(与本仓库的 DMS 脚本共用一份):
  11. // **显式参数 → 环境变量 → 本机文件 `.dms-token`**。
  12. // 关键是「每个请求现读」而不是启动时读一次 —— token 约 24 小时过期,以前换一次就要重启 dev server。
  13. import { DMS_TOKEN_FILE, readDmsToken, parseJwtPayload, describeDmsToken } from "./harness/tools/dms-token.mjs";
  14. // NLS(阿里云智能语音交互)token 的签名口径收在 harness/tools/nls-token.mjs。
  15. // **为什么由 dev server 换 token 而不是前端自己签**:签 token 要 AccessKeySecret,
  16. // 而前端 bundle 是公开的(谁都能拿 secret 签 token、烧账号额度)—— secret 只能待在 node 侧。
  17. // 生产环境要由后端/网关提供同样的端点,**不能把这段 dev 端点当生产方案**。
  18. import { createNlsTokenProvider, maskCredential, readNlsCredentials } from "./harness/tools/nls-token.mjs";
  19. /** 同一个状态只提醒一次,避免每个请求都刷屏 */
  20. let dmsTokenWarnedFor = "";
  21. /**
  22. * token 有问题时给出**明确**提示 —— 以前 token 过期是**静默失败**(DMS 写入不生效、
  23. * 只有一条埋在别处控制台里的 warn),排查「为什么数据没存上」要花很久。
  24. */
  25. const warnDmsTokenIfNeeded = (token: string): void => {
  26. if (!token) {
  27. if (dmsTokenWarnedFor === "missing") return;
  28. dmsTokenWarnedFor = "missing";
  29. console.warn(
  30. `\n[dms] ⚠️ 没有 DMS token,DMS 读写(会话/问答记录/反馈)会失败,聊天本身不受影响。\n` +
  31. `[dms] 最省事:在浏览器里复制 token,然后跑 \`npm run dms:token\`(从剪贴板读)。\n` +
  32. `[dms] 文件位置:${DMS_TOKEN_FILE}\n`
  33. );
  34. return;
  35. }
  36. const exp = Number(parseJwtPayload(token)?.exp);
  37. if (!Number.isFinite(exp)) return;
  38. const leftMs = exp * 1000 - Date.now();
  39. const at = new Date(exp * 1000).toLocaleString("zh-CN");
  40. if (leftMs <= 0) {
  41. if (dmsTokenWarnedFor === `expired:${exp}`) return;
  42. dmsTokenWarnedFor = `expired:${exp}`;
  43. console.warn(
  44. `\n[dms] ⚠️ DMS token 已于 ${at} 过期(DMS 读写会失败)。复制新 token 后跑 \`npm run dms:token\`。\n`
  45. );
  46. return;
  47. }
  48. if (leftMs < 2 * 3600 * 1000) {
  49. const key = `soon:${exp}`;
  50. if (dmsTokenWarnedFor === key) return;
  51. dmsTokenWarnedFor = key;
  52. console.warn(`\n[dms] ⏳ DMS token 将于 ${at} 过期(还剩 ${(leftMs / 3600000).toFixed(1)} 小时)。\n`);
  53. }
  54. };
  55. /** NLS 缺凭据只提醒一次,别每个请求刷屏 */
  56. let nlsWarnedFor = "";
  57. /**
  58. * 开发期端点:`GET /nls-token/` → `{ token, expireAt }`。
  59. *
  60. * 前端只拿 token,**拿不到 AK/SK**(凭据只在 node 侧用于签名)。
  61. * token 24 小时过期,提供者内部按 5 分钟提前刷新并做并发去重 ——
  62. * 所以前端可以放心每次要用就问一次。
  63. *
  64. * ⚠️ 仅 dev(`apply: 'serve'`)。生产要后端/网关实现同一形态:
  65. * 前端仍是同源 `GET /nls-token/`,secret 仍在服务端。
  66. */
  67. const nlsTokenEndpoint = (env: Record<string, string>) => {
  68. const credentials = readNlsCredentials(env);
  69. const provider = createNlsTokenProvider({
  70. ak: credentials.ak,
  71. sk: credentials.sk,
  72. onRefresh: ({ expireAt }) =>
  73. console.log(
  74. `[nls] token 已刷新,有效期至 ${new Date(expireAt).toLocaleString("zh-CN")}(AK=${maskCredential(credentials.ak)})`
  75. ),
  76. });
  77. return {
  78. name: "nls-token-endpoint",
  79. apply: "serve" as const,
  80. configureServer(server: any) {
  81. server.middlewares.use("/nls-token", async (_req: any, res: any) => {
  82. if (!credentials.ak || !credentials.sk) {
  83. if (nlsWarnedFor !== "missing") {
  84. nlsWarnedFor = "missing";
  85. console.warn(
  86. `\n[nls] ⚠️ 没有配置 NLS 凭据,语音识别拿不到 token。\n` +
  87. `[nls] 在 .env.development.local(按 .gitignore 不入库)里加:\n` +
  88. `[nls] ALIYUN_NLS_AK=… ALIYUN_NLS_SK=… ALIYUN_NLS_APPKEY=…\n` +
  89. `[nls] 改完重启 dev server(.env 只在启动时读)。\n`
  90. );
  91. }
  92. res.statusCode = 503;
  93. res.setHeader("Content-Type", "application/json; charset=utf-8");
  94. res.end(JSON.stringify({ error: "未配置 NLS 凭据(ALIYUN_NLS_AK / ALIYUN_NLS_SK)" }));
  95. return;
  96. }
  97. try {
  98. const { token, expireAt } = await provider.get();
  99. res.setHeader("Content-Type", "application/json; charset=utf-8");
  100. // 明确禁止缓存:token 轮换后前端再来问,别被浏览器缓存住旧的
  101. res.setHeader("Cache-Control", "no-store");
  102. // appkey 一起下发:它要出现在识别请求里,但**没必要打进前端产物** ——
  103. // 从端点拿,前端就零内置凭据,将来换 AppKey 也不用重新打包
  104. res.end(JSON.stringify({ token, expireAt, appkey: credentials.appkey }));
  105. } catch (error) {
  106. const message = error instanceof Error ? error.message : String(error);
  107. console.warn("[nls] 换 token 失败:", message);
  108. res.statusCode = 502;
  109. res.setHeader("Content-Type", "application/json; charset=utf-8");
  110. res.end(JSON.stringify({ error: message }));
  111. }
  112. });
  113. },
  114. };
  115. };
  116. // https://vitejs.dev/config/
  117. export default defineConfig(({ mode }) => {
  118. // 第三个参数传空串:不加前缀过滤,才能读到 DMS_TOKEN 这类非 VITE_ 前缀的变量
  119. // (DMS_TOKEN 只写在 .env.development.local,该文件按 .gitignore 不入库)
  120. const env = loadEnv(mode, process.cwd(), "");
  121. return {
  122. plugins: [
  123. vue(),
  124. Components({
  125. resolvers: [AntDesignVueResolver()],
  126. }),
  127. visualizer({
  128. open: false,
  129. gzipSize: true,
  130. file: "./dist/stats.html",
  131. brotliSize: true,
  132. }),
  133. svgLoader(),
  134. basicSsl(),
  135. nlsTokenEndpoint(env),
  136. ],
  137. server: {
  138. proxy: {
  139. //配置本地代理
  140. "/api/": {
  141. //匹配的路径
  142. target: "http://aixq.shqp.gov.cn", //目标url
  143. changeOrigin: true, //跨域
  144. rewrite: (path) => path.replace(/^\/api/, ""), //重写路径
  145. },
  146. // 新版聊天后端代理(POST /api/chat,SSE)
  147. // 开发服务器是 HTTPS 而后端是 HTTP,浏览器会拦截直连(混合内容),
  148. // 且后端未实现 CORS,因此开发环境统一走同源代理。
  149. // 对应 .env.development 中 VITE_CHAT_API="/chat-api"
  150. // target 取 VITE_CHAT_TARGET(地址收在 env 里,不再写死在配置文件中)
  151. "/chat-api/": {
  152. target: env.VITE_CHAT_TARGET || "http://192.168.2.23:8000",
  153. changeOrigin: true,
  154. rewrite: (path) => path.replace(/^\/chat-api/, ""),
  155. },
  156. // DMS 数据管理服务(会话/问答记录/反馈)
  157. // 两件事都在代理侧做,浏览器拿不到:
  158. // ① 补回 /dms 前缀(DMS 的真实路由都在 /dms/ 下)
  159. // ② 注入 token 头(DMS 的鉴权头名就叫 token,不是 Authorization)
  160. // token 来源:.env.development.local 的 DMS_TOKEN(不入库)
  161. "/dms-api/": {
  162. target: env.VITE_DMS_TARGET || "http://121.43.55.7:10081",
  163. changeOrigin: true,
  164. rewrite: (path) => path.replace(/^\/dms-api/, "/dms"),
  165. // token 在**每个请求**现读(见 readDmsToken 的说明):换 token 不用重启 dev server。
  166. // 用 configure 而不是静态 headers —— 后者的值在启动时就定死了。
  167. configure: (proxy) => {
  168. proxy.on("proxyReq", (proxyReq) => {
  169. // 现读:换 token 后不需要重启 dev server
  170. const token = readDmsToken(env.DMS_TOKEN);
  171. if (token) {
  172. // DMS 的鉴权头名就叫 token(不是 Authorization)
  173. proxyReq.setHeader("token", token);
  174. }
  175. warnDmsTokenIfNeeded(token);
  176. });
  177. },
  178. },
  179. // 取本机 IP 的接口(DMS 访客归属用,见 src/utils/client-ip.ts)
  180. // 走同源代理的原因与聊天后端一样:页面是 HTTPS,而它是 HTTP —— 直连会被混合内容拦掉
  181. // (接口本身带 Access-Control-Allow-Origin: *,跨域不是问题,混合内容才是)
  182. "/ip-api/": {
  183. target: env.VITE_IP_TARGET || "http://121.43.55.7:10011",
  184. changeOrigin: true,
  185. rewrite: (path) => path.replace(/^\/ip-api/, ""),
  186. },
  187. "/asr/": {
  188. //匹配的路径
  189. target: "https://human-screen-v3.metamaker.cn", //目标url
  190. changeOrigin: true, //跨域
  191. rewrite: (path) => path.replace(/^\/asr/, ""), //重写路径
  192. ws: true,
  193. },
  194. // [已移除] "/stream/" 代理(数字人视频流):src/index.html 里没有任何代码往它发请求,
  195. // 相关 env(VITE_STREAM_SERVER / VITE_STREAM_TOKEN / VITE_HUMAN_IDLE_MP4_URL)也已删除。
  196. // 注意 "/asr/" 代理**保留** —— 它是切到讯飞 ASR 分支(VITE_XF_ASR="/asr")的退路。
  197. },
  198. },
  199. build: {
  200. rollupOptions: {
  201. input: {
  202. main: path.resolve(__dirname, "index.html"),
  203. redirect: path.resolve(__dirname, "redirect.html"),
  204. },
  205. output: {
  206. manualChunks: (id) => {
  207. console.log(id);
  208. if (id.includes("node_modules/three")) {
  209. return "three";
  210. } else if (id.includes("node_modules")) {
  211. return "vendor";
  212. }
  213. },
  214. },
  215. },
  216. assetsInlineLimit: 20480,
  217. },
  218. resolve: {
  219. alias: {
  220. "@": path.resolve("./src/"),
  221. three$: path.resolve(__dirname, "./node_modules/three/build/three.cjs"),
  222. },
  223. },
  224. base: "./",
  225. css: {
  226. preprocessorOptions: {
  227. scss: {
  228. api: "modern-compiler",
  229. },
  230. },
  231. },
  232. };
  233. });