Browse Source

小天ai功能完成

fanghuisheng 2 weeks ago
parent
commit
3ce0316ad3

+ 1 - 0
package.json

@@ -53,6 +53,7 @@
     "@dcloudio/uni-mp-weixin": "3.0.0-alpha-4020320240703001",
     "@dcloudio/uni-mp-xhs": "3.0.0-alpha-4020320240703001",
     "@dcloudio/uni-quickapp-webview": "3.0.0-alpha-4020320240703001",
+    "@microsoft/fetch-event-source": "^2.0.1",
     "echarts": "^5.3.3",
     "jsencrypt": "^3.3.2",
     "pinia": "2.0.14",

+ 24 - 0
src/api/business/ai.js

@@ -0,0 +1,24 @@
+import { fetchs, request } from "@/utils/request";
+
+/**
+ * ai接口集合
+ * @method Dialogue 对话
+ */
+export function aiApi() {
+    return {
+        Dialogue: (data) => {
+            return fetchs({
+                url: `/service-ai/ai/aliTyqw`,
+                method: 'POST',
+                data,
+            });
+        },
+        recordSelect: (data) => {
+            return request({
+                url: `/service-ai/session/current`,
+                method: 'GET',
+                data,
+            });
+        },
+    };
+}

+ 7 - 0
src/components/oa-chatSSEClient/changelog.md

@@ -0,0 +1,7 @@
+## 1.3.2(2025-03-16)
+1. 示例项目添加了sse server供调试。
+2. 发生了错误接口会无限运行的问题解决,现在发生了错误会调用stop方法停止。
+## 1.3.1(2025-03-10)
+修复了get请求无法stop的bug
+## 1.3.0(2025-03-06)
+插件修改为 uni_modules 模式

+ 110 - 0
src/components/oa-chatSSEClient/children/ChatAppAndWeb.vue

@@ -0,0 +1,110 @@
+<script>
+export default {
+  props: {},
+  data() {
+    return {
+      stopCount: 0,
+      renderjsData: {
+        url: "",
+        key: 0,
+        body: "",
+        method: "",
+      },
+    };
+  },
+  methods: {
+    stopChat() {
+      this.stopCount += 1;
+    },
+    /**
+     * 开始chat对话
+     */
+    startChat(config) {
+      const { body } = config;
+      this.renderjsData = Object.assign({}, this.renderjsData, {
+        key: this.renderjsData.key + 1,
+        ...config,
+        body: body ? JSON.stringify(body) : 0,
+      });
+    },
+
+    open() {
+      this.$emit("onInnerOpen");
+    },
+    message(msg) {
+      this.$emit("onInnerMessage", msg);
+    },
+    error(err) {
+      this.$emit("onInnerError", err);
+      this.stopChat();
+    },
+    finish() {
+      this.$emit("onInnerFinish");
+    },
+  },
+};
+</script>
+
+<script module="chat" lang="renderjs">
+import { fetchEventSource } from '../fetch-event-source';
+
+export default {
+	data() {
+		return {
+			ctrl: null,
+		}
+	},
+	methods: {
+		/**
+		 * 停止生成
+		 */
+		stopChatCore() {
+			this.ctrl?.abort();
+		},
+
+		/**
+		 * 开始对话
+		 */
+		startChatCore({ url, body, headers, method }) {
+			if (!url) return;
+			try {
+				this.ctrl = new AbortController();
+				fetchEventSource(
+					url,
+					{
+					readJson: true,
+						method,
+            openWhenHidden: true,
+						signal: this.ctrl.signal,
+						headers: {
+							"Content-Type": "application/json",
+							...headers,
+						},
+						body: body ? body : undefined,
+						onopen: () => {
+							this.$ownerInstance.callMethod('open');
+						},
+						onmessage: ({ data }) => {
+							this.$ownerInstance.callMethod('message', data);
+						},
+						onerror: (err) => {
+              console.log(err)
+							this.$ownerInstance.callMethod('error', err);
+						},
+					}).then(() => {
+						this.$ownerInstance.callMethod('finish');
+				}).catch(err => {
+          console.log(err)
+					this.$ownerInstance.callMethod('error', err);
+				})
+			} catch (e) {
+				console.log(e);
+			}
+		}
+	}
+}
+</script>
+
+<template>
+  <view :renderjsData="renderjsData" :change:renderjsData="chat.startChatCore" :stopCount="stopCount" :change:stopCount="chat.stopChatCore" />
+</template>

+ 73 - 0
src/components/oa-chatSSEClient/children/ChatWxApplet.vue

@@ -0,0 +1,73 @@
+<script>
+let requestTask;
+export default {
+  props: {},
+  data() {
+    return {
+    }
+  },
+  methods: {
+    stopChat() {
+      requestTask.offChunkReceived(this.listener)
+      requestTask.abort();
+    },
+
+    decode(data) {
+      if(typeof data === 'string') {
+        return data;
+      }
+      let txt;
+      // 进行判断返回的对象是Uint8Array(开发者工具)或者ArrayBuffer(真机)
+      // 1.获取对象的准确的类型
+      const type = Object.prototype.toString.call(data); // Uni8Array的原型对象被更改了所以使用字符串的信息进行判断。
+      if(type ==="[object Uint8Array]"){
+        txt = decodeURIComponent(escape(String.fromCharCode(...data)))
+      }else if(data instanceof ArrayBuffer){
+        // 将ArrayBuffer转换为Uint8Array
+        const uint8Array = new Uint8Array(data);
+        txt=decodeURIComponent(escape(String.fromCharCode(...uint8Array)))
+      }
+      return txt;
+    },
+
+    /**
+     * 开始chat对话
+     * @param body
+     * @param url
+     * @param headers
+     * @param method
+     */
+    startChat({ body, url, headers, method }) {
+      requestTask = uni.request({
+        url: url,
+        method,
+        header: {
+          Accept: 'text/event-stream',
+          ...headers,
+        },
+        data: body,
+        enableChunked: true,
+        responseType: 'arraybuffer',
+        success: (res) => {},
+        fail: (error) => {
+          this.$emit("onInnerError", error)
+        },
+        complete: () => {
+          this.$emit("onInnerFinish")
+        },
+      });
+
+      requestTask.onChunkReceived(this.listener)
+      this.$emit("onInnerOpen")
+    },
+
+    listener(data) {
+      this.$emit("onInnerMessage", this.decode(data.data))
+    },
+  },
+}
+</script>
+
+<template>
+  <view />
+</template>

