perf: enhance reply functionality to support selected text quoting (#4387)
* feat(chat): enhance reply functionality to support selected text quoting * perf: improve ui * feat(chat): add label for tools used in tool calls and update translations * feat(chat): simplify reply handling by removing text truncation logic
This commit is contained in:
@@ -124,17 +124,20 @@ class WebChatAdapter(Platform):
|
||||
part_type = part.get("type")
|
||||
if part_type == "plain":
|
||||
text = part.get("text", "")
|
||||
components.append(Plain(text))
|
||||
components.append(Plain(text=text))
|
||||
text_parts.append(text)
|
||||
elif part_type == "reply":
|
||||
message_id = part.get("message_id")
|
||||
reply_chain = []
|
||||
reply_message_str = ""
|
||||
reply_message_str = part.get("selected_text", "")
|
||||
sender_id = None
|
||||
sender_name = None
|
||||
|
||||
# recursively get the content of the referenced message
|
||||
if depth < max_depth and message_id:
|
||||
if reply_message_str:
|
||||
reply_chain = [Plain(text=reply_message_str)]
|
||||
|
||||
# recursively get the content of the referenced message, if selected_text is empty
|
||||
if not reply_message_str and depth < max_depth and message_id:
|
||||
history = await self._get_message_history(message_id)
|
||||
if history and history.content:
|
||||
reply_parts = history.content.get("message", [])
|
||||
|
||||
@@ -166,7 +166,11 @@ class ChatRoute(Route):
|
||||
parts.append({"type": "plain", "text": part.get("text", "")})
|
||||
elif part_type == "reply":
|
||||
parts.append(
|
||||
{"type": "reply", "message_id": part.get("message_id")}
|
||||
{
|
||||
"type": "reply",
|
||||
"message_id": part.get("message_id"),
|
||||
"selected_text": part.get("selected_text", ""),
|
||||
}
|
||||
)
|
||||
elif attachment_id := part.get("attachment_id"):
|
||||
attachment = await self.db.get_attachment_by_id(attachment_id)
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
:isLoadingMessages="isLoadingMessages"
|
||||
@openImagePreview="openImagePreview"
|
||||
@replyMessage="handleReplyMessage"
|
||||
@replyWithText="handleReplyWithText"
|
||||
ref="messageList" />
|
||||
<div class="message-list-fade" :class="{ 'fade-dark': isDark }"></div>
|
||||
</div>
|
||||
@@ -208,7 +209,7 @@ const prompt = ref('');
|
||||
// 引用消息状态
|
||||
interface ReplyInfo {
|
||||
messageId: number; // PlatformSessionHistoryMessage 的 id
|
||||
messageContent: string; // 用于显示的消息内容
|
||||
selectedText?: string; // 选中的文本内容(可选)
|
||||
}
|
||||
const replyTo = ref<ReplyInfo | null>(null);
|
||||
|
||||
@@ -277,7 +278,7 @@ function handleReplyMessage(msg: any, index: number) {
|
||||
|
||||
replyTo.value = {
|
||||
messageId,
|
||||
messageContent: messageContent || '[媒体内容]'
|
||||
selectedText: messageContent || '[媒体内容]'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -285,6 +286,21 @@ function clearReply() {
|
||||
replyTo.value = null;
|
||||
}
|
||||
|
||||
function handleReplyWithText(replyData: any) {
|
||||
// 处理选中文本的引用
|
||||
const { messageId, selectedText, messageIndex } = replyData;
|
||||
|
||||
if (!messageId) {
|
||||
console.warn('Message does not have an id');
|
||||
return;
|
||||
}
|
||||
|
||||
replyTo.value = {
|
||||
messageId,
|
||||
selectedText: selectedText // 保存原始的选中文本
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSelectConversation(sessionIds: string[]) {
|
||||
if (!sessionIds[0]) return;
|
||||
|
||||
|
||||
@@ -11,13 +11,15 @@
|
||||
backgroundColor: isDark ? '#2d2d2d' : 'transparent'
|
||||
}">
|
||||
<!-- 引用预览区 -->
|
||||
<div class="reply-preview" v-if="props.replyTo">
|
||||
<div class="reply-content">
|
||||
<v-icon size="small" class="reply-icon">mdi-reply</v-icon>
|
||||
"<span class="reply-text">{{ props.replyTo.messageContent }}</span>"
|
||||
<transition name="slideReply" @after-leave="handleReplyAfterLeave">
|
||||
<div class="reply-preview" v-if="props.replyTo && !isReplyClosing">
|
||||
<div class="reply-content">
|
||||
<v-icon size="small" class="reply-icon">mdi-reply</v-icon>
|
||||
"<span class="reply-text">{{ props.replyTo.selectedText }}</span>"
|
||||
</div>
|
||||
<v-btn @click="handleClearReply" class="remove-reply-btn" icon="mdi-close" size="x-small" color="grey" variant="text" />
|
||||
</div>
|
||||
<v-btn @click="$emit('clearReply')" class="remove-reply-btn" icon="mdi-close" size="x-small" color="grey" variant="text" />
|
||||
</div>
|
||||
</transition>
|
||||
<textarea
|
||||
ref="inputField"
|
||||
v-model="localPrompt"
|
||||
@@ -109,7 +111,7 @@ interface StagedFileInfo {
|
||||
|
||||
interface ReplyInfo {
|
||||
messageId: number;
|
||||
messageContent: string;
|
||||
selectedText?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -155,6 +157,7 @@ const inputField = ref<HTMLTextAreaElement | null>(null);
|
||||
const imageInputRef = ref<HTMLInputElement | null>(null);
|
||||
const providerModelMenuRef = ref<InstanceType<typeof ProviderModelMenu> | null>(null);
|
||||
const showProviderSelector = ref(true);
|
||||
const isReplyClosing = ref(false);
|
||||
|
||||
const localPrompt = computed({
|
||||
get: () => props.prompt,
|
||||
@@ -173,6 +176,17 @@ const ctrlKeyDown = ref(false);
|
||||
const ctrlKeyTimer = ref<number | null>(null);
|
||||
const ctrlKeyLongPressThreshold = 300;
|
||||
|
||||
// 处理清除引用 - 触发关闭动画
|
||||
function handleClearReply() {
|
||||
isReplyClosing.value = true;
|
||||
}
|
||||
|
||||
// 动画完成后发送clearReply事件
|
||||
function handleReplyAfterLeave() {
|
||||
emit('clearReply');
|
||||
isReplyClosing.value = false;
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Enter 发送消息
|
||||
if (e.keyCode === 13 && !e.shiftKey) {
|
||||
@@ -286,6 +300,51 @@ defineExpose({
|
||||
background-color: rgba(103, 58, 183, 0.06);
|
||||
border-radius: 12px;
|
||||
gap: 8px;
|
||||
max-height: 500px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Transition animations for reply preview */
|
||||
.slideReply-enter-active {
|
||||
animation: slideDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
.slideReply-leave-active {
|
||||
animation: slideUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
to {
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.reply-content {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="messages-container" ref="messageContainer">
|
||||
<div class="messages-container" ref="messageContainer" :class="{ 'is-dark': isDark }">
|
||||
<!-- 加载指示器 -->
|
||||
<div v-if="isLoadingMessages" class="loading-overlay" :class="{ 'is-dark': isDark }">
|
||||
<v-progress-circular indeterminate size="48" width="4" color="primary"></v-progress-circular>
|
||||
</div>
|
||||
<!-- 聊天消息列表 -->
|
||||
<div class="message-list" :class="{ 'loading-blur': isLoadingMessages }">
|
||||
<div class="message-list" :class="{ 'loading-blur': isLoadingMessages }" @mouseup="handleTextSelection">
|
||||
<div class="message-item fade-in" v-for="(msg, index) in messages" :key="index">
|
||||
<!-- 用户消息 -->
|
||||
<div v-if="msg.content.type == 'user'" class="user-message">
|
||||
@@ -112,8 +112,9 @@
|
||||
<!-- Tool Calls Block -->
|
||||
<div v-if="part.type === 'tool_call' && part.tool_calls && part.tool_calls.length > 0"
|
||||
class="tool-calls-container">
|
||||
<div class="tool-calls-label">{{ tm('actions.toolsUsed') }}</div>
|
||||
<div v-for="(toolCall, tcIndex) in part.tool_calls" :key="toolCall.id"
|
||||
class="tool-call-card" :class="{ 'is-dark': isDark }" :style="isDark ? {
|
||||
class="tool-call-card" :class="{ 'is-dark': isDark, 'expanded': isToolCallExpanded(index, partIndex, tcIndex) }" :style="isDark ? {
|
||||
backgroundColor: 'rgba(40, 60, 100, 0.4)',
|
||||
borderColor: 'rgba(100, 140, 200, 0.4)'
|
||||
} : {}">
|
||||
@@ -150,7 +151,7 @@
|
||||
<span class="detail-label">ID:</span>
|
||||
<code class="detail-value"
|
||||
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ toolCall.id
|
||||
}}</code>
|
||||
}}</code>
|
||||
</div>
|
||||
<div class="tool-call-detail-row">
|
||||
<span class="detail-label">Args:</span>
|
||||
@@ -224,7 +225,7 @@
|
||||
</div>
|
||||
<div class="message-actions" v-if="!msg.content.isLoading || index === messages.length - 1">
|
||||
<span class="message-time" v-if="msg.created_at">{{ formatMessageTime(msg.created_at)
|
||||
}}</span>
|
||||
}}</span>
|
||||
<!-- Agent Stats Menu -->
|
||||
<v-menu v-if="msg.content.agentStats" location="bottom" open-on-hover
|
||||
:close-on-content-click="false">
|
||||
@@ -274,6 +275,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 浮动引用按钮 -->
|
||||
<div v-if="selectedText.content && selectedText.messageIndex !== null" class="selection-quote-button" :style="{
|
||||
top: selectedText.position.top + 'px',
|
||||
left: selectedText.position.left + 'px',
|
||||
position: 'fixed'
|
||||
}">
|
||||
<v-btn size="large" rounded="xl" @click="handleQuoteSelected" class="quote-btn"
|
||||
:class="{ 'dark-mode': isDark }">
|
||||
<v-icon left small>mdi-reply</v-icon>
|
||||
引用
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -311,7 +325,7 @@ export default {
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['openImagePreview', 'replyMessage'],
|
||||
emits: ['openImagePreview', 'replyMessage', 'replyWithText'],
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
@@ -332,6 +346,12 @@ export default {
|
||||
expandedToolCalls: new Set(), // Track which tool call cards are expanded
|
||||
elapsedTimeTimer: null, // Timer for updating elapsed time
|
||||
currentTime: Date.now() / 1000, // Current time for elapsed time calculation
|
||||
// 选中文本相关状态
|
||||
selectedText: {
|
||||
content: '',
|
||||
messageIndex: null,
|
||||
position: { top: 0, left: 0 }
|
||||
}
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
@@ -349,6 +369,86 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 处理文本选择
|
||||
handleTextSelection() {
|
||||
const selection = window.getSelection();
|
||||
const selectedText = selection.toString();
|
||||
|
||||
if (!selectedText.trim()) {
|
||||
// 清除选中状态
|
||||
this.selectedText.content = '';
|
||||
this.selectedText.messageIndex = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取被选中的元素,找到对应的message-item
|
||||
const range = selection.getRangeAt(0);
|
||||
const startContainer = range.startContainer;
|
||||
let messageItem = null;
|
||||
let node = startContainer.parentElement;
|
||||
|
||||
// 遍历DOM树向上查找message-item
|
||||
while (node && !node.classList.contains('message-item')) {
|
||||
node = node.parentElement;
|
||||
}
|
||||
|
||||
messageItem = node;
|
||||
|
||||
if (!messageItem) {
|
||||
this.selectedText.content = '';
|
||||
this.selectedText.messageIndex = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取message-item在messages数组中的索引
|
||||
const messageItems = this.$refs.messageContainer?.querySelectorAll('.message-item');
|
||||
let messageIndex = -1;
|
||||
if (messageItems) {
|
||||
for (let i = 0; i < messageItems.length; i++) {
|
||||
if (messageItems[i] === messageItem) {
|
||||
messageIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messageIndex === -1) {
|
||||
this.selectedText.content = '';
|
||||
this.selectedText.messageIndex = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取选中文本的位置(相对于viewport)
|
||||
const rect = selection.getRangeAt(0).getBoundingClientRect();
|
||||
|
||||
this.selectedText.content = selectedText;
|
||||
this.selectedText.messageIndex = messageIndex;
|
||||
this.selectedText.position = {
|
||||
top: Math.max(0, rect.bottom + 5),
|
||||
left: Math.max(0, (rect.left + rect.right) / 2)
|
||||
};
|
||||
},
|
||||
|
||||
// 处理引用选中的文本
|
||||
handleQuoteSelected() {
|
||||
if (this.selectedText.messageIndex === null) return;
|
||||
|
||||
const msg = this.messages[this.selectedText.messageIndex];
|
||||
if (!msg || !msg.id) return;
|
||||
|
||||
// 触发replyWithText事件,传递选中的文本内容
|
||||
this.$emit('replyWithText', {
|
||||
messageId: msg.id,
|
||||
selectedText: this.selectedText.content,
|
||||
messageIndex: this.selectedText.messageIndex
|
||||
});
|
||||
|
||||
// 清除选中状态
|
||||
this.selectedText.content = '';
|
||||
this.selectedText.messageIndex = null;
|
||||
window.getSelection().removeAllRanges();
|
||||
},
|
||||
|
||||
// 检查 message 中是否有音频
|
||||
hasAudio(messageParts) {
|
||||
if (!Array.isArray(messageParts)) return false;
|
||||
@@ -805,6 +905,23 @@ export default {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:deep(code.bg-secondary) {
|
||||
background-color: #ececec !important;
|
||||
color: #0d0d0d !important;
|
||||
}
|
||||
|
||||
:deep(code.rounded) {
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.messages-container.is-dark :deep(code.bg-secondary) {
|
||||
background-color: #424242 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.messages-container.is-dark :deep(.code-block-container) {
|
||||
background-color: #1f1f1f !important;
|
||||
}
|
||||
|
||||
/* 基础动画 */
|
||||
@keyframes fadeIn {
|
||||
@@ -1293,11 +1410,25 @@ export default {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.tool-calls-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--v-theme-secondaryText);
|
||||
opacity: 0.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tool-call-card {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: #eff3f6;
|
||||
margin: 8px 0px;
|
||||
max-width: 300px;
|
||||
transition: max-width 0.1s ease;
|
||||
}
|
||||
|
||||
.tool-call-card.expanded {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.tool-call-header {
|
||||
@@ -1374,6 +1505,36 @@ export default {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 浮动引用按钮样式 */
|
||||
.selection-quote-button {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
|
||||
.quote-btn {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
font-size: 14px;
|
||||
padding: 4px 24px;
|
||||
background-color: #f6f4fa !important;
|
||||
color: #333333 !important;
|
||||
}
|
||||
|
||||
.quote-btn:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
background-color: #f6f4fa !important;
|
||||
}
|
||||
|
||||
/* 深色主题 */
|
||||
.quote-btn.dark-mode {
|
||||
background-color: #2d2d2d !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.tool-call-status .status-icon.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@@ -45,13 +45,13 @@ export interface MessagePart {
|
||||
// embedded fields - 加载后填充
|
||||
embedded_url?: string; // blob URL for image, record
|
||||
embedded_file?: FileInfo; // for file (保留 attachment_id 用于按需下载)
|
||||
reply_content?: string; // for reply - 被引用消息的内容
|
||||
selected_text?: string; // for reply - 被引用消息的内容
|
||||
}
|
||||
|
||||
// 引用信息 (用于发送消息时)
|
||||
export interface ReplyInfo {
|
||||
messageId: number;
|
||||
messageContent: string;
|
||||
selectedText?: string; // 选中的文本内容(可选)
|
||||
}
|
||||
|
||||
// 简化的消息内容结构
|
||||
@@ -216,11 +216,12 @@ export function useMessages(
|
||||
const userMessageParts: MessagePart[] = [];
|
||||
|
||||
// 添加引用消息段
|
||||
console.log('ReplyTo in sendMessage:', replyTo);
|
||||
if (replyTo) {
|
||||
userMessageParts.push({
|
||||
type: 'reply',
|
||||
message_id: replyTo.messageId,
|
||||
reply_content: replyTo.messageContent
|
||||
selected_text: replyTo.selectedText
|
||||
});
|
||||
}
|
||||
|
||||
@@ -295,7 +296,8 @@ export function useMessages(
|
||||
if (replyTo) {
|
||||
parts.push({
|
||||
type: 'reply',
|
||||
message_id: replyTo.messageId
|
||||
message_id: replyTo.messageId,
|
||||
selected_text: replyTo.selectedText
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
"fullscreen": "Fullscreen Mode",
|
||||
"exitFullscreen": "Exit Fullscreen",
|
||||
"reply": "Reply",
|
||||
"providerConfig": "AI Configuration"
|
||||
"providerConfig": "AI Configuration",
|
||||
"toolsUsed": "Tool Used"
|
||||
},
|
||||
"conversation": {
|
||||
"newConversation": "New Conversation",
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
"fullscreen": "全屏模式",
|
||||
"exitFullscreen": "退出全屏",
|
||||
"reply": "引用回复",
|
||||
"providerConfig": "AI 配置"
|
||||
"providerConfig": "AI 配置",
|
||||
"toolsUsed": "已使用工具"
|
||||
},
|
||||
"conversation": {
|
||||
"newConversation": "新的聊天",
|
||||
|
||||
Reference in New Issue
Block a user