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

View File

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

View File

@@ -3,12 +3,12 @@ package types
// JimengConfig 即梦AI配置
type JimengConfig struct {
// 即梦AI的AccessKey和SecretKey
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
AccessKey string `json:"access_key,omitempty"`
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 任务状态

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,12 +8,12 @@ package types
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
type SmtpConfig struct {
UseTls bool `json:"use_tls"` // 是否使用 TLS 发送
Host string `json:"host"` // 邮件服务器地址
Port int `json:"port"` // 邮件服务器端口
AppName string `json:"app_name"` // 应用名称
From string `json:"from"` // 发件人邮箱地址
Password string `json:"password"` // 发件人邮箱密码
UseTls bool `json:"use_tls,omitempty"` // 是否使用 TLS 发送
Host string `json:"host,omitempty"` // 邮件服务器地址
Port int `json:"port,omitempty"` // 邮件服务器端口
AppName string `json:"app_name,omitempty"` // 应用名称
From string `json:"from,omitempty"` // 发件人邮箱地址
Password string `json:"password,omitempty"` // 发件人邮箱密码
}
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 {
var historyMessages []model.ChatMessage
dbSession := h.DB.Session(&gorm.Session{}).Where("chat_id", input.ChatId)
if input.LastMsgId > 0 { // 重新生成逻辑
if input.LastMsgId > 0 { // 重新生成和编辑逻辑
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 {
input.LastMsgId = 0
} else {
@@ -282,7 +282,7 @@ func (h *ChatHandler) sendMessage(ctx context.Context, input ChatInput, c *gin.C
}
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
if err == nil {

View File

@@ -179,6 +179,10 @@ func (h *JimengHandler) Jobs(c *gin.Context) {
query = query.Where("type = ?", types.JMTaskTypeImage)
case "video":
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 {

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<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-icon">
<img :src="data.icon" alt="User" />
@@ -29,73 +29,68 @@
</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 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 v-if="isEditing" class="edit-mode">
<div class="flex flex-row space-x-2 w-full">
<div>
<el-tooltip class="box-item" effect="dark" content="取消" placement="top">
<i
class="iconfont icon-error-line !text-lg mr-1 cursor-pointer"
@click="cancelEdit"
></i>
</el-tooltip>
</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 class="w-full">
<textarea
v-model="editText"
:rows="3"
placeholder="请输入修改后的内容..."
class="w-full p-3 border-2 border-purple-500 rounded-md text-sm"
resize="vertical"
style="background-color: var(--chat-content-bg); color: var(--theme-text-primary)"
></textarea>
</div>
<div>
<el-tooltip class="box-item" effect="dark" content="提交" placement="top">
<i
class="iconfont icon-back-circle cursor-pointer !text-3xl text-purple-500 mr-1 hover:text-purple-700 mb-2"
style="transform: rotate(90deg); display: inline-block"
@click="submitEdit"
></i>
</el-tooltip>
</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 class="bar" v-if="data.created_at > 0">
<span class="bar-item"
><el-icon><Clock /></el-icon> {{ dateFormat(data.created_at) }}</span
<div
class="flex text-gray-500 text-sm py-2 justify-end items-center space-x-2"
v-if="data.created_at > 0"
>
<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>
<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>
<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>
@@ -106,7 +101,7 @@
import { FormatFileSize, GetFileIcon, GetFileType } from '@/store/system'
import { showMessageSuccess } from '@/utils/dialog'
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 MarkdownIt from 'markdown-it'
import emoji from 'markdown-it-emoji'
@@ -157,15 +152,19 @@ const props = defineProps({
icon: '',
},
},
listStyle: {
type: String,
default: 'list',
messageIndex: {
type: Number,
default: -1,
},
})
const finalTokens = ref(props.data.tokens)
const content = ref(processPrompt(props.data.content.text))
const files = ref(props.data.content.files)
// 编辑相关状态
const isEditing = ref(false)
const editText = ref('')
// 定义emit事件
const emit = defineEmits(['edit'])
@@ -186,148 +185,40 @@ const copyContent = (text) => {
navigator.clipboard.writeText(text)
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>
<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;
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-page {
.chat-line-prompt-chat {
background: var(--chat-bg);
justify-content: center;
@@ -356,6 +247,7 @@ const copyContent = (text) => {
padding: 0;
overflow: hidden;
max-width: calc(100% - 110px);
width: 100%;
.file-list-box {
display: flex;
@@ -448,20 +340,9 @@ const copyContent = (text) => {
}
}
.bar {
padding: 10px 10px 10px 0;
.bar-item {
color: #888;
padding: 3px 5px;
margin-right: 10px;
border-radius: 5px;
.el-icon {
position: relative;
top: 2px;
}
}
.edit-mode {
width: 100%;
margin-top: 15px;
}
}
}

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>
<div class="chat-reply">
<div class="chat-line chat-line-reply-list" v-if="listStyle === '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="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 chat-line-reply-chat">
<div class="chat-line-inner">
<div class="chat-icon">
<img :src="data.icon" alt="ChatGPT" />
@@ -69,42 +16,48 @@
<span class="mr-2">AI 思考中</span> <Thinking :duration="1.5" />
</div>
</div>
<div class="bar text-gray-500" v-if="data.created_at">
<span class="bar-item text-sm"> {{ dateFormat(data.created_at) }}</span>
<span class="bar-item bg">
<el-tooltip class="box-item" effect="dark" content="复制回答" placement="bottom">
<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">
<el-icon class="copy-reply" :data-clipboard-text="data.content.text">
<DocumentCopy />
</el-icon>
</el-tooltip>
</span>
<span v-if="!readOnly" class="flex">
<span class="bar-item bg" @click="reGenerate(data.id)">
<el-tooltip class="box-item" effect="dark" content="重新生成" placement="bottom">
<el-icon><Refresh /></el-icon>
</el-tooltip>
</span>
<span class="flex items-center" @click="reGenerate">
<el-tooltip class="box-item" effect="dark" content="重新生成" placement="bottom">
<i class="iconfont icon-refresh cursor-pointer !text-sm"></i>
</el-tooltip>
</span>
<span class="bar-item bg">
<el-tooltip
class="box-item"
effect="dark"
content="生成语音朗读"
placement="bottom"
v-if="!isPlaying"
>
<i class="iconfont icon-speaker" @click="synthesis(data.content.text)"></i>
</el-tooltip>
<el-tooltip
class="box-item"
effect="dark"
content="暂停播放"
placement="bottom"
v-else
>
<el-image class="voice-icon" :src="playIcon" @click="stopSynthesis()" />
</el-tooltip>
</span>
<span class="flex items-center">
<el-tooltip
class="box-item"
effect="dark"
content="生成语音朗读"
placement="bottom"
v-if="!isPlaying"
>
<i
class="iconfont icon-speaker !text-sm cursor-pointer"
@click="synthesis(data.content.text)"
></i>
</el-tooltip>
<el-tooltip
class="box-item"
effect="dark"
content="暂停播放"
placement="bottom"
v-else
>
<el-image class="voice-icon" :src="playIcon" @click="stopSynthesis()" />
</el-tooltip>
</span>
</div>
</div>
@@ -118,7 +71,7 @@
import { useSharedStore } from '@/store/sharedata'
import { httpPost } from '@/utils/http'
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 hl from 'highlight.js'
import MarkdownIt from 'markdown-it'
@@ -141,13 +94,9 @@ const props = defineProps({
tokens: 0,
},
},
readOnly: {
type: Boolean,
default: false,
},
listStyle: {
type: String,
default: 'list',
messageIndex: {
type: Number,
default: -1,
},
})
@@ -232,8 +181,8 @@ const stopSynthesis = () => {
}
// 重新生成
const reGenerate = (messageId) => {
emits('regen', messageId)
const reGenerate = () => {
emits('regen', props.messageIndex)
}
// 添加代码块展开/收起功能
@@ -322,227 +271,6 @@ const setupCodeBlockEvents = () => {
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: 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 {
justify-content: center;
padding: 1.5rem;
@@ -584,42 +312,15 @@ const setupCodeBlockEvents = () => {
background-color: var(--chat-content-bg);
border-radius: 0 10px 10px 10px;
width: 100%;
}
}
.bar {
padding: 10px 10px 10px 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;
p:first-child {
margin-top: 0;
}
.el-icon {
position: relative;
top: 2px;
cursor: pointer;
p:last-child {
margin-bottom: 0;
}
}
.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="聊天配置"
>
<div class="chat-setting">
<el-form :model="data" label-width="100px" label-position="left">
<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-form :model="data" label-width="100px" label-position="top">
<el-form-item label="启用流式输出">
<el-switch
v-model="data.stream"
@change="
@@ -32,7 +19,7 @@
"
/>
</el-form-item>
<el-form-item label="语音音色">
<el-form-item label="朗读语音模型">
<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">
{{ v.name }}
@@ -51,7 +38,6 @@ import { computed, onMounted, ref } from 'vue'
const store = useSharedStore()
const data = ref({
style: store.chatListStyle,
stream: store.chatStream,
ttsModel: store.ttsModel,
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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