+ 89 - 0
src/components/oa-chatSSEClient/fetch-event-source/fetch.js

@@ -0,0 +1,89 @@
+var __rest = (this && this.__rest) || function (s, e) {
+    var t = {};
+    for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
+        t[p] = s[p];
+    if (s != null && typeof Object.getOwnPropertySymbols === "function")
+        for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
+            if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
+                t[p[i]] = s[p[i]];
+        }
+    return t;
+};
+import { getBytes, getLines, getMessages } from './parse';
+export const EventStreamContentType = 'text/event-stream';
+const DefaultRetryInterval = 1000;
+const LastEventId = 'last-event-id';
+export function fetchEventSource(input, _a) {
+    var { signal: inputSignal, headers: inputHeaders, onopen: inputOnOpen, onmessage, onclose, onerror, openWhenHidden, fetch: inputFetch } = _a, rest = __rest(_a, ["signal", "headers", "onopen", "onmessage", "onclose", "onerror", "openWhenHidden", "fetch"]);
+    return new Promise((resolve, reject) => {
+        const headers = Object.assign({}, inputHeaders);
+        if (!headers.accept) {
+            headers.accept = EventStreamContentType;
+        }
+        let curRequestController;
+        function onVisibilityChange() {
+            curRequestController.abort();
+            if (!document.hidden) {
+                create();
+            }
+        }
+        if (!openWhenHidden) {
+            document.addEventListener('visibilitychange', onVisibilityChange);
+        }
+        let retryInterval = DefaultRetryInterval;
+        let retryTimer = 0;
+        function dispose() {
+            document.removeEventListener('visibilitychange', onVisibilityChange);
+            window.clearTimeout(retryTimer);
+            curRequestController.abort();
+        }
+        inputSignal === null || inputSignal === void 0 ? void 0 : inputSignal.addEventListener('abort', () => {
+            dispose();
+            resolve();
+        });
+        const fetch = inputFetch !== null && inputFetch !== void 0 ? inputFetch : window.fetch;
+        const onopen = inputOnOpen !== null && inputOnOpen !== void 0 ? inputOnOpen : defaultOnOpen;
+        async function create() {
+            var _a;
+            curRequestController = new AbortController();
+            try {
+                const response = await fetch(input, Object.assign(Object.assign({}, rest), { headers, signal: curRequestController.signal }));
+                await onopen(response);
+                await getBytes(response.body, getLines(getMessages(id => {
+                    if (id) {
+                        headers[LastEventId] = id;
+                    }
+                    else {
+                        delete headers[LastEventId];
+                    }
+                }, retry => {
+                    retryInterval = retry;
+                }, onmessage)));
+                onclose === null || onclose === void 0 ? void 0 : onclose();
+                dispose();
+                resolve();
+            }
+            catch (err) {
+                if (!curRequestController.signal.aborted) {
+                    try {
+                        const interval = (_a = onerror === null || onerror === void 0 ? void 0 : onerror(err)) !== null && _a !== void 0 ? _a : retryInterval;
+                        window.clearTimeout(retryTimer);
+                        retryTimer = window.setTimeout(create, interval);
+                    }
+                    catch (innerErr) {
+                        dispose();
+                        reject(innerErr);
+                    }
+                }
+            }
+        }
+        create();
+    });
+}
+function defaultOnOpen(response) {
+    const contentType = response.headers.get('content-type');
+    if (!(contentType === null || contentType === void 0 ? void 0 : contentType.startsWith(EventStreamContentType))) {
+        throw new Error(`Expected content-type to be ${EventStreamContentType}, Actual: ${contentType}`);
+    }
+}
+//# sourceMappingURL=fetch.js.map

+ 2 - 0
src/components/oa-chatSSEClient/fetch-event-source/index.js

@@ -0,0 +1,2 @@
+export { fetchEventSource, EventStreamContentType } from './fetch';
+//# sourceMappingURL=index.js.map

+ 115 - 0
src/components/oa-chatSSEClient/fetch-event-source/parse.js

