Переглянути джерело

feat(ai): 集成AI服务登录与活动宣传图生成功能

- 新增AI服务登录接口,在主系统登录后自动调用AI登录
- 实现AI生成活动宣传图功能,支持根据文字描述自动生成海报
- 添加VITE_PROXY_AI环境变量配置,用于AI服务接口代理
- 扩展axios拦截器以支持AI服务的token管理与请求处理
- 在运营活动页面新增AI生成图片的输入框和生成按钮
- 支持将AI生成的base64图片转换为File对象并上传
- 更新vite配置以支持AI服务的跨域代理
- 调整登录逻辑,保存用户信息供AI服务使用
- 添加AI生成图片后的自动上传及表单验证逻辑
- 优化上传组件显示逻辑,AI生成图片时隐藏手动上传入口
congxuesong 1 тиждень тому
батько
коміт
6bc19e0011

+ 1 - 1
.env.development

@@ -16,7 +16,7 @@ VITE_PWA = false
 
 # 开发环境接口地址
 # VITE_API_URL_STORE = /api  # 开发环境使用
-VITE_API=/api
+VITE_API = /api
 VITE_API_URL_STORE = /api/alienStore #生产环境使用
 VITE_API_URL = /api/alienStore
 VITE_API_URL_SECOND = /api/alienSecond

+ 4 - 0
.env.production

@@ -25,6 +25,7 @@ VITE_PWA = true
 # VITE_PROXY = [["/api","http://localhost:8888"]]
 
 # 生产环境接口地址
+VITE_API = /api
 VITE_API_URL_STORE = /api/alienStore
 VITE_API_URL = /api/alienStore
 VITE_API_URL_SECOND = /api/alienSecond
@@ -32,3 +33,6 @@ VITE_API_URL_PLATFORM = /api/alienStorePlatform
 
 # 生产环境跨域代理,支持配置多个
 VITE_PROXY = [["/alienStore","https://api.ailien.shop/alienStore"]]
+
+# AI接口
+VITE_PROXY_AI = [["/ai-api","http://192.168.2.250:9000"]]

+ 1 - 1
build/getEnv.ts

