mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-21 09:47:13 +00:00
perf: advanced web chat (#1811)
* perf: supports for quoting message * feat: add supports for Voice and File * perf: reply button
This commit is contained in:
committed by
GitHub
parent
58312deb8c
commit
b5d192425e
@@ -123,6 +123,10 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
|
|
||||||
use_llm_model = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
use_llm_model = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
||||||
|
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f'localagent req: query={query.query_id} req_messages={req_messages} use_llm_model={query.use_llm_model_uuid}'
|
||||||
|
)
|
||||||
|
|
||||||
if not is_stream:
|
if not is_stream:
|
||||||
# 非流式输出,直接请求
|
# 非流式输出,直接请求
|
||||||
|
|
||||||
@@ -235,6 +239,10 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
|
|
||||||
req_messages.append(err_msg)
|
req_messages.append(err_msg)
|
||||||
|
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f'localagent req: query={query.query_id} req_messages={req_messages} use_llm_model={query.use_llm_model_uuid}'
|
||||||
|
)
|
||||||
|
|
||||||
if is_stream:
|
if is_stream:
|
||||||
tool_calls_map = {}
|
tool_calls_map = {}
|
||||||
msg_idx = 0
|
msg_idx = 0
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Plain,
|
Plain,
|
||||||
At,
|
At,
|
||||||
|
Quote,
|
||||||
|
Voice,
|
||||||
|
Source,
|
||||||
} from '@/app/infra/entities/message';
|
} from '@/app/infra/entities/message';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import AtBadge from './AtBadge';
|
import AtBadge from './AtBadge';
|
||||||
@@ -46,6 +49,8 @@ export default function DebugDialog({
|
|||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [previewImageUrl, setPreviewImageUrl] = useState<string>('');
|
const [previewImageUrl, setPreviewImageUrl] = useState<string>('');
|
||||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||||
|
const [quotedMessage, setQuotedMessage] = useState<Message | null>(null);
|
||||||
|
const [hoveredMessageId, setHoveredMessageId] = useState<number | null>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -285,7 +290,13 @@ export default function DebugDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = async () => {
|
||||||
if (!inputValue.trim() && !hasAt && selectedImages.length === 0) return;
|
if (
|
||||||
|
!inputValue.trim() &&
|
||||||
|
!hasAt &&
|
||||||
|
selectedImages.length === 0 &&
|
||||||
|
!quotedMessage
|
||||||
|
)
|
||||||
|
return;
|
||||||
if (!isConnected || !wsClientRef.current) {
|
if (!isConnected || !wsClientRef.current) {
|
||||||
toast.error(t('pipelines.debugDialog.notConnected'));
|
toast.error(t('pipelines.debugDialog.notConnected'));
|
||||||
return;
|
return;
|
||||||
@@ -296,6 +307,25 @@ export default function DebugDialog({
|
|||||||
|
|
||||||
const messageChain = [];
|
const messageChain = [];
|
||||||
|
|
||||||
|
// 添加引用消息(如果有)
|
||||||
|
if (quotedMessage) {
|
||||||
|
// 获取被引用消息的Source组件以获取message_id
|
||||||
|
const sourceComponent = quotedMessage.message_chain.find(
|
||||||
|
(c) => c.type === 'Source',
|
||||||
|
) as Source | undefined;
|
||||||
|
const messageId = sourceComponent
|
||||||
|
? sourceComponent.id
|
||||||
|
: quotedMessage.id;
|
||||||
|
|
||||||
|
messageChain.push({
|
||||||
|
type: 'Quote',
|
||||||
|
id: messageId,
|
||||||
|
origin: quotedMessage.message_chain.filter(
|
||||||
|
(c) => c.type !== 'Source',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let text_content = inputValue.trim();
|
let text_content = inputValue.trim();
|
||||||
if (hasAt) {
|
if (hasAt) {
|
||||||
text_content = ' ' + text_content;
|
text_content = ' ' + text_content;
|
||||||
@@ -334,9 +364,10 @@ export default function DebugDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清空输入框和图片
|
// 清空输入框、图片和引用消息
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
setHasAt(false);
|
setHasAt(false);
|
||||||
|
setQuotedMessage(null);
|
||||||
selectedImages.forEach((img) => URL.revokeObjectURL(img.preview));
|
selectedImages.forEach((img) => URL.revokeObjectURL(img.preview));
|
||||||
setSelectedImages([]);
|
setSelectedImages([]);
|
||||||
|
|
||||||
@@ -412,8 +443,53 @@ export default function DebugDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Voice':
|
case 'Voice': {
|
||||||
return <span key={index}>[语音]</span>;
|
const voice = component as Voice;
|
||||||
|
const voiceUrl = voice.url || (voice.base64 ? voice.base64 : '');
|
||||||
|
|
||||||
|
if (!voiceUrl) {
|
||||||
|
return <span key={index}>[语音]</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className="my-2 flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||||
|
</svg>
|
||||||
|
<audio
|
||||||
|
controls
|
||||||
|
src={voiceUrl}
|
||||||
|
className="h-8"
|
||||||
|
style={{ maxWidth: '200px' }}
|
||||||
|
>
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
{voice.length && voice.length > 0 && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{voice.length}s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Quote': {
|
||||||
|
const quote = component as Quote;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="mb-2 pl-3 border-l-2 border-gray-400 dark:border-gray-500"
|
||||||
|
>
|
||||||
|
<div className="text-sm opacity-75">
|
||||||
|
{quote.origin?.map((comp, idx) =>
|
||||||
|
renderMessageComponent(comp as MessageChainComponent, idx),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case 'Source':
|
case 'Source':
|
||||||
// Source 不显示
|
// Source 不显示
|
||||||
@@ -424,6 +500,60 @@ export default function DebugDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getMessageTimestamp = (message: Message): number => {
|
||||||
|
// 首先尝试从message_chain中的Source组件获取时间戳
|
||||||
|
const sourceComponent = message.message_chain.find(
|
||||||
|
(c) => c.type === 'Source',
|
||||||
|
) as Source | undefined;
|
||||||
|
|
||||||
|
if (sourceComponent && sourceComponent.timestamp) {
|
||||||
|
return sourceComponent.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有Source组件,使用message.timestamp
|
||||||
|
// 假设timestamp是ISO字符串,转换为Unix时间戳(秒)
|
||||||
|
if (message.timestamp) {
|
||||||
|
return Math.floor(new Date(message.timestamp).getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp: number): string => {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const hours = date.getHours().toString().padStart(2, '0');
|
||||||
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
|
||||||
|
// 判断是否是今天
|
||||||
|
const isToday = now.toDateString() === date.toDateString();
|
||||||
|
if (isToday) {
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否是昨天
|
||||||
|
const yesterday = new Date(now);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const isYesterday = yesterday.toDateString() === date.toDateString();
|
||||||
|
if (isYesterday) {
|
||||||
|
return `${t('bots.yesterday')} ${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否是今年
|
||||||
|
const isThisYear = now.getFullYear() === date.getFullYear();
|
||||||
|
if (isThisYear) {
|
||||||
|
const month = date.getMonth() + 1;
|
||||||
|
const day = date.getDate();
|
||||||
|
return t('bots.dateFormat', { month, day });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更早的日期
|
||||||
|
return t('bots.earlier');
|
||||||
|
};
|
||||||
|
|
||||||
const renderMessageContent = (message: Message) => {
|
const renderMessageContent = (message: Message) => {
|
||||||
return (
|
return (
|
||||||
<div className="text-base leading-relaxed align-middle whitespace-pre-wrap">
|
<div className="text-base leading-relaxed align-middle whitespace-pre-wrap">
|
||||||
@@ -489,31 +619,68 @@ export default function DebugDialog({
|
|||||||
<div
|
<div
|
||||||
key={message.id + message.timestamp}
|
key={message.id + message.timestamp}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex',
|
'flex group',
|
||||||
message.role === 'user' ? 'justify-end' : 'justify-start',
|
message.role === 'user' ? 'justify-end' : 'justify-start',
|
||||||
)}
|
)}
|
||||||
|
onMouseEnter={() => setHoveredMessageId(message.id)}
|
||||||
|
onMouseLeave={() => setHoveredMessageId(null)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-w-md px-5 py-3 rounded-2xl',
|
'relative flex items-end gap-2',
|
||||||
message.role === 'user'
|
message.role === 'user' ? 'flex-row-reverse' : 'flex-row',
|
||||||
? 'bg-[#2288ee] text-white rounded-br-none'
|
|
||||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderMessageContent(message)}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs mt-2',
|
'max-w-md px-5 py-3 rounded-2xl',
|
||||||
message.role === 'user'
|
message.role === 'user'
|
||||||
? 'text-white/70'
|
? 'bg-[#2288ee] text-white rounded-br-none'
|
||||||
: 'text-gray-500 dark:text-gray-400',
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{message.role === 'user'
|
{renderMessageContent(message)}
|
||||||
? t('pipelines.debugDialog.userMessage')
|
<div
|
||||||
: t('pipelines.debugDialog.botMessage')}
|
className={cn(
|
||||||
|
'text-xs mt-2 flex items-center justify-between gap-2',
|
||||||
|
message.role === 'user'
|
||||||
|
? 'text-white/70'
|
||||||
|
: 'text-gray-500 dark:text-gray-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{message.role === 'user'
|
||||||
|
? t('pipelines.debugDialog.userMessage')
|
||||||
|
: t('pipelines.debugDialog.botMessage')}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px]">
|
||||||
|
{formatTimestamp(getMessageTimestamp(message))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{hoveredMessageId === message.id && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
|
onClick={() => setQuotedMessage(message)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-3 h-3 mr-1"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{t('pipelines.debugDialog.reply')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -522,6 +689,34 @@ export default function DebugDialog({
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* 引用消息预览区域 */}
|
||||||
|
{quotedMessage && (
|
||||||
|
<div className="px-4 py-2 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 pl-3 border-l-2 border-[#2288ee]">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
{t('pipelines.debugDialog.replyTo')}{' '}
|
||||||
|
{quotedMessage.role === 'user'
|
||||||
|
? t('pipelines.debugDialog.userMessage')
|
||||||
|
: t('pipelines.debugDialog.botMessage')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-700 dark:text-gray-300 line-clamp-2">
|
||||||
|
{quotedMessage.message_chain
|
||||||
|
.filter((c) => c.type === 'Plain')
|
||||||
|
.map((c) => (c as Plain).text)
|
||||||
|
.join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setQuotedMessage(null)}
|
||||||
|
className="w-5 h-5 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 图片预览区域 */}
|
{/* 图片预览区域 */}
|
||||||
{selectedImages.length > 0 && (
|
{selectedImages.length > 0 && (
|
||||||
<div className="px-4 pb-2 bg-white dark:bg-black">
|
<div className="px-4 pb-2 bg-white dark:bg-black">
|
||||||
@@ -624,7 +819,10 @@ export default function DebugDialog({
|
|||||||
<Button
|
<Button
|
||||||
onClick={sendMessage}
|
onClick={sendMessage}
|
||||||
disabled={
|
disabled={
|
||||||
(!inputValue.trim() && !hasAt && selectedImages.length === 0) ||
|
(!inputValue.trim() &&
|
||||||
|
!hasAt &&
|
||||||
|
selectedImages.length === 0 &&
|
||||||
|
!quotedMessage) ||
|
||||||
!isConnected ||
|
!isConnected ||
|
||||||
isUploading
|
isUploading
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -525,6 +525,8 @@ const enUS = {
|
|||||||
connectionFailed: 'WebSocket connection failed',
|
connectionFailed: 'WebSocket connection failed',
|
||||||
notConnected: 'WebSocket not connected, please try again later',
|
notConnected: 'WebSocket not connected, please try again later',
|
||||||
imageUploadFailed: 'Image upload failed',
|
imageUploadFailed: 'Image upload failed',
|
||||||
|
reply: 'Reply',
|
||||||
|
replyTo: 'Reply to',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
knowledge: {
|
knowledge: {
|
||||||
|
|||||||
@@ -529,6 +529,8 @@ const jaJP = {
|
|||||||
notConnected:
|
notConnected:
|
||||||
'WebSocketに接続されていません。しばらくしてからやり直してください',
|
'WebSocketに接続されていません。しばらくしてからやり直してください',
|
||||||
imageUploadFailed: '画像のアップロードに失敗しました',
|
imageUploadFailed: '画像のアップロードに失敗しました',
|
||||||
|
reply: '返信',
|
||||||
|
replyTo: '返信先',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
knowledge: {
|
knowledge: {
|
||||||
|
|||||||
@@ -507,6 +507,8 @@ const zhHans = {
|
|||||||
connectionFailed: 'WebSocket连接失败',
|
connectionFailed: 'WebSocket连接失败',
|
||||||
notConnected: 'WebSocket未连接,请稍后重试',
|
notConnected: 'WebSocket未连接,请稍后重试',
|
||||||
imageUploadFailed: '图片上传失败',
|
imageUploadFailed: '图片上传失败',
|
||||||
|
reply: '回复',
|
||||||
|
replyTo: '回复给',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
knowledge: {
|
knowledge: {
|
||||||
|
|||||||
@@ -505,6 +505,8 @@ const zhHant = {
|
|||||||
connectionFailed: 'WebSocket連接失敗',
|
connectionFailed: 'WebSocket連接失敗',
|
||||||
notConnected: 'WebSocket未連接,請稍後重試',
|
notConnected: 'WebSocket未連接,請稍後重試',
|
||||||
imageUploadFailed: '圖片上傳失敗',
|
imageUploadFailed: '圖片上傳失敗',
|
||||||
|
reply: '回覆',
|
||||||
|
replyTo: '回覆給',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
knowledge: {
|
knowledge: {
|
||||||
|
|||||||
Reference in New Issue
Block a user