@@ -0,0 +1,115 @@
+export async function getBytes(stream, onChunk) {
+    const reader = stream.getReader();
+    let result;
+    while (!(result = await reader.read()).done) {
+        onChunk(result.value);
+    }
+}
+export function getLines(onLine) {
+    let buffer;
+    let position;
+    let fieldLength;
+    let discardTrailingNewline = false;
+    return function onChunk(arr) {
+        if (buffer === undefined) {
+            buffer = arr;
+            position = 0;
+            fieldLength = -1;
+        }
+        else {
+            buffer = concat(buffer, arr);
+        }
+        const bufLength = buffer.length;
+        let lineStart = 0;
+        while (position < bufLength) {
+            if (discardTrailingNewline) {
+                if (buffer[position] === 10) {
+                    lineStart = ++position;
+                }
+                discardTrailingNewline = false;
+            }
+            let lineEnd = -1;
+            for (; position < bufLength && lineEnd === -1; ++position) {
+                switch (buffer[position]) {
+                    case 58:
+                        if (fieldLength === -1) {
+                            fieldLength = position - lineStart;
+                        }
+                        break;
+                    case 13:
+                        discardTrailingNewline = true;
+                    case 10:
+                        lineEnd = position;
+                        break;
+                }
+            }
+            if (lineEnd === -1) {
+                break;
+            }
+            onLine(buffer.subarray(lineStart, lineEnd), fieldLength);
+            lineStart = position;
+            fieldLength = -1;
+        }
+        if (lineStart === bufLength) {
+            buffer = undefined;
+        }
+        else if (lineStart !== 0) {
+            buffer = buffer.subarray(lineStart);
+            position -= lineStart;
+        }
+    };
+}
+export function getMessages(onId, onRetry, onMessage) {
+    let message = newMessage();
+    const decoder = new TextDecoder();
+    return function onLine(line, fieldLength) {
+        if (line.length === 0) {
+            onMessage === null || onMessage === void 0 ? void 0 : onMessage(message);
+            message = newMessage();
+        }
+        else if (fieldLength > 0) {
+            const field = decoder.decode(line.subarray(0, fieldLength));
+            const valueOffset = fieldLength + (line[fieldLength + 1] === 32 ? 2 : 1);
+            const value = decoder.decode(line.subarray(valueOffset));
+            switch (field) {
+                case 'data':
+                    message.data = message.data
+                        ? message.data + '\n' + value
+                        : value;
+                    break;
+                case 'event':
+                    message.event = value;
+                    break;
+                case 'id':
+                    onId(message.id = value);
+                    break;
+                case 'retry':
+                    const retry = parseInt(value, 10);
+                    if (!isNaN(retry)) {
+                        onRetry(message.retry = retry);
+                    }
+                    break;
+                default:
+                    const msg = decoder.decode(line, { stream: true });
+                    message.data = msg
+                    onMessage(message);
+                    break;
+            }
+        }
+    };
+}
+function concat(a, b) {
+    const res = new Uint8Array(a.length + b.length);
+    res.set(a);
+    res.set(b, a.length);
+    return res;
+}
+function newMessage() {
+    return {
+        data: '',
+        event: '',
+        id: '',
+        retry: undefined,
+    };
+}
+//# sourceMappingURL=parse.js.map

+ 55 - 0
src/components/oa-chatSSEClient/index.vue

@@ -0,0 +1,55 @@
+<template>
+  <!--  #ifdef MP-WEIXIN-->
+  <ChatWxApplet ref="chatRef" @onInnerOpen="open" @onInnerError="error" @onInnerMessage="message" @onInnerFinish="finish" />
+  <!--  #endif-->
+
+  <!--  #ifdef APP-PLUS || H5-->
+  <ChatAppAndWeb ref="chatRef" @onInnerOpen="open" @onInnerError="error" @onInnerMessage="message" @onInnerFinish="finish" />
+  <!--  #endif-->
+</template>
+
+<script setup>
+import { ref } from "vue";
+// #ifdef MP-WEIXIN
+import ChatWxApplet from "./children/ChatWxApplet.vue";
+// #endif
+
+// #ifdef APP-PLUS || H5
+import ChatAppAndWeb from "./children/ChatAppAndWeb.vue";
+// #endif
+
+const chatRef = ref(null);
+const emits = defineEmits(["onOpen", "onMessage", "onError", "onFinish"]);
+
+function startChat(config) {
+  config["method"] = (config["method"] || "post").toUpperCase();
+  config["headers"] = config["headers"] || {};
+  chatRef.value.startChat(config);
+}
+
+function stopChat(...args) {
+  chatRef.value.stopChat(...args);
+}
+
+function open() {
+  emits("onOpen");
+}
+function message(msg) {
+  emits("onMessage", msg);
+}
+function error(err) {
+  emits("onError", err);
+}
+function finish() {
+  emits("onFinish");
+}
+
+defineExpose({
+  startChat,
+  stopChat,
+  open,
+  message,
+  error,
+  finish,
+});
+</script>

+ 65 - 0
src/components/oa-chatSSEClient/package.json

@@ -0,0 +1,65 @@
+{
+    "id": "gao-ChatSSEClient",
+    "name": "sse 客户端组件,支持兼容:v2、v3、安卓、ios、浏览器、微信小程序",
+    "displayName": "sse 客户端组件,支持兼容:v2、v3、安卓、ios、浏览器、微信小程序",
+    "version": "1.3.2",
+    "description": "sse 客户端组件,支持兼容:v2、v3、安卓、ios、浏览器、微信小程序",
+    "repository": "https://github.com/gaozhenqiang/uniapp-chatSSEClient",
+    "keywords": [
+        "sse",
+        "chat",
+        "微信小程序sse",
+        "流式接口",
+        "流式输出"
+    ],
+    "dcloudext": {
+        "declaration": {
+            "ads": "无",
+            "data": "插件不采集任何数据",
+            "permissions": "无"
+        },
+        "contact": {
+            "qq": "1933669775"
+        },
+        "type": "component-vue",
+        "sale": {
+            "regular": {
+                "price": "0.00"
+            },
+            "sourcecode": {
+                "price": "0.00"
+            }
+        }
+    },
+    "uni_modules": {
+        "platforms": {
+            "client": {
+                "Vue": {
+                    "vue2": "y",
+                    "vue3": "y"
+                },
+                "App": {
+                    "app-vue": "y",
+                    "app-harmony": "u",
+                    "app-nvue": "u",
+                    "app-uvue": "u"
+                },
+                "H5-mobile": {
+                    "Safari": "y",
+                    "Android Browser": "y",
+                    "微信浏览器(Android)": "y",
+                    "QQ浏览器(Android)": "y"
+                },
+                "H5-pc": {
+                    "Chrome": "y",
+                    "Edge": "y",
+                    "Firefox": "y",
+                    "Safari": "y"
+                },
+                "小程序": {
+                    "微信": "y"
+                }
+            }
+        }
+    }
+}

+ 124 - 0
src/components/oa-chatSSEClient/readme.md

