gaoluyang
2 天以前 b64a0deae5b5d33f9e20671a68936b27f0b9b00b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
<script setup lang="ts">
import type { PropType } from 'vue';
 
import type { AiChatConversationApi } from '#/api/ai/chat/conversation';
import type { AiChatMessageApi } from '#/api/ai/chat/message';
 
import { computed, nextTick, onMounted, ref, toRefs } from 'vue';
 
import { IconifyIcon, SvgGptIcon } from '@vben/icons';
import { preferences } from '@vben/preferences';
import { useUserStore } from '@vben/stores';
import { formatDateTime } from '@vben/utils';
 
import { useClipboard } from '@vueuse/core';
import { Avatar, Button, message } from 'ant-design-vue';
 
import { deleteChatMessage } from '#/api/ai/chat/message';
import { MarkdownView } from '#/components/markdown-view';
 
import MessageFiles from './files.vue';
import MessageKnowledge from './knowledge.vue';
import MessageReasoning from './reasoning.vue';
import MessageWebSearch from './web-search.vue';
 
const props = defineProps({
  conversation: {
    type: Object as PropType<AiChatConversationApi.ChatConversation>,
    required: true,
  },
  list: {
    type: Array as PropType<AiChatMessageApi.ChatMessage[]>,
    required: true,
  },
});
 
const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit']);
const { copy } = useClipboard(); // 初始化 copy 到粘贴板
const userStore = useUserStore();
 
// 判断“消息列表”滚动的位置(用于判断是否需要滚动到消息最下方)
const messageContainer: any = ref(null);
const isScrolling = ref(false); // 用于判断用户是否在滚动
 
const userAvatar = computed(
  () => userStore.userInfo?.avatar || preferences.app.defaultAvatar,
);
 
const { list } = toRefs(props); // 定义 emits
 
// ============ 处理对话滚动 ==============
 
/** 滚动到底部 */
async function scrollToBottom(isIgnore?: boolean) {
  // 注意要使用 nextTick 以免获取不到 dom
  await nextTick();
  if (isIgnore || !isScrolling.value) {
    messageContainer.value.scrollTop =
      messageContainer.value.scrollHeight - messageContainer.value.offsetHeight;
  }
}
 
function handleScroll() {
  const scrollContainer = messageContainer.value;
  const scrollTop = scrollContainer.scrollTop;
  const scrollHeight = scrollContainer.scrollHeight;
  const offsetHeight = scrollContainer.offsetHeight;
  isScrolling.value = scrollTop + offsetHeight < scrollHeight - 100;
}
 
/** 回到底部 */
async function handleGoBottom() {
  const scrollContainer = messageContainer.value;
  scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
 
/** 回到顶部 */
async function handlerGoTop() {
  const scrollContainer = messageContainer.value;
  scrollContainer.scrollTop = 0;
}
 
defineExpose({ scrollToBottom, handlerGoTop }); // 提供方法给 parent 调用
 
// ============ 处理消息操作 ==============
 
/** 复制 */
async function copyContent(content: string) {
  await copy(content);
  message.success('复制成功!');
}
 
/** 删除 */
async function handleDelete(id: number) {
  // 删除 message
  await deleteChatMessage(id);
  message.success('删除成功!');
  // 回调
  emits('onDeleteSuccess');
}
 
/** 刷新 */
async function handleRefresh(message: AiChatMessageApi.ChatMessage) {
  emits('onRefresh', message);
}
 
/** 编辑 */
async function handleEdit(message: AiChatMessageApi.ChatMessage) {
  emits('onEdit', message);
}
 
/** 初始化 */
onMounted(async () => {
  messageContainer.value.addEventListener('scroll', handleScroll);
});
</script>
<template>
  <div ref="messageContainer" class="relative h-full overflow-y-auto">
    <div
      v-for="(item, index) in list"
      :key="index"
      class="mt-12 flex flex-col overflow-y-hidden px-5"
    >
      <!-- 左侧消息:system、assistant -->
      <div v-if="item.type !== 'user'" class="flex flex-row">
        <div class="avatar">
          <Avatar
            v-if="conversation.roleAvatar"
            :src="conversation.roleAvatar"
            :size="28"
          />
          <SvgGptIcon v-else class="size-7" />
        </div>
        <div class="mx-4 flex flex-col text-left">
          <div class="text-left leading-10">
            {{ formatDateTime(item.createTime) }}
          </div>
          <div
            class="relative flex flex-col break-words rounded-lg bg-gray-100 p-2.5 pb-1 pt-2.5 shadow-sm"
          >
            <MessageReasoning
              :reasoning-content="item.reasoningContent || ''"
              :content="item.content || ''"
            />
            <MarkdownView
              class="text-sm text-gray-600"
              :content="item.content"
            />
            <MessageFiles :attachment-urls="item.attachmentUrls" />
            <MessageKnowledge v-if="item.segments" :segments="item.segments" />
            <MessageWebSearch
              v-if="item.webSearchPages"
              :web-search-pages="item.webSearchPages"
            />
          </div>
          <div class="mt-2 flex flex-row">
            <Button
              class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
              type="text"
              @click="copyContent(item.content)"
            >
              <IconifyIcon icon="lucide:copy" />
            </Button>
            <Button
              v-if="item.id > 0"
              class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
              type="text"
              @click="handleDelete(item.id)"
            >
              <IconifyIcon icon="lucide:trash" />
            </Button>
          </div>
        </div>
      </div>
 
      <!-- 右侧消息:user -->
      <div v-else class="flex flex-row-reverse justify-start">
        <div class="avatar">
          <Avatar :src="userAvatar" :size="28" />
        </div>
        <div class="mx-4 flex flex-col text-left">
          <div class="text-left leading-8">
            {{ formatDateTime(item.createTime) }}
          </div>
          <div
            v-if="item.attachmentUrls && item.attachmentUrls.length > 0"
            class="mb-2 flex flex-row-reverse"
          >
            <MessageFiles :attachment-urls="item.attachmentUrls" />
          </div>
          <div class="flex flex-row-reverse">
            <div
              v-if="item.content && item.content.trim()"
              class="inline w-auto whitespace-pre-wrap break-words rounded-lg bg-blue-500 p-2.5 text-sm text-white shadow-sm"
            >
              {{ item.content }}
            </div>
          </div>
          <div class="mt-2 flex flex-row-reverse">
            <Button
              class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
              type="text"
              @click="copyContent(item.content)"
            >
              <IconifyIcon icon="lucide:copy" />
            </Button>
            <Button
              class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
              type="text"
              @click="handleDelete(item.id)"
            >
              <IconifyIcon icon="lucide:trash" />
            </Button>
            <Button
              class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
              type="text"
              @click="handleRefresh(item)"
            >
              <IconifyIcon icon="lucide:refresh-cw" />
            </Button>
            <Button
              class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
              type="text"
              @click="handleEdit(item)"
            >
              <IconifyIcon icon="lucide:edit" />
            </Button>
          </div>
        </div>
      </div>
    </div>
  </div>
 
  <!-- 回到底部按钮 -->
  <div
    v-if="isScrolling"
    class="absolute bottom-0 right-1/2 z-1000"
    @click="handleGoBottom"
  >
    <Button shape="circle">
      <IconifyIcon icon="lucide:chevron-down" />
    </Button>
  </div>
</template>