perf: webchat支持传图
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import time
|
||||
import asyncio
|
||||
import uuid
|
||||
import os
|
||||
from typing import Awaitable, Any
|
||||
from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, MessageType, PlatformMetadata
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import * # noqa: F403
|
||||
from astrbot.api.message_components import Plain, Image # noqa: F403
|
||||
from astrbot.api import logger
|
||||
from astrbot.core import web_chat_queue, web_chat_back_queue
|
||||
from .webchat_event import WebChatMessageEvent
|
||||
@@ -29,6 +30,7 @@ class WebChatAdapter(Platform):
|
||||
self.config = platform_config
|
||||
self.settings = platform_settings
|
||||
self.unique_session = platform_settings['unique_session']
|
||||
self.imgs_dir = "data/webchat/imgs"
|
||||
|
||||
self.metadata = PlatformMetadata(
|
||||
"webchat",
|
||||
@@ -45,7 +47,7 @@ class WebChatAdapter(Platform):
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
async def convert_message(self, data: tuple) -> AstrBotMessage:
|
||||
username, cid, message = data
|
||||
username, cid, payload = data
|
||||
|
||||
|
||||
abm = AstrBotMessage()
|
||||
@@ -58,8 +60,20 @@ class WebChatAdapter(Platform):
|
||||
abm.session_id = f"webchat!{username}!{cid}"
|
||||
|
||||
abm.message_id = str(uuid.uuid4())
|
||||
abm.message = [Plain(message)]
|
||||
message_str = message
|
||||
abm.message = []
|
||||
|
||||
if payload['message']:
|
||||
abm.message.append(Plain(payload['message']))
|
||||
if payload['image_url']:
|
||||
if isinstance(payload['image_url'], list):
|
||||
for img in payload['image_url']:
|
||||
abm.message.append(Image.fromFileSystem(os.path.join(self.imgs_dir, img)))
|
||||
else:
|
||||
abm.message.append(Image.fromFileSystem(os.path.join(self.imgs_dir, payload['image_url'])))
|
||||
|
||||
logger.debug(f"WebChatAdapter: {abm.message}")
|
||||
|
||||
message_str = payload['message']
|
||||
abm.timestamp = int(time.time())
|
||||
abm.message_str = message_str
|
||||
abm.raw_message = data
|
||||
|
||||
@@ -209,6 +209,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
image_path = await download_image_by_url(image_url)
|
||||
image_data = await self.encode_image_bs64(image_path)
|
||||
else:
|
||||
if image_url.startswith("file:///"):
|
||||
image_url = image_url.replace("file:///", "")
|
||||
image_data = await self.encode_image_bs64(image_url)
|
||||
user_content["content"].append({"type": "image_url", "image_url": {"url": image_data}})
|
||||
return user_content
|
||||
|
||||
@@ -16,7 +16,8 @@ class ChatRoute(Route):
|
||||
'/chat/conversations': ('GET', self.get_conversations),
|
||||
'/chat/get_conversation': ('GET', self.get_conversation),
|
||||
'/chat/delete_conversation': ('GET', self.delete_conversation),
|
||||
'/chat/get_file': ('GET', self.get_file)
|
||||
'/chat/get_file': ('GET', self.get_file),
|
||||
'/chat/post_image': ('POST', self.post_image)
|
||||
}
|
||||
self.db = db
|
||||
self.register_routes()
|
||||
@@ -32,25 +33,43 @@ class ChatRoute(Route):
|
||||
return QuartResponse(f.read(), mimetype="image/jpeg")
|
||||
except FileNotFoundError:
|
||||
return Response().error("File not found").__dict__
|
||||
|
||||
async def post_image(self):
|
||||
post_data = await request.files
|
||||
if 'file' not in post_data:
|
||||
return Response().error("Missing key: file").__dict__
|
||||
|
||||
file = post_data['file']
|
||||
filename = str(uuid.uuid4()) + ".jpg"
|
||||
path = os.path.join(self.imgs_dir, filename)
|
||||
await file.save(path)
|
||||
|
||||
return Response().ok(data={
|
||||
'filename': filename
|
||||
}).__dict__
|
||||
|
||||
async def chat(self):
|
||||
username = g.get('username', 'guest')
|
||||
|
||||
post_data = await request.json
|
||||
if 'message' not in post_data:
|
||||
return Response().error("Missing key: message").__dict__
|
||||
if 'message' not in post_data and 'image_url' not in post_data:
|
||||
return Response().error("Missing key: message or image_url").__dict__
|
||||
|
||||
if 'conversation_id' not in post_data:
|
||||
return Response().error("Missing key: conversation_id").__dict__
|
||||
|
||||
message = post_data['message']
|
||||
conversation_id = post_data['conversation_id']
|
||||
if not message:
|
||||
return Response().error("Message is empty").__dict__
|
||||
image_url = post_data.get('image_url')
|
||||
if not message and not image_url:
|
||||
return Response().error("Message and image_url are empty").__dict__
|
||||
if not conversation_id:
|
||||
return Response().error("conversation_id is empty").__dict__
|
||||
|
||||
await web_chat_queue.put((username, conversation_id, message))
|
||||
await web_chat_queue.put((username, conversation_id, {
|
||||
'message': message,
|
||||
'image_url': image_url # list
|
||||
}))
|
||||
|
||||
async def stream():
|
||||
ret = []
|
||||
@@ -72,14 +91,14 @@ class ChatRoute(Route):
|
||||
except BaseException as e:
|
||||
print(e)
|
||||
history = []
|
||||
history.append({
|
||||
|
||||
new_his = {
|
||||
'type': 'user',
|
||||
'message': message
|
||||
})
|
||||
# history.append({
|
||||
# 'type': 'bot',
|
||||
# 'message': ret
|
||||
# })
|
||||
}
|
||||
if image_url:
|
||||
new_his['image_url'] = image_url
|
||||
history.append(new_his)
|
||||
for r in ret:
|
||||
history.append({
|
||||
'type': 'bot',
|
||||
|
||||
@@ -16,32 +16,27 @@ marked.setOptions({
|
||||
<div style="height: 100%; display: flex; gap: 16px;">
|
||||
<div style="max-width: 200px;">
|
||||
<!-- conversation -->
|
||||
<v-btn variant="tonal" rounded="xl" style="margin-bottom: 16px; min-width: 200px;" @click="newC" :disabled="!currCid">+ 创建对话</v-btn>
|
||||
|
||||
<v-card class="mx-auto" min-width="200">
|
||||
<v-list dense nav
|
||||
rounded="xl"
|
||||
v-if="conversations.length > 0"
|
||||
@update:selected="getConversationMessages"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(item, i) in conversations"
|
||||
:key="item.cid"
|
||||
:value="item.cid"
|
||||
color="primary"
|
||||
rounded="xl"
|
||||
>
|
||||
<v-list-item-title>新对话</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ formatDate(item.updated_at) }}</v-list-item-subtitle>
|
||||
<v-btn variant="tonal" rounded="xl" style="margin-bottom: 16px; min-width: 200px;" @click="newC"
|
||||
:disabled="!currCid">+ 创建对话</v-btn>
|
||||
|
||||
</v-list-item>
|
||||
<v-card class="mx-auto" min-width="200">
|
||||
<v-list dense nav rounded="xl" v-if="conversations.length > 0"
|
||||
@update:selected="getConversationMessages">
|
||||
<v-list-item v-for="(item, i) in conversations" :key="item.cid" :value="item.cid"
|
||||
color="primary" rounded="xl">
|
||||
<v-list-item-title>新对话</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ formatDate(item.updated_at) }}</v-list-item-subtitle>
|
||||
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
|
||||
<v-btn variant="tonal" rounded="xl" style="position: fixed; bottom: 48px; margin-bottom: 16px; min-width: 200px;" v-if="currCid" @click="deleteConversation(currCid)" color="error">删除此对话</v-btn>
|
||||
<v-btn variant="tonal" rounded="xl"
|
||||
style="position: fixed; bottom: 48px; margin-bottom: 16px; min-width: 200px;" v-if="currCid"
|
||||
@click="deleteConversation(currCid)" color="error">删除此对话</v-btn>
|
||||
</div>
|
||||
<div style="height: 100%; width: 100%;">
|
||||
<div style="height: calc(100% - 64px); overflow-y: auto; padding: 16px; " ref="messageContainer">
|
||||
<div style="height: calc(100% - 130px); overflow-y: auto; padding: 16px; " ref="messageContainer">
|
||||
<div class="fade-in" v-if="messages.length == 0"
|
||||
style="height: 100%; display: flex; justify-content: center; align-items: center; flex-direction: column;">
|
||||
<div>
|
||||
@@ -49,11 +44,12 @@ marked.setOptions({
|
||||
<span style="font-weight: 1000; font-size: 28px; margin-left: 8px;">AstrBot ⭐</span>
|
||||
</div>
|
||||
<div style="margin-top: 8px; color: #aaa;">
|
||||
<span >输入</span>
|
||||
<span style="background-color: #eee; padding-left: 4px; padding-right: 4px; margin: 2px; border-radius: 4px;">/help</span>
|
||||
<span >获取帮助 😊</span>
|
||||
<span>输入</span>
|
||||
<span
|
||||
style="background-color: #eee; padding-left: 4px; padding-right: 4px; margin: 2px; border-radius: 4px;">/help</span>
|
||||
<span>获取帮助 😊</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div v-else style="max-height: 100%; padding: 16px; max-width: 700px; margin: 0 auto;">
|
||||
<div class="fade-in" v-for="(msg, index) in messages" :key="index"
|
||||
@@ -62,6 +58,13 @@ marked.setOptions({
|
||||
<div
|
||||
style="padding: 12px; border-radius: 8px; background-color: rgba(94, 53, 177, 0.15)">
|
||||
<span>{{ msg.message }}</span>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px;" v-if="msg.image_url && msg.image_url.length > 0">
|
||||
<div v-for="(img, index) in msg.image_url" :key="index"
|
||||
style="position: relative; display: inline-block;">
|
||||
<img :src="img"
|
||||
style="width: 100px; height: 100px; border-radius: 8px; box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="display: flex; justify-content: flex-start; gap: 16px;">
|
||||
@@ -72,19 +75,38 @@ marked.setOptions({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fade-in" style="bottom: 16px; width: 100%; padding: 16px; ">
|
||||
<div style="width: 100%; justify-content: center; align-items: center; display: flex; ">
|
||||
<v-text-field variant="outlined" v-model="prompt" label="聊天吧!" placeholder="Start typing..."
|
||||
loading clear-icon="mdi-close-circle" clearable @click:clear="clearMessage"
|
||||
@keyup.enter="sendMessage" style="width: 100%; max-width: 930px;">
|
||||
<template v-slot:loader>
|
||||
<div class="fade-in" style="bottom: 16px; width: 100%; padding: 8px; ">
|
||||
|
||||
<div
|
||||
style="width: 100%; justify-content: center; align-items: center; display: flex; flex-direction: column; margin-top: 8px;">
|
||||
|
||||
<v-text-field id="input-field" variant="outlined" v-model="prompt" label="聊天吧!"
|
||||
placeholder="Start typing..." loading clear-icon="mdi-close-circle" clearable
|
||||
@click:clear="clearMessage" @keyup.enter="sendMessage"
|
||||
style="width: 100%; max-width: 930px;">
|
||||
<template v-slot:loader>
|
||||
<v-progress-linear
|
||||
:active="loadingChat"
|
||||
:color="color"
|
||||
height="6"
|
||||
indeterminate
|
||||
></v-progress-linear>
|
||||
</template>
|
||||
|
||||
<template v-slot:append>
|
||||
<v-icon @click="sendMessage" size="35" icon="mdi-arrow-up-circle" />
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<div>
|
||||
<div v-for="(img, index) in stagedImagesUrl" :key="index"
|
||||
style="position: relative; display: inline-block;">
|
||||
<img :src="img"
|
||||
style="width: 50px; height: 50px; border-radius: 8px; box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);" />
|
||||
<v-icon @click="removeImage(index)" size="20" color="red"
|
||||
style="position: absolute; top: 0; right: 0; cursor: pointer;">mdi-close-circle</v-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,15 +126,52 @@ export default {
|
||||
prompt: '',
|
||||
messages: [],
|
||||
conversations: [],
|
||||
currCid: ''
|
||||
currCid: '',
|
||||
stagedImagesUrl: [],
|
||||
loadingChat: false
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.getConversations();
|
||||
let inputField = document.getElementById('input-field');
|
||||
inputField.addEventListener('paste', this.handlePaste);
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
async handlePaste(event) {
|
||||
console.log('Pasting image...');
|
||||
const items = event.clipboardData.items;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf('image') !== -1) {
|
||||
const file = items[i].getAsFile();
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/chat/post_image', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
}
|
||||
});
|
||||
|
||||
const img = response.data.data.filename;
|
||||
this.stagedImagesUrl.push(`/api/chat/get_file?filename=${img}`);
|
||||
|
||||
scrollToBottom();
|
||||
} catch (err) {
|
||||
console.error('Error uploading image:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
removeImage(index) {
|
||||
this.stagedImagesUrl.splice(index, 1);
|
||||
},
|
||||
|
||||
clearMessage() {
|
||||
this.prompt = '';
|
||||
},
|
||||
@@ -126,7 +185,7 @@ export default {
|
||||
getConversationMessages(cid) {
|
||||
if (!cid[0])
|
||||
return;
|
||||
axios.get('/api/chat/get_conversation?conversation_id='+cid[0]).then(response => {
|
||||
axios.get('/api/chat/get_conversation?conversation_id=' + cid[0]).then(response => {
|
||||
this.currCid = cid[0];
|
||||
let message = JSON.parse(response.data.data.history);
|
||||
for (let i = 0; i < message.length; i++) {
|
||||
@@ -134,6 +193,11 @@ export default {
|
||||
let img = message[i].message.replace('[IMAGE]', '');
|
||||
message[i].message = `<img src="/api/chat/get_file?filename=${img}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
|
||||
}
|
||||
if (message[i].image_url && message[i].image_url.length > 0) {
|
||||
for (let j = 0; j < message[i].image_url.length; j++) {
|
||||
message[i].image_url[j] = `/api/chat/get_file?filename=${message[i].image_url[j]}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.messages = message;
|
||||
}).catch(err => {
|
||||
@@ -169,7 +233,7 @@ export default {
|
||||
},
|
||||
|
||||
deleteConversation(cid) {
|
||||
axios.get('/api/chat/delete_conversation?conversation_id='+cid).then(response => {
|
||||
axios.get('/api/chat/delete_conversation?conversation_id=' + cid).then(response => {
|
||||
this.getConversations();
|
||||
this.currCid = '';
|
||||
this.messages = [];
|
||||
@@ -185,7 +249,8 @@ export default {
|
||||
|
||||
this.messages.push({
|
||||
type: 'user',
|
||||
message: this.prompt
|
||||
message: this.prompt,
|
||||
image_url: this.stagedImagesUrl
|
||||
});
|
||||
|
||||
// let bot_resp = {
|
||||
@@ -197,6 +262,14 @@ export default {
|
||||
|
||||
this.scrollToBottom();
|
||||
|
||||
let image_filenames = [];
|
||||
for (let i = 0; i < this.stagedImagesUrl.length; i++) {
|
||||
let img = this.stagedImagesUrl[i].replace('/api/chat/get_file?filename=', '');
|
||||
image_filenames.push(img);
|
||||
}
|
||||
|
||||
this.loadingChat = true;
|
||||
|
||||
|
||||
fetch('/api/chat/send', {
|
||||
method: 'POST',
|
||||
@@ -204,10 +277,13 @@ export default {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
},
|
||||
body: JSON.stringify({ message: this.prompt, conversation_id: this.currCid }) // 发送请求体
|
||||
body: JSON.stringify({ message: this.prompt, conversation_id: this.currCid, image_url: image_filenames }) // 发送请求体
|
||||
})
|
||||
.then(response => {
|
||||
this.prompt = '';
|
||||
this.stagedImagesUrl = [];
|
||||
|
||||
this.loadingChat = false;
|
||||
|
||||
const reader = response.body.getReader(); // 获取流的 Reader
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
Reference in New Issue
Block a user