@@ -27,7 +27,7 @@ export function wrapperEnv(envConf: Recordable): ViteEnv {
     let realName = envConf[envName].replace(/\\n/g, "\n");
     realName = realName === "true" ? true : realName === "false" ? false : realName;
     if (envName === "VITE_PORT") realName = Number(realName);
-    if (envName === "VITE_PROXY") {
+    if (envName === "VITE_PROXY" || envName === "VITE_PROXY_AI") {
       try {
         realName = JSON.parse(realName);
       } catch (error) {}

+ 223 - 0
src/api/indexAi.ts

@@ -0,0 +1,223 @@
+import axios, { AxiosInstance, AxiosError, AxiosRequestConfig, InternalAxiosRequestConfig, AxiosResponse } from "axios";
+import { showFullScreenLoading, tryHideFullScreenLoading } from "@/components/Loading/fullScreen";
+import { LOGIN_URL } from "@/config";
+import { ElMessage } from "element-plus";
+import { ResultData } from "@/api/interface";
+import { ResultEnum } from "@/enums/httpEnum";
+import { checkStatus } from "./helper/checkStatus";
+import { AxiosCanceler } from "./helper/axiosCancel";
+import { useUserStore } from "@/stores/modules/user";
+import router from "@/routers";
+import { localGet, localSet } from "@/utils";
+
+export interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
+  loading?: boolean;
+  cancel?: boolean;
+}
+
+const config = {
+  // AI服务地址,使用 /ai-api 前缀,由 VITE_PROXY_AI 代理到 http://192.168.2.250:9000
+  baseURL: "/ai-api",
+  // 设置超时时间
+  timeout: ResultEnum.TIMEOUT as number,
+  // 跨域时候允许携带凭证
+  withCredentials: true
+};
+
+const axiosCanceler = new AxiosCanceler();
+
+// AI Token存储key
+const AI_TOKEN_KEY = "ai-service-token";
+
+/**
+ * 获取AI服务Token
+ */
+const getAiToken = (): string | null => {
+  return localGet(AI_TOKEN_KEY) || null;
+};
+
+/**
+ * 设置AI服务Token
+ */
+const setAiToken = (token: string) => {
+  localSet(AI_TOKEN_KEY, token);
+};
+
+/**
+ * AI服务登录
+ * @returns AI服务token,如果登录失败返回null
+ */
+export const aiLogin = async (): Promise<string | null> => {
+  try {
+    // 获取主系统的用户信息
+    const userInfo = localGet("geeker-user");
+    if (!userInfo || !userInfo.userInfo) {
+      console.error("无法获取用户信息,AI服务登录失败");
+      return null;
+    }
+
+    // 创建一个临时的axios实例用于登录(不使用拦截器,避免循环)
+    const loginInstance = axios.create({
+      baseURL: "/ai-api",
+      timeout: ResultEnum.TIMEOUT as number,
+      withCredentials: true
+    });
+
+    // 调用AI登录接口,使用主系统的用户名和密码
+    // 根据实际接口要求调整参数
+    const loginParams: any = {
+      username: userInfo.userInfo.nickName,
+      password: userInfo.userInfo.password
+    };
+
+    // 如果没有密码,可能需要使用主系统的token来换取AI服务的token
+    // 或者使用其他认证方式,这里需要根据实际接口文档调整
+    if (!loginParams.password) {
+      // 如果主系统没有存储密码,可能需要使用token或其他方式
+      // 暂时尝试使用主系统的token
+      loginParams.token = userInfo.token;
+    }
+
+    const response = await loginInstance.post("/ai/user-auth-core/api/v1/auth/login", loginParams);
+
+    // 根据实际返回格式调整
+    if (response.data) {
+      // 可能返回格式:{ code: 200, data: { token: "..." } }
+      // 或者:{ code: 200, token: "..." }
+      const token = response.data.data?.token || response.data.token;
+      if (token) {
+        setAiToken(token);
+        return token;
+      }
+    }
+    return null;
+  } catch (error: any) {
+    console.error("AI服务登录失败:", error);
+    // 如果登录失败,返回null,让请求失败
+    return null;
+  }
+};
+
+class RequestHttp {
+  service: AxiosInstance;
+  private isLoggingIn = false; // 防止重复登录
+  private loginPromise: Promise<string | null> | null = null; // 登录Promise,用于并发请求时共享登录结果
+
+  public constructor(config: AxiosRequestConfig) {
+    // instantiation
+    this.service = axios.create(config);
+
+    /**
+     * @description 请求拦截器
+     * 客户端发送请求 -> [请求拦截器] -> 服务器
+     * token校验(JWT) : 先获取AI服务token,如果没有则先登录获取token
+     */
+    this.service.interceptors.request.use(
+      async (config: CustomAxiosRequestConfig) => {
+        // 重复请求不需要取消,在 api 服务中通过指定的第三个参数: { cancel: false } 来控制
+        config.cancel ??= true;
+        config.cancel && axiosCanceler.addPending(config);
+        // 当前请求不需要显示 loading,在 api 服务中通过指定的第三个参数: { loading: false } 来控制
+        config.loading ??= true;
+        config.loading && showFullScreenLoading();
+
+        // 获取AI服务token
+        let aiToken = getAiToken();
+
+        // 如果没有token,先登录获取token
+        if (!aiToken) {
+          // 如果正在登录,等待登录完成
+          if (this.isLoggingIn && this.loginPromise) {
+            aiToken = await this.loginPromise;
+          } else {
+            // 开始登录
+            this.isLoggingIn = true;
+            this.loginPromise = aiLogin();
+            aiToken = await this.loginPromise;
+            this.isLoggingIn = false;
+            this.loginPromise = null;
+          }
+        }
+
+        // 设置Authorization header
+        if (config.headers) {
+          if (typeof config.headers.set === "function") {
+            config.headers.set("Authorization", aiToken || "");
+          } else {
+            (config.headers as any)["Authorization"] = aiToken || "";
+          }
+        }
+
+        return config;
+      },
+      (error: AxiosError) => {
+        return Promise.reject(error);
+      }
+    );
+
+    /**
+     * @description 响应拦截器
+     *  服务器换返回信息 -> [拦截统一处理] -> 客户端JS获取到信息
+     */
+    this.service.interceptors.response.use(
+      (response: AxiosResponse & { config: CustomAxiosRequestConfig }) => {
+        const { data, config } = response;
+        const userStore = useUserStore();
+        axiosCanceler.removePending(config);
+        config.loading && tryHideFullScreenLoading();
+        // 登录失效 - AI服务token过期,清除token,下次请求时会重新登录
+        if (data.code == ResultEnum.OVERDUE || data.code === 401) {
+          localSet(AI_TOKEN_KEY, null);
+          // 如果是登录接口本身失败,不显示错误(避免循环)
+          if (!config.url?.includes("/auth/login")) {
+            ElMessage.error(data.msg || "AI服务认证失败,请重试");
+          }
+          return Promise.reject(data);
+        }
+        // 全局错误信息拦截(防止下载文件的时候返回数据流,没有 code 直接报错)
+        if (data.code && data.code !== ResultEnum.SUCCESS) {
+          ElMessage.error(data.msg);
+          return Promise.reject(data);
+        }
+        // 成功请求(在页面上除非特殊情况,否则不用处理失败逻辑)
+        return data;
+      },
+      async (error: AxiosError) => {
+        const { response } = error;
+        tryHideFullScreenLoading();
+        // 请求超时 && 网络错误单独判断,没有 response
+        if (error.message.indexOf("timeout") !== -1) ElMessage.error("请求超时!请您稍后重试");
+        if (error.message.indexOf("Network Error") !== -1) ElMessage.error("网络错误!请您稍后重试");
+        // 根据服务器响应的错误状态码,做不同的处理
+        if (response && response.data && (response.data as any).message) {
+          ElMessage.error((response.data as any).message);
+        } else {
+          if (response) checkStatus(response.status);
+        }
+        // 服务器结果都没有返回(可能服务器错误可能客户端断网),断网处理:可以跳转到断网页面
+        if (!window.navigator.onLine) router.replace("/500");
+        return Promise.reject(error);
+      }
+    );
+  }
+  /**
+   * @description 常用请求方法封装
+   */
+  get<T>(url: string, params?: object, _object = {}): Promise<ResultData<T>> {
+    return this.service.get(url, { params, ..._object });
+  }
+  post<T>(url: string, params?: object | string, _object = {}): Promise<ResultData<T>> {
+    return this.service.post(url, params, _object);
+  }
+  put<T>(url: string, params?: object, _object = {}): Promise<ResultData<T>> {
+    return this.service.put(url, params, _object);
+  }
+  delete<T>(url: string, params?: any, _object = {}): Promise<ResultData<T>> {
+    return this.service.delete(url, { params, ..._object });
+  }
+  download(url: string, params?: object, _object = {}): Promise<BlobPart> {
+    return this.service.post(url, params, { ..._object, responseType: "blob" });
+  }
+}
+
+export default new RequestHttp(config);

+ 10 - 0
src/api/modules/operationManagement.ts

@@ -2,6 +2,7 @@ import { ResPage, StoreUser } from "@/api/interface/index";
 import { PORT_NONE } from "@/api/config/servicePort";
 import http from "@/api";
 import http_store from "@/api/indexStore";
+import http_ai from "@/api/indexAi";
 
 /**
  * 获取优惠券列表
@@ -83,3 +84,12 @@ export const updateActivityStatus = params => {
 export const getActivityRuleOptions = (params?: any) => {
   return http_store.get<any>(PORT_NONE + `/userActionRewardRule/getRewardRuleList`, params);
 };
+
+/**
+ * AI生成活动宣传图
+ * @param params 请求参数 { text: string } - 活动描述文本
+ * @returns 生成的图片数据
+ */
+export const generatePromotionImage = (params: { text: string }) => {
+  return http_ai.post<any>(`/ai/life-manager/api/v1/promotion_image/generate`, params);
+};

+ 24 - 1
src/views/login/components/LoginForm.vue

@@ -95,6 +95,8 @@ import { useKeepAliveStore } from "@/stores/modules/keepAlive";
 import { initDynamicRouter } from "@/routers/modules/dynamicRouter";
 import type { ElForm } from "element-plus";
 import md5 from "md5";
+import { aiLogin } from "@/api/indexAi";
+import { localSet } from "@/utils";
 
 const router = useRouter();
 const userStore = useUserStore();
@@ -256,8 +258,29 @@ const handleLogin = async () => {
 
       const { data } = (await loginApi(loginParams)) as { data: Login.ResLogin };
 
-      if (data.result) {
+      if (data) {
         userStore.setToken(data.token);
+        console.log("AI登录");
+
+        // 保存用户信息到localStorage,供AI登录使用
+        const userInfo = {
+          userInfo: {
+            nickName: formData.username, // 使用登录时的用户名
+            phone: formData.username, // 假设用户名就是手机号
+            password: loginType.value === "password" ? formData.password : undefined // 保存密码用于AI登录
+          },
+          token: data.token
+        };
+        localSet("geeker-user", userInfo);
+
+        // 旧登录接口成功后,调用AI登录接口
+        try {
+          console.log("AI登录");
+          await aiLogin();
+        } catch (error) {
+          console.error("AI服务登录失败:", error);
+          // AI登录失败不影响主系统登录流程,静默处理
+        }
 
         // 添加动态路由
         await initDynamicRouter();

+ 8 - 0
src/views/login/index.vue

@@ -614,6 +614,7 @@ import { getMerchantByPhone } from "@/api/modules/homeEntry";
 import { localGet, localRemove, localSet } from "@/utils";
 import * as path from "node:path";
 import { checkMenuClickPermission } from "@/utils/permission";
+import { aiLogin } from "@/api/indexAi";
 
 const router = useRouter();
 const route = useRoute();
@@ -935,6 +936,13 @@ const handleLogin = async () => {
         } else {
           localRemove("mealsFlag");
         }
+        // 旧登录接口成功后,调用AI登录接口
+        // AI登录失败不影响主系统登录流程,静默处理
+        try {
+          await aiLogin();
+        } catch (error) {
+          console.error("AI服务登录失败:", error);
+        }
         // 登录成功后,立即获取完整的用户信息(包括头像)
         if (res.data.phone) {
           try {

+ 199 - 5
src/views/operationManagement/newActivity.vue

@@ -59,6 +59,29 @@
               <el-input v-model="activityModel.couponQuantity" placeholder="请输入" maxlength="5" />
             </el-form-item>
 
+            <!-- AI生成活动宣传图 -->
+            <el-form-item label="活动宣传图描述">
+              <div class="ai-generate-wrapper">
+                <el-input
+                  v-model="aiPromptText"
+                  :rows="4"
+                  maxlength="200"
+                  placeholder="请描述您想举办的活动图,AI助手会帮您自动生成海报图~"
+                  show-word-limit
+                  type="textarea"
+                />
+                <el-button
+                  :disabled="!aiPromptText.trim() || aiGenerating"
+                  :loading="aiGenerating"
+                  class="generate-btn"
+                  type="primary"
+                  @click="handleAIGenerate"
+                >
+                  {{ aiGenerating ? "生成中..." : "生成图片" }}
+                </el-button>
+              </div>
+            </el-form-item>
+
             <!-- 活动标题图 -->
             <el-form-item label="活动标题图" prop="activityTitleImage">
               <div class="upload-item-wrapper">
@@ -75,10 +98,11 @@
                     :on-preview="handlePictureCardPreview"
                     :on-remove="handleTitleRemove"
                     :show-file-list="true"
+                    :class="{ 'upload-hidden': aiGenerated }"
                     list-type="picture-card"
                   >
                     <template #trigger>
-                      <div v-if="titleFileList.length < 1" class="upload-trigger-card el-upload--picture-card">
+                      <div v-if="titleFileList.length < 1 && !aiGenerated" class="upload-trigger-card el-upload--picture-card">
                         <el-icon>
                           <Plus />
                         </el-icon>
@@ -86,7 +110,9 @@
                     </template>
                   </el-upload>
                 </div>
-                <div class="upload-hint">请上传21:9尺寸图片效果更佳,支持jpg、jpeg、png格式,上传图片不得超过20M</div>
+                <div v-if="!aiGenerated" class="upload-hint">
+                  请上传21:9尺寸图片效果更佳,支持jpg、jpeg、png格式,上传图片不得超过20M
+                </div>
               </div>
             </el-form-item>
 
@@ -106,10 +132,11 @@
                     :on-preview="handlePictureCardPreview"
                     :on-remove="handleDetailRemove"
                     :show-file-list="true"
+                    :class="{ 'upload-hidden': aiGenerated }"
                     list-type="picture-card"
                   >
                     <template #trigger>
-                      <div v-if="detailFileList.length < 1" class="upload-trigger-card el-upload--picture-card">
+                      <div v-if="detailFileList.length < 1 && !aiGenerated" class="upload-trigger-card el-upload--picture-card">
                         <el-icon>
                           <Plus />
                         </el-icon>
@@ -117,7 +144,7 @@
                     </template>
                   </el-upload>
                 </div>
-                <div class="upload-hint">请上传竖版图片,支持jpg、jpeg、png格式,上传图片不得超过20M</div>
+                <div v-if="!aiGenerated" class="upload-hint">请上传竖版图片,支持jpg、jpeg、png格式,上传图片不得超过20M</div>
               </div>
             </el-form-item>
           </div>
@@ -156,7 +183,8 @@ import {
   getActivityDetail,
   getActivityRuleOptions,
   getCouponList,
-  updateActivity
+  updateActivity,
+  generatePromotionImage
 } from "@/api/modules/operationManagement";
 import { uploadContractImage } from "@/api/modules/licenseManagement";
 import { localGet } from "@/utils";
@@ -177,6 +205,11 @@ const ruleFormRef = ref<FormInstance>();
 // 优惠券列表
 const couponList = ref<any[]>([]);
 
+// AI生成相关
+const aiPromptText = ref<string>("");
+const aiGenerating = ref<boolean>(false);
+const aiGenerated = ref<boolean>(false);
+
 // 文件上传相关
 const titleFileList = ref<UploadFile[]>([]);
 const detailFileList = ref<UploadFile[]>([]);
@@ -435,6 +468,12 @@ const handleTitleRemove: UploadProps["onRemove"] = (uploadFile, uploadFiles) =>
     URL.revokeObjectURL(file.url);
   }
   titleFileList.value = [...uploadFiles];
+
+  // 如果删除的是AI生成的图片,重置AI生成状态
+  if (aiGenerated.value && uploadFiles.length === 0) {
+    aiGenerated.value = false;
+  }
+
   ElMessage.success("图片已删除");
 };
 
@@ -459,6 +498,12 @@ const handleDetailRemove: UploadProps["onRemove"] = (uploadFile, uploadFiles) =>
     URL.revokeObjectURL(file.url);
   }
   detailFileList.value = [...uploadFiles];
+
+  // 如果删除的是AI生成的图片,重置AI生成状态
+  if (aiGenerated.value && uploadFiles.length === 0) {
+    aiGenerated.value = false;
+  }
+
   ElMessage.success("图片已删除");
 };
 
@@ -828,6 +873,101 @@ const goBack = () => {
 };
 
 /**
+ * AI生成活动宣传图
+ */
+const handleAIGenerate = async () => {
+  if (!aiPromptText.value.trim()) {
+    ElMessage.warning("请输入活动宣传图描述");
+    return;
+  }
+
+  aiGenerating.value = true;
+
+  try {
+    const res: any = await generatePromotionImage({
+      text: aiPromptText.value.trim()
+    });
+
+    if (res && res.code === 200 && res.data) {
+      const { banner_image, vertical_image } = res.data;
+
+      // 处理横版图片(活动标题图)
+      if (banner_image && banner_image.image_base64) {
+        const bannerFile = await base64ToFile(banner_image.image_base64, `banner_${Date.now()}.png`, "image/png");
+        const bannerUploadFile: UploadFile = {
+          uid: Date.now(),
+          name: bannerFile.name,
+          status: "success",
+          percentage: 100,
+          url: `data:image/png;base64,${banner_image.image_base64}`,
+          raw: bannerFile
+        } as UploadFile;
+
+        titleFileList.value = [bannerUploadFile];
+        titleImageUrl.value = `data:image/png;base64,${banner_image.image_base64}`;
+        activityModel.value.activityTitleImg = { url: titleImageUrl.value };
+        activityModel.value.activityTitleImage = titleImageUrl.value;
+      }
+
+      // 处理竖版图片(活动详情图)
+      if (vertical_image && vertical_image.image_base64) {
+        const verticalFile = await base64ToFile(vertical_image.image_base64, `vertical_${Date.now()}.png`, "image/png");
+        const verticalUploadFile: UploadFile = {
+          uid: Date.now() + 1,
+          name: verticalFile.name,
+          status: "success",
+          percentage: 100,
+          url: `data:image/png;base64,${vertical_image.image_base64}`,
+          raw: verticalFile
+        } as UploadFile;
+
+        detailFileList.value = [verticalUploadFile];
+        detailImageUrl.value = `data:image/png;base64,${vertical_image.image_base64}`;
+        activityModel.value.activityDetailImg = { url: detailImageUrl.value };
+        activityModel.value.activityDetailImage = detailImageUrl.value;
+      }
+
+      aiGenerated.value = true;
+
+      // 触发表单验证
+      nextTick(() => {
+        ruleFormRef.value?.validateField("activityTitleImage");
+        ruleFormRef.value?.validateField("activityDetailImage");
+      });
+
+      ElMessage.success("图片生成成功");
+    } else {
+      ElMessage.error(res?.message || "生成失败");
+    }
+  } catch (error: any) {
+    console.error("AI生成失败:", error);
+    ElMessage.error(error?.message || "生成失败,请稍后重试");
+  } finally {
+    aiGenerating.value = false;
+  }
+};
+
+/**
+ * Base64转File对象
+ */
+const base64ToFile = async (base64: string, filename: string, mimeType: string): Promise<File> => {
+  // 如果base64包含data URL前缀,去除它
+  const base64Data = base64.includes(",") ? base64.split(",")[1] : base64;
+
+  // 将base64转换为二进制数据
+  const byteCharacters = atob(base64Data);
+  const byteNumbers = new Array(byteCharacters.length);
+  for (let i = 0; i < byteCharacters.length; i++) {
+    byteNumbers[i] = byteCharacters.charCodeAt(i);
+  }
+  const byteArray = new Uint8Array(byteNumbers);
+  const blob = new Blob([byteArray], { type: mimeType });
+
+  // 创建File对象
+  return new File([blob], filename, { type: mimeType });
+};
+
+/**
  * 提交表单
  */
 const handleSubmit = async () => {
@@ -841,6 +981,37 @@ const handleSubmit = async () => {
 
   await ruleFormRef.value.validate(async valid => {
     if (valid) {
+      // 如果是AI生成的图片,需要先上传
+      if (aiGenerated.value) {
+        try {
+          // 上传标题图
+          if (titleFileList.value.length > 0 && titleFileList.value[0].raw) {
+            const titleFormData = new FormData();
+            titleFormData.append("file", titleFileList.value[0].raw);
+            titleFormData.append("user", "text");
+            const titleResult: any = await uploadContractImage(titleFormData);
+            if (titleResult?.code === 200 && titleResult.data) {
+              titleImageUrl.value = titleResult.data[0];
+            }
+          }
+
+          // 上传详情图
+          if (detailFileList.value.length > 0 && detailFileList.value[0].raw) {
+            const detailFormData = new FormData();
+            detailFormData.append("file", detailFileList.value[0].raw);
+            detailFormData.append("user", "text");
+            const detailResult: any = await uploadContractImage(detailFormData);
+            if (detailResult?.code === 200 && detailResult.data) {
+              detailImageUrl.value = detailResult.data[0];
+            }
+          }
+        } catch (error) {
+          console.error("上传AI生成图片失败:", error);
+          ElMessage.error("上传图片失败");
+          return;
+        }
+      }
+
       const [startTime, endTime] = activityModel.value.activityTimeRange || [];
       const params: any = {
         activityName: activityModel.value.activityName,
@@ -935,6 +1106,22 @@ const handleSubmit = async () => {
   }
 }
 
+/* AI生成包装器 */
+.ai-generate-wrapper {
+  display: flex;
+  gap: 12px;
+  align-items: flex-start;
+  width: 100%;
+  :deep(.el-textarea) {
+    flex: 1;
+  }
+  .generate-btn {
+    flex-shrink: 0;
+    align-self: flex-start;
+    margin-top: 2px;
+  }
+}
+
 /* 上传项容器 */
 .upload-item-wrapper {
   display: flex;
@@ -950,6 +1137,13 @@ const handleSubmit = async () => {
   color: #909399;
 }
 
+/* 隐藏上传触发器 */
+:deep(.upload-hidden) {
+  .el-upload--picture-card {
+    display: none !important;
+  }
+}
+
 /* 规则表格容器 */
 .rule-table-container {
   margin-top: 20px;

+ 4 - 1
vite.config.ts

@@ -43,7 +43,10 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
       port: viteEnv.VITE_PORT,
       open: viteEnv.VITE_OPEN,
       cors: true,
-      proxy: createProxy(viteEnv.VITE_PROXY)
+      proxy: {
+        ...createProxy(viteEnv.VITE_PROXY),
+        ...createProxy(viteEnv.VITE_PROXY_AI)
+      }
     },
     plugins: [tailwindcss(), createVitePlugins(viteEnv)],
     esbuild: {