diff --git a/package.json b/package.json index 68fb388..cec67fa 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,14 @@ "zip": "powershell -Command \"$timestamp = (Get-Date).ToString('yyyyMMdd-HHmmss'); 7z a -tzip dist/shop-web-$timestamp.zip ./dist/* -xr'!*.zip'\"" }, "dependencies": { + "@microsoft/fetch-event-source": "2.0.1", "@vant/touch-emulator": "1.4.0", "axios": "1.7.9", "compressorjs": "1.2.1", "dayjs": "1.11.13", "js-cookie": "3.0.5", "lodash-es": "4.17.21", + "marked": "17.0.1", "normalize.css": "8.0.1", "pinia": "3.0.1", "unocss": "66.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 836ac3e..bb1fc92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@microsoft/fetch-event-source': + specifier: 2.0.1 + version: 2.0.1 '@vant/touch-emulator': specifier: 1.4.0 version: 1.4.0 @@ -26,6 +29,9 @@ importers: lodash-es: specifier: 4.17.21 version: 4.17.21 + marked: + specifier: 17.0.1 + version: 17.0.1 normalize.css: specifier: 8.0.1 version: 8.0.1 @@ -1242,6 +1248,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@microsoft/fetch-event-source@2.0.1': + resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2763,6 +2772,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4845,6 +4859,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@microsoft/fetch-event-source@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -6580,6 +6596,8 @@ snapshots: markdown-table@3.0.4: {} + marked@17.0.1: {} + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: diff --git a/src/common/apis/agent/index.ts b/src/common/apis/agent/index.ts new file mode 100644 index 0000000..fa5884c --- /dev/null +++ b/src/common/apis/agent/index.ts @@ -0,0 +1,121 @@ +const AGENT_BASE_URL = 'http://115.190.18.84:7328/api/agent'; +// const AGENT_BASE_URL = 'http://localhost:7328/api/agent'; + +import { useWxStore } from '@/pinia/stores/wx'; +import { fetchEventSource } from '@microsoft/fetch-event-source'; + +export const baseInfoApi = () => { + return fetch(`${AGENT_BASE_URL}`, { + method: 'GET', + }); +} + +/** + * 流式对话接口,返回流式 JSON 数据,形如 {"text":"我来"} + * @param message 用户消息 + * @param callbacks 回调函数对象 + * @param options 可选配置,包含 abort signal + * @example + * // 使用示例: + * streamApi('你好', { + * onopen(response) { + * console.log('连接已建立', response); + * }, + * onmessage(data) { + * // data 是服务器发送的原始字符串,如 '{"text":"我来"}' + * console.log('收到消息:', data); + * }, + * onclose() { + * console.log('连接已关闭'); + * }, + * onerror(error) { + * console.error('连接错误:', error); + * } + * }, { signal: abortController.signal }); + */ +export const streamApi = ( + message: string, + callbacks: { + onopen?: (response: Response) => void; + onmessage?: (data: string) => void; + onclose?: () => void; + onerror?: (error: Error) => void; + }, + options?: { signal?: AbortSignal; thread?: string; resource?: string } +) => { + const wxStore = useWxStore(); + const thread = options?.thread; + const resource = options?.resource; + + const requestBody: Record = { + message: `企业微信id:${wxStore.corpid} + ${message}`, + options: { + temperature: 0.8, + /* thinking: { + type: 'enabled' + } */ + } + }; + + if (thread && resource) { + requestBody.options.memory = { + thread, + resource + }; + } + + fetchEventSource(`${AGENT_BASE_URL}/stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + signal: options?.signal, + async onopen(response) { + // 检查响应是否正常 + if (response.ok && response.headers.get('content-type')?.includes('text/event-stream')) { + // 连接成功,调用 onopen 回调 + callbacks.onopen?.(response); + } else { + // 如果响应不正常,抛出错误 + throw new Error(`SSE 连接失败: ${response.status} ${response.statusText}`); + } + }, + onmessage(msg) { + // 处理服务器发送的消息 + // msg.data 应该是 JSON 字符串,如 {"text":"..."} + try { + const data = msg.data; + console.log('Received message:', data); + if (data && data !== '[DONE]') { + callbacks.onmessage?.(data); + } + } catch (error) { + console.warn('处理 SSE 消息时出错:', error); + } + }, + onclose() { + // 连接关闭 + console.log('SSE 连接已关闭'); + callbacks.onclose?.(); + }, + onerror(err) { + // 发生错误 + console.error('SSE 连接错误:', err); + callbacks.onerror?.(err instanceof Error ? err : new Error(String(err))); + + // 重新抛出错误以停止重试 + throw err; + }, + }).catch(error => { + // 捕获 fetchEventSource 的 Promise 拒绝 + if (error.name !== 'AbortError') { + console.error('fetchEventSource 错误:', error); + callbacks.onerror?.(error instanceof Error ? error : new Error(String(error))); + } else { + // AbortError 是正常的取消操作,不需要调用 onerror + console.log('请求已被取消'); + } + }); +} \ No newline at end of file diff --git a/src/common/types/chat.ts b/src/common/types/chat.ts new file mode 100644 index 0000000..7939c0c --- /dev/null +++ b/src/common/types/chat.ts @@ -0,0 +1,15 @@ +/** 消息角色 */ +export type MessageRole = "user" | "assistant" | "system" + +/** 消息状态 */ +export type MessageStatus = "pending" | "sending" | "sent" | "error" + +/** 消息对象 */ +export interface ChatMessage { + id: string + role: MessageRole + content: string + timestamp: number + status: MessageStatus + extra?: Record +} diff --git a/src/common/utils/storage.ts b/src/common/utils/storage.ts new file mode 100644 index 0000000..2981583 --- /dev/null +++ b/src/common/utils/storage.ts @@ -0,0 +1,76 @@ +import type { ChatMessage } from "@/common/types/chat" + +const CHAT_HISTORY_KEY = "chat_history"; +const CHAT_RESOURCE_KEY = "chat_resource"; +const CHAT_THREAD_KEY = "chat_thread"; + +export const chatStorage = { + save(messages: ChatMessage[]) { + try { + localStorage.setItem(CHAT_HISTORY_KEY, JSON.stringify(messages)) + } + catch (e) { + console.error("保存对话历史失败", e) + } + }, + + load(): ChatMessage[] { + try { + const data = localStorage.getItem(CHAT_HISTORY_KEY) + return data ? JSON.parse(data) : [] + } + catch (e) { + console.error("加载对话历史失败", e) + return [] + } + }, + + clear() { + localStorage.removeItem(CHAT_HISTORY_KEY) + localStorage.removeItem(CHAT_THREAD_KEY) + }, + + getResource(): string { + let resource = localStorage.getItem(CHAT_RESOURCE_KEY) + if (!resource) { + resource = `user-${Math.random().toString(36).substring(2, 11)}` + localStorage.setItem(CHAT_RESOURCE_KEY, resource) + } + return resource + }, + + getThread(): string { + let thread = localStorage.getItem(CHAT_THREAD_KEY) + if (!thread) { + thread = `t-${Date.now()}` + localStorage.setItem(CHAT_THREAD_KEY, thread) + } + return thread + }, + + clearThread() { + localStorage.removeItem(CHAT_THREAD_KEY) + } +} + +export const intro = ` +您好!我是您的智能助手,很高兴为您服务!😊 +我可以帮您做以下事情: +**1. 查询商品信息** +- 查看商品列表或单个商品详情 +- 按价格、分类、状态等条件筛选商品 +- 支持模糊搜索商品名称 +**2. 查询门店和智能柜** +- 查看您企业下的所有门店列表 +- 查询门店的运行模式(支付、审批、借还、会员、耗材、暂存等) +- 查看智能柜的详细信息 +**3. 查询借还记录** +- 查看商品的借出和归还记录 +- 按商品、格口、状态等条件筛选动态记录 +**使用示例:** +- \"帮我查一下所有商品\" +- \"查看XX门店的智能柜信息\" +- \"查询最近借出的商品记录\" +- \"价格在100-500元之间的商品有哪些\" +有什么我可以帮您的吗?随时告诉我!✨ +` \ No newline at end of file diff --git a/src/layout/components/Tabbar.vue b/src/layout/components/Tabbar.vue index 1518631..3ec3b98 100644 --- a/src/layout/components/Tabbar.vue +++ b/src/layout/components/Tabbar.vue @@ -21,6 +21,12 @@ const tabbarItemList = computed(() => { icon: 'manager-o', path: '/cabinet' }); + + items.push({ + title: '智能助手', + icon: 'chat-o', + path: '/chat' + }); } if (names.includes('ShopGoods')) { diff --git a/src/router/index.ts b/src/router/index.ts index ccb0e0f..f8509cb 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -316,6 +316,25 @@ export const routes: RouteRecordRaw[] = [ meta: { title: "登录" } + }, + { + path: "/chat", + component: () => import("@/views/chat/index.vue"), + name: "Chat", + meta: { + title: "智能助手", + layout: { + navBar: { + showNavBar: false, + showLeftArrow: true + }, + tabbar: { + showTabbar: true, + icon: "user-o" + } + }, + requiresAuth: true + } } ] diff --git a/src/views/chat/components/ChatBubble.vue b/src/views/chat/components/ChatBubble.vue new file mode 100644 index 0000000..0b4602b --- /dev/null +++ b/src/views/chat/components/ChatBubble.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/src/views/chat/index.vue b/src/views/chat/index.vue new file mode 100644 index 0000000..df1d8bb --- /dev/null +++ b/src/views/chat/index.vue @@ -0,0 +1,305 @@ + + + + +