|
|
@@ -0,0 +1,544 @@
|
|
|
+<template>
|
|
|
+ <div
|
|
|
+ class="message-item mobile"
|
|
|
+ :class="[ 'type-' + message.role ]"
|
|
|
+ @longpress="onLongpress"
|
|
|
+ >
|
|
|
+ <!-- 多选模式 - 左 -->
|
|
|
+ <div v-if="!message.isUser && isSelectMode" class="select-container">
|
|
|
+ <CheckBox
|
|
|
+ :modelValue="isThisItemSelected"
|
|
|
+ @update:modelValue="setSelected($event as boolean)"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 消息内容 -->
|
|
|
+ <div class="message-content-container">
|
|
|
+
|
|
|
+ <!-- 主消息内容 -->
|
|
|
+ <div
|
|
|
+ class="message"
|
|
|
+ :class="{ 'expanded': contentExpanded }"
|
|
|
+ >
|
|
|
+ <!-- 工具图标 -->
|
|
|
+ <i v-if="message.role === 'tool'" class="prefix icon iconfont icon-Jigsaw"></i>
|
|
|
+ <!-- 系统图标 -->
|
|
|
+ <i v-else-if="message.role === 'system'" class="prefix icon iconfont icon-a-ConfusedMind "></i>
|
|
|
+ <!-- 加载和失败状态 -->
|
|
|
+ <ActivityIndicator v-if="message.state === 'loading'" class="prefix icon" />
|
|
|
+ <Icon icon="warning" v-else-if="message.state === 'error'" class="prefix icon" />
|
|
|
+
|
|
|
+ <div class="content">
|
|
|
+
|
|
|
+ <span v-if="message.statusText" :class="[ 'status-text', message.state ]">
|
|
|
+ {{ message.statusText }}
|
|
|
+ </span>
|
|
|
+
|
|
|
+ <!-- 思考内容 -->
|
|
|
+ <text v-if="message.reasoningContent" class="reasoning-content">
|
|
|
+ {{ message.reasoningContent }}
|
|
|
+ </text>
|
|
|
+
|
|
|
+ <!-- 文本内容 -->
|
|
|
+ <div v-if="message.type === 'text' && content" class="text-content">
|
|
|
+ {{ content }}
|
|
|
+ </div>
|
|
|
+ <!-- 图片内容 -->
|
|
|
+ <div v-if="message.type === 'image_url' && message.image_url" class="image-content">
|
|
|
+ <image :src="message.image_url" />
|
|
|
+ </div>
|
|
|
+ <!-- 音频内容 -->
|
|
|
+ <div v-if="message.type === 'input_audio' && message.input_audio?.data" class="audio-content">
|
|
|
+ <audio :src="message.input_audio?.data" controls />
|
|
|
+ </div>
|
|
|
+ <!-- 视频内容 -->
|
|
|
+ <div v-if="message.type === 'video' && message.content" class="video-content">
|
|
|
+ <video :src="message.content" controls />
|
|
|
+ </div>
|
|
|
+ <!-- 视频内容 -->
|
|
|
+ <div v-if="message.type === 'video_url' && message.video_url" class="video-content">
|
|
|
+ <video :src="message.video_url" controls />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 额外内容 -->
|
|
|
+ <div
|
|
|
+ v-if="message.extra"
|
|
|
+ :class="{
|
|
|
+ 'extra-container': true,
|
|
|
+ 'text-content': true,
|
|
|
+ 'expanded': extraExpanded
|
|
|
+ }"
|
|
|
+ >
|
|
|
+ <FlexCol v-if="extraExpanded" backgroundColor="background.tertiary" padding="15" radius="radius.sm">
|
|
|
+ <text class="extra-content" >
|
|
|
+ {{ message.extra }}
|
|
|
+ </text>
|
|
|
+ </FlexCol>
|
|
|
+ <div class="button-container">
|
|
|
+ <ExpandButton v-model:expanded="extraExpanded" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 额外快捷操作 -->
|
|
|
+ <div v-if="message.actions" class="actions-container">
|
|
|
+ <Button v-for="action in message.actions" :key="action" @click="emit('action', action)">
|
|
|
+ {{ action }}
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 多选模式 - 右 -->
|
|
|
+ <div v-if="message.isUser && isSelectMode" class="select-container">
|
|
|
+ <CheckBox
|
|
|
+ :modelValue="isThisItemSelected"
|
|
|
+ @update:modelValue="setSelected($event as boolean)"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script lang="ts" setup>
|
|
|
+import type { ChatMessage } from '../model/Message';
|
|
|
+import type { PropType } from 'vue';
|
|
|
+import { computed, ref, toRef, watch } from 'vue';
|
|
|
+import { useChatSelectionChild } from '../composables/useChatSelection';
|
|
|
+import { TimeUtils } from '@imengyu/imengyu-utils';
|
|
|
+import ExpandButton from './Compoents/ExpandButton.vue';
|
|
|
+import CheckBox from '@/components/form/CheckBox.vue';
|
|
|
+import ActivityIndicator from '@/components/basic/ActivityIndicator.vue';
|
|
|
+import Icon from '@/components/basic/Icon.vue';
|
|
|
+import Button from '@/components/basic/Button.vue';
|
|
|
+import FlexRow from '@/components/layout/FlexRow.vue';
|
|
|
+import FlexCol from '@/components/layout/FlexCol.vue';
|
|
|
+import { actionSheet } from '@/components/dialog/CommonRoot';
|
|
|
+
|
|
|
+const props = defineProps({
|
|
|
+ message: {
|
|
|
+ type: Object as PropType<ChatMessage>,
|
|
|
+ default: () => ({})
|
|
|
+ },
|
|
|
+ isGlobalLoading: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+const emit = defineEmits([
|
|
|
+ 'delete', 'save', 'mulitSelect',
|
|
|
+ 'action', 'confirmEdit', 'regenerate'
|
|
|
+]);
|
|
|
+
|
|
|
+//多选
|
|
|
+
|
|
|
+const {
|
|
|
+ isSelectMode,
|
|
|
+ isThisItemSelected,
|
|
|
+ setSelected,
|
|
|
+} = useChatSelectionChild(toRef(props, 'message'));
|
|
|
+
|
|
|
+//时间
|
|
|
+
|
|
|
+const time = computed(() => {
|
|
|
+ return TimeUtils.getBetterDate(props.message.timestamp)
|
|
|
+})
|
|
|
+
|
|
|
+//内容显示
|
|
|
+
|
|
|
+const reasoningContentExpanded = ref(false);
|
|
|
+const reasoningUnExpandContent = computed(() => {
|
|
|
+ if (!props.message.reasoningContent)
|
|
|
+ return '';
|
|
|
+ switch (props.message.state) {
|
|
|
+ case 'loading':
|
|
|
+ case 'error':
|
|
|
+ return '';
|
|
|
+ case 'success':
|
|
|
+ return '深度思考完成' + (props.message.reasoningTime ?
|
|
|
+ `,已思考 ${(props.message.reasoningTime / 1000).toFixed(1)}s` :
|
|
|
+ ''
|
|
|
+ );
|
|
|
+ }
|
|
|
+})
|
|
|
+const contentExpanded = ref(true);
|
|
|
+const contentExpandable = computed(() => {
|
|
|
+ return props.message.content &&
|
|
|
+ props.message.content.length > 70 &&
|
|
|
+ props.message.type === 'text'
|
|
|
+})
|
|
|
+const content = computed(() => {
|
|
|
+ const content = props.message.content;
|
|
|
+ return (contentExpanded.value && contentExpandable.value) || content.length < 70 ?
|
|
|
+ content :
|
|
|
+ (content.slice(0, 70) + '...')
|
|
|
+})
|
|
|
+const extraExpanded = ref(false);
|
|
|
+
|
|
|
+//是否显示操作
|
|
|
+
|
|
|
+const canShowActions = computed(() => {
|
|
|
+ return !props.isGlobalLoading && (props.message.role === 'user'
|
|
|
+ ? true
|
|
|
+ : (!props.message.isEphemeral && props.message.state !== 'loading'))
|
|
|
+})
|
|
|
+
|
|
|
+const isUser = computed(() => {
|
|
|
+ return props.message.role === 'user'
|
|
|
+})
|
|
|
+const isAI = computed(() => {
|
|
|
+ return props.message.role === 'assistant'
|
|
|
+})
|
|
|
+
|
|
|
+watch(() => props.message, (m) => {
|
|
|
+ //工具消息默认不展开内容
|
|
|
+ if (m.role === 'tool') {
|
|
|
+ contentExpanded.value = false;
|
|
|
+ } else {
|
|
|
+ contentExpanded.value = true;
|
|
|
+ }
|
|
|
+}, { immediate: true });
|
|
|
+
|
|
|
+let oldChangedExpanded = '';
|
|
|
+
|
|
|
+watch(isSelectMode, (v) => {
|
|
|
+ if (v) {
|
|
|
+ oldChangedExpanded = contentExpanded.value ? 'expanded' : '';
|
|
|
+ contentExpanded.value = false;
|
|
|
+ } else {
|
|
|
+ if (oldChangedExpanded) {
|
|
|
+ contentExpanded.value = oldChangedExpanded === 'expanded';
|
|
|
+ oldChangedExpanded = '';
|
|
|
+ }
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+//保存
|
|
|
+function copyMessage(message: ChatMessage) {
|
|
|
+ uni.setClipboardData({
|
|
|
+ data: message.content,
|
|
|
+ success: () => {
|
|
|
+ uni.showToast({
|
|
|
+ title: '复制成功',
|
|
|
+ icon: 'success',
|
|
|
+ });
|
|
|
+ },
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function onLongpress() {
|
|
|
+ if (!canShowActions.value)
|
|
|
+ return;
|
|
|
+ actionSheet({
|
|
|
+ title: '消息操作',
|
|
|
+ actions: [
|
|
|
+ {
|
|
|
+ name: '复制',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: '删除',
|
|
|
+ color: 'danger',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: '多选',
|
|
|
+ color: 'primary',
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ }).then((result) => {
|
|
|
+ switch (result) {
|
|
|
+ case 0:
|
|
|
+ copyMessage(props.message);
|
|
|
+ break;
|
|
|
+ case 1:
|
|
|
+ emit('delete', props.message.id);
|
|
|
+ break;
|
|
|
+ case 2:
|
|
|
+ emit('mulitSelect', [props.message.id]);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ });
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss">
|
|
|
+.message-item {
|
|
|
+ --color-background: #fff;
|
|
|
+ --color-background-light: #f5f5f5;
|
|
|
+ --color-ai: #ffffff;
|
|
|
+ --color-user: #fafafa;
|
|
|
+ --color-border: #e0e0e0;
|
|
|
+ --color-border-light: #f0f0f0;
|
|
|
+ --color-border-dark: #d0d0d0;
|
|
|
+ --color-text: #333333;
|
|
|
+ --color-text-light: #666666;
|
|
|
+ --color-content-light: #d0d0d0;
|
|
|
+ --color-content-dark: #333333;
|
|
|
+ --color-surface: #ffffff;
|
|
|
+ --color-surface-hover: #fafafa;
|
|
|
+ --border-radius: 15rpx;
|
|
|
+ --border-radius-small: 10rpx;
|
|
|
+
|
|
|
+ position: relative;
|
|
|
+ max-width: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: row;
|
|
|
+ align-items: flex-start;
|
|
|
+ gap: 10rpx;
|
|
|
+ margin-bottom: 20rpx;
|
|
|
+ user-select: text;
|
|
|
+ -webkit-user-select: text;
|
|
|
+
|
|
|
+ .message-content-container {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ justify-content: flex-start;
|
|
|
+ align-items: flex-start;
|
|
|
+ flex: 1;
|
|
|
+ max-width: 100%;
|
|
|
+ min-width: 0;
|
|
|
+ }
|
|
|
+ .message {
|
|
|
+ position: relative;
|
|
|
+ padding: 12px 15px;
|
|
|
+ border-radius: var(--border-radius);
|
|
|
+ max-width: 95%;
|
|
|
+ min-width: 0;
|
|
|
+ word-wrap: break-word;
|
|
|
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
|
+ display: flex;
|
|
|
+ flex-direction: row;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ overflow: hidden;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
|
|
+ }
|
|
|
+ &.expanded {
|
|
|
+ align-items: flex-start;
|
|
|
+ }
|
|
|
+
|
|
|
+ >.icon {
|
|
|
+ flex-shrink: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .status-container {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 2px;
|
|
|
+
|
|
|
+ .status-text {
|
|
|
+ font-size: 13px;
|
|
|
+ color: var(--color-text-light);
|
|
|
+
|
|
|
+ &.error {
|
|
|
+ color: var(--color-danger);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ //内容控制
|
|
|
+ .content {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+ font-size: 1rem;
|
|
|
+ line-height: 1.5;
|
|
|
+ color: var(--color-text);
|
|
|
+ max-width: 100%;
|
|
|
+ min-width: 0;
|
|
|
+
|
|
|
+ //不同类型的内容样式
|
|
|
+ .image-content {
|
|
|
+ img {
|
|
|
+ max-width: 300px;
|
|
|
+ height: auto;
|
|
|
+ object-fit: cover;
|
|
|
+ border-radius: var(--border-radius-small);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .audio-content {
|
|
|
+ audio {
|
|
|
+ width: 400px;
|
|
|
+ border-radius: var(--border-radius-small);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .video-content {
|
|
|
+ video {
|
|
|
+ max-width: 100%;
|
|
|
+ height: auto;
|
|
|
+ border-radius: var(--border-radius-small);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .file-content {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ }
|
|
|
+
|
|
|
+ //思考内容
|
|
|
+ .reasoning-content {
|
|
|
+ position: relative;
|
|
|
+ font-size: 0.8rem;
|
|
|
+ max-width: 500px;
|
|
|
+ color: var(--color-text-light);
|
|
|
+ }
|
|
|
+
|
|
|
+ .prefix {
|
|
|
+ display: inline-block;
|
|
|
+ margin-right: 8px;
|
|
|
+ vertical-align: middle;
|
|
|
+ }
|
|
|
+
|
|
|
+ //文本内容样式
|
|
|
+ .text-content {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .time {
|
|
|
+ font-size: 0.8rem;
|
|
|
+ color: var(--color-text-light);
|
|
|
+ margin-top: 6px;
|
|
|
+ opacity: 0.8;
|
|
|
+ }
|
|
|
+ .type-user .time {
|
|
|
+ align-self: flex-end;
|
|
|
+ }
|
|
|
+ .type-ai .time {
|
|
|
+ align-self: flex-start;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .extra-container {
|
|
|
+ position: relative;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+ max-width: 100%;
|
|
|
+
|
|
|
+ span {
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.expanded {
|
|
|
+ overflow: hidden;
|
|
|
+
|
|
|
+ .expand-icon {
|
|
|
+ transform: rotate(180deg);
|
|
|
+ }
|
|
|
+ .button-container {
|
|
|
+ position: relative;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .expand-icon {
|
|
|
+ transition: transform 0.3s ease;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .actions-container {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ justify-content: flex-start;
|
|
|
+ align-items: flex-start;
|
|
|
+ margin-top: 10px;
|
|
|
+ gap: 2px;
|
|
|
+ }
|
|
|
+ .message-actions {
|
|
|
+ position: absolute;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ bottom: -26px;
|
|
|
+ right: 6px;
|
|
|
+ left: 6px;
|
|
|
+ visibility: hidden;
|
|
|
+ z-index: 10;
|
|
|
+ gap: 12px;
|
|
|
+
|
|
|
+ > div {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .time {
|
|
|
+ font-size: 0.8rem;
|
|
|
+ color: var(--color-text-light);
|
|
|
+ margin: 0 6px;
|
|
|
+ margin-top: 3px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .select-container {
|
|
|
+ padding: 14px 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 不同消息类型的样式 */
|
|
|
+ &.type-user {
|
|
|
+ flex-direction: row-reverse;
|
|
|
+
|
|
|
+ .message {
|
|
|
+ background-color: var(--color-user);
|
|
|
+ align-self: flex-end;
|
|
|
+ border: 1px solid var(--color-border);
|
|
|
+ border-top-right-radius: 0;
|
|
|
+ }
|
|
|
+ .message-actions {
|
|
|
+ left: unset;
|
|
|
+ right: 46px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ &.type-system,
|
|
|
+ &.type-tool,
|
|
|
+ &.type-assistant {
|
|
|
+ .message {
|
|
|
+ background-color: var(--color-ai);
|
|
|
+ align-self: flex-start;
|
|
|
+ border: 1px solid var(--color-border-light);
|
|
|
+ border-top-left-radius: 0;
|
|
|
+ }
|
|
|
+ .message-actions {
|
|
|
+ left: 46px;
|
|
|
+ justify-content: flex-start;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ &.type-assistant {
|
|
|
+ .message-actions {
|
|
|
+ left: 46px;
|
|
|
+ right: 66px;
|
|
|
+ justify-content: space-between;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ &.type-tool {
|
|
|
+ .message {
|
|
|
+ border: none;
|
|
|
+ padding: 5px 10px;
|
|
|
+ box-shadow: none;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.mobile {
|
|
|
+ .message-actions {
|
|
|
+ visibility: visible;
|
|
|
+ justify-content: space-between;
|
|
|
+ left: 0px;
|
|
|
+ right: 0px;
|
|
|
+ bottom: -36px;
|
|
|
+ }
|
|
|
+ .message {
|
|
|
+ padding: 5px 10px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.message-item {
|
|
|
+ margin-bottom: 46rpx;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.type-user {
|
|
|
+ .message-actions {
|
|
|
+ justify-content: flex-end;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+</style>
|