diff --git a/.eslintrc.js b/off.eslintrc.js similarity index 100% rename from .eslintrc.js rename to off.eslintrc.js diff --git a/package.json b/package.json index b95bf71..48192b7 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "clipboard": "2.0.8", "core-js": "3.25.3", "dayjs": "^1.11.8", + "dompurify": "^3.2.6", "echarts": "5.4.0", "echarts-gl": "^2.0.9", "element-china-area-data": "^6.1.0", @@ -63,6 +64,7 @@ "jspdf": "^2.5.2", "lodash": "^4.17.21", "mapv-three": "^1.0.18", + "markdown-it": "^13.0.2", "marked": "^4.3.0", "nprogress": "0.2.0", "print-js": "^1.6.0", diff --git a/src/api/aiChat/ai_index.js b/src/api/aiChat/ai_index.js new file mode 100644 index 0000000..a365fe7 --- /dev/null +++ b/src/api/aiChat/ai_index.js @@ -0,0 +1,110 @@ +import request from '@/utils/ai_request' + +/** + * 获取聊天历史记录 + * @param {Object} params 请求参数 + * @param {string} params.conversationId 会话ID + * @param {string} params.user 用户ID + * @param {number} [params.limit=20] 返回记录数量 + * @param {string} [params.beforeId] 获取此ID之前的记录 + * @returns {Promise} 包含历史记录的Promise + */ +export const getHistory = ({ + conversationId, + user, + limit = 20, + beforeId +}) => { + const params = { + conversationId, + user, + limit + } + + // 如果有beforeId参数,添加到请求中(后端参数名为firstId) + if (beforeId) { + params.firstId = beforeId + } + + return request({ + url: '/aitutor/aichat/getMessagesToUser', + method: 'get', + params + }) +} + +/** + * 发送反馈(点赞/点踩) + * @param {Object} params 请求参数 + * @param {string} params.messageId 消息ID + * @param {number} params.action 1-点赞 0-点踩 + * @param {string} params.user 用户ID + * @returns {Promise} 包含操作结果的Promise + */ +export const sendFeedback = ({ + messageId, + action, + user +}) => { + return request({ + url: '/aitutor/aichat/feedback', + method: 'post', + data: { + message_id: messageId, + rating: action === 1 ? 'like' : 'dislike', + user + } + }) +} + +/** + * 上传文件 + * @param {FormData} formData 包含文件的FormData + * @param {string} user 用户ID + * @returns {Promise} 包含文件URL的Promise + */ +export const uploadFile = (formData, user) => { + formData.append('user', user) + return request({ + url: '/aitutor/aichat/files/upload', + method: 'post', + data: formData, + headers: { + 'Content-Type': 'multipart/form-data' + } + }) +} + +/** + * 创建新会话 + * @param {string} user 用户ID + * @param {string} title 会话标题 + * @returns {Promise} 包含新会话ID的Promise + */ +export const createConversation = (user, title) => { + return request({ + url: '/aitutor/aichat/conversation/create', + method: 'post', + data: { + user, + title + } + }) +} + +/** + * 删除会话 + * @param {string} conversationId 会话ID + * @param {string} user 用户ID + * @returns {Promise} 包含操作结果的Promise + */ +export const deleteConversation = (conversationId, user) => { + return request({ + url: '/aitutor/aichat/conversation/delete', + method: 'post', + data: { + conversation_id: conversationId, + user + } + }) +} \ No newline at end of file diff --git a/src/assets/ai/AI.png b/src/assets/ai/AI.png new file mode 100644 index 0000000..1eea8d3 Binary files /dev/null and b/src/assets/ai/AI.png differ diff --git a/src/assets/ai/good.svg b/src/assets/ai/good.svg new file mode 100644 index 0000000..b676949 --- /dev/null +++ b/src/assets/ai/good.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/ai/tread.svg b/src/assets/ai/tread.svg new file mode 100644 index 0000000..9ea37b3 --- /dev/null +++ b/src/assets/ai/tread.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/ai/yonghu.png b/src/assets/ai/yonghu.png new file mode 100644 index 0000000..f03cd4b Binary files /dev/null and b/src/assets/ai/yonghu.png differ diff --git a/src/assets/ai_icon/AI.png b/src/assets/ai_icon/AI.png new file mode 100644 index 0000000..1eea8d3 Binary files /dev/null and b/src/assets/ai_icon/AI.png differ diff --git a/src/assets/ai_icon/add.svg b/src/assets/ai_icon/add.svg new file mode 100644 index 0000000..4ee40ab --- /dev/null +++ b/src/assets/ai_icon/add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/ai_icon/good.svg b/src/assets/ai_icon/good.svg new file mode 100644 index 0000000..b676949 --- /dev/null +++ b/src/assets/ai_icon/good.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/ai_icon/history.svg b/src/assets/ai_icon/history.svg new file mode 100644 index 0000000..97e80dd --- /dev/null +++ b/src/assets/ai_icon/history.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/ai_icon/newChat.svg b/src/assets/ai_icon/newChat.svg new file mode 100644 index 0000000..e987847 --- /dev/null +++ b/src/assets/ai_icon/newChat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/ai_icon/search.svg b/src/assets/ai_icon/search.svg new file mode 100644 index 0000000..d152fab --- /dev/null +++ b/src/assets/ai_icon/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/ai_icon/send.svg b/src/assets/ai_icon/send.svg new file mode 100644 index 0000000..953c9c9 --- /dev/null +++ b/src/assets/ai_icon/send.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/ai_icon/tread.svg b/src/assets/ai_icon/tread.svg new file mode 100644 index 0000000..9ea37b3 --- /dev/null +++ b/src/assets/ai_icon/tread.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/ai_icon/voice.svg b/src/assets/ai_icon/voice.svg new file mode 100644 index 0000000..282a92e --- /dev/null +++ b/src/assets/ai_icon/voice.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/ai_icon/yonghu.png b/src/assets/ai_icon/yonghu.png new file mode 100644 index 0000000..f03cd4b Binary files /dev/null and b/src/assets/ai_icon/yonghu.png differ diff --git a/src/components/aiChat/HistoryDrawer.vue b/src/components/aiChat/HistoryDrawer.vue new file mode 100644 index 0000000..129f688 --- /dev/null +++ b/src/components/aiChat/HistoryDrawer.vue @@ -0,0 +1,499 @@ + + + + + diff --git a/src/composables/useToast.js b/src/composables/useToast.js new file mode 100644 index 0000000..48e2c10 --- /dev/null +++ b/src/composables/useToast.js @@ -0,0 +1,17 @@ +import { showToast, showSuccess, showError, showWarning, showInfo } from '@/utils/toast' + +/** + * Toast composable + * 提供统一的消息提示功能 + */ +export function useToast() { + return { + showToast, + showSuccess, + showError, + showWarning, + showInfo + } +} + +export default useToast \ No newline at end of file diff --git a/src/layout/components/Aichat/ChatPopup.vue b/src/layout/components/Aichat/ChatPopup.vue new file mode 100644 index 0000000..d305626 --- /dev/null +++ b/src/layout/components/Aichat/ChatPopup.vue @@ -0,0 +1,1235 @@ + + + + + diff --git a/src/layout/index.vue b/src/layout/index.vue index 373ee9b..17e87ae 100644 --- a/src/layout/index.vue +++ b/src/layout/index.vue @@ -1,3 +1,4 @@ + @@ -30,13 +37,15 @@ import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components' import ResizeMixin from './mixin/ResizeHandler' import { mapState } from 'vuex' import variables from '@/assets/styles/variables.scss' +import ChatPopup from '../layout/components/Aichat/ChatPopup.vue' import { initCoze -} from "@/utils/ai.js"; +} from '@/utils/ai.js' import { getAccessToken -} from "@/api/aiJWT/aiJWT.js" +} from '@/api/aiJWT/aiJWT.js' + export default { name: 'Layout', components: { @@ -45,9 +54,15 @@ export default { RightPanel, Settings, Sidebar, - TagsView + TagsView, + ChatPopup // 注册ChatPopup组件 }, mixins: [ResizeMixin], + data() { + return { + showAI: false // 控制AI弹窗显示/隐藏的变量 + } + }, computed: { ...mapState({ theme: state => state.settings.theme, @@ -70,42 +85,54 @@ export default { } }, variables() { - return variables; + return variables } }, methods: { handleClickOutside() { this.$store.dispatch('app/closeSideBar', { withoutAnimation: false }) }, - async showAI() { - let userInfo = { - roleGroup: this.userInfo.roles[0].roleName || "student", - nickName: this.userInfo.nickName, - username: this.userInfo.userName, - avater: this.avatar, - user_token: this.token - } - console.log("请求AI的信息", userInfo) - - //1.获取token - userInfo.accessToken = (await this.getAccessToken()).access_token; - userInfo.onRefreshToken = async () => (await this.getAccessToken()).accessToken; - const sdk = await initCoze(userInfo); - sdk.showChatBot(); + // 切换AI弹窗显示状态 + toggleAI() { + this.showAI = !this.showAI + // 如果需要在显示时执行原有逻辑,可以取消下面的注释 + // if (this.showAI) { + // this.initializeAI() + // } + }, + // 原有AI初始化逻辑,保持注释状态 + async initializeAI() { + // let userInfo = { + // roleGroup: this.userInfo.roles[0].roleName || "student", + // nickName: this.userInfo.nickName, + // username: this.userInfo.userName, + // avater: this.avatar, + // user_token: this.token + // } + // console.log("请求AI的信息", userInfo) + // + // //1.获取token + // userInfo.accessToken = (await this.getAccessToken()).access_token; + // userInfo.onRefreshToken = async () => (await this.getAccessToken()).accessToken; + // const sdk = await initCoze(userInfo); + // sdk.showChatBot(); }, async getAccessToken() { - const res = await getAccessToken(); // 调用请求函数 - const data = JSON.parse(res.data); // 解析数据 - return data; // ✅ 返回 data + const res = await getAccessToken() // 调用请求函数 + const data = JSON.parse(res.data) // 解析数据 + return data // ✅ 返回 data } } } + diff --git a/src/utils/ai_request.js b/src/utils/ai_request.js new file mode 100644 index 0000000..43f9947 --- /dev/null +++ b/src/utils/ai_request.js @@ -0,0 +1,54 @@ +import axios from 'axios' +import { getTokenKeySessionStorage } from './auth' +import { useRouter } from 'vue-router' +import { showToast } from '@/utils/toast' // 请替换为你的Toast组件 + +// 创建axios实例 +const service = axios.create({ + baseURL: process.env.VUE_APP_API_BASE_URL || 'http://localhost:8088', + timeout: 15000, + headers: { + 'Content-Type': 'application/json' + } +}) + +// 请求拦截器 +service.interceptors.request.use( + config => { + // 从本地存储获取token + const token = getTokenKeySessionStorage() + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + error => { + return Promise.reject(error) + } +) + +// 响应拦截器 +service.interceptors.response.use( + response => { + // 对响应数据做处理 + return response.data + }, + error => { + const router = useRouter() + + // 处理401未授权 + if (error.response?.status === 401) { + showToast('登录已过期,请重新登录', 'error') + router.push('/login') + } + + // 处理其他错误状态码 + if (error.response?.status === 500) { + showToast('服务器错误,请稍后再试', 'error') + } + + return Promise.reject(error) + } +) + +export default service \ No newline at end of file diff --git a/src/utils/ai_stream.js b/src/utils/ai_stream.js new file mode 100644 index 0000000..a3c19ca --- /dev/null +++ b/src/utils/ai_stream.js @@ -0,0 +1,115 @@ +import { getTokenKeySessionStorage } from "@/utils/auth"; + +// 使用环境变量配置基础URL +const BASE_URL = process.env.VUE_APP_API_BASE_URL || "http://localhost:8088"; + +/** + * 创建聊天流式连接 + * @param {Object} params 请求参数 + * @param {string} params.prompt 用户输入 + * @param {string} params.userId 用户ID + * @param {string} params.userName 用户名 + * @param {string} [params.conversationId] 会话ID + * @returns {Object} 包含stream和cancel方法的对象 + */ +export function createChatStream(params) { + const requestId = `req-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 8)}`; + const url = `${BASE_URL}/aitutor/aichat/stream`; + const token = getTokenKeySessionStorage(); + + if (!token) { + throw new Error("请先登录"); + } + + const controller = new AbortController(); + + const fetchPromise = fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-Request-ID": requestId, + }, + body: JSON.stringify({ + query: params.prompt, + user_id: params.userId, + user_name: params.userName, + user_token: params.user_token || "123", + user_role: "student", + conversation_id: params.conversationId || null, + }), + signal: controller.signal, + }).then((response) => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + if (!response.body) { + throw new Error("Response body is null"); + } + return { + reader: response.body.getReader(), + decoder: new TextDecoder("utf-8"), + }; + }); + + return { + stream: fetchPromise, + cancel: (reason) => { + if (!controller.signal.aborted) { + controller.abort(reason); + } + }, + }; +} + +/** + * 处理流式响应 + * @param {ReadableStreamDefaultReader} reader 读取器 + * @param {TextDecoder} decoder 文本解码器 + * @param {Function} onMessage 消息回调 + * @param {Function} onError 错误回调 + * @param {Function} onComplete 完成回调 + */ +export async function processStream( + reader, + decoder, + { onMessage, onError, onComplete } +) { + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.trim()) continue; + + try { + const data = JSON.parse(line); + if (typeof onMessage === "function") { + onMessage(data); + } + } catch (e) { + console.warn("解析消息失败:", line, e); + } + } + } + + if (typeof onComplete === "function") { + onComplete(); + } + } catch (error) { + if (error.name !== "AbortError" && typeof onError === "function") { + onError(error); + } + } finally { + reader.releaseLock(); + } +} diff --git a/src/utils/toast.js b/src/utils/toast.js new file mode 100644 index 0000000..2b45e22 --- /dev/null +++ b/src/utils/toast.js @@ -0,0 +1,48 @@ +import { Message } from 'element-ui' + +/** + * 显示Toast消息 + * @param {string} message - 消息内容 + * @param {string} type - 消息类型: 'success', 'warning', 'info', 'error' + * @param {number} duration - 显示时长,默认3000ms + */ +export function showToast(message, type = 'info', duration = 3000) { + Message({ + message, + type, + duration, + showClose: true + }) +} + +/** + * 显示成功消息 + * @param {string} message - 消息内容 + */ +export function showSuccess(message) { + showToast(message, 'success') +} + +/** + * 显示错误消息 + * @param {string} message - 消息内容 + */ +export function showError(message) { + showToast(message, 'error') +} + +/** + * 显示警告消息 + * @param {string} message - 消息内容 + */ +export function showWarning(message) { + showToast(message, 'warning') +} + +/** + * 显示信息消息 + * @param {string} message - 消息内容 + */ +export function showInfo(message) { + showToast(message, 'info') +} \ No newline at end of file