@@ -0,0 +1,124 @@
+# sse 客户端组件,支持v2、v3、安卓、ios、浏览器、微信小程序
+
+## 使用说明
+
+### 导入组件
+
+点击右上角 `下载插件并导入HBuilderX`
+
+uniapp插件地址:https://ext.dcloud.net.cn/plugin?id=20971
+
+或者你可以参考我的示例
+
+### 示例代码
+
+```javascript
+<template>
+  <button @click="start">开始</button>
+<button @click="stop">停止</button>
+<template v-if="loading">
+  <view>{{ openLoading ? "正在连接sse..." : '连接完成!' }}</view>
+  <view>{{ loading ? "加载中..." : '' }}</view>
+</template>
+
+<view>
+  {{ responseText }}
+</view>
+
+<gao-ChatSSEClient
+  ref="chatSSEClientRef"
+    @onOpen="openCore"
+@onError="errorCore"
+@onMessage="messageCore"
+@onFinish="finishCore"
+  />
+  </template>
+
+<script setup>
+  import { ref } from 'vue'
+
+  const chatSSEClientRef = ref(null);
+  const responseText = ref("");
+  const loading = ref(false);
+  const openLoading = ref(false);
+
+  const openCore = () => {
+  openLoading.value = false;
+  console.log("open sse");
+}
+  const errorCore = (err) => {
+  console.log("error sse:", err);
+}
+  const messageCore = (msg) => {
+  console.log("message sse:", msg);
+  responseText.value += `${msg}\n`
+}
+  const finishCore = () => {
+  console.log("finish sse")
+  loading.value = false;
+}
+
+  const start = () => {
+  if (loading.value) return;
+
+  openLoading.value = true;
+  loading.value = true;
+  responseText.value = "";
+
+  chatSSEClientRef.value.startChat({
+  // 将它换成你的地址
+  url: import.meta.env.VITE_CHAT_URL,
+  // 请求头
+  headers: {
+  Authorization: import.meta.env.VITE_CHAT_AUTHORIZATION,
+},
+  // 默认为 post
+  method: 'post',
+  body: {
+  "stream":true,
+  "model": "deepseek-chat",
+  "messages": [
+{"role": "system", "content": "你是来自艺咖科技的数字员工,你的名字叫小咖。"}]
+}
+})
+}
+  const stop = () => {
+  chatSSEClientRef.value.stopChat()
+  console.log("stop");
+}
+</script>
+```
+
+# 温馨提示
+
+示例项目根目录的`sse-server.js`文件提供了一个简单的sse测试服务,使用 `node sse-server.js`运行
+
+**提出问题之前请先确保你的接口没有问题**
+
+---
+
+**请仔细阅读我提供的示例代码。**
+
+**如果你的程序有问题请先下载我提供的示例项目调试!**
+
+---
+
+如果想了解原理请看我掘金的文章: [点击前往](https://juejin.cn/post/7435632766375084082)
+
+本插件依赖于 `fetch-event-source` 库,将编辑后的js集成,因为我修改了原来库解析的逻辑,使其更适用于中国宝宝体质。
+
+---
+
+**如果这个组件解决了你的问题,麻烦去[github](https://github.com/gaozhenqiang/uniapp-chatSSEClient/) 帮我点个赞,谢谢大家**
+
+有新需求或者bug可以在github上提issues,或者加我q `1933669775`
+
+已知的需求有(我抽空会加上的):
+1. 微信小程序会一次返回多条数据连在一起,这是因为没有解析数据的问题,h5端也有这种问题,只是`fetch-event-source`帮我们处理了。
+
+# 常见问题
+
+## ios报错:TypeError: Load failed
+
+后端接口处理一下跨域即可解决。
+

+ 3 - 1
src/main.js

@@ -22,7 +22,7 @@ import oaTransForm from "@/components/oa-transForm/index"
 import oaTtsAudio from "@/components/oa-ttsAudio/index"
 import oaWeather from "@/components/oa-weather/index"
 import oaSteps from "@/components/oa-steps/index"
-
+import oaChatSSEClient from "@/components/oa-chatSSEClient/index"
 
 // import hideHead from "./utils/hideHead.js";
 
@@ -45,6 +45,8 @@ export function createApp() {
   app.component('oa-ttsAudio', oaTtsAudio)
   app.component('oa-weather', oaWeather)
   app.component('oa-steps', oaSteps)
+  app.component('oa-chatSSEClient', oaChatSSEClient)
+
 
   // 挂载全局json导出
   app.component("downloadExcel", JsonExcel);

+ 31 - 0
src/pages.json

@@ -1063,6 +1063,37 @@
                     }
                 }
             ]
