add prompt edit function

This commit is contained in:
GeekMaster
2025-09-17 16:04:04 +08:00
parent 48203e0d31
commit 3eb0177188
24 changed files with 1018 additions and 767 deletions

View File

@@ -6,6 +6,7 @@
- Bug 修复:微信登录配置更新后,没有同步更新到系统配置 - Bug 修复:微信登录配置更新后,没有同步更新到系统配置
- 功能优化: 给 AI 对话 API 加上线程锁,确保同一个用户同时只有一个对话请求 - 功能优化: 给 AI 对话 API 加上线程锁,确保同一个用户同时只有一个对话请求
- 功能新增:支持即梦 AI 4.0 图片编辑,即梦 AI 数字人,动作迁移功能。🔥🔥🔥 - 功能新增:支持即梦 AI 4.0 图片编辑,即梦 AI 数字人,动作迁移功能。🔥🔥🔥
- 功能新增:新增 AI 对话编辑功能,并优化了重新生成逻辑
## v4.2.6 ## v4.2.6

View File

@@ -20,14 +20,14 @@ func init() {
// CaptchaConfig 行为验证码配置 // CaptchaConfig 行为验证码配置
type CaptchaConfig struct { type CaptchaConfig struct {
ApiKey string `json:"api_key"` ApiKey string `json:"api_key,omitempty"`
Type string `json:"type"` // 验证码类型, 可选值: "dot" 或 "slide" Type string `json:"type,omitempty"` // 验证码类型, 可选值: "dot" 或 "slide"
Enabled bool `json:"enabled"` Enabled bool `json:"enabled,omitempty"`
} }
// WxLoginConfig 微信登录配置 // WxLoginConfig 微信登录配置
type WxLoginConfig struct { type WxLoginConfig struct {
ApiKey string `json:"api_key"` ApiKey string `json:"api_key,omitempty"`
NotifyURL string `json:"notify_url"` // 登录成功回调 URL NotifyURL string `json:"notify_url,omitempty"` // 登录成功回调 URL
Enabled bool `json:"enabled"` // 是否启用微信登录 Enabled bool `json:"enabled,omitempty"` // 是否启用微信登录
} }

View File

@@ -3,12 +3,12 @@ package types
// JimengConfig 即梦AI配置 // JimengConfig 即梦AI配置
type JimengConfig struct { type JimengConfig struct {
// 即梦AI的AccessKey和SecretKey // 即梦AI的AccessKey和SecretKey
AccessKey string `json:"access_key"` AccessKey string `json:"access_key,omitempty"`
SecretKey string `json:"secret_key"` SecretKey string `json:"secret_key,omitempty"`
// 火山引擎大模型专用的验证方式 // 火山引擎大模型专用的验证方式
ApiKey string `json:"api_key"` ApiKey string `json:"api_key,omitempty"`
// 算力配置 // 算力配置
Powers map[string]int `json:"powers"` Powers map[string]int `json:"powers,omitempty"`
} }
// JMTaskStatus 任务状态 // JMTaskStatus 任务状态

View File

@@ -9,13 +9,13 @@ package types
// 文本审查 // 文本审查
type ModerationConfig struct { type ModerationConfig struct {
Enable bool `json:"enable"` // 是否启用文本审查 Enable bool `json:"enable,omitempty"` // 是否启用文本审查
Active string `json:"active"` Active string `json:"active,omitempty"`
EnableGuide bool `json:"enable_guide"` // 是否启用模型引导提示词 EnableGuide bool `json:"enable_guide,omitempty"` // 是否启用模型引导提示词
GuidePrompt string `json:"guide_prompt"` // 模型引导提示词 GuidePrompt string `json:"guide_prompt,omitempty"` // 模型引导提示词
Gitee ModerationGiteeConfig `json:"gitee"` Gitee ModerationGiteeConfig `json:"gitee,omitempty"`
Baidu ModerationBaiduConfig `json:"baidu"` Baidu ModerationBaiduConfig `json:"baidu,omitempty"`
Tencent ModerationTencentConfig `json:"tencent"` Tencent ModerationTencentConfig `json:"tencent,omitempty"`
} }
const ( const (
@@ -26,26 +26,26 @@ const (
// GiteeAI 文本审查配置 // GiteeAI 文本审查配置
type ModerationGiteeConfig struct { type ModerationGiteeConfig struct {
ApiKey string `json:"api_key"` ApiKey string `json:"api_key,omitempty"`
Model string `json:"model"` // 文本审核模型 Model string `json:"model,omitempty"` // 文本审核模型
} }
// 百度文本审查配置 // 百度文本审查配置
type ModerationBaiduConfig struct { type ModerationBaiduConfig struct {
AccessKey string `json:"access_key"` AccessKey string `json:"access_key,omitempty"`
SecretKey string `json:"secret_key"` SecretKey string `json:"secret_key,omitempty"`
} }
// 腾讯云文本审查配置 // 腾讯云文本审查配置
type ModerationTencentConfig struct { type ModerationTencentConfig struct {
AccessKey string `json:"access_key"` AccessKey string `json:"access_key,omitempty"`
SecretKey string `json:"secret_key"` SecretKey string `json:"secret_key,omitempty"`
} }
type ModerationResult struct { type ModerationResult struct {
Flagged bool `json:"flagged"` Flagged bool `json:"flagged,omitempty"`
Categories map[string]bool `json:"categories"` Categories map[string]bool `json:"categories,omitempty"`
CategoryScores map[string]float64 `json:"category_scores"` CategoryScores map[string]float64 `json:"category_scores,omitempty"`
} }
var ModerationCategories = map[string]string{ var ModerationCategories = map[string]string{

View File

@@ -8,39 +8,39 @@ package types
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ // * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
type OSSConfig struct { type OSSConfig struct {
Active string `json:"active"` Active string `json:"active,omitempty"`
Local LocalStorageConfig `json:"local"` Local LocalStorageConfig `json:"local,omitempty"`
Minio MiniOssConfig `json:"minio"` Minio MiniOssConfig `json:"minio,omitempty"`
QiNiu QiNiuOssConfig `json:"qiniu"` QiNiu QiNiuOssConfig `json:"qiniu,omitempty"`
AliYun AliYunOssConfig `json:"aliyun"` AliYun AliYunOssConfig `json:"aliyun,omitempty"`
} }
type MiniOssConfig struct { type MiniOssConfig struct {
Endpoint string `json:"endpoint"` Endpoint string `json:"endpoint,omitempty"`
AccessKey string `json:"access_key"` AccessKey string `json:"access_key,omitempty"`
AccessSecret string `json:"access_secret"` AccessSecret string `json:"access_secret,omitempty"`
Bucket string `json:"bucket"` Bucket string `json:"bucket,omitempty"`
UseSSL bool `json:"use_ssl"` UseSSL bool `json:"use_ssl,omitempty"`
Domain string `json:"domain"` Domain string `json:"domain,omitempty"`
} }
type QiNiuOssConfig struct { type QiNiuOssConfig struct {
Zone string `json:"zone"` Zone string `json:"zone,omitempty"`
AccessKey string `json:"access_key"` AccessKey string `json:"access_key,omitempty"`
AccessSecret string `json:"access_secret"` AccessSecret string `json:"access_secret,omitempty"`
Bucket string `json:"bucket"` Bucket string `json:"bucket,omitempty"`
Domain string `json:"domain"` Domain string `json:"domain,omitempty"`
} }
type AliYunOssConfig struct { type AliYunOssConfig struct {
Endpoint string `json:"endpoint"` Endpoint string `json:"endpoint,omitempty"`
AccessKey string `json:"access_key"` AccessKey string `json:"access_key,omitempty"`
AccessSecret string `json:"access_secret"` AccessSecret string `json:"access_secret,omitempty"`
Bucket string `json:"bucket"` Bucket string `json:"bucket,omitempty"`
Domain string `json:"domain"` Domain string `json:"domain,omitempty"`
} }
type LocalStorageConfig struct { type LocalStorageConfig struct {
BasePath string `json:"base_path"` BasePath string `json:"base_path,omitempty"`
BaseURL string `json:"base_url"` BaseURL string `json:"base_url,omitempty"`
} }

View File

@@ -1,19 +1,19 @@
package types package types
type PaymentConfig struct { type PaymentConfig struct {
Alipay AlipayConfig `json:"alipay"` // 支付宝支付渠道配置 Alipay AlipayConfig `json:"alipay,omitempty"` // 支付宝支付渠道配置
Epay EpayConfig `json:"epay"` // 易支付配置 Epay EpayConfig `json:"epay,omitempty"` // 易支付配置
WxPay WxPayConfig `json:"wxpay"` // 微信支付渠道配置 WxPay WxPayConfig `json:"wxpay,omitempty"` // 微信支付渠道配置
} }
// AlipayConfig 支付宝支付配置 // AlipayConfig 支付宝支付配置
type AlipayConfig struct { type AlipayConfig struct {
Enabled bool `json:"enabled"` // 是否启用该支付通道 Enabled bool `json:"enabled,omitempty"` // 是否启用该支付通道
SandBox bool `json:"sandbox"` // 是否沙盒环境 SandBox bool `json:"sandbox,omitempty"` // 是否沙盒环境
AppId string `json:"app_id"` // 应用 ID AppId string `json:"app_id,omitempty"` // 应用 ID
PrivateKey string `json:"private_key"` // 应用私钥 PrivateKey string `json:"private_key,omitempty"` // 应用私钥
AlipayPublicKey string `json:"alipay_public_key"` // 支付宝公钥 AlipayPublicKey string `json:"alipay_public_key,omitempty"` // 支付宝公钥
Domain string `json:"domain"` // 支付回调域名 Domain string `json:"domain,omitempty"` // 支付回调域名
} }
func (c *AlipayConfig) Equal(other *AlipayConfig) bool { func (c *AlipayConfig) Equal(other *AlipayConfig) bool {
@@ -25,13 +25,13 @@ func (c *AlipayConfig) Equal(other *AlipayConfig) bool {
// WxPayConfig 微信支付配置 // WxPayConfig 微信支付配置
type WxPayConfig struct { type WxPayConfig struct {
Enabled bool `json:"enabled"` // 是否启用该支付通道 Enabled bool `json:"enabled,omitempty"` // 是否启用该支付通道
AppId string `json:"app_id"` // 公众号的APPID,如wxd678efh567hg6787 AppId string `json:"app_id,omitempty"` // 公众号的APPID,如wxd678efh567hg6787
MchId string `json:"mch_id"` // 直连商户的商户号,由微信支付生成并下发 MchId string `json:"mch_id,omitempty"` // 直连商户的商户号,由微信支付生成并下发
SerialNo string `json:"serial_no"` // 商户证书的证书序列号 SerialNo string `json:"serial_no,omitempty"` // 商户证书的证书序列号
PrivateKey string `json:"private_key"` // 商户证书私钥 PrivateKey string `json:"private_key,omitempty"` // 商户证书私钥
ApiV3Key string `json:"api_v3_key"` // API V3 秘钥 ApiV3Key string `json:"api_v3_key,omitempty"` // API V3 秘钥
Domain string `json:"domain"` // 支付回调域名 Domain string `json:"domain,omitempty"` // 支付回调域名
} }
func (c *WxPayConfig) Equal(other *WxPayConfig) bool { func (c *WxPayConfig) Equal(other *WxPayConfig) bool {
@@ -45,11 +45,11 @@ func (c *WxPayConfig) Equal(other *WxPayConfig) bool {
// EpayConfig 易支付配置 // EpayConfig 易支付配置
type EpayConfig struct { type EpayConfig struct {
Enabled bool `json:"enabled"` // 是否启用该支付通道 Enabled bool `json:"enabled,omitempty"` // 是否启用该支付通道
AppId string `json:"app_id"` // 商户 ID AppId string `json:"app_id,omitempty"` // 商户 ID
PrivateKey string `json:"private_key"` // 私钥 PrivateKey string `json:"private_key,omitempty"` // 私钥
ApiURL string `json:"api_url"` // z支付 API 网关 ApiURL string `json:"api_url,omitempty"` // z支付 API 网关
Domain string `json:"domain"` // 支付回调域名 Domain string `json:"domain,omitempty"` // 支付回调域名
} }
func (c *EpayConfig) Equal(other *EpayConfig) bool { func (c *EpayConfig) Equal(other *EpayConfig) bool {

View File

@@ -8,23 +8,23 @@ package types
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ // * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
type SMSConfig struct { type SMSConfig struct {
Active string `json:"active"` Active string `json:"active,omitempty"`
Ali SmsConfigAli `json:"aliyun"` Ali SmsConfigAli `json:"aliyun,omitempty"`
Bao SmsConfigBao `json:"bao"` Bao SmsConfigBao `json:"bao,omitempty"`
} }
// SmsConfigAli 阿里云短信平台配置 // SmsConfigAli 阿里云短信平台配置
type SmsConfigAli struct { type SmsConfigAli struct {
AccessKey string `json:"access_key"` AccessKey string `json:"access_key,omitempty"`
AccessSecret string `json:"access_secret"` AccessSecret string `json:"access_secret,omitempty"`
Sign string `json:"sign"` // 短信签名 Sign string `json:"sign,omitempty"` // 短信签名
CodeTempId string `json:"code_temp_id"` // 验证码短信模板 ID CodeTempId string `json:"code_temp_id,omitempty"` // 验证码短信模板 ID
} }
// SmsConfigBao 短信宝平台配置 // SmsConfigBao 短信宝平台配置
type SmsConfigBao struct { type SmsConfigBao struct {
Username string `json:"username"` //短信宝平台注册的用户名 Username string `json:"username,omitempty"` //短信宝平台注册的用户名
Password string `json:"password"` //短信宝平台注册的密码 Password string `json:"password,omitempty"` //短信宝平台注册的密码
Sign string `json:"sign"` // 短信签名 Sign string `json:"sign,omitempty"` // 短信签名
CodeTemplate string `json:"code_template"` // 验证码短信模板 匹配 CodeTemplate string `json:"code_template,omitempty"` // 验证码短信模板 匹配
} }

View File

@@ -8,12 +8,12 @@ package types
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ // * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
type SmtpConfig struct { type SmtpConfig struct {
UseTls bool `json:"use_tls"` // 是否使用 TLS 发送 UseTls bool `json:"use_tls,omitempty"` // 是否使用 TLS 发送
Host string `json:"host"` // 邮件服务器地址 Host string `json:"host,omitempty"` // 邮件服务器地址
Port int `json:"port"` // 邮件服务器端口 Port int `json:"port,omitempty"` // 邮件服务器端口
AppName string `json:"app_name"` // 应用名称 AppName string `json:"app_name,omitempty"` // 应用名称
From string `json:"from"` // 发件人邮箱地址 From string `json:"from,omitempty"` // 发件人邮箱地址
Password string `json:"password"` // 发件人邮箱密码 Password string `json:"password,omitempty"` // 发件人邮箱密码
} }
func (s *SmtpConfig) Equal(other *SmtpConfig) bool { func (s *SmtpConfig) Equal(other *SmtpConfig) bool {

View File

@@ -272,9 +272,9 @@ func (h *ChatHandler) sendMessage(ctx context.Context, input ChatInput, c *gin.C
if h.App.SysConfig.Base.ContextDeep > 0 { if h.App.SysConfig.Base.ContextDeep > 0 {
var historyMessages []model.ChatMessage var historyMessages []model.ChatMessage
dbSession := h.DB.Session(&gorm.Session{}).Where("chat_id", input.ChatId) dbSession := h.DB.Session(&gorm.Session{}).Where("chat_id", input.ChatId)
if input.LastMsgId > 0 { // 重新生成逻辑 if input.LastMsgId > 0 { // 重新生成和编辑逻辑
var lastMessage model.ChatMessage var lastMessage model.ChatMessage
err = dbSession.Where("id <= ?", input.LastMsgId).Where("type", types.PromptMsg).First(&lastMessage).Error err = dbSession.Where("id < ?", input.LastMsgId).Where("type", types.ReplyMsg).Order("id DESC").First(&lastMessage).Error
if err != nil { if err != nil {
input.LastMsgId = 0 input.LastMsgId = 0
} else { } else {
@@ -282,7 +282,7 @@ func (h *ChatHandler) sendMessage(ctx context.Context, input ChatInput, c *gin.C
} }
dbSession = dbSession.Where("id < ?", input.LastMsgId) dbSession = dbSession.Where("id < ?", input.LastMsgId)
// 删除对应的聊天记录 // 删除对应的聊天记录
h.DB.Debug().Where("chat_id", input.ChatId).Where("id >= ?", input.LastMsgId).Delete(&model.ChatMessage{}) h.DB.Debug().Where("chat_id", input.ChatId).Where("id > ?", input.LastMsgId).Delete(&model.ChatMessage{})
} }
err = dbSession.Limit(h.App.SysConfig.Base.ContextDeep).Order("id DESC").Find(&historyMessages).Error err = dbSession.Limit(h.App.SysConfig.Base.ContextDeep).Order("id DESC").Find(&historyMessages).Error
if err == nil { if err == nil {

View File

@@ -179,6 +179,10 @@ func (h *JimengHandler) Jobs(c *gin.Context) {
query = query.Where("type = ?", types.JMTaskTypeImage) query = query.Where("type = ?", types.JMTaskTypeImage)
case "video": case "video":
query = query.Where("type = ?", types.JMTaskTypeVideo) query = query.Where("type = ?", types.JMTaskTypeVideo)
case "virtual_human":
query = query.Where("type = ?", types.JMTaskTypeVirtualHuman)
case "action_transfer":
query = query.Where("type = ?", types.JMTaskTypeActionTransfer)
} }
if len(req.Ids) > 0 { if len(req.Ids) > 0 {

View File

@@ -343,6 +343,10 @@
margin: 12px 0 16px; margin: 12px 0 16px;
background-color: var(--el-fill-color-blank); background-color: var(--el-fill-color-blank);
.el-collapse {
--el-collapse-border-color: none;
}
.guide-title { .guide-title {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -14,7 +14,7 @@
} }
:deep(.el-textarea__inner) { :deep(.el-textarea__inner) {
background: transparent; // background: transparent;
color: var(--text-theme-color); color: var(--text-theme-color);
} }
@@ -104,7 +104,8 @@
color: var(--text-theme-color); color: var(--text-theme-color);
} }
.el-input, .el-slider { .el-input,
.el-slider {
width: 100%; width: 100%;
} }
@@ -422,7 +423,8 @@
flex-flow: column; flex-flow: column;
padding: 0 20px; padding: 0 20px;
.prompt, .failed { .prompt,
.failed {
padding: 0; padding: 0;
font-size: 16px; font-size: 16px;
max-height: 80px; max-height: 80px;
@@ -547,7 +549,8 @@
.left .container { .left .container {
width: 120px; width: 120px;
.video, .el-image { .video,
.el-image {
width: 120px; width: 120px;
} }
} }
@@ -562,4 +565,4 @@
} }
} }
} }
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="chat-line chat-line-prompt-list" v-if="listStyle === 'list'"> <div class="chat-line chat-line-prompt-chat">
<div class="chat-line-inner"> <div class="chat-line-inner">
<div class="chat-icon"> <div class="chat-icon">
<img :src="data.icon" alt="User" /> <img :src="data.icon" alt="User" />
@@ -29,73 +29,68 @@
</div> </div>
</div> </div>
</div> </div>
<div class="content position-relative">
<div v-html="content"></div>
</div>
<div class="bar" v-if="data.created_at > 0">
<span class="bar-item"
><el-icon><Clock /></el-icon> {{ dateFormat(data.created_at) }}</span
>
<span class="bar-item">
<el-tooltip class="box-item" effect="dark" content="复制" placement="bottom">
<i
class="iconfont icon-copy cursor-pointer"
@click="copyContent(data.content.text)"
></i>
</el-tooltip>
</span>
</div>
</div>
</div>
</div>
<div class="chat-line chat-line-prompt-chat" v-else> <!-- 编辑模式 -->
<div class="chat-line-inner"> <div v-if="isEditing" class="edit-mode">
<div class="chat-icon"> <div class="flex flex-row space-x-2 w-full">
<img :src="data.icon" alt="User" /> <div>
</div> <el-tooltip class="box-item" effect="dark" content="取消" placement="top">
<i
<div class="chat-item"> class="iconfont icon-error-line !text-lg mr-1 cursor-pointer"
<div v-if="files && files.length > 0" class="file-list-box"> @click="cancelEdit"
<div v-for="file in files" :key="file.url"> ></i>
<div class="image" v-if="isImage(file.ext)"> </el-tooltip>
<el-image :src="file.url" fit="cover" />
</div> </div>
<div class="item" v-else> <div class="w-full">
<div class="icon"> <textarea
<el-image :src="GetFileIcon(file.ext)" fit="cover" /> v-model="editText"
</div> :rows="3"
<div class="body"> placeholder="请输入修改后的内容..."
<div class="title"> class="w-full p-3 border-2 border-purple-500 rounded-md text-sm"
<el-link :href="file.url" target="_blank" style="--el-font-weight-primary: bold" resize="vertical"
>{{ file.name }} style="background-color: var(--chat-content-bg); color: var(--theme-text-primary)"
</el-link> ></textarea>
</div> </div>
<div class="info"> <div>
<span>{{ GetFileType(file.ext) }}</span> <el-tooltip class="box-item" effect="dark" content="提交" placement="top">
<span>{{ FormatFileSize(file.size) }}</span> <i
</div> class="iconfont icon-back-circle cursor-pointer !text-3xl text-purple-500 mr-1 hover:text-purple-700 mb-2"
</div> style="transform: rotate(90deg); display: inline-block"
@click="submitEdit"
></i>
</el-tooltip>
</div> </div>
</div> </div>
</div> </div>
<div class="content-wrapper">
<div class="content position-relative"> <!-- 显示模式 -->
<div v-html="content"></div> <div v-else>
<div class="content-wrapper">
<div class="content position-relative">
<div v-html="content"></div>
</div>
</div> </div>
</div> <div
<div class="bar" v-if="data.created_at > 0"> class="flex text-gray-500 text-sm py-2 justify-end items-center space-x-2"
<span class="bar-item" v-if="data.created_at > 0"
><el-icon><Clock /></el-icon> {{ dateFormat(data.created_at) }}</span
> >
<span class="bar-item"> <span class="flex items-center"
<el-tooltip class="box-item" effect="dark" content="复制" placement="bottom"> ><i class="iconfont icon-clock mr-1"></i> {{ dateFormat(data.created_at) }}</span
<i >
class="iconfont icon-copy cursor-pointer" <span class="flex items-center">
@click="copyContent(data.content.text)" <el-tooltip class="box-item" effect="dark" content="复制" placement="top">
></i> <i
</el-tooltip> class="iconfont icon-copy cursor-pointer !text-sm"
</span> @click="copyContent(data.content.text)"
></i>
</el-tooltip>
</span>
<span class="flex items-center">
<el-tooltip class="box-item" effect="dark" content="修改提问" placement="top">
<i class="iconfont icon-edit cursor-pointer !text-sm" @click="startEdit"></i>
</el-tooltip>
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -106,7 +101,7 @@
import { FormatFileSize, GetFileIcon, GetFileType } from '@/store/system' import { FormatFileSize, GetFileIcon, GetFileType } from '@/store/system'
import { showMessageSuccess } from '@/utils/dialog' import { showMessageSuccess } from '@/utils/dialog'
import { dateFormat, isImage, processPrompt } from '@/utils/libs' import { dateFormat, isImage, processPrompt } from '@/utils/libs'
import { Clock } from '@element-plus/icons-vue' import { ElMessage } from 'element-plus'
import hl from 'highlight.js' import hl from 'highlight.js'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import emoji from 'markdown-it-emoji' import emoji from 'markdown-it-emoji'
@@ -157,15 +152,19 @@ const props = defineProps({
icon: '', icon: '',
}, },
}, },
listStyle: { messageIndex: {
type: String, type: Number,
default: 'list', default: -1,
}, },
}) })
const finalTokens = ref(props.data.tokens) const finalTokens = ref(props.data.tokens)
const content = ref(processPrompt(props.data.content.text)) const content = ref(processPrompt(props.data.content.text))
const files = ref(props.data.content.files) const files = ref(props.data.content.files)
// 编辑相关状态
const isEditing = ref(false)
const editText = ref('')
// 定义emit事件 // 定义emit事件
const emit = defineEmits(['edit']) const emit = defineEmits(['edit'])
@@ -186,148 +185,40 @@ const copyContent = (text) => {
navigator.clipboard.writeText(text) navigator.clipboard.writeText(text)
showMessageSuccess('复制成功') showMessageSuccess('复制成功')
} }
// 开始编辑
const startEdit = () => {
isEditing.value = true
editText.value = props.data.content.text
}
// 取消编辑
const cancelEdit = () => {
isEditing.value = false
editText.value = ''
}
// 提交编辑
const submitEdit = () => {
if (!editText.value.trim()) {
ElMessage.warning('内容不能为空')
return
}
// 发送重新提交事件,传递修改后的内容
emit('edit', {
messageIndex: props.messageIndex,
newContent: editText.value.trim(),
})
// 退出编辑模式
isEditing.value = false
editText.value = ''
}
</script> </script>
<style lang="scss"> <style lang="scss">
@use '@/assets/css/markdown/vue.css' as *; @use '@/assets/css/markdown/vue.css' as *;
.chat-page, .chat-page {
.chat-export {
.chat-line-prompt-list {
background-color: var(--chat-content-bg-list);
color: var(--theme-text-color-primary);
justify-content: center;
width: 100%;
padding-bottom: 1.5rem;
padding-top: 1.5rem;
// border-bottom: 0.5px solid var(--el-border-color);
.chat-line-inner {
display: flex;
width: 100%;
max-width: 900px;
padding-left: 10px;
.chat-icon {
margin-right: 20px;
img {
width: 36px;
height: 36px;
border-radius: 50%;
padding: 1px;
}
}
.chat-item {
width: 100%;
padding: 0 5px 0 0;
overflow: hidden;
.file-list-box {
display: flex;
flex-flow: column;
.image {
display: flex;
flex-flow: row;
margin-right: 10px;
position: relative;
justify-content: start;
.el-image {
border: 1px solid #e3e3e3;
border-radius: 10px;
margin-bottom: 10px;
max-width: 150px;
max-height: 150px;
}
}
.item {
display: flex;
flex-flow: row;
border-radius: 10px;
background-color: var(--chat-content-bg);
border: 1px solid #e3e3e3;
color: var(--theme-text-color-primary);
padding: 6px;
margin-bottom: 10px;
.icon {
.el-image {
width: 40px;
height: 40px;
}
}
.body {
margin-left: 8px;
font-size: 14px;
.title {
font-weight: bold;
line-height: 24px;
color: #0d0d0d;
}
.info {
color: #b4b4b4;
span {
margin-right: 10px;
}
}
}
}
}
.content {
word-break: break-word;
padding: 0;
color: var(--theme-text-color-primary);
font-size: var(--content-font-size);
border-radius: 5px;
overflow: auto;
img {
max-width: 600px;
border-radius: 10px;
margin: 10px 0;
}
p {
line-height: 1.5;
}
p:last-child {
margin-bottom: 0;
}
p:first-child {
margin-top: 0;
}
}
.bar {
padding: 10px 10px 10px 0;
.bar-item {
// background-color #f7f7f8;
color: #888;
padding: 3px 5px;
margin-right: 10px;
border-radius: 5px;
.el-icon {
position: relative;
top: 2px;
}
}
}
}
}
}
.chat-line-prompt-chat { .chat-line-prompt-chat {
background: var(--chat-bg); background: var(--chat-bg);
justify-content: center; justify-content: center;
@@ -356,6 +247,7 @@ const copyContent = (text) => {
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
max-width: calc(100% - 110px); max-width: calc(100% - 110px);
width: 100%;
.file-list-box { .file-list-box {
display: flex; display: flex;
@@ -448,20 +340,9 @@ const copyContent = (text) => {
} }
} }
.bar { .edit-mode {
padding: 10px 10px 10px 0; width: 100%;
margin-top: 15px;
.bar-item {
color: #888;
padding: 3px 5px;
margin-right: 10px;
border-radius: 5px;
.el-icon {
position: relative;
top: 2px;
}
}
} }
} }
} }

View File

@@ -0,0 +1,265 @@
<template>
<div class="chat-line chat-line-prompt-list">
<div class="chat-line-inner">
<div class="chat-icon">
<img :src="data.icon" alt="User" />
</div>
<div class="chat-item">
<div v-if="files && files.length > 0" class="file-list-box">
<div v-for="file in files" :key="file.url">
<div class="image" v-if="isImage(file.ext)">
<el-image :src="file.url" fit="cover" />
</div>
<div class="item" v-else>
<div class="icon">
<el-image :src="GetFileIcon(file.ext)" fit="cover" />
</div>
<div class="body">
<div class="title">
<el-link :href="file.url" target="_blank" style="--el-font-weight-primary: bold"
>{{ file.name }}
</el-link>
</div>
<div class="info">
<span>{{ GetFileType(file.ext) }}</span>
<span>{{ FormatFileSize(file.size) }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="content position-relative">
<div v-html="content"></div>
</div>
<div
class="flex text-gray-500 text-sm py-2 items-center space-x-2"
v-if="data.created_at > 0"
>
<span class="flex items-center"
><i class="iconfont icon-clock mr-1"></i> {{ dateFormat(data.created_at) }}</span
>
<span class="flex items-center">
<el-tooltip class="box-item" effect="dark" content="复制" placement="top">
<i
class="iconfont icon-copy cursor-pointer !text-sm"
@click="copyContent(data.content.text)"
></i>
</el-tooltip>
</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { FormatFileSize, GetFileIcon, GetFileType } from '@/store/system'
import { showMessageSuccess } from '@/utils/dialog'
import { dateFormat, isImage, processPrompt } from '@/utils/libs'
import hl from 'highlight.js'
import MarkdownIt from 'markdown-it'
import emoji from 'markdown-it-emoji'
import mathjaxPlugin from 'markdown-it-mathjax3'
import { onMounted, ref } from 'vue'
const md = new MarkdownIt({
breaks: true,
html: true,
linkify: true,
typographer: true,
highlight: function (str, lang) {
const codeIndex = parseInt(Date.now()) + Math.floor(Math.random() * 10000000)
// 显示复制代码按钮
const copyBtn = `<span class="copy-code-btn" data-clipboard-action="copy" data-clipboard-target="#copy-target-${codeIndex}">复制</span>
<textarea style="position: absolute;top: -9999px;left: -9999px;z-index: -9999;" id="copy-target-${codeIndex}">${str.replace(
/<\/textarea>/g,
'&lt;/textarea>'
)}</textarea>`
if (lang && hl.getLanguage(lang)) {
const langHtml = `<span class="lang-name">${lang}</span>`
// 处理代码高亮
const preCode = hl.highlight(str, { language: lang, ignoreIllegals: true }).value
// 将代码包裹在 pre 中
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn} ${langHtml}</pre>`
}
// 处理代码高亮
const preCode = md.utils.escapeHtml(str)
// 将代码包裹在 pre 中
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn}</pre>`
},
})
md.use(mathjaxPlugin)
md.use(emoji)
const props = defineProps({
data: {
type: Object,
default: {
content: {
text: '',
files: [],
},
created_at: '',
tokens: 0,
model: '',
icon: '',
},
},
listStyle: {
type: String,
default: 'list',
},
})
const finalTokens = ref(props.data.tokens)
const content = ref(processPrompt(props.data.content.text))
const files = ref(props.data.content.files)
// 定义emit事件
const emit = defineEmits(['edit'])
onMounted(() => {
processFiles()
})
const processFiles = () => {
if (!props.data.content) {
return
}
content.value = md.render(content.value.trim())
}
const isExternalImg = (link, files) => {
return isImage(link) && !files.find((file) => file.url === link)
}
const copyContent = (text) => {
navigator.clipboard.writeText(text)
showMessageSuccess('复制成功')
}
</script>
<style lang="scss">
@use '@/assets/css/markdown/vue.css' as *;
.chat-page,
.chat-export {
.chat-line-prompt-list {
background-color: var(--chat-content-bg-list);
color: var(--theme-text-color-primary);
justify-content: center;
width: 100%;
padding-bottom: 1.5rem;
padding-top: 1.5rem;
// border-bottom: 0.5px solid var(--el-border-color);
.chat-line-inner {
display: flex;
width: 100%;
max-width: 900px;
padding-left: 10px;
.chat-icon {
margin-right: 20px;
min-width: 36px;
img {
width: 36px;
height: 36px;
border-radius: 50%;
padding: 1px;
}
}
.chat-item {
width: 100%;
padding: 0 5px 0 0;
overflow: hidden;
.file-list-box {
display: flex;
flex-flow: column;
.image {
display: flex;
flex-flow: row;
margin-right: 10px;
position: relative;
justify-content: start;
.el-image {
border: 1px solid #e3e3e3;
border-radius: 10px;
margin-bottom: 10px;
max-width: 150px;
max-height: 150px;
}
}
.item {
display: flex;
flex-flow: row;
border-radius: 10px;
background-color: var(--chat-content-bg);
border: 1px solid #e3e3e3;
color: var(--theme-text-color-primary);
padding: 6px;
margin-bottom: 10px;
.icon {
.el-image {
width: 40px;
height: 40px;
}
}
.body {
margin-left: 8px;
font-size: 14px;
.title {
font-weight: bold;
line-height: 24px;
color: #0d0d0d;
}
.info {
color: #b4b4b4;
span {
margin-right: 10px;
}
}
}
}
}
.content {
word-break: break-word;
padding: 0;
color: var(--theme-text-color-primary);
font-size: var(--content-font-size);
border-radius: 5px;
overflow: auto;
img {
max-width: 600px;
border-radius: 10px;
margin: 10px 0;
}
p {
line-height: 1.5;
}
p:last-child {
margin-bottom: 0;
}
p:first-child {
margin-top: 0;
}
}
}
}
}
}
</style>

View File

@@ -1,59 +1,6 @@
<template> <template>
<div class="chat-reply"> <div class="chat-reply">
<div class="chat-line chat-line-reply-list" v-if="listStyle === 'list'"> <div class="chat-line chat-line-reply-chat">
<div class="chat-line-inner">
<div class="chat-icon">
<img :src="data.icon" alt="ChatGPT" />
</div>
<div class="chat-item">
<div
class="content-wrapper"
v-html="md.render(processContent(data.content.text))"
v-if="data.content.text"
></div>
<div class="content-wrapper flex justify-start items-center" v-else>
<span class="mr-2">AI 思考中</span> <Thinking :duration="1.5" />
</div>
<div class="bar flex text-gray-500" v-if="data.created_at">
<span class="bar-item text-sm">{{ dateFormat(data.created_at) }}</span>
<!-- <span class="bar-item">tokens: {{ data.tokens }}</span> -->
<span class="bar-item">
<el-tooltip class="box-item" effect="dark" content="复制回答" placement="bottom">
<el-icon class="copy-reply" :data-clipboard-text="data.content">
<DocumentCopy />
</el-icon>
</el-tooltip>
</span>
<span v-if="!readOnly" class="flex">
<span class="bar-item" @click="reGenerate(data.id)">
<el-tooltip class="box-item" effect="dark" content="重新生成" placement="bottom">
<el-icon><Refresh /></el-icon>
</el-tooltip>
</span>
<span class="bar-item">
<el-tooltip
class="box-item"
effect="dark"
content="生成语音朗读"
placement="bottom"
>
<i
class="iconfont icon-speaker"
v-if="!isPlaying"
@click="synthesis(data.content)"
></i>
<el-image class="voice-icon" :src="playIcon" v-else />
</el-tooltip>
</span>
</span>
</div>
</div>
</div>
</div>
<div class="chat-line chat-line-reply-chat" v-else>
<div class="chat-line-inner"> <div class="chat-line-inner">
<div class="chat-icon"> <div class="chat-icon">
<img :src="data.icon" alt="ChatGPT" /> <img :src="data.icon" alt="ChatGPT" />
@@ -69,42 +16,48 @@
<span class="mr-2">AI 思考中</span> <Thinking :duration="1.5" /> <span class="mr-2">AI 思考中</span> <Thinking :duration="1.5" />
</div> </div>
</div> </div>
<div class="bar text-gray-500" v-if="data.created_at"> <div
<span class="bar-item text-sm"> {{ dateFormat(data.created_at) }}</span> class="flex text-gray-500 text-sm py-2 items-center space-x-2"
<span class="bar-item bg"> v-if="data.created_at"
<el-tooltip class="box-item" effect="dark" content="复制回答" placement="bottom"> >
<span class="flex items-center"
><i class="iconfont icon-clock mr-1"></i> {{ dateFormat(data.created_at) }}</span
>
<span class="flex items-center">
<el-tooltip class="box-item" effect="dark" content="复制回答" placement="top">
<el-icon class="copy-reply" :data-clipboard-text="data.content.text"> <el-icon class="copy-reply" :data-clipboard-text="data.content.text">
<DocumentCopy /> <DocumentCopy />
</el-icon> </el-icon>
</el-tooltip> </el-tooltip>
</span> </span>
<span v-if="!readOnly" class="flex"> <span class="flex items-center" @click="reGenerate">
<span class="bar-item bg" @click="reGenerate(data.id)"> <el-tooltip class="box-item" effect="dark" content="重新生成" placement="bottom">
<el-tooltip class="box-item" effect="dark" content="重新生成" placement="bottom"> <i class="iconfont icon-refresh cursor-pointer !text-sm"></i>
<el-icon><Refresh /></el-icon> </el-tooltip>
</el-tooltip> </span>
</span>
<span class="bar-item bg"> <span class="flex items-center">
<el-tooltip <el-tooltip
class="box-item" class="box-item"
effect="dark" effect="dark"
content="生成语音朗读" content="生成语音朗读"
placement="bottom" placement="bottom"
v-if="!isPlaying" v-if="!isPlaying"
> >
<i class="iconfont icon-speaker" @click="synthesis(data.content.text)"></i> <i
</el-tooltip> class="iconfont icon-speaker !text-sm cursor-pointer"
<el-tooltip @click="synthesis(data.content.text)"
class="box-item" ></i>
effect="dark" </el-tooltip>
content="暂停播放" <el-tooltip
placement="bottom" class="box-item"
v-else effect="dark"
> content="暂停播放"
<el-image class="voice-icon" :src="playIcon" @click="stopSynthesis()" /> placement="bottom"
</el-tooltip> v-else
</span> >
<el-image class="voice-icon" :src="playIcon" @click="stopSynthesis()" />
</el-tooltip>
</span> </span>
</div> </div>
</div> </div>
@@ -118,7 +71,7 @@
import { useSharedStore } from '@/store/sharedata' import { useSharedStore } from '@/store/sharedata'
import { httpPost } from '@/utils/http' import { httpPost } from '@/utils/http'
import { dateFormat, processContent } from '@/utils/libs' import { dateFormat, processContent } from '@/utils/libs'
import { DocumentCopy, Refresh } from '@element-plus/icons-vue' import { DocumentCopy } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import hl from 'highlight.js' import hl from 'highlight.js'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
@@ -141,13 +94,9 @@ const props = defineProps({
tokens: 0, tokens: 0,
}, },
}, },
readOnly: { messageIndex: {
type: Boolean, type: Number,
default: false, default: -1,
},
listStyle: {
type: String,
default: 'list',
}, },
}) })
@@ -232,8 +181,8 @@ const stopSynthesis = () => {
} }
// 重新生成 // 重新生成
const reGenerate = (messageId) => { const reGenerate = () => {
emits('regen', messageId) emits('regen', props.messageIndex)
} }
// 添加代码块展开/收起功能 // 添加代码块展开/收起功能
@@ -322,227 +271,6 @@ const setupCodeBlockEvents = () => {
sans-serif; sans-serif;
font-family: var(--font-family); font-family: var(--font-family);
.chat-line {
.boxed {
border: 1px solid var(--el-border-color);
border-radius: 5px;
padding: 0 5px;
}
.chat-item {
.content-wrapper {
img {
max-width: 600px;
border-radius: 10px;
}
p {
line-height: 1.5;
code {
color: var(--theme-text-color-primary);
font-weight: 600;
}
}
p:last-child {
margin-bottom: 0;
}
p:first-child {
margin-top: 0;
}
.code-container {
background-color: #2b2b2b;
border-radius: 10px;
position: relative;
.hljs {
border-radius: 10px;
width: 100%;
}
.copy-code-btn {
cursor: pointer;
font-size: 12px;
color: #c1c1c1;
&:hover {
color: #20a0ff;
}
}
}
// 添加代码块展开/收起样式
.code-collapsed {
.hljs {
max-height: 200px;
overflow: hidden;
position: relative;
transition: max-height 0.3s ease;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30px;
background: linear-gradient(transparent, #2b2b2b);
pointer-events: none;
}
}
}
.code-expanded {
.hljs {
max-height: none;
overflow: auto;
transition: max-height 0.3s ease;
&::after {
display: none;
}
}
}
.expand-btn {
transition: color 0.2s ease;
&:hover {
color: #20a0ff !important;
}
}
.lang-name {
color: #00e0e0;
}
// 设置表格边框
table {
width: 100%;
margin-bottom: 1rem;
border-collapse: collapse;
border: 1px solid #dee2e6;
background-color: var(--chat-content-bg);
color: var(--theme-text-color-primary);
thead {
th {
border: 1px solid #dee2e6;
vertical-align: bottom;
border-bottom: 2px solid #dee2e6;
padding: 10px;
}
}
td {
border: 1px solid #dee2e6;
padding: 10px;
}
}
// 代码快
blockquote {
margin: 0 0 0.8rem 0;
background-color: var(--quote-bg-color);
padding: 0.8rem 1.5rem;
color: var(--quote-text-color);
border-left: 0.4rem solid #6b50e1; /* 紫色边框 */
font-size: 16px;
line-height: 1.6;
}
}
}
}
.chat-line-reply-list {
justify-content: center;
background-color: var(--chat-content-bg);
color: var(--theme-text-color-primary);
width: 100%;
padding-bottom: 1.5rem;
padding-top: 1.5rem;
border: 1px solid var(--el-border-color);
border-radius: 10px;
.chat-line-inner {
display: flex;
width: 100%;
max-width: 900px;
padding-left: 10px;
.chat-icon {
margin-right: 20px;
img {
width: 36px;
height: 36px;
border-radius: 50%;
padding: 1px;
}
}
.chat-item {
width: 100%;
position: relative;
padding: 0;
overflow: hidden;
.content-wrapper {
min-height: 20px;
word-break: break-word;
padding: 0;
color: var(--theme-text-color-primary);
font-size: var(--content-font-size);
border-radius: 5px;
overflow: auto;
}
.bar {
padding: 10px 10px 10px 0;
.bar-item {
margin-right: 10px;
border-radius: 5px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
height: 26px;
.voice-icon {
width: 20px;
height: 20px;
}
.el-icon {
position: relative;
top: 2px;
cursor: pointer;
}
}
.el-button {
height: 20px;
padding: 5px 2px;
}
}
}
.tool-box {
font-size: 16px;
.el-button {
height: 20px;
padding: 5px 2px;
}
}
}
}
.chat-line-reply-chat { .chat-line-reply-chat {
justify-content: center; justify-content: center;
padding: 1.5rem; padding: 1.5rem;
@@ -584,42 +312,15 @@ const setupCodeBlockEvents = () => {
background-color: var(--chat-content-bg); background-color: var(--chat-content-bg);
border-radius: 0 10px 10px 10px; border-radius: 0 10px 10px 10px;
width: 100%; width: 100%;
}
}
.bar { p:first-child {
padding: 10px 10px 10px 0; margin-top: 0;
display: flex;
.bar-item {
margin-right: 10px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
height: 26px;
.voice-icon {
width: 20px;
height: 20px;
} }
.el-icon { p:last-child {
position: relative; margin-bottom: 0;
top: 2px;
cursor: pointer;
} }
} }
.bar-item.bg {
// background-color var( --gray-btn-bg)
cursor: pointer;
}
.el-button {
height: 20px;
padding: 5px 2px;
}
} }
} }

View File

@@ -0,0 +1,450 @@
<template>
<div class="chat-reply">
<div class="chat-line chat-line-reply-list">
<div class="chat-line-inner">
<div class="chat-icon">
<img :src="data.icon" alt="ChatGPT" />
</div>
<div class="chat-item">
<div
class="content-wrapper"
v-html="md.render(processContent(data.content.text))"
v-if="data.content.text"
></div>
<div class="content-wrapper flex justify-start items-center" v-else>
<span class="mr-2">AI 思考中</span> <Thinking :duration="1.5" />
</div>
<div
class="flex text-gray-500 text-sm py-2 items-center space-x-2"
v-if="data.created_at"
>
<span class="flex items-center">
<i class="iconfont icon-clock mr-1"></i> {{ dateFormat(data.created_at) }}</span
>
<span class="flex items-center">
<el-tooltip class="box-item" effect="dark" content="复制回答" placement="top">
<i
class="iconfont icon-copy cursor-pointer !text-sm"
:data-clipboard-text="data.content"
>
</i>
</el-tooltip>
</span>
</div>
</div>
</div>
</div>
<audio ref="audio" @ended="isPlaying = false" />
</div>
</template>
<script setup>
import { useSharedStore } from '@/store/sharedata'
import { httpPost } from '@/utils/http'
import { dateFormat, processContent } from '@/utils/libs'
import { ElMessage } from 'element-plus'
import hl from 'highlight.js'
import MarkdownIt from 'markdown-it'
import emoji from 'markdown-it-emoji'
import mathjaxPlugin from 'markdown-it-mathjax3'
import { nextTick, onMounted, reactive, ref, watchEffect } from 'vue'
import Thinking from './Thinking.vue'
// eslint-disable-next-line no-undef,no-unused-vars
const props = defineProps({
data: {
type: Object,
default: {
type: 'text',
icon: '',
content: {
text: '',
files: [],
},
created_at: '',
tokens: 0,
},
},
readOnly: {
type: Boolean,
default: false,
},
listStyle: {
type: String,
default: 'list',
},
})
const audio = ref(null)
const isPlaying = ref(false)
const playIcon = ref('/images/voice.gif')
const store = useSharedStore()
// 添加代码块展开/收起状态管理
const codeBlockStates = reactive({})
const md = new MarkdownIt({
breaks: true,
html: true,
linkify: true,
typographer: true,
highlight: function (str, lang) {
const codeIndex = parseInt(Date.now()) + Math.floor(Math.random() * 10000000)
// 显示复制代码按钮和展开/收起按钮
const copyBtn = `<div class="flex">
<span class="text-[12px] mr-2 text-[#00e0e0] cursor-pointer expand-btn" data-code-id="${codeIndex}" onclick="window.toggleCodeBlock('${codeIndex}')">收起</span>
<span class="copy-code-btn" data-clipboard-action="copy" data-clipboard-target="#copy-target-${codeIndex}">复制</span>
</div><textarea style="position: absolute;top: -9999px;left: -9999px;z-index: -9999;" id="copy-target-${codeIndex}">${str.replace(
/<\/textarea>/g,
'&lt;/textarea>'
)}</textarea>`
let langHtml = ''
let preCode = ''
// 处理代码高亮
if (lang && hl.getLanguage(lang)) {
langHtml = `<span class="lang-name">${lang}</span>`
preCode = hl.highlight(str, { language: lang }).value
} else {
preCode = md.utils.escapeHtml(str)
}
// 将代码包裹在 pre 中,添加展开状态的类(默认展开)
return `<pre class="code-container flex flex-col code-expanded" data-code-id="${codeIndex}">
<div class="flex justify-between bg-[#50505a] w-full rounded-tl-[10px] rounded-tr-[10px] px-3 py-1">${langHtml}${copyBtn}</div>
<code class="language-${lang} hljs">${preCode}</code>
<span class="copy-code-btn absolute right-3 bottom-3" data-clipboard-action="copy" data-clipboard-target="#copy-target-${codeIndex}">复制</span></pre>`
},
})
md.use(mathjaxPlugin)
md.use(emoji)
const emits = defineEmits(['regen'])
if (!props.data.icon) {
props.data.icon = 'images/gpt-icon.png'
}
const synthesis = (text) => {
isPlaying.value = true
httpPost('/api/chat/tts', { text: text, model_id: store.ttsModel }, { responseType: 'blob' })
.then((response) => {
// 创建 Blob 对象,明确指定 MIME 类型
const blob = new Blob([response], { type: 'audio/mpeg' }) // 假设音频格式为 MP3
const audioUrl = URL.createObjectURL(blob)
// 播放音频
audio.value.src = audioUrl
audio.value
.play()
.then(() => {
// 播放完成后释放 URL
URL.revokeObjectURL(audioUrl)
})
.catch(() => {
ElMessage.error('音频播放失败,请检查浏览器是否支持该音频格式')
isPlaying.value = false
})
})
.catch((e) => {
ElMessage.error('语音合成失败:' + e.message)
isPlaying.value = false
})
}
const stopSynthesis = () => {
isPlaying.value = false
audio.value.pause()
audio.value.currentTime = 0
}
// 重新生成
const reGenerate = (messageId) => {
emits('regen', messageId)
}
// 添加代码块展开/收起功能
const toggleCodeBlock = (codeId) => {
const codeContainer = document.querySelector(`pre[data-code-id="${codeId}"]`)
const expandBtn = document.querySelector(`.expand-btn[data-code-id="${codeId}"]`)
if (codeContainer && expandBtn) {
if (codeContainer.classList.contains('code-collapsed')) {
codeContainer.classList.remove('code-collapsed')
codeContainer.classList.add('code-expanded')
expandBtn.textContent = '收起'
} else {
codeContainer.classList.remove('code-expanded')
codeContainer.classList.add('code-collapsed')
expandBtn.textContent = '展开'
}
}
}
// 将函数暴露到全局作用域
window.toggleCodeBlock = toggleCodeBlock
// 添加事件监听
onMounted(() => {
nextTick(() => {
setupCodeBlockEvents()
})
})
// 监听内容变化,重新绑定事件
watchEffect(() => {
if (props.data.content.text) {
nextTick(() => {
// 延迟一点时间确保DOM完全渲染
setTimeout(() => {
setupCodeBlockEvents()
}, 100)
})
}
})
const setupCodeBlockEvents = () => {
// 检查所有代码块并设置展开按钮的显示状态
const expandBtns = document.querySelectorAll('.expand-btn')
expandBtns.forEach((btn) => {
const codeId = btn.getAttribute('data-code-id')
const codeContainer = document.querySelector(`pre[data-code-id="${codeId}"]`)
const codeElement = codeContainer?.querySelector('.hljs')
if (codeElement) {
// 临时移除高度限制来获取真实高度
const originalMaxHeight = codeElement.style.maxHeight
codeElement.style.maxHeight = 'none'
const realHeight = codeElement.scrollHeight
codeElement.style.maxHeight = originalMaxHeight
// 如果代码块高度小于等于200px隐藏展开按钮
if (realHeight <= 200) {
btn.style.display = 'none'
// 移除收起状态的类,让短代码块完全展示
codeContainer.classList.remove('code-collapsed')
codeContainer.classList.add('code-expanded')
} else {
btn.style.display = 'inline'
// 确保长代码块默认展开
if (
!codeContainer.classList.contains('code-expanded') &&
!codeContainer.classList.contains('code-collapsed')
) {
codeContainer.classList.add('code-expanded')
}
}
}
})
}
</script>
<style lang="scss">
@use '@/assets/css/markdown/vue.css' as *;
.chat-page,
.chat-export {
--font-family: Menlo, '微软雅黑', 'Roboto Mono', 'Courier New', Courier, monospace, 'Inter',
sans-serif;
font-family: var(--font-family);
.chat-line {
.boxed {
border: 1px solid var(--el-border-color);
border-radius: 5px;
padding: 0 5px;
}
.chat-item {
.content-wrapper {
img {
max-width: 600px;
border-radius: 10px;
}
p {
line-height: 1.5;
code {
color: var(--theme-text-color-primary);
font-weight: 600;
}
}
p:last-child {
margin-bottom: 0;
}
p:first-child {
margin-top: 0;
}
.code-container {
background-color: #2b2b2b;
border-radius: 10px;
position: relative;
.hljs {
border-radius: 10px;
width: 100%;
}
.copy-code-btn {
cursor: pointer;
font-size: 12px;
color: #c1c1c1;
&:hover {
color: #20a0ff;
}
}
}
// 添加代码块展开/收起样式
.code-collapsed {
.hljs {
max-height: 200px;
overflow: hidden;
position: relative;
transition: max-height 0.3s ease;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30px;
background: linear-gradient(transparent, #2b2b2b);
pointer-events: none;
}
}
}
.code-expanded {
.hljs {
max-height: none;
overflow: auto;
transition: max-height 0.3s ease;
&::after {
display: none;
}
}
}
.expand-btn {
transition: color 0.2s ease;
&:hover {
color: #20a0ff !important;
}
}
.lang-name {
color: #00e0e0;
}
// 设置表格边框
table {
width: 100%;
margin-bottom: 1rem;
border-collapse: collapse;
border: 1px solid #dee2e6;
background-color: var(--chat-content-bg);
color: var(--theme-text-color-primary);
thead {
th {
border: 1px solid #dee2e6;
vertical-align: bottom;
border-bottom: 2px solid #dee2e6;
padding: 10px;
}
}
td {
border: 1px solid #dee2e6;
padding: 10px;
}
}
// 代码快
blockquote {
margin: 0 0 0.8rem 0;
background-color: var(--quote-bg-color);
padding: 0.8rem 1.5rem;
color: var(--quote-text-color);
border-left: 0.4rem solid #6b50e1; /* 紫色边框 */
font-size: 16px;
line-height: 1.6;
}
}
}
}
.chat-line-reply-list {
justify-content: center;
background-color: var(--chat-content-bg);
color: var(--theme-text-color-primary);
width: 100%;
padding-bottom: 0.5rem;
padding-top: 1rem;
padding-right: 1rem;
border: 1px solid var(--el-border-color);
border-radius: 10px;
.chat-line-inner {
display: flex;
width: 100%;
max-width: 900px;
padding-left: 10px;
.chat-icon {
margin-right: 20px;
img {
width: 36px;
height: 36px;
border-radius: 50%;
padding: 1px;
}
}
.chat-item {
width: 100%;
position: relative;
padding: 0;
overflow: hidden;
.content-wrapper {
min-height: 20px;
word-break: break-word;
padding: 0;
color: var(--theme-text-color-primary);
font-size: var(--content-font-size);
border-radius: 5px;
overflow: auto;
p:first-child {
margin-top: 0;
}
p:last-child {
margin-bottom: 0;
}
}
}
.tool-box {
font-size: 16px;
.el-button {
height: 20px;
padding: 5px 2px;
}
}
}
}
}
</style>

View File

@@ -8,21 +8,8 @@
title="聊天配置" title="聊天配置"
> >
<div class="chat-setting"> <div class="chat-setting">
<el-form :model="data" label-width="100px" label-position="left"> <el-form :model="data" label-width="100px" label-position="top">
<el-form-item label="聊天样式"> <el-form-item label="启用流式输出">
<el-radio-group
v-model="data.style"
@change="
(val) => {
store.setChatListStyle(val)
}
"
>
<el-radio value="list">列表样式</el-radio>
<el-radio value="chat">对话样式</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="流式输出:">
<el-switch <el-switch
v-model="data.stream" v-model="data.stream"
@change=" @change="
@@ -32,7 +19,7 @@
" "
/> />
</el-form-item> </el-form-item>
<el-form-item label="语音音色"> <el-form-item label="朗读语音模型">
<el-select v-model="data.ttsModel" placeholder="请选择语音音色" @change="changeTTSModel"> <el-select v-model="data.ttsModel" placeholder="请选择语音音色" @change="changeTTSModel">
<el-option v-for="v in models" :value="v.id" :label="v.name" :key="v.id"> <el-option v-for="v in models" :value="v.id" :label="v.name" :key="v.id">
{{ v.name }} {{ v.name }}
@@ -51,7 +38,6 @@ import { computed, onMounted, ref } from 'vue'
const store = useSharedStore() const store = useSharedStore()
const data = ref({ const data = ref({
style: store.chatListStyle,
stream: store.chatStream, stream: store.chatStream,
ttsModel: store.ttsModel, ttsModel: store.ttsModel,
}) })

View File

@@ -322,6 +322,13 @@ export const useJimengStore = defineStore('jimeng', () => {
// 页面卸载时清理轮询 // 页面卸载时清理轮询
const cleanup = () => { const cleanup = () => {
page.value = 1
pageSize.value = 10
total.value = 0
taskFilter.value = 'all'
currentList.value = []
isOver.value = false
loading.value = false
stopPolling() stopPolling()
} }

View File

@@ -81,7 +81,6 @@ let waterfallOptions = {
export const useSharedStore = defineStore('shared', { export const useSharedStore = defineStore('shared', {
state: () => ({ state: () => ({
showLoginDialog: false, showLoginDialog: false,
chatListStyle: Storage.get('chat_list_style', 'chat'),
chatStream: Storage.get('chat_stream', true), chatStream: Storage.get('chat_stream', true),
theme: Storage.get('theme', 'light'), theme: Storage.get('theme', 'light'),
isLogin: false, isLogin: false,
@@ -94,10 +93,6 @@ export const useSharedStore = defineStore('shared', {
setShowLoginDialog(value) { setShowLoginDialog(value) {
this.showLoginDialog = value this.showLoginDialog = value
}, },
setChatListStyle(value) {
this.chatListStyle = value
Storage.set('chat_list_style', value)
},
setChatStream(value) { setChatStream(value) {
this.chatStream = value this.chatStream = value
Storage.set('chat_stream', value) Storage.set('chat_stream', value)

View File

@@ -6,8 +6,8 @@
</div> </div>
<div v-for="item in chatData" :key="item.id"> <div v-for="item in chatData" :key="item.id">
<chat-prompt v-if="item.type === 'prompt'" :data="item" list-style="list" /> <chat-prompt-line v-if="item.type === 'prompt'" :data="item" list-style="list" />
<chat-reply <chat-reply-line
v-else-if="item.type === 'reply'" v-else-if="item.type === 'reply'"
:data="item" :data="item"
:read-only="true" :read-only="true"
@@ -19,8 +19,8 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import ChatPrompt from '@/components/ChatPrompt.vue' import ChatPromptLine from '@/components/ChatPromptLine.vue'
import ChatReply from '@/components/ChatReply.vue' import ChatReplyLine from '@/components/ChatReplyLine.vue'
import { httpGet } from '@/utils/http' import { httpGet } from '@/utils/http'
import Clipboard from 'clipboard' import Clipboard from 'clipboard'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'

View File

@@ -263,19 +263,18 @@
<div v-if="showHello"> <div v-if="showHello">
<welcome @send="autofillPrompt" /> <welcome @send="autofillPrompt" />
</div> </div>
<div v-for="item in chatData" :key="item.id" v-else> <div v-for="(item, index) in chatData" :key="item.id" v-else>
<chat-prompt <chat-prompt
v-if="item.type === 'prompt'" v-if="item.type === 'prompt'"
:data="item" :data="item"
:list-style="listStyle" :message-index="index"
@edit="editUserPrompt" @edit="editUserPrompt"
/> />
<chat-reply <chat-reply
v-else-if="item.type === 'reply'" v-else-if="item.type === 'reply'"
:data="item" :data="item"
@regen="reGenerate" @regen="reGenerate"
:read-only="false" :message-index="index"
:list-style="listStyle"
/> />
</div> </div>
@@ -1171,28 +1170,27 @@ const stopGenerate = function () {
} }
// 重新生成 // 重新生成
const reGenerate = function (messageId) { const reGenerate = function (messageIndex) {
// 恢复发送按钮状态 // 恢复发送按钮状态
if (isGenerating.value) { if (isGenerating.value) {
ElMessage.warning('AI 正在作答中,请稍后...') ElMessage.warning('AI 正在作答中,请稍后...')
return return
} }
console.log('messageId', messageId) if (messageIndex === -1 || isNaN(messageIndex)) {
console.log('chatData.value', chatData.value) ElMessage.error('找不到要编辑的消息')
// 判断 messageId 是整数
if (messageId !== '' && isNaN(messageId)) {
ElMessage.warning('消息 ID 不合法,无法重新生成')
return return
} }
chatData.value = chatData.value.filter((item) => item.id < messageId && !item.isHello) // 找到该消息的ID
const messageId = chatData.value[messageIndex].id
// 移除该消息之后的所有消息
chatData.value = chatData.value.slice(0, messageIndex)
const userPrompt = chatData.value.pop() const userPrompt = chatData.value.pop()
prompt.value = userPrompt.content.text prompt.value = userPrompt.content.text
sendMessage(messageId)
// 将光标定位到输入框并聚焦 // 将光标定位到输入框并聚焦
nextTick(() => { nextTick(() => {
sendMessage(messageId)
if (inputRef.value) { if (inputRef.value) {
inputRef.value.focus() inputRef.value.focus()
// 触发输入事件以更新文本高度 // 触发输入事件以更新文本高度
@@ -1201,76 +1199,32 @@ const reGenerate = function (messageId) {
}) })
} }
// 编辑用户消息 // 编辑用户消息提交
const editUserPrompt = function (messageId) { const editUserPrompt = function (editData) {
// 找到要编辑的消息及其索引 const { messageIndex, newContent } = editData
let messageIndex = -1
let messageContent = ''
for (let i = 0; i < chatData.value.length; i++) { if (messageIndex === -1 || isNaN(messageIndex)) {
if (chatData.value[i].id === messageId) { ElMessage.error('找不到要编辑的消息')
messageIndex = i return
messageContent = chatData.value[i].content
break
}
} }
if (messageIndex === -1) return // 找到该消息下一条消息的ID
const messageId = chatData.value[messageIndex + 1].id
// 弹出编辑对话框 // 移除该消息之后的所有消息
ElMessageBox.prompt('', '编辑消息', { chatData.value = chatData.value.slice(0, messageIndex)
confirmButtonText: '确定', console.log('chatData.value', chatData.value)
cancelButtonText: '取消', // 设置该消息的内容
inputValue: messageContent, prompt.value = newContent
inputType: 'textarea', console.log('messageId', messageId)
customClass: 'edit-prompt-dialog', // 发送消息
roundButton: true, nextTick(() => {
sendMessage(messageId)
if (inputRef.value) {
inputRef.value.focus()
// 触发输入事件以更新文本高度
onInput({ keyCode: null })
}
}) })
.then(({ value }) => {
if (value.trim() === '') {
ElMessage.warning('消息内容不能为空')
return
}
// 更新用户消息
chatData.value[messageIndex].content = value
// 移除该消息之后的所有消息
chatData.value = chatData.value.slice(0, messageIndex + 1)
// 添加空回复消息
const _role = getRoleById(roleId.value)
chatData.value.push({
chat_id: chatId,
role_id: roleId.value,
type: 'reply',
id: randString(32),
icon: _role['icon'],
content: '',
})
disableInput(false)
// 发送编辑后的消息
store.socket.conn.send(
JSON.stringify({
channel: 'chat',
type: 'text',
body: {
role_id: roleId.value,
model_id: modelID.value,
chat_id: chatId.value,
content: value,
tools: toolSelected.value,
stream: stream.value,
edit_message: true,
},
})
)
})
.catch(() => {
// 取消编辑
})
} }
const chatName = ref('') const chatName = ref('')

View File

@@ -19,7 +19,7 @@
</div> </div>
<!-- 提示词编写指南可折叠 --> <!-- 提示词编写指南可折叠 -->
<div class="prompt-guide pl-2"> <div class="prompt-guide px-2">
<el-collapse v-model="guideActive"> <el-collapse v-model="guideActive">
<el-collapse-item name="guide"> <el-collapse-item name="guide">
<template #title> <template #title>

View File

@@ -207,8 +207,8 @@ const resetConfig = () => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@use '../../../assets/css/admin/form.scss' as *; @use '@/assets/css/admin/form.scss' as *;
@use '../../../assets/css/main.scss' as *; @use '@/assets/css/main.scss' as *;
.system-config { .system-config {
display: flex; display: flex;

View File

@@ -175,7 +175,7 @@
v-if="item.power" v-if="item.power"
class="jimeng-create__works-item-info-tags-item jimeng-create__works-item-info-tags-item--power" class="jimeng-create__works-item-info-tags-item jimeng-create__works-item-info-tags-item--power"
> >
{{ item.power }}算力 {{ item.power }} 积分
</span> </span>
</div> </div>
</div> </div>