feat: 对话中消息编辑与重新生成与删除功能

This commit is contained in:
Gsh
2026-01-31 17:39:23 +08:00
parent 4441244575
commit ec382995b4
9 changed files with 943 additions and 102 deletions

View File

@@ -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) {

View File

@@ -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;
/** /**
* 模型名称 * 模型名称
*/ */

View File

@@ -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: '' },

View File

@@ -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 {

View File

@@ -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: '', // 售后群二维码
}, },
}; };

View File

@@ -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>

View File

@@ -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">

View File

@@ -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,

View File

@@ -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']