+        },
+        {
+            "name": "ai",
+            "root": "pages/business/ai/",
+            "pages": [
+                {
+                    "path": "conv/index",
+                    "style": {
+                        "navigationBarTitleText": "小天ai",
+                        "enablePullDownRefresh": false,
+                        "navigationStyle": "custom",
+                        "app-plus": {
+                            "bounce": "none",
+                            "titleNView": false,
+                            "softinputMode": "adjustResize"
+                        }
+                    }
+                },
+                {
+                    "path": "conv/record",
+                    "style": {
+                        "navigationBarTitleText": "历史会话",
+                        "enablePullDownRefresh": false,
+                        "navigationStyle": "custom",
+                        "app-plus": {
+                            "bounce": "none",
+                            "titleNView": false
+                        }
+                    }
+                }
+            ]
         }
     ],
     "globalStyle": {

+ 446 - 0
src/pages/business/ai/conv/index.vue

@@ -0,0 +1,446 @@
+<template>
+  <u-navbar :titleStyle="{ color: '#000' }" :autoBack="true" title="小天AI" :placeholder="true" :safeAreaInsetTop="true" bgColor="#fff">
+    <template #left>
+      <view class="u-navbar__content__left__item">
+        <u-icon name="arrow-left" size="20" color="#000"></u-icon>
+      </view>
+    </template>
+  </u-navbar>
+
+  <oa-scroll
+    customClass="doorList-container scroll-height"
+    :isSticky="true"
+    :customStyle="{
+      //#ifdef APP-PLUS || MP-WEIXIN
+      height: `calc(100vh - (44px + ${footerHeight}px + ${proxy.$settingStore.StatusBarHeight}))`,
+      //#endif
+      //#ifdef H5
+      height: `calc(100vh - (44px + ${footerHeight}px))`,
+      //#endif
+    }"
+    :refresherLoad="false"
+    :refresherEnabled="false"
+    :scrollTop="scrollTop"
+    :scrollIntoView="scrollIntoView"
+    :refresherDefaultStyle="'none'"
+    :refresherBackground="'#fffff'"
+    :data-theme="'theme-' + proxy.$settingStore.themeColor.name"
+  >
+    <template #default>
+      <u-loading-page :loading="state.loading" fontSize="16" style="z-index: 99"></u-loading-page>
+      <view class="mainArea">
+        <view class="center-area">
+          <view class="center-area-item">
+            <viwe class="center-area-item__avatar">
+              <image src="@/static/ai-avatar.png" mode="widthFix"></image>
+            </viwe>
+            <view class="center-area-item__content">
+              <span class="defaultQuestion-title">您好,我是智能助手小天!</span>
+              作为您的智能助手,我能帮助您解决各种问题。 除此之外,小天还可以尝试帮助解决其他方面的问题或在您需要的时候陪您聊天!
+              <!-- 您可以试着问我:
+              <view class="defaultQuestion">
+                <view class="defaultQuestion-item" @click="onClickDefaultQuestion($event, item)" v-for="item in defaultQuestion.list" :key="item.id" :span="12">
+                  {{ item.value }}
+                </view>
+              </view> -->
+            </view>
+          </view>
+
+          <viwe v-for="(item, index) in answerList" :key="index" :class="['center-area-item', { roleUser: item.role === 'user' }]" :id="index == answerList.length - 1 ? 'bottomInfo' : ''">
+            <viwe class="center-area-item__avatar">
+              <image v-show="item.role === 'assistant'" src="@/static/ai-avatar.png" mode="widthFix"></image>
+              <image v-show="item.role === 'user'" src="@/static/ai-question-avatar.png" mode="widthFix"></image>
+            </viwe>
+
+            <viwe v-if="item.role === 'user'" class="center-area-item__content">{{ item.content }}</viwe>
+            <viwe v-else-if="item.role === 'assistant'" class="center-area-item__content">
+              <viwe class="center-area-item__outputReasonContent">{{ item.reasoningContent }}</viwe>
+              <oa-chatSSEClient ref="chatSSEClientRef" @onOpen="openCore" @onError="errorCore" @onMessage="messageCore" @onFinish="finishCore" />
+            </viwe>
+          </viwe>
+        </view>
+      </view>
+    </template>
+  </oa-scroll>
+
+  <view class="footer-area">
+    <view class="footer-area-top">
+      <view class="footer-area-top__record" @click="handle('Record')">对话历史</view>
+      <view class="footer-area-top__new" @click="handle('CreateChat')">新话题</view>
+    </view>
+    <view class="footer-area-center">
+      <textarea
+        class="footer-area-center-textarea"
+        v-model="answerKeyword"
+        placeholder="请输入问题,可通过回车换行"
+        maxlength="500"
+        @confirm="onSubmit"
+        @linechange="linechange"
+        auto-height
+      ></textarea>
+      <button class="footer-area-center-button" type="primary" :loading="submitLoading" @click="onSubmit">发送</button>
+    </view>
+    <view class="footer-area-prompt">以上内容均由AI大模型生成,仅供参考</view>
+  </view>
+
+  <uni-popup ref="popup" type="left" safeArea backgroundColor="#fff">
+    <view class="chatArea">
+      <view class="content">
+        <view class="content-title">近30天</view>
+        <view class="content-item" v-for="item in state.chatHistory.list" :key="item" @click="onClickChatItem(item)"> {{ item.question }}</view>
+      </view>
+    </view>
+  </uni-popup>
+</template>
+
+<script setup>
+/*----------------------------------依赖引入-----------------------------------*/
+import config from "@/config";
+import { getToken } from "@/utils/auth";
+import { onLoad, onShow, onReady, onHide, onLaunch, onUnload, onNavigationBarButtonTap, onPageScroll } from "@dcloudio/uni-app";
+import { ref, reactive, computed, getCurrentInstance, toRefs, inject, nextTick } from "vue";
+/*----------------------------------接口引入-----------------------------------*/
+import { aiApi } from "@/api/business/ai.js";
+/*----------------------------------组件引入-----------------------------------*/
+/*----------------------------------store引入-----------------------------------*/
+import { useStores, commonStores, controlStores } from "@/store/modules/index";
+/*----------------------------------公共方法引入-----------------------------------*/
+/*----------------------------------公共变量-----------------------------------*/
+const { proxy } = getCurrentInstance();
+const useStore = useStores();
+const controlStore = controlStores();
+/*----------------------------------变量声明-----------------------------------*/
+const state = reactive({
+  loading: false,
+  dataList: [],
+  pageSize: 20,
+  current: 1,
+  total: 0,
+
+  buffer: "", // 用于存储流式数据不完整的行
+  defaultQuestion: {
+    list: [
+      { id: 2, value: "如何看待中国锂矿储量从全球占比6%升至16.5%,从世界第六跃至第二?" },
+      { id: 3, value: "为什么特斯拉和理想,都不想承认自己是「汽车公司」?" },
+      { id: 4, value: "全美第二大的城市洛杉矶大火,为什么能蔓延到无法控制的地步?" },
+      { id: 4, value: "为什么主流都不再力推英特尔 CPU?" },
+    ],
+  },
+  submitLoading: false,
+  answerKeyword: undefined,
+  answerList: [],
+  // 会话记录
+  chatHistory: {
+    list: [],
+    sessionId: undefined,
+  },
+
+  footerHeight: 0, //底部区域高度
+  scrollIntoView: "", //滚动位置
+});
+
+const { tabsList, tabsCurrent, dataList, buffer, defaultQuestion, submitLoading, answerKeyword, answerList, chatHistory, footerHeight, scrollIntoView } = toRefs(state);
+
+/** 操作 */
+function handle(type) {
+  if (type == "Record") {
+    getChatList();
+    proxy.$refs["popup"].open();
+  } else if (type == "CreateChat") {
+    if (state.chatHistory.list.length >= 10) {
+      return uni.showToast({ title: "您最多可同时建立10个对话,如需新建对话,请先删除会话!", icon: "none" });
+    }
+    state.chatHistory.sessionId = undefined;
+    state.answerList = [];
+    state.answerKeyword = undefined;
+  } else if ("Delete") {
+    uni.showModal({ title: "提示", content: "确定删除吗" });
+  } else if ("Update") {
+    uni.showToast({ title: "触发修改", icon: "none" });
+    getChatList();
+  }
+}
+
+function onClickEditTitle(item) {
+  state.chatHistory.list.map((item) => (item._edit = false));
+  item._edit = true;
+  nextTick(() => {});
+}
+
+function onClickDefaultQuestion(event, item) {
+  state.answerKeyword = item.value;
+  onSubmit(event);
+}
+
+/** 获取历史对话列表 */
+function getChatList() {
+  aiApi()
+    .recordSelect()
+    .then((response) => {
+      state.chatHistory.list = response.data;
+    });
+}
+
+/** 点击历史对话事件 */
+function onClickChatItem(item) {
+  state.scrollIntoView = "";
+  state.chatHistory.sessionId = item.sessionId;
+  state.answerList = item.itemList;
+
+  nextTick(() => {
+    state.scrollIntoView = "bottomInfo";
+  });
+}
+
+/** 当输入框行数发生变化 */
+function linechange(e) {
+  nextTick(() => {
+    const query = uni.createSelectorQuery();
+    query
+      .select(".footer-area")
+      .boundingClientRect((rect) => {
+        state.footerHeight = rect.height;
+      })
+      .exec();
+  });
+}
+
+async function onSubmit(event) {
+  if (event.keyCode === 13 || event.key === "Enter") {
+    event.preventDefault(); // 阻止默认行为,即回车换行
+    if (event.shiftKey) {
+      state.answerKeyword += "\n";
+    }
+  }
+  if (!state.answerKeyword) return uni.showToast({ title: "请输入内容", icon: "none" });
+  if (state.submitLoading) return;
+
+  state.submitLoading = true;
+  state.answerList.push({
+    role: "user",
+    content: state.answerKeyword,
+  });
+
+  proxy.$refs["chatSSEClientRef"].startChat({
+    // 将它换成你的地址
+    url: config.baseUrl + "/service-ai/ai/aliTyqw",
+    // 请求头
+    headers: {
+      Authorization: getToken(),
+      "Content-Type": "application/json; charset=utf-8",
+    },
+    // 默认为 post
+    method: "post",
+    body: {
+      sessionId: state.chatHistory.sessionId,
+      content: state.answerKeyword,
+    },
+  });
+}
+
+//数据处理
+function processSSEChunk(chunk) {
+  state.scrollIntoView = "";
+  try {
+    const jsonData = JSON.parse(chunk);
+
+    if (jsonData.sessionId && !state.chatHistory.sessionId) {
+      state.chatHistory.sessionId = jsonData.sessionId;
+    }
+    if (jsonData.reasoningContent) {
+      state.answerList[state.answerList.length - 1].reasoningContent += jsonData.reasoningContent;
+    } else if (jsonData.content) {
+      state.answerList[state.answerList.length - 1].content += jsonData.content;
+    }
+  } catch (error) {
+    console.log("parsing JSON错误", jsonString, index);
+    console.log("parsing JSON错误", error);
+  }
+
+  nextTick(() => {
+    state.scrollIntoView = "bottomInfo";
+  });
+}
+
+//开始回答回调事件
+function openCore() {
+  console.log("open sse");
+
+  state.answerKeyword = undefined;
+
+  // 定义一个函数来读取流数据
+  state.answerList.push({
+    role: "assistant",
+    content: "",
+    reasoningContent: "",
+  });
+}
+
+//回答异常回调事件
+function errorCore(err) {
+  console.log("error sse:", err);
+  state.answerKeyword = undefined;
+}
+
+//回答中回调事件
+function messageCore(response) {
+  processSSEChunk(response);
+}
+
+//回答完毕回调事件
+function finishCore() {
+  console.log("finish sse");
+  state.submitLoading = false;
+}
+
+//停止回答
+function stop() {
+  proxy.$refs["chatSSEClientRef"].stopChat();
+  console.log("stop");
+}
+
+onReady(() => {});
+
+onShow(() => {});
+
+onLoad((options) => {});
+
+onUnload(() => {});
+</script>
+
+<style lang="scss" scoped>
+.doorList-container {
+  background-color: #ffffff;
+}
+
+.center-area {
+  height: auto;
+  overflow: auto;
+
+  &-item {
+    display: flex;
+    color: #05073b;
+    padding: 15px;
+
+    &__avatar {
+      width: 26px;
+      margin-top: 2px;
+      margin-right: 10px;
+
+      uni-image {
+        border-radius: 50%;
+      }
+    }
+
+    &__content {
+      background: #f4f5f9;
+      border-radius: 8px;
+      box-shadow: 0 16px 20px 0 #f4f5f9;
+      display: flex;
+      flex-direction: column;
+      padding: 10px 15px;
+      position: relative;
+      font-size: 14px;
+      line-height: 1.75;
+      min-height: 44.5px;
+      box-sizing: border-box;
+      word-break: break-all; //文字默认换行
+    }
+  }
+
+  .roleUser {
+    justify-content: end;
+    margin-left: auto;
+
+    .center-area-item__avatar {
+      margin-right: 0;
+      margin-left: 10px;
+      order: 2;
+    }
+  }
+}
+
+.footer-area {
+  position: fixed;
+  bottom: 0;
+  width: 100%;
+  // height: 137px;
+  background-color: #ffffff;
+
+  &-top {
+    display: flex;
+    margin: 10px 20px 0 20px;
+    color: #7f7f7f;
+
+    &__record {
+      margin-left: auto;
+    }
+
+    &__new {
+      margin-left: 15px;
+    }
+  }
+
+  &-center {
+    display: flex;
+    width: calc(100% - 30px);
+    background-color: #f4f5f9;
+    margin: 10px 15px 10px 15px;
+    padding: 15px;
+    border-radius: 8px;
+
+    &-textarea {
+      width: 100%;
+      margin-right: 20px;
+    }
+
+    &-button {
+      width: 54px;
+      height: 33px;
+      display: flex;
+      justify-content: flex-end;
+      font-size: 13px;
+      white-space: nowrap;
+    }
+  }
+
+  &-prompt {
+    margin: 10px 0;
+    color: #adadad;
+    font-size: 20rpx;
+    text-align: center;
+  }
+}
+
+.chatArea {
+  width: 70vw;
+  overflow-y: auto;
+  height: 100vh;
+  padding: 15px;
+
+  .content {
+    &-title {
+      color: #8b8b8b;
+      margin-bottom: 5px;
+    }
+
+    &-item {
+      padding: 10px 5px;
+      border-radius: 5px;
+      letter-spacing: 1px;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis; /* 超出部分显示省略号 */
+      position: relative; /* 为伪元素定位 */
+
+      &:active {
+        background-color: #e5e5e5;
+      }
+
+      &:hover {
+        background-color: #e5e5e5;
+      }
+    }
+  }
+}
+</style>

