|
|
<script setup>
|
|
|
import { saveDocx, saveHtml, uploadFile, uploadFileToDataset } from '@/api/aiapi';
|
|
|
import { ref, onMounted, computed } from 'vue';
|
|
|
import { MAX_SIZE, INVALID_TYPES } from '@/utils/config.js';
|
|
|
const messages = ref([]);
|
|
|
const openaiMessages = ref([]);
|
|
|
const inputMessage = ref('');
|
|
|
const isStreaming = ref(false);
|
|
|
const ws = ref(null);
|
|
|
const isConnected = ref(false);
|
|
|
const currentMode = ref('Dify');
|
|
|
const modelOptions = ref(['gpt-4o-mini', 'o3-mini', 'gpt-5-mini', 'dall-e-3', 'gpt-3.5-turbo']);
|
|
|
const selectedModel = ref('gpt-4o-mini');
|
|
|
const fileUrl = ref('');
|
|
|
const showFile = ref(false);
|
|
|
|
|
|
const triggerMode = () => {
|
|
|
if (currentMode.value === 'Dify') {
|
|
|
currentMode.value = 'OpenAI';
|
|
|
} else {
|
|
|
currentMode.value = 'Dify';
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const getCurrentMessages = () => {
|
|
|
return currentMode.value === 'Dify' ? messages.value : openaiMessages.value;
|
|
|
};
|
|
|
|
|
|
onMounted(() => {
|
|
|
// 初始化WebSocket连接
|
|
|
ws.value = new WebSocket('ws://192.168.0.24/ws/chat/378');
|
|
|
|
|
|
ws.value.onopen = () => {
|
|
|
isConnected.value = true;
|
|
|
};
|
|
|
|
|
|
ws.value.onmessage = (event) => {
|
|
|
const response = JSON.parse(event.data);
|
|
|
console.log("*-**-*--------*", response)
|
|
|
if (response.code === 200) {
|
|
|
// 渲染AI回复
|
|
|
let aiMessage;
|
|
|
if (response.task_id !== null) {
|
|
|
aiMessage = messages.value.find(m => m.id === response.id && m.role === 'ai');
|
|
|
} else {
|
|
|
openaiMessages.value.map(item => {
|
|
|
console.log(item.id, response.id, item.id === response.id)
|
|
|
if (item.id === response.id) {
|
|
|
aiMessage = item
|
|
|
}
|
|
|
})
|
|
|
aiMessage = openaiMessages.value.find(m => m.id === response.id);
|
|
|
}
|
|
|
if (!aiMessage) {
|
|
|
aiMessage = {
|
|
|
role: response.task_id !== null ? 'ai' : 'assistant',
|
|
|
type: 'text',
|
|
|
content: '',
|
|
|
id: response.id,
|
|
|
};
|
|
|
if (currentMode.value === 'OpenAI') {
|
|
|
openaiMessages.value.push(aiMessage);
|
|
|
} else {
|
|
|
aiMessage.conversation_id = response.conversation_id
|
|
|
messages.value.push(aiMessage);
|
|
|
}
|
|
|
}
|
|
|
// 追加流式内容
|
|
|
aiMessage.content += response.answer;
|
|
|
} else if (response.code === 203) {
|
|
|
// 回复结束
|
|
|
isStreaming.value = false;
|
|
|
} else if (response.code === 500) {
|
|
|
// 消息失败处理
|
|
|
if (currentMode.value === 'OpenAI') {
|
|
|
openaiMessages.value.push({
|
|
|
role: 'system',
|
|
|
type: 'text',
|
|
|
content: '消息发送失败,请重试'
|
|
|
});
|
|
|
} else {
|
|
|
messages.value.push({
|
|
|
role: 'system',
|
|
|
type: 'text',
|
|
|
content: '消息发送失败,请重试'
|
|
|
});
|
|
|
}
|
|
|
isStreaming.value = false;
|
|
|
} else if (response.code >= 300) {
|
|
|
messages.value.push({
|
|
|
role: 'system',
|
|
|
type: 'text',
|
|
|
content: response.message
|
|
|
});
|
|
|
}
|
|
|
};
|
|
|
|
|
|
ws.value.onclose = () => {
|
|
|
isConnected.value = false;
|
|
|
};
|
|
|
});
|
|
|
|
|
|
const sendMessage = () => {
|
|
|
if (!inputMessage.value.trim() || !isConnected.value) return;
|
|
|
let data;
|
|
|
if (currentMode.value === 'Dify') {
|
|
|
data = difySendMessage()
|
|
|
} else {
|
|
|
openaiSendMessage()
|
|
|
return
|
|
|
}
|
|
|
console.log("data:", data)
|
|
|
|
|
|
ws.value.send(JSON.stringify({
|
|
|
method: currentMode.value,
|
|
|
hasFile: true,
|
|
|
dto: data
|
|
|
}));
|
|
|
fileUrl.value = '';
|
|
|
showFile.value = false;
|
|
|
inputMessage.value = '';
|
|
|
isStreaming.value = true;
|
|
|
};
|
|
|
|
|
|
const difySendMessage = () => {
|
|
|
// 添加用户消息
|
|
|
messages.value.push({
|
|
|
role: 'user',
|
|
|
type: 'text',
|
|
|
content: inputMessage.value
|
|
|
});
|
|
|
let data = {
|
|
|
query: inputMessage.value
|
|
|
}
|
|
|
const aiMessage = messages.value.find(m => m.role === 'ai');
|
|
|
if (aiMessage && aiMessage.conversation_id) {
|
|
|
data.conversation_id = aiMessage.conversation_id;
|
|
|
}
|
|
|
return data
|
|
|
}
|
|
|
const openaiSendMessage = () => {
|
|
|
let data = {
|
|
|
model: selectedModel.value,
|
|
|
messages: [],
|
|
|
}
|
|
|
const newMessage = {
|
|
|
role: 'user',
|
|
|
type: 'text',
|
|
|
content: inputMessage.value,
|
|
|
};
|
|
|
// 如果有上传图片,添加到新消息
|
|
|
if (fileType.value !== null && fileType.value === 'image') {
|
|
|
newMessage.image_url = uploadedImageUrl.value;
|
|
|
} else if (fileType.value !== null && fileType.value === 'file') {
|
|
|
data.fileContent = fileUrl.value;
|
|
|
}
|
|
|
openaiMessages.value.push(newMessage);
|
|
|
openaiMessages.value.map(item => {
|
|
|
if (item.hasOwnProperty('image_url')) {
|
|
|
data.messages.push({
|
|
|
role: item.role,
|
|
|
content: [{
|
|
|
type: 'text',
|
|
|
text: item.content
|
|
|
}, {
|
|
|
type: 'image_url',
|
|
|
image_url: {
|
|
|
url: item.image_url
|
|
|
}
|
|
|
}]
|
|
|
})
|
|
|
} else {
|
|
|
data.messages.push({
|
|
|
role: item.role,
|
|
|
content: item.content
|
|
|
})
|
|
|
}
|
|
|
})
|
|
|
console.log("11data:", data)
|
|
|
ws.value.send(JSON.stringify({
|
|
|
method: currentMode.value,
|
|
|
hasFile: true,
|
|
|
dto: data
|
|
|
}));
|
|
|
fileUrl.value = '';
|
|
|
showFile.value = false;
|
|
|
inputMessage.value = '';
|
|
|
isStreaming.value = true;
|
|
|
}
|
|
|
|
|
|
const triggerFileUpload = () => {
|
|
|
document.getElementById('file-upload').click();
|
|
|
};
|
|
|
|
|
|
const handleFileUpload = async (event) => {
|
|
|
try {
|
|
|
const file = event.target.files[0];
|
|
|
if (!file) return;
|
|
|
const formData = new FormData();
|
|
|
formData.append('file', file);
|
|
|
const res = await uploadFileToDataset(formData);
|
|
|
|
|
|
// if (file.size > MAX_SIZE) {
|
|
|
// alert('文件大小不能超过5MB');
|
|
|
// return;
|
|
|
// }
|
|
|
// const res = await uploadFile(formData);
|
|
|
// console.log("res:", res);
|
|
|
// fileUrl.value = res.data; // 保存返回的URL
|
|
|
// showFile.value = true;
|
|
|
event.target.value = '';
|
|
|
} catch (err) {
|
|
|
console.log("err:", err);
|
|
|
alert('文件上传失败,请重试');
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const cancel = () => {
|
|
|
fileUrl.value = '';
|
|
|
showFile.value = false;
|
|
|
}
|
|
|
|
|
|
const fileType = computed(() => {
|
|
|
if (!fileUrl.value) return null;
|
|
|
|
|
|
const url = fileUrl.value;
|
|
|
const extension = url.split('.').pop().toLowerCase();
|
|
|
|
|
|
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
|
|
|
|
|
return imageExtensions.includes(extension) ? 'image' : 'file';
|
|
|
});
|
|
|
|
|
|
const extractHtmlFromString = (str) => {
|
|
|
const htmlStart = str.indexOf('```html') + '```html'.length;
|
|
|
const htmlEnd = str.indexOf('```', htmlStart);
|
|
|
|
|
|
if (htmlStart === -1 || htmlEnd === -1) {
|
|
|
return null; // 没有找到HTML代码块
|
|
|
}
|
|
|
|
|
|
return str.substring(htmlStart, htmlEnd).trim();
|
|
|
}
|
|
|
|
|
|
const saveToHtml = async (content) => {
|
|
|
const html = extractHtmlFromString(content)
|
|
|
const res = await saveHtml({
|
|
|
html: html
|
|
|
})
|
|
|
console.log("res:", res)
|
|
|
if (res.code == '200' && res.data) {
|
|
|
alert("保存成功")
|
|
|
} else {
|
|
|
alert("保存失败")
|
|
|
}
|
|
|
}
|
|
|
function test1(input) {
|
|
|
let processed = input;
|
|
|
const hasSeparator = input.includes('---');
|
|
|
if (hasSeparator) {
|
|
|
// 按分隔符分割并过滤空内容
|
|
|
const parts = input.split('---')
|
|
|
.map(part => part.trim())
|
|
|
.filter(part => part.length > 0);
|
|
|
// 取中间部分或首个有效内容
|
|
|
processed = parts.length > 1 ? parts.slice(1, -1).join('\n\n') || parts[1] : parts[0];
|
|
|
}
|
|
|
const lines = processed.split('\n').map(line => line.trim());
|
|
|
let startIndex = -1;
|
|
|
let endIndex = lines.length;
|
|
|
// 找到第一个#标题的位置
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
|
if (lines[i].startsWith('#')) {
|
|
|
startIndex = i;
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
if (startIndex === -1) {
|
|
|
return lines.filter(line => line).join('\n');
|
|
|
}
|
|
|
// 找到文章结束位置(连续空行或内容结束)
|
|
|
for (let i = lines.length - 1; i >= startIndex; i--) {
|
|
|
if (lines[i].length > 0) {
|
|
|
endIndex = i + 1;
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
return lines.slice(startIndex, endIndex).join('\n');
|
|
|
}
|
|
|
|
|
|
const saveFile = async (content, type) => {
|
|
|
const str = test1(content)
|
|
|
const res = await saveDocx({
|
|
|
content: str,
|
|
|
type
|
|
|
})
|
|
|
console.log("res", res)
|
|
|
}
|
|
|
|
|
|
const test = async () => {
|
|
|
// const file = event.target.files[0];
|
|
|
// const formData = new FormData();
|
|
|
// formData.append('file', file);
|
|
|
// if (file.size > MAX_SIZE) {
|
|
|
// alert('文件大小不能超过5MB');
|
|
|
// return;
|
|
|
// }
|
|
|
// const res = await uploadFile(formData);
|
|
|
// console.log("res:", res);
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
<template>
|
|
|
<div class="chat-container">
|
|
|
<div class="chat-messages">
|
|
|
<div v-for="(message, index) in getCurrentMessages()" :key="index" class="message">
|
|
|
<div :class="['message-content', message.role]">
|
|
|
<img v-if="message.role === 'user'" src="@/assets/user-icon.png" class="avatar" />
|
|
|
<img v-else src="@/assets/robot.png" class="avatar" />
|
|
|
<div class="message-body">
|
|
|
<div v-if="message.type === 'text'">
|
|
|
{{ message.content }}
|
|
|
<button v-if="message.content.includes('```html') && message.content.includes('```')"
|
|
|
@click="saveToHtml(message.content)" class="save-btn">
|
|
|
保存HTML
|
|
|
</button>
|
|
|
<button @click="saveFile(message.content, 'docx')" class="save-btn">
|
|
|
生成DOCX
|
|
|
</button>
|
|
|
<button @click="saveFile(message.content, 'pdf')" class="save-btn">
|
|
|
生成PDF
|
|
|
</button>
|
|
|
</div>
|
|
|
<img v-else-if="message.type === 'image'" :src="message.content" class="uploaded-image" />
|
|
|
<div v-else-if="message.type === 'file'">
|
|
|
<a :href="message.content" target="_blank">下载文件</a>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div v-if="isStreaming" class="streaming-indicator">
|
|
|
<div class="typing-dots">
|
|
|
<span></span><span></span><span></span>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div v-if="showFile" class="image-preview">
|
|
|
<img v-if="fileType === 'image'" :src="fileUrl" alt="预览图片" />
|
|
|
<span v-else>已上传文件:{{ fileUrl.split('/').pop() }}</span>
|
|
|
|
|
|
<button class="cancel-btn" @click="cancel">取消</button>
|
|
|
</div>
|
|
|
|
|
|
<div class="chat-input">
|
|
|
<div class="upload-buttons">
|
|
|
<button class="small-button" @click="triggerMode">当前模式:{{ currentMode }}</button>
|
|
|
<select v-if="currentMode === 'OpenAI'" v-model="selectedModel" class="model-select">
|
|
|
<option v-for="(model, index) in modelOptions" :value="model" :key="index">{{ model }}</option>
|
|
|
</select>
|
|
|
<div class="vertical-buttons" v-if="currentMode === 'OpenAI'">
|
|
|
<input type="file" id="file-upload" @change="handleFileUpload" style="display: none" />
|
|
|
<button class="small-button" @click="triggerFileUpload">上传文件</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
<textarea v-model="inputMessage" @keyup.enter="sendMessage" placeholder="输入消息..."></textarea>
|
|
|
<button @click="sendMessage">发送</button>
|
|
|
<button @click="saveFile">测试</button>
|
|
|
<button @click="test">测试1</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
.chat-container {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
height: 100vh;
|
|
|
max-width: 800px;
|
|
|
margin: 0 auto;
|
|
|
border: 1px solid #ddd;
|
|
|
}
|
|
|
|
|
|
.chat-messages {
|
|
|
flex: 1;
|
|
|
overflow-y: auto;
|
|
|
padding: 20px;
|
|
|
}
|
|
|
|
|
|
.message {
|
|
|
margin-bottom: 15px;
|
|
|
}
|
|
|
|
|
|
.message-content {
|
|
|
display: flex;
|
|
|
align-items: flex-start;
|
|
|
gap: 10px;
|
|
|
}
|
|
|
|
|
|
.message-content.user {
|
|
|
flex-direction: row-reverse;
|
|
|
}
|
|
|
|
|
|
.avatar {
|
|
|
width: 40px;
|
|
|
height: 40px;
|
|
|
border-radius: 50%;
|
|
|
object-fit: cover;
|
|
|
}
|
|
|
|
|
|
.message-body {
|
|
|
max-width: 70%;
|
|
|
padding: 10px 15px;
|
|
|
border-radius: 18px;
|
|
|
background: #f0f0f0;
|
|
|
}
|
|
|
|
|
|
.message-content.user .message-body {
|
|
|
background: #007bff;
|
|
|
color: white;
|
|
|
}
|
|
|
|
|
|
.uploaded-image {
|
|
|
max-width: 200px;
|
|
|
max-height: 200px;
|
|
|
border-radius: 8px;
|
|
|
}
|
|
|
|
|
|
.chat-input {
|
|
|
display: flex;
|
|
|
padding: 10px;
|
|
|
border-top: 1px solid #ddd;
|
|
|
background: white;
|
|
|
gap: 10px;
|
|
|
}
|
|
|
|
|
|
.upload-buttons {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
gap: 5px;
|
|
|
min-width: 120px;
|
|
|
}
|
|
|
|
|
|
.vertical-buttons {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
gap: 5px;
|
|
|
}
|
|
|
|
|
|
/* 图片预览样式 */
|
|
|
.image-preview {
|
|
|
padding: 10px;
|
|
|
text-align: center;
|
|
|
background: #f5f5f5;
|
|
|
border-bottom: 1px solid #ddd;
|
|
|
}
|
|
|
|
|
|
.image-preview img {
|
|
|
width: 80px;
|
|
|
height: 80px;
|
|
|
object-fit: cover;
|
|
|
margin-bottom: 5px;
|
|
|
}
|
|
|
|
|
|
.cancel-btn {
|
|
|
padding: 3px 8px;
|
|
|
font-size: 12px;
|
|
|
background: #ff4d4f;
|
|
|
}
|
|
|
|
|
|
textarea {
|
|
|
flex: 1;
|
|
|
padding: 10px;
|
|
|
border: 1px solid #ddd;
|
|
|
border-radius: 4px;
|
|
|
resize: none;
|
|
|
height: 60px;
|
|
|
}
|
|
|
|
|
|
button {
|
|
|
padding: 10px 15px;
|
|
|
background: #007bff;
|
|
|
color: white;
|
|
|
border: none;
|
|
|
border-radius: 4px;
|
|
|
cursor: pointer;
|
|
|
}
|
|
|
|
|
|
.streaming-indicator {
|
|
|
padding: 10px;
|
|
|
}
|
|
|
|
|
|
.typing-dots span {
|
|
|
display: inline-block;
|
|
|
width: 8px;
|
|
|
height: 8px;
|
|
|
border-radius: 50%;
|
|
|
background: #ccc;
|
|
|
margin: 0 2px;
|
|
|
animation: bounce 1.4s infinite ease-in-out;
|
|
|
}
|
|
|
|
|
|
.typing-dots span:nth-child(2) {
|
|
|
animation-delay: 0.2s;
|
|
|
}
|
|
|
|
|
|
.typing-dots span:nth-child(3) {
|
|
|
animation-delay: 0.4s;
|
|
|
}
|
|
|
|
|
|
@keyframes bounce {
|
|
|
|
|
|
0%,
|
|
|
60%,
|
|
|
100% {
|
|
|
transform: translateY(0);
|
|
|
}
|
|
|
|
|
|
30% {
|
|
|
transform: translateY(-5px);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.small-button {
|
|
|
padding: 5px 10px;
|
|
|
font-size: 12px;
|
|
|
height: 30px;
|
|
|
}
|
|
|
|
|
|
.model-select {
|
|
|
padding: 5px;
|
|
|
border: 1px solid #ddd;
|
|
|
border-radius: 4px;
|
|
|
margin-right: 10px;
|
|
|
height: 30px;
|
|
|
}
|
|
|
|
|
|
.image-preview {
|
|
|
padding: 10px;
|
|
|
border-bottom: 1px solid #ddd;
|
|
|
text-align: center;
|
|
|
}
|
|
|
|
|
|
.image-preview img {
|
|
|
max-width: 200px;
|
|
|
max-height: 200px;
|
|
|
margin-bottom: 10px;
|
|
|
}
|
|
|
</style> |
|
|
\ No newline at end of file |
...
|
...
|
|