feat: 对话中消息编辑与重新生成与删除功能
This commit is contained in:
@@ -1,5 +1,19 @@
|
|||||||
import type { ChatMessageVo, GetChatListParams, SendDTO } from './types';
|
import type { ChatMessageVo, GetChatListParams, SendDTO } from './types';
|
||||||
import { get, post } from '@/utils/request';
|
import { del, get, post } from '@/utils/request';
|
||||||
|
|
||||||
|
// 删除消息接口
|
||||||
|
export interface DeleteMessageParams {
|
||||||
|
ids: (number | string)[];
|
||||||
|
isDeleteSubsequent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteMessages(data: DeleteMessageParams) {
|
||||||
|
const idsQuery = data.ids.map(id => `ids=${encodeURIComponent(id)}`).join('&');
|
||||||
|
const subsequentQuery = data.isDeleteSubsequent !== undefined ? `isDeleteSubsequent=${data.isDeleteSubsequent}` : '';
|
||||||
|
const query = [idsQuery, subsequentQuery].filter(Boolean).join('&');
|
||||||
|
const url = `/message${query ? `?${query}` : ''}`;
|
||||||
|
return del<void>(url).json();
|
||||||
|
}
|
||||||
|
|
||||||
// 发送消息(旧接口)
|
// 发送消息(旧接口)
|
||||||
export function send(data: SendDTO) {
|
export function send(data: SendDTO) {
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export interface GetChatListParams {
|
|||||||
/**
|
/**
|
||||||
* 主键
|
* 主键
|
||||||
*/
|
*/
|
||||||
id?: number;
|
id?: number | string;
|
||||||
/**
|
/**
|
||||||
* 排序的方向desc或者asc
|
* 排序的方向desc或者asc
|
||||||
*/
|
*/
|
||||||
@@ -195,7 +195,7 @@ export interface ChatMessageVo {
|
|||||||
/**
|
/**
|
||||||
* 主键
|
* 主键
|
||||||
*/
|
*/
|
||||||
id?: number;
|
id?: number | string;
|
||||||
/**
|
/**
|
||||||
* 模型名称
|
* 模型名称
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { createOrder, getOrderStatus } from '@/api';
|
|||||||
import { getGoodsList, GoodsCategoryType } from '@/api/pay';
|
import { getGoodsList, GoodsCategoryType } from '@/api/pay';
|
||||||
import ProductPage from '@/pages/products/index.vue';
|
import ProductPage from '@/pages/products/index.vue';
|
||||||
import { useUserStore } from '@/stores';
|
import { useUserStore } from '@/stores';
|
||||||
import NewbieGuide from './NewbieGuide.vue';
|
|
||||||
import ActivationGuide from './ActivationGuide.vue';
|
import ActivationGuide from './ActivationGuide.vue';
|
||||||
|
import NewbieGuide from './NewbieGuide.vue';
|
||||||
import PackageTab from './PackageTab.vue';
|
import PackageTab from './PackageTab.vue';
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close']);
|
||||||
@@ -171,7 +171,7 @@ const benefitsData2 = {
|
|||||||
qy: [
|
qy: [
|
||||||
{ name: '需先成为意心会员后方可购买使用', value: '' },
|
{ name: '需先成为意心会员后方可购买使用', value: '' },
|
||||||
{ name: '意心会员过期后,尊享Token包会临时冻结', value: '' },
|
{ name: '意心会员过期后,尊享Token包会临时冻结', value: '' },
|
||||||
{ name: '可重复购买,将自动累积Token,在个人中心查看', value: '' },
|
{ name: '尊享Token = 实际消耗Token * 当前模型倍率,模型倍率可前往【模型库】查看', value: '' },
|
||||||
{ name: 'Token长期有效,无限流限制', value: '' },
|
{ name: 'Token长期有效,无限流限制', value: '' },
|
||||||
{ name: '几乎是全网最低价,让人人用的起Agent', value: '' },
|
{ name: '几乎是全网最低价,让人人用的起Agent', value: '' },
|
||||||
{ name: '附带claude code独家教程,手把手对接', value: '' },
|
{ name: '附带claude code独家教程,手把手对接', value: '' },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup xmlns="http://www.w3.org/1999/html">
|
||||||
/**
|
/**
|
||||||
* 翻牌抽奖活动组件
|
* 翻牌抽奖活动组件
|
||||||
* 功能说明:
|
* 功能说明:
|
||||||
@@ -914,7 +914,13 @@ function getCardClass(record: CardFlipRecord): string[] {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lucky-label">
|
<div class="lucky-label">
|
||||||
翻牌幸运值
|
<div class="lucky-main-text">
|
||||||
|
<span class="fire-icon">🔥</span>幸运值{{ luckyValue }}%
|
||||||
|
</div>
|
||||||
|
<div class="lucky-sub-text">
|
||||||
|
<span v-if="luckyValue < 100" class="lucky-highlight bounce">继续翻!后面奖励超高</span>
|
||||||
|
<span v-else class="lucky-highlight full">幸运值满!奖励MAX</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1283,20 +1289,23 @@ function getCardClass(record: CardFlipRecord): string[] {
|
|||||||
|
|
||||||
/* 幸运值悬浮球 */
|
/* 幸运值悬浮球 */
|
||||||
.lucky-float-ball {
|
.lucky-float-ball {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
bottom: 16px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
bottom: 20px;
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateX(-50%) scale(1.1);
|
transform: translateX(-50%) scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
bottom: 15px;
|
bottom: 12px;
|
||||||
|
|
||||||
.lucky-circle {
|
.lucky-circle {
|
||||||
width: 70px;
|
width: 70px;
|
||||||
@@ -1312,16 +1321,14 @@ function getCardClass(record: CardFlipRecord): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lucky-label {
|
.lucky-label {
|
||||||
font-size: 11px;
|
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.lucky-full {
|
&.lucky-full {
|
||||||
animation: lucky-celebration 2s ease-in-out infinite;
|
|
||||||
|
|
||||||
.lucky-circle {
|
.lucky-circle {
|
||||||
box-shadow: 0 0 20px rgba(255, 215, 0, 0.8), 0 0 40px rgba(255, 215, 0, 0.6);
|
box-shadow: 0 0 20px rgba(255, 215, 0, 0.8), 0 0 40px rgba(255, 215, 0, 0.6);
|
||||||
|
animation: lucky-celebration 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lucky-content {
|
.lucky-content {
|
||||||
@@ -1392,11 +1399,109 @@ function getCardClass(record: CardFlipRecord): string[] {
|
|||||||
.lucky-label {
|
.lucky-label {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 12px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 140px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
|
||||||
|
.lucky-main-text {
|
||||||
|
font-size: 14px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: rgba(255, 255, 255, 0.95);
|
color: #fff;
|
||||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.fire-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
animation: fireShake 0.5s ease-in-out infinite;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lucky-sub-text {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lucky-highlight {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&.bounce {
|
||||||
|
background: linear-gradient(90deg, #ff6b6b 0%, #ffd93d 50%, #ff6b6b 100%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 10px rgba(255, 107, 107, 0.5);
|
||||||
|
animation: gradientMove 2s linear infinite, bounceHint 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.full {
|
||||||
|
background: linear-gradient(135deg, #FFD700 0%, #ff6b9d 50%, #c06c84 100%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 15px rgba(255, 215, 0, 0.8);
|
||||||
|
animation: gradientMove 2s linear infinite, glowPulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
margin-top: 4px;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 80px;
|
||||||
|
max-width: 120px;
|
||||||
|
|
||||||
|
.lucky-main-text {
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
.fire-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lucky-highlight {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fireShake {
|
||||||
|
0%, 100% { transform: rotate(-5deg) scale(1); }
|
||||||
|
50% { transform: rotate(5deg) scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounceHint {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-3px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradientMove {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
100% { background-position: 200% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glowPulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 10px rgba(255, 215, 0, 0.6); }
|
||||||
|
50% { box-shadow: 0 0 20px rgba(255, 215, 0, 1), 0 0 30px rgba(255, 107, 157, 0.5); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes lucky-celebration {
|
@keyframes lucky-celebration {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const contactConfig = {
|
|||||||
// 二维码图片路径
|
// 二维码图片路径
|
||||||
images: {
|
images: {
|
||||||
customerService: ' https://ccnetcore.com/prod-api/wwwroot/aihub/wx.png ', // 客服微信二维码
|
customerService: ' https://ccnetcore.com/prod-api/wwwroot/aihub/wx.png ', // 客服微信二维码
|
||||||
communityGroup: ' https://ccnetcore.com/prod-api/wwwroot/aihub/jlq.png', // 交流群二维码
|
communityGroup: ' https://ccnetcore.com/prod-api/wwwroot/aihub/jlq_yxai.png', // 交流群二维码
|
||||||
afterSalesGroup: '', // 售后群二维码
|
afterSalesGroup: '', // 售后群二维码
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
|
|||||||
import type { BubbleListInstance } from 'vue-element-plus-x/types/BubbleList';
|
import type { BubbleListInstance } from 'vue-element-plus-x/types/BubbleList';
|
||||||
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
|
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
|
||||||
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
|
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
|
||||||
import { ArrowLeftBold, ArrowRightBold, Document, Loading } from '@element-plus/icons-vue';
|
import type { UnifiedMessage } from '@/utils/apiFormatConverter';
|
||||||
|
import { ArrowLeftBold, ArrowRightBold, Clock, Delete, Document, DocumentCopy, Edit, Loading, Refresh } from '@element-plus/icons-vue';
|
||||||
import { ElIcon, ElMessage } from 'element-plus';
|
import { ElIcon, ElMessage } from 'element-plus';
|
||||||
import { useHookFetch } from 'hook-fetch/vue';
|
import { useHookFetch } from 'hook-fetch/vue';
|
||||||
import mammoth from 'mammoth';
|
import mammoth from 'mammoth';
|
||||||
@@ -14,11 +15,11 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
|||||||
import { Sender } from 'vue-element-plus-x';
|
import { Sender } from 'vue-element-plus-x';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { unifiedSend } from '@/api';
|
import { deleteMessages, unifiedSend } from '@/api';
|
||||||
|
import MarkedMarkdown from '@/components/MarkedMarkdown/index.vue';
|
||||||
import ModelSelect from '@/components/ModelSelect/index.vue';
|
import ModelSelect from '@/components/ModelSelect/index.vue';
|
||||||
import Collapse from '@/layouts/components/Header/components/Collapse.vue';
|
|
||||||
import { parseStreamChunk, convertToApiFormat, type UnifiedMessage } from '@/utils/apiFormatConverter';
|
|
||||||
|
|
||||||
|
import Collapse from '@/layouts/components/Header/components/Collapse.vue';
|
||||||
import CreateChat from '@/layouts/components/Header/components/CreateChat.vue';
|
import CreateChat from '@/layouts/components/Header/components/CreateChat.vue';
|
||||||
import TitleEditing from '@/layouts/components/Header/components/TitleEditing.vue';
|
import TitleEditing from '@/layouts/components/Header/components/TitleEditing.vue';
|
||||||
import { useDesignStore, useGuideTourStore } from '@/stores';
|
import { useDesignStore, useGuideTourStore } from '@/stores';
|
||||||
@@ -27,8 +28,7 @@ import { useFilesStore } from '@/stores/modules/files';
|
|||||||
import { useModelStore } from '@/stores/modules/model';
|
import { useModelStore } from '@/stores/modules/model';
|
||||||
import { useSessionStore } from '@/stores/modules/session';
|
import { useSessionStore } from '@/stores/modules/session';
|
||||||
import { useUserStore } from '@/stores/modules/user';
|
import { useUserStore } from '@/stores/modules/user';
|
||||||
import { getUserProfilePicture, systemProfilePicture } from '@/utils/user.ts';
|
import { convertToApiFormat, parseStreamChunk } from '@/utils/apiFormatConverter';
|
||||||
import MarkedMarkdown from '@/components/MarkedMarkdown/index.vue';
|
|
||||||
import '@/styles/github-markdown.css';
|
import '@/styles/github-markdown.css';
|
||||||
import '@/styles/yixin-markdown.scss';
|
import '@/styles/yixin-markdown.scss';
|
||||||
|
|
||||||
@@ -38,14 +38,18 @@ const sessionStore = useSessionStore();
|
|||||||
const currentSession = computed(() => sessionStore.currentSession);
|
const currentSession = computed(() => sessionStore.currentSession);
|
||||||
|
|
||||||
type MessageItem = BubbleProps & {
|
type MessageItem = BubbleProps & {
|
||||||
key: number;
|
key: number | string;
|
||||||
role: 'ai' | 'user' | 'assistant';
|
id?: number | string; // 消息ID,用于删除
|
||||||
avatar: string;
|
role: 'ai' | 'user' | 'assistant' | string;
|
||||||
|
avatar?: string;
|
||||||
|
showAvatar?: boolean; // 是否显示头像
|
||||||
thinkingStatus?: ThinkingStatus;
|
thinkingStatus?: ThinkingStatus;
|
||||||
thinlCollapse?: boolean;
|
thinlCollapse?: boolean;
|
||||||
reasoning_content?: string;
|
reasoning_content?: string;
|
||||||
images?: Array<{ url: string; name?: string }>; // 用户消息中的图片列表
|
images?: Array<{ url: string; name?: string }>; // 用户消息中的图片列表
|
||||||
files?: Array<{ name: string; size: number }>; // 用户消息中的文件列表
|
files?: Array<{ name: string; size: number }>; // 用户消息中的文件列表
|
||||||
|
creationTime?: string;
|
||||||
|
tokenUsage?: { prompt: number; completion: number; total: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -67,6 +71,18 @@ const bubbleItems = ref<MessageItem[]>([]);
|
|||||||
const bubbleListRef = ref<BubbleListInstance | null>(null);
|
const bubbleListRef = ref<BubbleListInstance | null>(null);
|
||||||
const isSending = ref(false);
|
const isSending = ref(false);
|
||||||
|
|
||||||
|
// 删除模式相关状态
|
||||||
|
const isDeleteMode = ref(false);
|
||||||
|
const selectedMessageIds = ref<(number | string)[]>([]);
|
||||||
|
const deleteTargetMessage = ref<MessageItem | null>(null); // 记录触发删除的消息
|
||||||
|
|
||||||
|
// 编辑模式相关状态
|
||||||
|
const editingMessageKey = ref<number | string | null>(null);
|
||||||
|
const editingContent = ref('');
|
||||||
|
|
||||||
|
// 临时ID计数器(用于新消息)
|
||||||
|
let tempIdCounter = -1;
|
||||||
|
|
||||||
// 文件处理相关常量(与 FilesSelect 保持一致)
|
// 文件处理相关常量(与 FilesSelect 保持一致)
|
||||||
const MAX_FILE_SIZE = 3 * 1024 * 1024;
|
const MAX_FILE_SIZE = 3 * 1024 * 1024;
|
||||||
const MAX_TOTAL_CONTENT_LENGTH = 150000;
|
const MAX_TOTAL_CONTENT_LENGTH = 150000;
|
||||||
@@ -82,10 +98,10 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dis
|
|||||||
const currentRequestApiType = ref<string>('');
|
const currentRequestApiType = ref<string>('');
|
||||||
|
|
||||||
// 创建统一发送请求的包装函数
|
// 创建统一发送请求的包装函数
|
||||||
const unifiedSendWrapper = (params: any) => {
|
function unifiedSendWrapper(params: any) {
|
||||||
const { data, apiType, modelId, sessionId } = params;
|
const { data, apiType, modelId, sessionId } = params;
|
||||||
return unifiedSend(data, apiType, modelId, sessionId);
|
return unifiedSend(data, apiType, modelId, sessionId);
|
||||||
};
|
}
|
||||||
|
|
||||||
const { stream, loading: isLoading, cancel } = useHookFetch({
|
const { stream, loading: isLoading, cancel } = useHookFetch({
|
||||||
request: unifiedSendWrapper,
|
request: unifiedSendWrapper,
|
||||||
@@ -113,6 +129,11 @@ let isThinking = false;
|
|||||||
watch(
|
watch(
|
||||||
() => route.params?.id,
|
() => route.params?.id,
|
||||||
async (_id_) => {
|
async (_id_) => {
|
||||||
|
// 切换会话时重置状态
|
||||||
|
exitDeleteMode();
|
||||||
|
cancelEdit();
|
||||||
|
tempIdCounter = -1;
|
||||||
|
|
||||||
if (_id_) {
|
if (_id_) {
|
||||||
if (_id_ !== 'not_login') {
|
if (_id_ !== 'not_login') {
|
||||||
// 判断的当前会话id是否有聊天记录,有缓存则直接赋值展示
|
// 判断的当前会话id是否有聊天记录,有缓存则直接赋值展示
|
||||||
@@ -220,14 +241,15 @@ function handleError(err: any) {
|
|||||||
* 发送消息并处理流式响应
|
* 发送消息并处理流式响应
|
||||||
* 支持发送文本、图片和文件
|
* 支持发送文本、图片和文件
|
||||||
* @param {string} chatContent - 用户输入的文本内容
|
* @param {string} chatContent - 用户输入的文本内容
|
||||||
|
* @param {boolean} skipAddUserMessage - 是否跳过添加用户消息(用于重新生成)
|
||||||
*/
|
*/
|
||||||
async function startSSE(chatContent: string) {
|
async function startSSE(chatContent: string, skipAddUserMessage = false) {
|
||||||
if (isSending.value)
|
if (isSending.value)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// 检查是否有未上传完成的文件
|
// 检查是否有未上传完成的文件
|
||||||
const hasUnuploadedFiles = filesStore.filesList.some(f => !f.isUploaded);
|
const hasUnuploadedFiles = filesStore.filesList.some(f => !f.isUploaded);
|
||||||
if (hasUnuploadedFiles) {
|
if (hasUnuploadedFiles && !skipAddUserMessage) {
|
||||||
ElMessage.warning('文件正在上传中,请稍候...');
|
ElMessage.warning('文件正在上传中,请稍候...');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -261,7 +283,10 @@ async function startSSE(chatContent: string) {
|
|||||||
size: f.fileSize!,
|
size: f.fileSize!,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 如果不是跳过添加用户消息(正常发送模式),则添加用户消息
|
||||||
|
if (!skipAddUserMessage) {
|
||||||
addMessage(chatContent, true, images, files);
|
addMessage(chatContent, true, images, files);
|
||||||
|
}
|
||||||
addMessage('', false);
|
addMessage('', false);
|
||||||
|
|
||||||
// 立即清空文件列表(不要等到响应完成)
|
// 立即清空文件列表(不要等到响应完成)
|
||||||
@@ -429,22 +454,25 @@ async function cancelSSE() {
|
|||||||
* @param {Array<{name: string, size: number}>} files - 文件列表(可选)
|
* @param {Array<{name: string, size: number}>} files - 文件列表(可选)
|
||||||
*/
|
*/
|
||||||
function addMessage(message: string, isUser: boolean, images?: Array<{ url: string; name?: string }>, files?: Array<{ name: string; size: number }>) {
|
function addMessage(message: string, isUser: boolean, images?: Array<{ url: string; name?: string }>, files?: Array<{ name: string; size: number }>) {
|
||||||
const i = bubbleItems.value.length;
|
const tempId = tempIdCounter--;
|
||||||
const obj: MessageItem = {
|
const obj: MessageItem = {
|
||||||
key: i,
|
key: tempId,
|
||||||
avatar: isUser
|
id: tempId,
|
||||||
? getUserProfilePicture()
|
// 头像不显示(后续可能会显示)
|
||||||
: systemProfilePicture,
|
// avatar: isUser ? getUserProfilePicture() : systemProfilePicture,
|
||||||
avatarSize: '32px',
|
// avatarSize: '32px',
|
||||||
role: isUser ? 'user' : 'assistant',
|
role: isUser ? 'user' : 'assistant',
|
||||||
placement: isUser ? 'end' : 'start',
|
placement: isUser ? 'end' : 'start',
|
||||||
|
// 用户消息:气泡形状;AI消息:无气泡形状,宽度100%
|
||||||
isMarkdown: !isUser,
|
isMarkdown: !isUser,
|
||||||
loading: !isUser,
|
loading: !isUser,
|
||||||
content: message || '',
|
content: message || '',
|
||||||
reasoning_content: '',
|
reasoning_content: '',
|
||||||
thinkingStatus: 'start',
|
thinkingStatus: 'start',
|
||||||
thinlCollapse: false,
|
thinlCollapse: false,
|
||||||
|
// AI消息使用 noStyle 去除气泡样式,宽度100%
|
||||||
noStyle: !isUser,
|
noStyle: !isUser,
|
||||||
|
shape: isUser ? 'corner' : undefined,
|
||||||
images: images && images.length > 0 ? images : undefined,
|
images: images && images.length > 0 ? images : undefined,
|
||||||
files: files && files.length > 0 ? files : undefined,
|
files: files && files.length > 0 ? files : undefined,
|
||||||
};
|
};
|
||||||
@@ -495,6 +523,188 @@ function copy(item: any) {
|
|||||||
.catch(() => ElMessage.error('复制失败'));
|
.catch(() => ElMessage.error('复制失败'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进入删除模式(从指定消息开始)
|
||||||
|
* @param item - 触发删除的消息
|
||||||
|
*/
|
||||||
|
function enterDeleteMode(item?: MessageItem) {
|
||||||
|
isDeleteMode.value = true;
|
||||||
|
selectedMessageIds.value = [];
|
||||||
|
deleteTargetMessage.value = item || null;
|
||||||
|
|
||||||
|
// 如果指定了消息,自动选中该消息及其之后的所有消息
|
||||||
|
if (item && item.id) {
|
||||||
|
const itemIndex = bubbleItems.value.findIndex(msg => msg.key === item.key);
|
||||||
|
if (itemIndex !== -1) {
|
||||||
|
const messagesToDelete = bubbleItems.value.slice(itemIndex);
|
||||||
|
selectedMessageIds.value = messagesToDelete.filter(msg => msg.id).map(msg => msg.id!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出删除模式
|
||||||
|
*/
|
||||||
|
function exitDeleteMode() {
|
||||||
|
isDeleteMode.value = false;
|
||||||
|
selectedMessageIds.value = [];
|
||||||
|
deleteTargetMessage.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换消息选中状态
|
||||||
|
*/
|
||||||
|
function toggleMessageSelection(item: MessageItem) {
|
||||||
|
if (!item.id)
|
||||||
|
return;
|
||||||
|
const index = selectedMessageIds.value.indexOf(item.id);
|
||||||
|
if (index > -1) {
|
||||||
|
selectedMessageIds.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
selectedMessageIds.value.push(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认删除选中的消息
|
||||||
|
*/
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (selectedMessageIds.value.length === 0) {
|
||||||
|
ElMessage.warning('请选择要删除的消息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 区分已保存消息(有有效ID)和临时消息(负数ID或undefined)
|
||||||
|
const savedIds = selectedMessageIds.value.filter(id => typeof id === 'string' || (typeof id === 'number' && id > 0));
|
||||||
|
const tempIds = selectedMessageIds.value.filter(id => typeof id === 'number' && id < 0);
|
||||||
|
console.log('savedIds---', savedIds);
|
||||||
|
try {
|
||||||
|
// 只有已保存的消息才调用删除接口
|
||||||
|
if (savedIds.length > 0) {
|
||||||
|
await deleteMessages({ ids: savedIds, isDeleteSubsequent: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从本地列表中移除已删除的消息(包括临时消息)
|
||||||
|
bubbleItems.value = bubbleItems.value.filter((item) => {
|
||||||
|
const itemId = item.id;
|
||||||
|
if (itemId === undefined)
|
||||||
|
return true;
|
||||||
|
return !savedIds.includes(itemId) && !tempIds.includes(itemId as number);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
if (route.params?.id && route.params.id !== 'not_login') {
|
||||||
|
chatStore.chatMap[`${route.params.id}`] = bubbleItems.value as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.success('删除成功');
|
||||||
|
exitDeleteMode();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
ElMessage.error('删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新生成AI消息
|
||||||
|
* 删除对应的用户消息及之后的所有消息,然后重新发送用户消息
|
||||||
|
* 这样可以避免后端重复保存用户消息(因为旧的用户消息已被删除)
|
||||||
|
*/
|
||||||
|
async function regenerateMessage(item: MessageItem) {
|
||||||
|
if (isSending.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const itemIndex = bubbleItems.value.findIndex(msg => msg.key === item.key);
|
||||||
|
if (itemIndex === -1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// 找到该AI消息对应的用户消息(往前找最近的一条用户消息)
|
||||||
|
let targetUserMessageIndex = -1;
|
||||||
|
for (let i = itemIndex - 1; i >= 0; i--) {
|
||||||
|
if (bubbleItems.value[i]?.role === 'user') {
|
||||||
|
targetUserMessageIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUserMessageIndex === -1) {
|
||||||
|
ElMessage.error('未找到对应的用户消息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户消息的ID(只传这一个ID,后端会根据 isDeleteSubsequent 删除后续消息)
|
||||||
|
const targetUserMessage = bubbleItems.value[targetUserMessageIndex];
|
||||||
|
const userMessageId = targetUserMessage.id;
|
||||||
|
const userContent = targetUserMessage.content || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用删除接口(只传用户消息ID,后端会自动删除后续消息)
|
||||||
|
if (userMessageId !== undefined && (typeof userMessageId === 'string' || (typeof userMessageId === 'number' && userMessageId > 0))) {
|
||||||
|
await deleteMessages({ ids: [userMessageId], isDeleteSubsequent: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从本地列表中移除(包括临时消息)
|
||||||
|
bubbleItems.value = bubbleItems.value.slice(0, targetUserMessageIndex);
|
||||||
|
|
||||||
|
// 调用 startSSE 重新发送用户消息(这会添加新的用户消息和AI消息,然后发送请求)
|
||||||
|
await startSSE(userContent);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
ElMessage.error('重新生成失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进入编辑模式
|
||||||
|
*/
|
||||||
|
function startEditMessage(item: MessageItem) {
|
||||||
|
editingMessageKey.value = item.key;
|
||||||
|
editingContent.value = item.content || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消编辑
|
||||||
|
*/
|
||||||
|
function cancelEdit() {
|
||||||
|
editingMessageKey.value = null;
|
||||||
|
editingContent.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交编辑后的消息
|
||||||
|
* 删除当前消息及之后的消息,发送新消息
|
||||||
|
*/
|
||||||
|
async function submitEditMessage(item: MessageItem) {
|
||||||
|
if (isSending.value || !editingContent.value.trim())
|
||||||
|
return;
|
||||||
|
|
||||||
|
const itemIndex = bubbleItems.value.findIndex(msg => msg.key === item.key);
|
||||||
|
if (itemIndex === -1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// 获取当前编辑消息的ID(只传这一个ID,后端会根据 isDeleteSubsequent 删除后续消息)
|
||||||
|
const messageId = item.id;
|
||||||
|
const newContent = editingContent.value.trim();
|
||||||
|
cancelEdit();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用删除接口(只传当前消息ID,后端会自动删除后续消息)
|
||||||
|
if (messageId !== undefined && (typeof messageId === 'string' || (typeof messageId === 'number' && messageId > 0))) {
|
||||||
|
await deleteMessages({ ids: [messageId], isDeleteSubsequent: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从本地列表中移除(包括临时消息)
|
||||||
|
bubbleItems.value = bubbleItems.value.slice(0, itemIndex);
|
||||||
|
|
||||||
|
// 发送新消息
|
||||||
|
await startSSE(newContent);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
ElMessage.error('编辑失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 图片预览
|
* 图片预览
|
||||||
* 在新窗口中打开图片
|
* 在新窗口中打开图片
|
||||||
@@ -572,10 +782,47 @@ function isTextFile(file: File): boolean {
|
|||||||
return true;
|
return true;
|
||||||
|
|
||||||
const textExtensions = [
|
const textExtensions = [
|
||||||
'txt', 'log', 'md', 'markdown', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'conf', 'config',
|
'txt',
|
||||||
'js', 'jsx', 'ts', 'tsx', 'vue', 'html', 'htm', 'css', 'scss', 'sass', 'less',
|
'log',
|
||||||
'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'py', 'rb', 'go', 'rs', 'swift', 'kt', 'php',
|
'md',
|
||||||
'sh', 'bash', 'sql', 'csv', 'tsv',
|
'markdown',
|
||||||
|
'json',
|
||||||
|
'xml',
|
||||||
|
'yaml',
|
||||||
|
'yml',
|
||||||
|
'toml',
|
||||||
|
'ini',
|
||||||
|
'conf',
|
||||||
|
'config',
|
||||||
|
'js',
|
||||||
|
'jsx',
|
||||||
|
'ts',
|
||||||
|
'tsx',
|
||||||
|
'vue',
|
||||||
|
'html',
|
||||||
|
'htm',
|
||||||
|
'css',
|
||||||
|
'scss',
|
||||||
|
'sass',
|
||||||
|
'less',
|
||||||
|
'java',
|
||||||
|
'c',
|
||||||
|
'cpp',
|
||||||
|
'h',
|
||||||
|
'hpp',
|
||||||
|
'cs',
|
||||||
|
'py',
|
||||||
|
'rb',
|
||||||
|
'go',
|
||||||
|
'rs',
|
||||||
|
'swift',
|
||||||
|
'kt',
|
||||||
|
'php',
|
||||||
|
'sh',
|
||||||
|
'bash',
|
||||||
|
'sql',
|
||||||
|
'csv',
|
||||||
|
'tsv',
|
||||||
];
|
];
|
||||||
|
|
||||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||||
@@ -854,19 +1101,147 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- 聊天内容区域 -->
|
<!-- 聊天内容区域 -->
|
||||||
<div class="chat-warp">
|
<div class="chat-warp">
|
||||||
<BubbleList ref="bubbleListRef" :list="bubbleItems" max-height="calc(100vh - 240px)">
|
<!-- 删除模式工具栏 -->
|
||||||
|
<div v-if="isDeleteMode" class="delete-mode-toolbar">
|
||||||
|
<span class="selected-count">已选择 {{ selectedMessageIds.length }} 条消息</span>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button type="danger" size="small" @click="confirmDelete">
|
||||||
|
确认删除
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" @click="exitDeleteMode">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BubbleList
|
||||||
|
ref="bubbleListRef"
|
||||||
|
:list="bubbleItems"
|
||||||
|
max-height="calc(100vh - 240px)"
|
||||||
|
:class="{ 'delete-mode': isDeleteMode }"
|
||||||
|
>
|
||||||
<template #header="{ item }">
|
<template #header="{ item }">
|
||||||
<Thinking
|
<Thinking
|
||||||
v-if="item.reasoning_content" v-model="item.thinlCollapse" :content="item.reasoning_content"
|
v-if="item.reasoning_content && !isDeleteMode"
|
||||||
:status="item.thinkingStatus" class="thinking-chain-warp" @change="handleChange"
|
v-model="item.thinlCollapse"
|
||||||
|
:content="item.reasoning_content"
|
||||||
|
:status="item.thinkingStatus"
|
||||||
|
class="thinking-chain-warp"
|
||||||
|
@change="handleChange"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 自定义气泡内容 -->
|
<!-- 自定义气泡内容 -->
|
||||||
<template #content="{ item }">
|
<template #content="{ item }">
|
||||||
<!-- chat 内容走 markdown -->
|
<!-- 删除模式:勾选框(绝对定位在 content 左上角) -->
|
||||||
<MarkedMarkdown v-if="item.content && (item.role === 'assistant' || item.role === 'system')" class="markdown-body" :content="item.content" />
|
<template v-if="isDeleteMode">
|
||||||
<!-- user 内容 纯文本 + 图片 + 文件 -->
|
<div
|
||||||
<div v-if="item.role === 'user'" class="user-content-wrapper">
|
v-if="item.id"
|
||||||
|
class="delete-checkbox-inline"
|
||||||
|
@click.stop="toggleMessageSelection(item)"
|
||||||
|
>
|
||||||
|
<el-checkbox
|
||||||
|
:model-value="selectedMessageIds.includes(item.id)"
|
||||||
|
@click.stop
|
||||||
|
@update:model-value="toggleMessageSelection(item)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI 消息内容(点击切换选中) -->
|
||||||
|
<template v-if="item.role !== 'user'">
|
||||||
|
<div
|
||||||
|
class="ai-content-wrapper delete-mode-ai-content"
|
||||||
|
@click="item.id && toggleMessageSelection(item)"
|
||||||
|
>
|
||||||
|
<MarkedMarkdown v-if="item.content" class="markdown-body" :content="item.content" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- user 内容(气泡样式,点击切换选中) -->
|
||||||
|
<template v-if="item.role === 'user'">
|
||||||
|
<div
|
||||||
|
class="user-content-wrapper delete-mode-user-content"
|
||||||
|
@click="item.id && toggleMessageSelection(item)"
|
||||||
|
>
|
||||||
|
<template v-if="editingMessageKey === item.key">
|
||||||
|
<div class="edit-message-wrapper-full">
|
||||||
|
<el-input
|
||||||
|
v-model="editingContent"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 10 }"
|
||||||
|
placeholder="编辑消息内容"
|
||||||
|
/>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<el-button type="primary" size="small" @click.stop="submitEditMessage(item)">
|
||||||
|
发送
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" @click.stop="cancelEdit">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<!-- 用户消息内容(直接显示,无气泡背景) -->
|
||||||
|
<div v-if="item.images && item.images.length > 0" class="user-images">
|
||||||
|
<img
|
||||||
|
v-for="(image, index) in item.images"
|
||||||
|
:key="index"
|
||||||
|
:src="image.url"
|
||||||
|
:alt="image.name || '图片'"
|
||||||
|
class="user-image"
|
||||||
|
@click.stop="() => handleImagePreview(image.url)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-if="item.files && item.files.length > 0" class="user-files">
|
||||||
|
<div
|
||||||
|
v-for="(file, index) in item.files"
|
||||||
|
:key="index"
|
||||||
|
class="user-file-item"
|
||||||
|
>
|
||||||
|
<ElIcon class="file-icon">
|
||||||
|
<Document />
|
||||||
|
</ElIcon>
|
||||||
|
<span class="file-name">{{ file.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="item.content" class="user-content">
|
||||||
|
{{ item.content }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 正常模式:AI 消息 -->
|
||||||
|
<template v-if="!isDeleteMode && item.role !== 'user'">
|
||||||
|
<div class="ai-content-wrapper">
|
||||||
|
<MarkedMarkdown v-if="item.content" class="markdown-body" :content="item.content" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 正常模式:user 内容 -->
|
||||||
|
<template v-if="!isDeleteMode && item.role === 'user'">
|
||||||
|
<div class="user-content-wrapper">
|
||||||
|
<!-- 编辑模式 - 宽度铺开 -->
|
||||||
|
<div v-if="editingMessageKey === item.key" class="edit-message-wrapper-full">
|
||||||
|
<el-input
|
||||||
|
v-model="editingContent"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 10 }"
|
||||||
|
placeholder="编辑消息内容"
|
||||||
|
/>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<el-button type="primary" size="small" @click="submitEditMessage(item)">
|
||||||
|
发送
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" @click="cancelEdit">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 正常显示模式 -->
|
||||||
|
<template v-else>
|
||||||
<!-- 图片列表 -->
|
<!-- 图片列表 -->
|
||||||
<div v-if="item.images && item.images.length > 0" class="user-images">
|
<div v-if="item.images && item.images.length > 0" class="user-images">
|
||||||
<img
|
<img
|
||||||
@@ -895,18 +1270,51 @@ onUnmounted(() => {
|
|||||||
<div v-if="item.content" class="user-content">
|
<div v-if="item.content" class="user-content">
|
||||||
{{ item.content }}
|
{{ item.content }}
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 自定义底部 -->
|
<!-- 自定义底部 -->
|
||||||
<template #footer="{ item }">
|
<template #footer="{ item }">
|
||||||
<div class="footer-wrapper">
|
<div v-if="!isDeleteMode" class="footer-wrapper">
|
||||||
<div class="footer-container">
|
<div class="footer-container">
|
||||||
<div class="footer-time">
|
<!-- 时间和token信息 -->
|
||||||
<span v-if="item.creationTime "> {{ item.creationTime }}</span>
|
<div class="footer-info">
|
||||||
<span v-if="((item.role === 'ai' || item.role === 'assistant') && item?.tokenUsage?.total) " style="margin-left: 10px;" class="footer-token">
|
<span v-if="item.creationTime" class="footer-time">
|
||||||
{{ ((item.role === 'ai' || item.role === 'assistant') && item?.tokenUsage?.total) ? `token:${item?.tokenUsage?.total}` : '' }}</span>
|
<ElIcon><Clock /></ElIcon>
|
||||||
<el-button icon="DocumentCopy" size="small" circle @click="copy(item)" />
|
{{ item.creationTime }}
|
||||||
|
</span>
|
||||||
|
<span v-if="item.role !== 'user' && item?.tokenUsage?.total" class="footer-token">
|
||||||
|
{{ item?.tokenUsage?.total }} tokens
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- 操作按钮组 -->
|
||||||
|
<div class="footer-actions">
|
||||||
|
<!-- 复制按钮 -->
|
||||||
|
<el-tooltip content="复制" placement="top">
|
||||||
|
<el-button text @click="copy(item)">
|
||||||
|
<ElIcon><DocumentCopy /></ElIcon>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
<!-- AI消息:重新生成按钮 (role可能是'assistant'或'ai') -->
|
||||||
|
<el-tooltip v-if="item.role !== 'user'" content="重新生成" placement="top">
|
||||||
|
<el-button text :disabled="isSending" @click="regenerateMessage(item)">
|
||||||
|
<ElIcon><Refresh /></ElIcon>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
<!-- 用户消息:编辑按钮 -->
|
||||||
|
<el-tooltip v-if="item.role === 'user'" content="编辑" placement="top">
|
||||||
|
<el-button text :disabled="isSending" @click="startEditMessage(item)">
|
||||||
|
<ElIcon><Edit /></ElIcon>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
<!-- 删除按钮 -->
|
||||||
|
<el-tooltip content="删除" placement="top">
|
||||||
|
<el-button text @click="enterDeleteMode(item)">
|
||||||
|
<ElIcon><Delete /></ElIcon>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -999,10 +1407,124 @@ onUnmounted(() => {
|
|||||||
:deep() {
|
:deep() {
|
||||||
.el-bubble-list {
|
.el-bubble-list {
|
||||||
padding-top: 24px;
|
padding-top: 24px;
|
||||||
|
|
||||||
|
// 删除模式
|
||||||
|
&.delete-mode {
|
||||||
|
.el-bubble {
|
||||||
|
// 删除模式下:用户消息加回 flex:auto
|
||||||
|
&[class*="end"] .el-bubble-content-wrapper {
|
||||||
|
flex: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删除模式下的 content 样式 - 全宽背景
|
||||||
|
.el-bubble-content {
|
||||||
|
position: relative;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 44px;
|
||||||
|
|
||||||
|
// 悬浮状态
|
||||||
|
&:hover {
|
||||||
|
background-color: #e8f0fe;
|
||||||
|
border-color: #c6dafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 勾选框绝对定位在左上角
|
||||||
|
.delete-checkbox-inline {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 12px;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
.el-checkbox {
|
||||||
|
--el-checkbox-input-height: 20px;
|
||||||
|
--el-checkbox-input-width: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 内容区域左边距
|
||||||
|
.ai-content-wrapper {
|
||||||
|
margin-left: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户消息容器 - 宽度铺满(和 AI 消息一样)
|
||||||
|
.user-content-wrapper {
|
||||||
|
margin-left: 36px;
|
||||||
|
width: calc(100% - 36px);
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
// 编辑模式宽度100%
|
||||||
|
.edit-message-wrapper-full {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除模式下:覆盖默认的 end 布局,让宽度 100%
|
||||||
|
&[class*="end"] {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
.el-bubble-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中状态
|
||||||
|
&:has(.el-checkbox.is-checked) .el-bubble-content {
|
||||||
|
background-color: #d2e3fc;
|
||||||
|
border-color: #8ab4f8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.el-bubble {
|
.el-bubble {
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
padding-bottom: 24px;
|
padding-bottom: 24px;
|
||||||
|
|
||||||
|
// AI消息(非用户消息):宽度100%,无气泡形状
|
||||||
|
&[class*="start"] {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
.el-bubble-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户消息(end布局):宽度100%
|
||||||
|
&[class*="end"] {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
// 正常模式下:去掉 flex:auto
|
||||||
|
.el-bubble-content-wrapper {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑模式下:加回 flex:auto
|
||||||
|
&:has(.edit-message-wrapper-full) .el-bubble-content-wrapper {
|
||||||
|
flex: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-bubble-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 隐藏头像
|
||||||
|
.el-avatar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.el-typewriter {
|
.el-typewriter {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1012,6 +1534,13 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
// 编辑模式下:宽度100%,无气泡样式
|
||||||
|
&:has(.edit-message-wrapper-full) {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.user-images {
|
.user-images {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1060,8 +1589,9 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.user-content {
|
.user-content {
|
||||||
// 换行
|
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
}
|
}
|
||||||
.markdown-body {
|
.markdown-body {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
@@ -1082,26 +1612,112 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footer-wrapper {
|
.footer-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.footer-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
.footer-time {
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-top: 3px;
|
color: #8c8c8c;
|
||||||
|
|
||||||
|
.footer-time {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #bfbfbf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.footer-token {
|
.footer-token {
|
||||||
background: rgba(1, 183, 86, 0.53);
|
display: flex;
|
||||||
padding: 0 4px;
|
align-items: center;
|
||||||
margin: 0 2px;
|
gap: 4px;
|
||||||
border-radius: 4px;
|
color: #8c8c8c;
|
||||||
color: #ffffff;
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
background: #bfbfbf;
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-container {
|
.footer-actions {
|
||||||
:deep(.el-button + .el-button) {
|
display: flex;
|
||||||
margin-left: 8px;
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
opacity: 0.4;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
|
||||||
|
.footer-wrapper:hover & {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #409eff;
|
||||||
|
background: #f0f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #e6f2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
color: #d9d9d9;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除模式:整行点击区域(覆盖整个内容区)
|
||||||
|
// 删除模式:用户内容右对齐,宽度铺满(和AI消息一样)
|
||||||
|
.delete-mode-user-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.user-content-wrapper {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除模式:AI 内容宽度100%,点击不穿透
|
||||||
|
.delete-mode-ai-content {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
@@ -1118,6 +1734,106 @@ onUnmounted(() => {
|
|||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删除模式工具栏
|
||||||
|
.delete-mode-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: linear-gradient(135deg, #fff7ed 0%, #ffedd5 100%);
|
||||||
|
border: 1px solid #fed7aa;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.selected-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ea580c;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: #ea580c;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.el-button--danger) {
|
||||||
|
&:hover {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #fed7aa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常模式内容
|
||||||
|
.normal-mode-content {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
// AI 消息包装器:宽度100%,无气泡形状
|
||||||
|
.ai-content-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
.markdown-body {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑消息样式 - 全宽
|
||||||
|
.edit-message-wrapper-full {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
:deep(.el-textarea__inner) {
|
||||||
|
min-height: 80px !important;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 移动端响应式优化 */
|
/* 移动端响应式优化 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.chat-with-id-container {
|
.chat-with-id-container {
|
||||||
@@ -1241,7 +1957,4 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.footer-time .el-button{
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -249,6 +249,9 @@ onMounted(() => {
|
|||||||
<p class="banner-subtitle">
|
<p class="banner-subtitle">
|
||||||
探索并接入全球顶尖AI模型,覆盖文本、图像、嵌入等多个领域
|
探索并接入全球顶尖AI模型,覆盖文本、图像、嵌入等多个领域
|
||||||
</p>
|
</p>
|
||||||
|
<p class="banner-subtitle">
|
||||||
|
尊享Token = 实际消耗Token * 当前模型倍率
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- 统计信息卡片 -->
|
<!-- 统计信息卡片 -->
|
||||||
<div class="stats-cards">
|
<div class="stats-cards">
|
||||||
|
|||||||
@@ -109,18 +109,22 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
key: item.id,
|
id: item.id, // 保留后端返回的消息ID
|
||||||
|
key: item.id ?? Math.random().toString(36).substring(2, 9),
|
||||||
placement: isUser ? 'end' : 'start',
|
placement: isUser ? 'end' : 'start',
|
||||||
|
// 用户消息:气泡形状;AI消息:无气泡形状,宽度100%
|
||||||
isMarkdown: !isUser,
|
isMarkdown: !isUser,
|
||||||
avatar: isUser
|
// 头像不显示(后续可能会显示)
|
||||||
? getUserProfilePicture()
|
// avatar: isUser ? getUserProfilePicture() : systemProfilePicture,
|
||||||
: systemProfilePicture,
|
// avatarSize: '32px',
|
||||||
avatarSize: '32px',
|
|
||||||
typing: false,
|
typing: false,
|
||||||
reasoning_content: thinkContent,
|
reasoning_content: thinkContent,
|
||||||
thinkingStatus: 'end',
|
thinkingStatus: 'end',
|
||||||
content: finalContent,
|
content: finalContent,
|
||||||
thinlCollapse: false,
|
thinlCollapse: false,
|
||||||
|
// AI消息使用 noStyle 去除气泡样式
|
||||||
|
noStyle: !isUser,
|
||||||
|
shape: isUser ? 'corner' : undefined,
|
||||||
// 保留图片和文件信息(优先使用解析出来的,如果没有则使用原有的)
|
// 保留图片和文件信息(优先使用解析出来的,如果没有则使用原有的)
|
||||||
images: images.length > 0 ? images : item.images,
|
images: images.length > 0 ? images : item.images,
|
||||||
files: files.length > 0 ? files : item.files,
|
files: files.length > 0 ? files : item.files,
|
||||||
|
|||||||
2
Yi.Ai.Vue3/types/components.d.ts
vendored
2
Yi.Ai.Vue3/types/components.d.ts
vendored
@@ -16,6 +16,7 @@ declare module 'vue' {
|
|||||||
ContactUs: typeof import('./../src/components/ContactUs/index.vue')['default']
|
ContactUs: typeof import('./../src/components/ContactUs/index.vue')['default']
|
||||||
DailyTask: typeof import('./../src/components/userPersonalCenter/components/DailyTask.vue')['default']
|
DailyTask: typeof import('./../src/components/userPersonalCenter/components/DailyTask.vue')['default']
|
||||||
DeepThinking: typeof import('./../src/components/DeepThinking/index.vue')['default']
|
DeepThinking: typeof import('./../src/components/DeepThinking/index.vue')['default']
|
||||||
|
Demo: typeof import('./../src/components/FontAwesomeIcon/demo.vue')['default']
|
||||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
@@ -67,6 +68,7 @@ declare module 'vue' {
|
|||||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
FilesSelect: typeof import('./../src/components/FilesSelect/index.vue')['default']
|
FilesSelect: typeof import('./../src/components/FilesSelect/index.vue')['default']
|
||||||
|
FontAwesomeIcon: typeof import('./../src/components/FontAwesomeIcon/index.vue')['default']
|
||||||
IconSelect: typeof import('./../src/components/IconSelect/index.vue')['default']
|
IconSelect: typeof import('./../src/components/IconSelect/index.vue')['default']
|
||||||
Indexl: typeof import('./../src/components/SupportModelProducts/indexl.vue')['default']
|
Indexl: typeof import('./../src/components/SupportModelProducts/indexl.vue')['default']
|
||||||
LoginDialog: typeof import('./../src/components/LoginDialog/index.vue')['default']
|
LoginDialog: typeof import('./../src/components/LoginDialog/index.vue')['default']
|
||||||
|
|||||||
Reference in New Issue
Block a user