+ 125 - 0
src/pages/business/ai/conv/record.vue

@@ -0,0 +1,125 @@
+<template>
+  <u-sticky class="shadow-default" bgColor="#fff" style="top: 0">
+    <u-navbar :titleStyle="{ color: '#000' }" :autoBack="true" title="U-SkyNexus" :placeholder="true" :safeAreaInsetTop="true" bgColor="#fff">
+      <template #left>
+        <view class="u-navbar__content__left__item">
+          <u-icon name="arrow-left" size="20" color="#000"></u-icon>
+        </view>
+      </template>
+    </u-navbar>
+  </u-sticky>
+
+  <oa-scroll
+    customClass="doorList-container scroll-height"
+    :pageSize="state.pageSize"
+    :total="state.total"
+    :isSticky="true"
+    :customStyle="{
+      //#ifdef APP-PLUS || MP-WEIXIN
+      height: `calc(100vh - (44px + ${proxy.$settingStore.StatusBarHeight}))`,
+      //#endif
+      //#ifdef H5
+      height: `calc(100vh - (44px))`,
+      //#endif
+    }"
+    :refresherLoad="true"
+    :refresherEnabled="true"
+    :refresherDefaultStyle="'none'"
+    :refresherThreshold="44"
+    :lowerThreshold="44"
+    :refresherBackground="'#f5f6f7'"
+    @load="load"
+    @refresh="refresh"
+    :data-theme="'theme-' + proxy.$settingStore.themeColor.name"
+  >
+    <template #default>
+      <u-loading-page :loading="state.loading" fontSize="16" style="z-index: 99"></u-loading-page>
+    </template>
+  </oa-scroll>
+</template>
+
+<script setup>
+/*----------------------------------依赖引入-----------------------------------*/
+import { onLoad, onShow, onReady, onHide, onLaunch, onUnload, onNavigationBarButtonTap, onPageScroll } from "@dcloudio/uni-app";
+import { ref, reactive, computed, getCurrentInstance, toRefs, inject } from "vue";
+/*----------------------------------接口引入-----------------------------------*/
+/*----------------------------------组件引入-----------------------------------*/
+/*----------------------------------store引入-----------------------------------*/
+import { useStores, commonStores, controlStores } from "@/store/modules/index";
+/*----------------------------------公共方法引入-----------------------------------*/
+/*----------------------------------公共变量-----------------------------------*/
+const { proxy } = getCurrentInstance();
+const useStore = useStores();
+const controlStore = controlStores();
+/*----------------------------------变量声明-----------------------------------*/
+const state = reactive({
+  loading: false,
+  dataList: [],
+  pageSize: 20,
+  current: 1,
+  total: 0,
+});
+
+const { tabsList, tabsCurrent, dataList } = toRefs(state);
+
+/**
+ * @初始化
+ */
+function init() {
+  state.loading = false;
+  //   doorApi()
+  //     .MyPage({
+  //       current: state.current, //页数
+  //       size: state.pageSize, //条数
+  //     })
+  //     .then((requset) => {
+  //       if (requset.data.records.length > 0) {
+  //         state.dataList = requset.data.records;
+  //         state.total = requset.data.total;
+  //         state.loading = false;
+  //       }
+  //     })
+  //     .catch((err) => {
+  //       state.loading = false;
+  //     });
+}
+
+/**
+ * @scrollView加载数据
+ */
+function load() {
+  state.pageSize += 10;
+  init();
+}
+
+/**
+ * @scrollView刷新数据
+ */
+function refresh() {
+  state.pageSize = 20;
+  init();
+}
+
+/**
+ * @tabs点击事件
+ */
+function tabsClick(e) {
+  state.tabsCurrent = e.index;
+  init();
+}
+
+onReady(() => {});
+
+onShow(() => {});
+
+onLoad((options) => {
+  init();
+});
+
+onUnload(() => {});
+</script>
+
+<style lang="scss" scoped>
+.content-area {
+}
+</style>

