add
This commit is contained in:
parent
ee481a8b05
commit
582c29d50c
|
|
@ -11,6 +11,7 @@
|
|||
"ant-design-vue": "^4.0.8",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.511.0",
|
||||
"marked": "^17.0.1",
|
||||
"pinia": "^2.1.7",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"vue": "^3.4.15",
|
||||
|
|
@ -3827,6 +3828,18 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "17.0.1",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
|
||||
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"ant-design-vue": "^4.0.8",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.511.0",
|
||||
"marked": "^17.0.1",
|
||||
"pinia": "^2.1.7",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"vue": "^3.4.15",
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ html, body {
|
|||
|
||||
#app {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 字体大小类 */
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ export class MockAPIService {
|
|||
}
|
||||
|
||||
// 模拟聊天流式响应
|
||||
async *chatStream(message: string, conversationId?: string): AsyncGenerator<SSEResponse> {
|
||||
async *chatStream(message: string, conversationId?: string, tag?: 'order' | 'product'): AsyncGenerator<SSEResponse> {
|
||||
const sessionId = generateSessionId();
|
||||
|
||||
try {
|
||||
|
|
@ -136,7 +136,8 @@ export class MockAPIService {
|
|||
await new Promise(resolve => setTimeout(resolve, randomDelay(300, 800)));
|
||||
|
||||
// 2. 判断是否需要显示订单信息
|
||||
const shouldShowOrder = message.includes('订单') || message.includes('查询') || message.includes('购买');
|
||||
const shouldShowOrder = tag === 'order' || message.includes('订单') || message.includes('查询') || message.includes('购买');
|
||||
const isProductQuery = tag === 'product' || message.includes('商品') || message.includes('价格') || message.includes('库存');
|
||||
|
||||
if (shouldShowOrder) {
|
||||
// 显示订单相关回复
|
||||
|
|
@ -158,6 +159,9 @@ export class MockAPIService {
|
|||
},
|
||||
randomOrder
|
||||
);
|
||||
} else if (isProductQuery) {
|
||||
const response = '我来为您查询商品信息与库存情况...';
|
||||
yield* this.generateStreamResponse(sessionId, response, 'general_chat_stream');
|
||||
} else {
|
||||
// 普通回复
|
||||
const response = mockResponses[Math.floor(Math.random() * mockResponses.length)];
|
||||
|
|
|
|||
|
|
@ -14,14 +14,14 @@ export class DevServer {
|
|||
|
||||
// 处理聊天请求
|
||||
async handleChatRequest(request: ChatRequest): Promise<Response> {
|
||||
const { message, conversation_id } = request;
|
||||
const { message, conversation_id, tag } = request;
|
||||
|
||||
// 创建可读流
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
// 使用 Mock API 生成流式响应
|
||||
for await (const response of mockAPI.chatStream(message, conversation_id)) {
|
||||
for await (const response of mockAPI.chatStream(message, conversation_id, tag)) {
|
||||
// 格式化为 SSE 格式
|
||||
const sseData = `data: ${JSON.stringify(response)}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(sseData));
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@
|
|||
v-for="action in quickActions"
|
||||
:key="action.text"
|
||||
class="quick-action-btn"
|
||||
@click="selectQuickAction(action.text)"
|
||||
@click="selectQuickAction(action)"
|
||||
>
|
||||
{{ action.text }}
|
||||
</button>
|
||||
|
|
@ -63,6 +63,7 @@ import { ref, computed, nextTick, onMounted } from 'vue';
|
|||
interface QuickAction {
|
||||
text: string;
|
||||
value?: string;
|
||||
tag?: 'order' | 'product';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
|
@ -75,7 +76,7 @@ interface Props {
|
|||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'send', message: string): void;
|
||||
(e: 'send', message: string, options?: { tag?: 'order' | 'product' }): void;
|
||||
(e: 'input', value: string): void;
|
||||
}
|
||||
|
||||
|
|
@ -85,10 +86,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
isLoading: false,
|
||||
showQuickActions: true,
|
||||
quickActions: () => [
|
||||
{ text: '查询订单' },
|
||||
{ text: '退换货' },
|
||||
{ text: '物流信息' },
|
||||
{ text: '联系人工客服' }
|
||||
{ text: '订单诊断', tag: 'order' },
|
||||
{ text: '商品查询', tag: 'product' }
|
||||
],
|
||||
maxLength: 1000
|
||||
});
|
||||
|
|
@ -160,20 +159,18 @@ const handleSend = () => {
|
|||
|
||||
const message = inputText.value.trim();
|
||||
if (message) {
|
||||
emit('send', message);
|
||||
emit('send', message, currentTag.value ? { tag: currentTag.value } : undefined);
|
||||
inputText.value = '';
|
||||
currentTag.value = undefined;
|
||||
adjustHeight();
|
||||
}
|
||||
};
|
||||
|
||||
// 选择快捷操作
|
||||
const selectQuickAction = (text: string) => {
|
||||
inputText.value = text;
|
||||
adjustHeight();
|
||||
// 自动发送快捷操作
|
||||
nextTick(() => {
|
||||
handleSend();
|
||||
});
|
||||
const currentTag = ref<'order' | 'product' | undefined>(undefined);
|
||||
|
||||
const selectQuickAction = (action: QuickAction) => {
|
||||
currentTag.value = action.tag;
|
||||
};
|
||||
|
||||
// 聚焦输入框
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@
|
|||
</div>
|
||||
<div class="ai-content">
|
||||
<div class="message-bubble ai-bubble">
|
||||
<div class="typing-text" v-if="isTyping">
|
||||
{{ displayContent }}
|
||||
<div v-if="isTyping" class="typing-text">
|
||||
<div class="markdown" v-html="rendered"></div>
|
||||
<span class="cursor">|</span>
|
||||
</div>
|
||||
<div v-else>{{ message.content }}</div>
|
||||
<div v-else class="markdown" v-html="rendered"></div>
|
||||
</div>
|
||||
<div class="message-time">
|
||||
{{ formatTime(message.timestamp) }}
|
||||
|
|
@ -58,6 +58,10 @@
|
|||
v-if="message.component?.type === 'order_card'"
|
||||
:order-data="message.component.data"
|
||||
/>
|
||||
<SimpleOrderCard
|
||||
v-else-if="(message as any).component?.order"
|
||||
:order="(message as any).component.order"
|
||||
/>
|
||||
</div>
|
||||
<div class="message-time">
|
||||
{{ formatTime(message.timestamp) }}
|
||||
|
|
@ -70,8 +74,10 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import type { Message } from '@/types';
|
||||
import OrderCard from './OrderCard.vue';
|
||||
import SimpleOrderCard from './SimpleOrderCard.vue';
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
|
|
@ -84,6 +90,9 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
|
||||
// 显示内容(用于打字机效果)
|
||||
const displayContent = ref('');
|
||||
const rendered = ref('');
|
||||
|
||||
const sanitizeMd = (s: string) => (s || '').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// 消息样式类
|
||||
const messageClass = computed(() => ({
|
||||
|
|
@ -126,13 +135,13 @@ const formatTime = (timestamp: number): string => {
|
|||
|
||||
// 打字机效果
|
||||
watch(() => props.message.content, (newContent) => {
|
||||
if (props.isTyping && props.message.type === 'ai') {
|
||||
displayContent.value = newContent;
|
||||
}
|
||||
rendered.value = marked.parse(sanitizeMd(newContent || ''));
|
||||
}, { immediate: true });
|
||||
|
||||
onMounted(() => {
|
||||
displayContent.value = props.message.content;
|
||||
rendered.value = marked.parse(sanitizeMd(displayContent.value || ''));
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -179,6 +188,16 @@ onMounted(() => {
|
|||
@apply bg-gray-100 text-gray-800 px-4 py-2 rounded-2xl rounded-bl-md max-w-xs md:max-w-md break-words;
|
||||
}
|
||||
|
||||
.markdown :is(h1,h2,h3,h4,h5,h6) { @apply font-semibold text-gray-900 }
|
||||
.markdown p { @apply text-gray-800 leading-6 }
|
||||
.markdown code { @apply bg-gray-200 rounded px-1 py-0.5 text-sm }
|
||||
.markdown pre { @apply bg-gray-200 rounded p-3 overflow-auto text-sm }
|
||||
.markdown ul { @apply list-disc pl-5 }
|
||||
.markdown ol { @apply list-decimal pl-5 }
|
||||
.dark .markdown :is(h1,h2,h3,h4,h5,h6) { @apply text-gray-100 }
|
||||
.dark .markdown p { @apply text-gray-100 }
|
||||
.dark .markdown code, .dark .markdown pre { @apply bg-gray-700 text-gray-100 }
|
||||
|
||||
/* 思考过程样式 */
|
||||
.thinking-message {
|
||||
@apply flex items-start space-x-3 opacity-70;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<div class="simple-order-card">
|
||||
<div class="row">
|
||||
<span class="label">订单号</span>
|
||||
<span class="value">{{ order.id }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">商品</span>
|
||||
<span class="value">{{ order.product }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">数量</span>
|
||||
<span class="value">{{ order.amount }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">状态</span>
|
||||
<span class="value" :class="statusClass">{{ order.status }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">创建时间</span>
|
||||
<span class="value">{{ order.create_time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { WSOrder } from '@/types'
|
||||
|
||||
interface Props {
|
||||
order: WSOrder
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const statusClass = computed(() => {
|
||||
const s = props.order.status
|
||||
return {
|
||||
'status-failed': s === 'failed',
|
||||
'status-success': s === 'success' || s === 'completed',
|
||||
'status-pending': s === 'pending'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.simple-order-card {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: 6px 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.row { display: contents }
|
||||
.label { color: #6b7280; font-size: 12px }
|
||||
.value { color: #111827; font-size: 14px }
|
||||
.status-failed { color: #b91c1c }
|
||||
.status-success { color: #16a34a }
|
||||
.status-pending { color: #ca8a04 }
|
||||
.dark .simple-order-card { background: #1f2937; border-color: #374151 }
|
||||
.dark .label { color: #9ca3af }
|
||||
.dark .value { color: #e5e7eb }
|
||||
</style>
|
||||
|
|
@ -9,37 +9,7 @@
|
|||
{{ connectionStatusText }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="clearChat"
|
||||
:disabled="!hasMessages"
|
||||
title="清空对话"
|
||||
>
|
||||
<svg class="action-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1-1H8a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="goToHistory"
|
||||
title="历史对话"
|
||||
>
|
||||
<svg class="action-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="goToSettings"
|
||||
title="设置"
|
||||
>
|
||||
<svg class="action-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="header-actions"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -89,16 +59,13 @@
|
|||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { useHistoryStore } from '@/stores/history';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { setupMockAPI } from '@/api/server';
|
||||
import MessageList from '@/components/MessageList.vue';
|
||||
import ChatInput from '@/components/ChatInput.vue';
|
||||
|
||||
// 路由和状态管理
|
||||
const router = useRouter();
|
||||
const chatStore = useChatStore();
|
||||
const historyStore = useHistoryStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// 组件引用
|
||||
|
|
@ -128,7 +95,7 @@ const connectionStatusText = computed(() => {
|
|||
});
|
||||
|
||||
// 发送消息
|
||||
const handleSendMessage = async (message: string) => {
|
||||
const handleSendMessage = async (message: string, options?: { tag?: 'order' | 'product' }) => {
|
||||
try {
|
||||
clearError();
|
||||
|
||||
|
|
@ -138,12 +105,9 @@ const handleSendMessage = async (message: string) => {
|
|||
}
|
||||
|
||||
// 发送消息
|
||||
await chatStore.sendMessage(message);
|
||||
await chatStore.sendMessage(message, options);
|
||||
|
||||
|
||||
// 保存对话到历史记录
|
||||
if (chatStore.currentConversation) {
|
||||
historyStore.saveConversation(chatStore.currentConversation);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
|
|
@ -162,14 +126,10 @@ const clearChat = () => {
|
|||
};
|
||||
|
||||
// 导航到历史页面
|
||||
const goToHistory = () => {
|
||||
router.push('/history');
|
||||
};
|
||||
const goToHistory = () => {};
|
||||
|
||||
// 导航到设置页面
|
||||
const goToSettings = () => {
|
||||
router.push('/settings');
|
||||
};
|
||||
const goToSettings = () => {};
|
||||
|
||||
// 显示错误信息
|
||||
const showError = (message: string) => {
|
||||
|
|
@ -224,21 +184,19 @@ const handleKeydown = (event: KeyboardEvent) => {
|
|||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 设置Mock API
|
||||
setupMockAPI();
|
||||
|
||||
// 加载设置
|
||||
settingsStore.loadSettings();
|
||||
settingsStore.initializeTheme();
|
||||
|
||||
// 加载历史对话
|
||||
historyStore.loadConversations();
|
||||
|
||||
// 如果没有当前对话,创建新对话
|
||||
if (!chatStore.currentConversation) {
|
||||
chatStore.createNewConversation();
|
||||
}
|
||||
|
||||
// 页面初始化建立 WebSocket 连接
|
||||
chatStore.connectWS();
|
||||
|
||||
// 聚焦输入框
|
||||
chatInputRef.value?.focus();
|
||||
|
||||
|
|
@ -304,7 +262,7 @@ onUnmounted(() => {
|
|||
|
||||
/* 主体区域 */
|
||||
.chat-main {
|
||||
@apply flex-1 overflow-hidden;
|
||||
@apply flex-1 overflow-y-auto;
|
||||
}
|
||||
|
||||
/* 底部输入区域 */
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||
import ChatPage from '@/pages/ChatPage.vue';
|
||||
import HistoryPage from '@/pages/HistoryPage.vue';
|
||||
import SettingsPage from '@/pages/SettingsPage.vue';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
|
|
@ -12,22 +10,7 @@ const routes: RouteRecordRaw[] = [
|
|||
title: 'AI客服对话'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/history',
|
||||
name: 'History',
|
||||
component: HistoryPage,
|
||||
meta: {
|
||||
title: '历史对话'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
component: SettingsPage,
|
||||
meta: {
|
||||
title: '设置'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/'
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||
const isLoading = ref(false);
|
||||
const isConnected = ref(false);
|
||||
const currentSessionId = ref<string>('');
|
||||
const wsRef = ref<WebSocket | null>(null);
|
||||
|
||||
// 计算属性
|
||||
const hasMessages = computed(() => messages.value.length > 0);
|
||||
|
|
@ -71,6 +72,17 @@ export const useChatStore = defineStore('chat', () => {
|
|||
}
|
||||
};
|
||||
|
||||
const appendToLastMessage = (chunk: string, isComplete = false) => {
|
||||
if (messages.value.length > 0) {
|
||||
const lastMsg = messages.value[messages.value.length - 1];
|
||||
lastMsg.content = (lastMsg.content || '') + (chunk || '');
|
||||
if (currentConversation.value) {
|
||||
currentConversation.value.messages = [...messages.value];
|
||||
currentConversation.value.updated_at = Date.now();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理SSE响应
|
||||
const handleSSEResponse = (response: SSEResponse) => {
|
||||
const { type, payload } = response;
|
||||
|
|
@ -86,30 +98,29 @@ export const useChatStore = defineStore('chat', () => {
|
|||
// 根据数据类型处理不同的消息
|
||||
switch (data_type) {
|
||||
case 'general_chat_stream':
|
||||
if (content.chunk) {
|
||||
// 如果是新的AI回复,创建新消息
|
||||
if (content.chunk || content.full_message) {
|
||||
if (messages.value.length === 0 || messages.value[messages.value.length - 1].type !== 'ai') {
|
||||
addMessage({
|
||||
type: 'ai',
|
||||
content: content.chunk
|
||||
});
|
||||
addMessage({ type: 'ai', content: content.full_message || content.chunk });
|
||||
} else {
|
||||
// 更新现有消息
|
||||
updateLastMessage(content.full_message || content.chunk, content.is_final);
|
||||
if (content.full_message) {
|
||||
updateLastMessage(content.full_message, content.is_final);
|
||||
} else if (content.chunk) {
|
||||
appendToLastMessage(content.chunk, content.is_final);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'thinking_process':
|
||||
if (content.chunk) {
|
||||
// 思考过程消息
|
||||
if (content.chunk || content.full_message) {
|
||||
if (messages.value.length === 0 || messages.value[messages.value.length - 1].type !== 'thinking') {
|
||||
addMessage({
|
||||
type: 'thinking',
|
||||
content: content.chunk
|
||||
});
|
||||
addMessage({ type: 'thinking', content: content.full_message || content.chunk });
|
||||
} else {
|
||||
updateLastMessage(content.full_message || content.chunk, content.is_final);
|
||||
if (content.full_message) {
|
||||
updateLastMessage(content.full_message, content.is_final);
|
||||
} else if (content.chunk) {
|
||||
appendToLastMessage(content.chunk, content.is_final);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
|
@ -131,7 +142,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||
};
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = async (content: string) => {
|
||||
const sendMessage = async (content: string, options?: { tag?: 'order' | 'product' }) => {
|
||||
if (!content.trim()) return;
|
||||
|
||||
// 如果没有当前对话,创建新对话
|
||||
|
|
@ -148,61 +159,69 @@ export const useChatStore = defineStore('chat', () => {
|
|||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
// 调用Mock API(这里先用简单的模拟)
|
||||
const response = await fetch('/api/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: content,
|
||||
conversation_id: currentConversation.value?.id
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
|
||||
// 处理SSE流
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (reader) {
|
||||
if (wsRef.value && wsRef.value.readyState === WebSocket.OPEN) {
|
||||
const payload: any = { message: content.trim(), session_id: 'default' };
|
||||
if (options?.tag) payload.tag = options.tag;
|
||||
wsRef.value.send(JSON.stringify(payload));
|
||||
} else {
|
||||
const WS_URL = 'ws://127.0.0.1:8302/api/chat/ws';
|
||||
const ws = new WebSocket(WS_URL);
|
||||
wsRef.value = ws;
|
||||
ws.onopen = () => {
|
||||
isConnected.value = true;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const payload: any = { message: content.trim(), session_id: 'default' };
|
||||
if (options?.tag) payload.tag = options.tag;
|
||||
ws.send(JSON.stringify(payload));
|
||||
};
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
const raw = typeof event.data === 'string' ? event.data : '';
|
||||
const data: SSEResponse = JSON.parse(raw);
|
||||
handleSSEResponse(data);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE data:', e);
|
||||
console.error('WS message parse error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isConnected.value = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
};
|
||||
ws.onerror = () => {
|
||||
isLoading.value = false;
|
||||
isConnected.value = false;
|
||||
|
||||
// 添加错误消息
|
||||
addMessage({
|
||||
type: 'ai',
|
||||
content: '抱歉,发送消息时出现错误,请稍后重试。'
|
||||
});
|
||||
addMessage({ type: 'ai', content: '抱歉,连接后端失败,请稍后重试。' });
|
||||
};
|
||||
ws.onclose = () => {
|
||||
wsRef.value = null;
|
||||
isConnected.value = false;
|
||||
isLoading.value = false;
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
isLoading.value = false;
|
||||
isConnected.value = false;
|
||||
addMessage({ type: 'ai', content: '抱歉,建立连接失败,请稍后重试。' });
|
||||
}
|
||||
};
|
||||
|
||||
const connectWS = () => {
|
||||
if (wsRef.value && wsRef.value.readyState === WebSocket.OPEN) return;
|
||||
const WS_URL = 'ws://127.0.0.1:8302/api/chat/ws';
|
||||
const ws = new WebSocket(WS_URL);
|
||||
wsRef.value = ws;
|
||||
ws.onopen = () => {
|
||||
isConnected.value = true;
|
||||
};
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const raw = typeof event.data === 'string' ? event.data : '';
|
||||
const data: SSEResponse = JSON.parse(raw);
|
||||
handleSSEResponse(data);
|
||||
} catch {}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
isConnected.value = false;
|
||||
};
|
||||
ws.onclose = () => {
|
||||
wsRef.value = null;
|
||||
isConnected.value = false;
|
||||
};
|
||||
};
|
||||
|
||||
// 清空当前对话
|
||||
|
|
@ -237,6 +256,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||
updateLastMessage,
|
||||
handleSSEResponse,
|
||||
sendMessage,
|
||||
connectWS,
|
||||
clearCurrentConversation,
|
||||
loadConversation
|
||||
};
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export interface SSEResponse {
|
|||
full_message: string;
|
||||
is_final: boolean;
|
||||
};
|
||||
component?: OrderCardData;
|
||||
component?: OrderCardData | { order: WSOrder };
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -59,6 +59,7 @@ export interface SSEErrorResponse {
|
|||
export interface ChatRequest {
|
||||
message: string;
|
||||
conversation_id?: string;
|
||||
tag?: 'order' | 'product';
|
||||
}
|
||||
|
||||
// LocalStorage 键值结构
|
||||
|
|
@ -78,3 +79,11 @@ export interface Settings {
|
|||
fontSize: 'small' | 'medium' | 'large';
|
||||
autoSave: boolean;
|
||||
}
|
||||
|
||||
export interface WSOrder {
|
||||
id: string;
|
||||
product: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
create_time: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
// vite.config.ts
|
||||
import { defineConfig } from "file:///mnt/e/code/courseware/vue3-frontend/node_modules/vite/dist/node/index.js";
|
||||
import vue from "file:///mnt/e/code/courseware/vue3-frontend/node_modules/@vitejs/plugin-vue/dist/index.mjs";
|
||||
import { resolve } from "path";
|
||||
import Inspector from "file:///mnt/e/code/courseware/vue3-frontend/node_modules/unplugin-vue-dev-locator/dist/vite.mjs";
|
||||
import traeBadgePlugin from "file:///mnt/e/code/courseware/vue3-frontend/node_modules/vite-plugin-trae-solo-badge/dist/vite-plugin.esm.js";
|
||||
var __vite_injected_original_dirname = "/mnt/e/code/courseware/vue3-frontend";
|
||||
var vite_config_default = defineConfig({
|
||||
build: {
|
||||
sourcemap: "hidden"
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
Inspector(),
|
||||
traeBadgePlugin({
|
||||
variant: "dark",
|
||||
position: "bottom-right",
|
||||
prodOnly: true,
|
||||
clickable: true,
|
||||
clickUrl: "https://www.trae.ai/solo?showJoin=1",
|
||||
autoTheme: true,
|
||||
autoThemeTarget: "#app"
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__vite_injected_original_dirname, "src")
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3e3,
|
||||
open: true
|
||||
}
|
||||
});
|
||||
export {
|
||||
vite_config_default as default
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvbW50L2UvY29kZS9jb3Vyc2V3YXJlL3Z1ZTMtZnJvbnRlbmRcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9tbnQvZS9jb2RlL2NvdXJzZXdhcmUvdnVlMy1mcm9udGVuZC92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vbW50L2UvY29kZS9jb3Vyc2V3YXJlL3Z1ZTMtZnJvbnRlbmQvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlJ1xuaW1wb3J0IHZ1ZSBmcm9tICdAdml0ZWpzL3BsdWdpbi12dWUnXG5pbXBvcnQgeyByZXNvbHZlIH0gZnJvbSAncGF0aCdcbmltcG9ydCBJbnNwZWN0b3IgZnJvbSAndW5wbHVnaW4tdnVlLWRldi1sb2NhdG9yL3ZpdGUnXG5pbXBvcnQgdHJhZUJhZGdlUGx1Z2luIGZyb20gJ3ZpdGUtcGx1Z2luLXRyYWUtc29sby1iYWRnZSdcblxuLy8gaHR0cHM6Ly92aXRlLmRldi9jb25maWcvXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBidWlsZDoge1xuICAgIHNvdXJjZW1hcDogJ2hpZGRlbicsXG4gIH0sXG4gIHBsdWdpbnM6IFtcbiAgICB2dWUoKSxcbiAgICBJbnNwZWN0b3IoKSxcbiAgICB0cmFlQmFkZ2VQbHVnaW4oe1xuICAgICAgdmFyaWFudDogJ2RhcmsnLFxuICAgICAgcG9zaXRpb246ICdib3R0b20tcmlnaHQnLFxuICAgICAgcHJvZE9ubHk6IHRydWUsXG4gICAgICBjbGlja2FibGU6IHRydWUsXG4gICAgICBjbGlja1VybDogJ2h0dHBzOi8vd3d3LnRyYWUuYWkvc29sbz9zaG93Sm9pbj0xJyxcbiAgICAgIGF1dG9UaGVtZTogdHJ1ZSxcbiAgICAgIGF1dG9UaGVtZVRhcmdldDogJyNhcHAnLFxuICAgIH0pLFxuICBdLFxuICByZXNvbHZlOiB7XG4gICAgYWxpYXM6IHtcbiAgICAgICdAJzogcmVzb2x2ZShfX2Rpcm5hbWUsICdzcmMnKVxuICAgIH1cbiAgfSxcbiAgc2VydmVyOiB7XG4gICAgcG9ydDogMzAwMCxcbiAgICBvcGVuOiB0cnVlXG4gIH1cbn0pXG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQThSLFNBQVMsb0JBQW9CO0FBQzNULE9BQU8sU0FBUztBQUNoQixTQUFTLGVBQWU7QUFDeEIsT0FBTyxlQUFlO0FBQ3RCLE9BQU8scUJBQXFCO0FBSjVCLElBQU0sbUNBQW1DO0FBT3pDLElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLE9BQU87QUFBQSxJQUNMLFdBQVc7QUFBQSxFQUNiO0FBQUEsRUFDQSxTQUFTO0FBQUEsSUFDUCxJQUFJO0FBQUEsSUFDSixVQUFVO0FBQUEsSUFDVixnQkFBZ0I7QUFBQSxNQUNkLFNBQVM7QUFBQSxNQUNULFVBQVU7QUFBQSxNQUNWLFVBQVU7QUFBQSxNQUNWLFdBQVc7QUFBQSxNQUNYLFVBQVU7QUFBQSxNQUNWLFdBQVc7QUFBQSxNQUNYLGlCQUFpQjtBQUFBLElBQ25CLENBQUM7QUFBQSxFQUNIO0FBQUEsRUFDQSxTQUFTO0FBQUEsSUFDUCxPQUFPO0FBQUEsTUFDTCxLQUFLLFFBQVEsa0NBQVcsS0FBSztBQUFBLElBQy9CO0FBQUEsRUFDRjtBQUFBLEVBQ0EsUUFBUTtBQUFBLElBQ04sTUFBTTtBQUFBLElBQ04sTUFBTTtBQUFBLEVBQ1I7QUFDRixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// vite.config.ts
|
||||
import { defineConfig } from "file:///mnt/e/code/courseware/vue3-frontend/node_modules/vite/dist/node/index.js";
|
||||
import vue from "file:///mnt/e/code/courseware/vue3-frontend/node_modules/@vitejs/plugin-vue/dist/index.mjs";
|
||||
import { resolve } from "path";
|
||||
import Inspector from "file:///mnt/e/code/courseware/vue3-frontend/node_modules/unplugin-vue-dev-locator/dist/vite.mjs";
|
||||
import traeBadgePlugin from "file:///mnt/e/code/courseware/vue3-frontend/node_modules/vite-plugin-trae-solo-badge/dist/vite-plugin.esm.js";
|
||||
var __vite_injected_original_dirname = "/mnt/e/code/courseware/vue3-frontend";
|
||||
var vite_config_default = defineConfig({
|
||||
build: {
|
||||
sourcemap: "hidden"
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
Inspector(),
|
||||
traeBadgePlugin({
|
||||
variant: "dark",
|
||||
position: "bottom-right",
|
||||
prodOnly: true,
|
||||
clickable: true,
|
||||
clickUrl: "https://www.trae.ai/solo?showJoin=1",
|
||||
autoTheme: true,
|
||||
autoThemeTarget: "#app"
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__vite_injected_original_dirname, "src")
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3e3,
|
||||
open: true
|
||||
}
|
||||
});
|
||||
export {
|
||||
vite_config_default as default
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvbW50L2UvY29kZS9jb3Vyc2V3YXJlL3Z1ZTMtZnJvbnRlbmRcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9tbnQvZS9jb2RlL2NvdXJzZXdhcmUvdnVlMy1mcm9udGVuZC92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vbW50L2UvY29kZS9jb3Vyc2V3YXJlL3Z1ZTMtZnJvbnRlbmQvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlJ1xuaW1wb3J0IHZ1ZSBmcm9tICdAdml0ZWpzL3BsdWdpbi12dWUnXG5pbXBvcnQgeyByZXNvbHZlIH0gZnJvbSAncGF0aCdcbmltcG9ydCBJbnNwZWN0b3IgZnJvbSAndW5wbHVnaW4tdnVlLWRldi1sb2NhdG9yL3ZpdGUnXG5pbXBvcnQgdHJhZUJhZGdlUGx1Z2luIGZyb20gJ3ZpdGUtcGx1Z2luLXRyYWUtc29sby1iYWRnZSdcblxuLy8gaHR0cHM6Ly92aXRlLmRldi9jb25maWcvXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBidWlsZDoge1xuICAgIHNvdXJjZW1hcDogJ2hpZGRlbicsXG4gIH0sXG4gIHBsdWdpbnM6IFtcbiAgICB2dWUoKSxcbiAgICBJbnNwZWN0b3IoKSxcbiAgICB0cmFlQmFkZ2VQbHVnaW4oe1xuICAgICAgdmFyaWFudDogJ2RhcmsnLFxuICAgICAgcG9zaXRpb246ICdib3R0b20tcmlnaHQnLFxuICAgICAgcHJvZE9ubHk6IHRydWUsXG4gICAgICBjbGlja2FibGU6IHRydWUsXG4gICAgICBjbGlja1VybDogJ2h0dHBzOi8vd3d3LnRyYWUuYWkvc29sbz9zaG93Sm9pbj0xJyxcbiAgICAgIGF1dG9UaGVtZTogdHJ1ZSxcbiAgICAgIGF1dG9UaGVtZVRhcmdldDogJyNhcHAnLFxuICAgIH0pLFxuICBdLFxuICByZXNvbHZlOiB7XG4gICAgYWxpYXM6IHtcbiAgICAgICdAJzogcmVzb2x2ZShfX2Rpcm5hbWUsICdzcmMnKVxuICAgIH1cbiAgfSxcbiAgc2VydmVyOiB7XG4gICAgcG9ydDogMzAwMCxcbiAgICBvcGVuOiB0cnVlXG4gIH1cbn0pXG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQThSLFNBQVMsb0JBQW9CO0FBQzNULE9BQU8sU0FBUztBQUNoQixTQUFTLGVBQWU7QUFDeEIsT0FBTyxlQUFlO0FBQ3RCLE9BQU8scUJBQXFCO0FBSjVCLElBQU0sbUNBQW1DO0FBT3pDLElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLE9BQU87QUFBQSxJQUNMLFdBQVc7QUFBQSxFQUNiO0FBQUEsRUFDQSxTQUFTO0FBQUEsSUFDUCxJQUFJO0FBQUEsSUFDSixVQUFVO0FBQUEsSUFDVixnQkFBZ0I7QUFBQSxNQUNkLFNBQVM7QUFBQSxNQUNULFVBQVU7QUFBQSxNQUNWLFVBQVU7QUFBQSxNQUNWLFdBQVc7QUFBQSxNQUNYLFVBQVU7QUFBQSxNQUNWLFdBQVc7QUFBQSxNQUNYLGlCQUFpQjtBQUFBLElBQ25CLENBQUM7QUFBQSxFQUNIO0FBQUEsRUFDQSxTQUFTO0FBQUEsSUFDUCxPQUFPO0FBQUEsTUFDTCxLQUFLLFFBQVEsa0NBQVcsS0FBSztBQUFBLElBQy9CO0FBQUEsRUFDRjtBQUFBLEVBQ0EsUUFBUTtBQUFBLElBQ04sTUFBTTtBQUFBLElBQ04sTUFBTTtBQUFBLEVBQ1I7QUFDRixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=
|
||||
Loading…
Reference in New Issue