BIN
src/static/ai-avatar.png


BIN
src/static/ai-question-avatar.png


+ 119 - 66
src/utils/request.js

@@ -6,54 +6,7 @@ import common from "@/plugins/common.plugins";
 
 let timeout = 10000;
 
-/**
- * @校验结果方法
- * @param {数据集} res 
- * @param {结果抛出} resolve 
- * @param {控制台打印} reject 
- */
-function verification(res, resolve, reject) {
-  const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data;
-  const code = data.code || 200;
-
-  if (code === 401) {
-    useStores().LogOut().then(() => {
-      //#ifdef H5
-      uni.reLaunch({ url: "/pages/login" });
-      //#endif
-
-      //#ifdef APP-PLUS || MP-WEIXIN
-      uni.reLaunch({ url: "/pages/login" });
-      //#endif
-    });
-    modal.closeLoading();
-    reject("无效的会话,或者会话已过期,请重新登录。");
-  } else if (code === 404 || res.statusCode === 404) {
-    if (data.msg.indexOf(":") !== -1) {
-      modal.msg(data.msg.split(":")[1]);
-      reject(data.msg.split(":")[1]);
-    } else {
-      modal.msg(data.msg);
-      reject(data.msg);
-    }
-    modal.closeLoading();
-  } else if (code === 500 || res.statusCode === 500 || code === 'BIZ-0000') {
-    if (data.msg.indexOf(":") !== -1) {
-      modal.msg(data.msg.split(":")[1]);
-      reject(data.msg.split(":")[1]);
-    } else {
-      modal.msg(data.msg);
-      reject(data.msg);
-    }
-    modal.closeLoading();
-  } else if (code !== 200 && code !== "0") {
-    reject(code);
-  }
-  resolve(data);
-}
-
 const request = (config) => {
-
   // 是否需要设置 token
   const isToken = (config.headers || {}).isToken === false;
   config.header = config.header || {};
@@ -87,27 +40,10 @@ const request = (config) => {
         dataType: "json",
       })
       .then((res) => {
-        if (res.error) {
-          modal.msg("后端接口连接异常");
-          reject("后端接口连接异常");
-          return;
-        }
         verification(res, resolve, reject);//调用校验结果方法
       })
       .catch((error) => {
-        let { errMsg } = error;
-        if (!errMsg) return;
-        if (errMsg === "Network Error") {
-          errMsg = "后端接口连接异常";
-        } else if (errMsg.includes("timeout")) {
-          errMsg = "系统接口请求超时";
-        } else if (errMsg.includes("Request failed with status code")) {
-          errMsg = "系统接口" + errMsg.substr(errMsg.length - 3) + "异常";
-        }
-        if (errMsg != "request:fail") {
-          modal.msg(errMsg);
-        }
-        reject(error);
+        errorFonction(error, resolve, reject)
       });
   });
 };
@@ -156,7 +92,124 @@ const uploads = (config) => {
   });
 };
 
+const fetchs = (config) => {
+  // 是否需要设置 token
+  const isToken = (config.headers || {}).isToken === false;
+  config.headers = config.headers || {};
+  if (getToken() && !isToken) {
+    config.headers["Authorization"] = getToken();
+    config.headers['Content-Type'] = "application/json; charset=utf-8"
+  }
+
+  // get请求映射params参数
+  if (config.params) {
+    let url = config.url + "?" + common.tansParams(config.params);
+    url = url.slice(0, -1);
+    config.url = url;
+  }
+
+  // 外部接口get请求映射params参数
+  if (config.baseUrl) {
+    let url = config.url + "?" + common.tansParams(config.params);
+    url = url.slice(0, -1);
+    config.url = url;
+    config.baseUrl = config.baseUrl + url
+  }
+
+  return new Promise((resolve, reject) => {
+    fetch(config.baseUrl || configs.baseUrl + config.url, {
+      method: config.method || "get",
+      headers: config.headers,
+      body: JSON.stringify(config.data),
+    }).then((response) => {
+      console.log(2)
+      if (!response.ok) {
+        throw new Error("Network response was not ok");
+      }
+
+      resolve(response)
+    }).catch((error) => {
+      reject(error)
+    })
+  })
+};
+
+/**
+ * @校验结果方法
+ * @param {数据集} res 
+ * @param {结果抛出} resolve 
+ * @param {控制台打印} reject 
+ */
+function verification(res, resolve, reject) {
+  if (res.error) {
+    modal.msg("后端接口连接异常");
+    reject("后端接口连接异常");
+    return;
+  }
+
+  const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data;
+  const code = data.code || 200;
+
+  if (code === 401) {
+    useStores().LogOut().then(() => {
+      //#ifdef H5
+      uni.reLaunch({ url: "/pages/login" });
+      //#endif
+
+      //#ifdef APP-PLUS || MP-WEIXIN
+      uni.reLaunch({ url: "/pages/login" });
+      //#endif
+    });
+    modal.closeLoading();
+    reject("无效的会话,或者会话已过期,请重新登录。");
+  } else if (code === 404 || res.statusCode === 404) {
+    if (data.msg.indexOf(":") !== -1) {
+      modal.msg(data.msg.split(":")[1]);
+      reject(data.msg.split(":")[1]);
+    } else {
+      modal.msg(data.msg);
+      reject(data.msg);
+    }
+    modal.closeLoading();
+  } else if (code === 500 || res.statusCode === 500 || code === 'BIZ-0000') {
+    if (data.msg.indexOf(":") !== -1) {
+      modal.msg(data.msg.split(":")[1]);
+      reject(data.msg.split(":")[1]);
+    } else {
+      modal.msg(data.msg);
+      reject(data.msg);
+    }
+    modal.closeLoading();
+  } else if (code !== 200 && code !== "0") {
+    reject(code);
+  }
+  resolve(data);
+}
+
+/**
+ * @异常结果方法
+ * @param {返回集} error 
+ * @param {结果抛出} resolve 
+ * @param {控制台打印} reject 
+ */
+function errorFonction(error, resolve, reject) {
+  let { errMsg } = error;
+  if (!errMsg) return;
+  if (errMsg === "Network Error") {
+    errMsg = "后端接口连接异常";
+  } else if (errMsg.includes("timeout")) {
+    errMsg = "系统接口请求超时";
+  } else if (errMsg.includes("Request failed with status code")) {
+    errMsg = "系统接口" + errMsg.substr(errMsg.length - 3) + "异常";
+  }
+  if (errMsg != "request:fail") {
+    modal.msg(errMsg);
+  }
+  reject(error);
+}
+
 export {
   request,
-  uploads
+  uploads,
+  fetchs
 };