feat: new UI for chat file manager is ready

This commit is contained in:
RockYang 2024-06-25 18:59:27 +08:00
parent d63536d5ef
commit f8fed83507
16 changed files with 386 additions and 113 deletions

View File

@ -22,6 +22,7 @@ type WsMessage struct {
Type WsMsgType `json:"type"` // 消息类别start, end, img Type WsMsgType `json:"type"` // 消息类别start, end, img
Content interface{} `json:"content"` Content interface{} `json:"content"`
} }
type WsMsgType string type WsMsgType string
const ( const (

View File

@ -348,7 +348,7 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
} }
data = append(data, gin.H{ data = append(data, gin.H{
"type": "text", "type": "text",
"text": text, "text": strings.TrimSpace(text),
}) })
content = data content = data
} else { } else {

View File

@ -9,6 +9,7 @@ package handler
import ( import (
"geekai/core" "geekai/core"
"geekai/core/types"
"geekai/service/oss" "geekai/service/oss"
"geekai/store/model" "geekai/store/model"
"geekai/store/vo" "geekai/store/vo"
@ -35,6 +36,12 @@ func (h *UploadHandler) Upload(c *gin.Context) {
return return
} }
logger.Info("upload file: %s", file.Name)
// cut the file name if it's too long
if len(file.Name) > 100 {
file.Name = file.Name[:90] + file.Ext
}
userId := h.GetLoginUserId(c) userId := h.GetLoginUserId(c)
res := h.DB.Create(&model.File{ res := h.DB.Create(&model.File{
UserId: int(userId), UserId: int(userId),
@ -54,10 +61,24 @@ func (h *UploadHandler) Upload(c *gin.Context) {
} }
func (h *UploadHandler) List(c *gin.Context) { func (h *UploadHandler) List(c *gin.Context) {
var data struct {
Urls []string `json:"urls"`
}
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
logger.Info(data)
userId := h.GetLoginUserId(c) userId := h.GetLoginUserId(c)
var items []model.File var items []model.File
var files = make([]vo.File, 0) var files = make([]vo.File, 0)
h.DB.Where("user_id = ?", userId).Find(&items) session := h.DB.Session(&gorm.Session{})
session = session.Where("user_id = ?", userId)
if len(data.Urls) > 0 {
session = session.Where("url IN ?", data.Urls)
}
session.Find(&items)
if len(items) > 0 { if len(items) > 0 {
for _, v := range items { for _, v := range items {
var file vo.File var file vo.File

View File

@ -255,7 +255,7 @@ func main() {
}), }),
fx.Invoke(func(s *core.AppServer, h *handler.UploadHandler) { fx.Invoke(func(s *core.AppServer, h *handler.UploadHandler) {
s.Engine.POST("/api/upload", h.Upload) s.Engine.POST("/api/upload", h.Upload)
s.Engine.GET("/api/upload/list", h.List) s.Engine.POST("/api/upload/list", h.List)
s.Engine.GET("/api/upload/remove", h.Remove) s.Engine.GET("/api/upload/remove", h.Remove)
}), }),
fx.Invoke(func(s *core.AppServer, h *handler.SmsHandler) { fx.Invoke(func(s *core.AppServer, h *handler.SmsHandler) {

View File

@ -6,4 +6,4 @@ VUE_APP_ADMIN_USER=admin
VUE_APP_ADMIN_PASS=admin123 VUE_APP_ADMIN_PASS=admin123
VUE_APP_KEY_PREFIX=ChatPLUS_DEV_ VUE_APP_KEY_PREFIX=ChatPLUS_DEV_
VUE_APP_TITLE="Geek-AI 创作系统" VUE_APP_TITLE="Geek-AI 创作系统"
VUE_APP_VERSION=v4.0.9 VUE_APP_VERSION=v4.1.0

View File

@ -2,4 +2,4 @@ VUE_APP_API_HOST=
VUE_APP_WS_HOST= VUE_APP_WS_HOST=
VUE_APP_KEY_PREFIX=ChatPLUS_ VUE_APP_KEY_PREFIX=ChatPLUS_
VUE_APP_TITLE="Geek-AI 创作系统" VUE_APP_TITLE="Geek-AI 创作系统"
VUE_APP_VERSION=v4.0.9 VUE_APP_VERSION=v4.1.0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@ -252,25 +252,35 @@ $borderColor = #4676d0;
border: 2px solid #21AA93 border: 2px solid #21AA93
border-radius 10px border-radius 10px
padding 10px padding 10px
background-color #F4F4F4
.input-inner {
.prompt-input::-webkit-scrollbar { display flex
width: 0; flex-flow column
height: 0;
}
.prompt-input {
width 100% width 100%
line-height: 24px
border none .file-list {
font-size 14px padding-bottom 10px
background none }
resize: none .prompt-input::-webkit-scrollbar {
white-space: pre-wrap; /* */ width: 0;
word-wrap: break-word; /* */ height: 0;
overflow-wrap: break-word; /* */ }
.prompt-input {
width 100%
line-height: 24px
border none
font-size 14px
background none
resize: none
white-space: pre-wrap; /* */
word-wrap: break-word; /* */
overflow-wrap: break-word; /* */
}
} }
.send-btn { .send-btn {
width 32px width 32px
margin-left 10px margin-left 10px

View File

@ -6,6 +6,25 @@
</div> </div>
<div class="chat-item"> <div class="chat-item">
<div v-if="files.length > 0" class="file-list-box">
<div v-for="file in files">
<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">{{file.name}}</div>
<div class="info">
<span>{{GetFileType(file.ext)}}</span>
<span>{{FormatFileSize(file.size)}}</span>
</div>
</div>
</div>
</div>
</div>
<div class="content" v-html="content"></div> <div class="content" v-html="content"></div>
<div class="bar" v-if="createdAt"> <div class="bar" v-if="createdAt">
<span class="bar-item"><el-icon><Clock/></el-icon> {{ createdAt }}</span> <span class="bar-item"><el-icon><Clock/></el-icon> {{ createdAt }}</span>
@ -17,50 +36,87 @@
</div> </div>
</template> </template>
<script> <script setup>
import {defineComponent} from "vue" import {onMounted, ref} from "vue"
import {Clock} from "@element-plus/icons-vue"; import {Clock} from "@element-plus/icons-vue";
import {httpPost} from "@/utils/http"; import {httpPost} from "@/utils/http";
import hl from "highlight.js";
import {isImage, processPrompt, substr} from "@/utils/libs";
import {FormatFileSize, GetFileIcon, GetFileType} from "@/store/system";
export default defineComponent({ const mathjaxPlugin = require('markdown-it-mathjax3')
name: 'ChatPrompt', const md = require('markdown-it')({
components: {Clock}, breaks: true,
methods: {}, html: true,
props: { linkify: true,
content: { typographer: true,
type: String, highlight: function (str, lang) {
default: '', const codeIndex = parseInt(Date.now()) + Math.floor(Math.random() * 10000000)
}, //
icon: { const copyBtn = `<span class="copy-code-btn" data-clipboard-action="copy" data-clipboard-target="#copy-target-${codeIndex}">复制</span>
type: String, <textarea style="position: absolute;top: -9999px;left: -9999px;z-index: -9999;" id="copy-target-${codeIndex}">${str.replace(/<\/textarea>/g, '&lt;/textarea>')}</textarea>`
default: 'images/user-icon.png', if (lang && hl.getLanguage(lang)) {
}, const langHtml = `<span class="lang-name">${lang}</span>`
createdAt: { //
type: String, const preCode = hl.highlight(lang, str, true).value
default: '', // pre
}, return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn} ${langHtml}</pre>`
tokens: {
type: Number,
default: 0,
},
model: {
type: String,
default: '',
},
},
data() {
return {
finalTokens: this.tokens
} }
//
const preCode = md.utils.escapeHtml(str)
// pre
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn}</pre>`
}
});
md.use(mathjaxPlugin)
const props = defineProps({
content: {
type: String,
default: '',
}, },
mounted() { icon: {
if (!this.finalTokens) { type: String,
httpPost("/api/chat/tokens", {text: this.content, model: this.model}).then(res => { default: 'images/user-icon.png',
this.finalTokens = res.data; },
}).catch(() => { createdAt: {
}) type: String,
default: '',
},
tokens: {
type: Number,
default: 0,
},
model: {
type: String,
default: '',
},
})
const finalTokens = ref(props.tokens)
const content =ref(processPrompt(props.content))
const files = ref([])
onMounted(() => {
if (!finalTokens.value) {
httpPost("/api/chat/tokens", {text: props.content, model: props.model}).then(res => {
finalTokens.value = res.data;
}).catch(() => {
})
}
const linkRegex = /(https?:\/\/\S+)/g;
const links = props.content.match(linkRegex);
if (links) {
httpPost("/api/upload/list", {urls: links}).then(res => {
files.value = res.data
}).catch(() => {
})
for (let link of links) {
content.value = content.value.replace(link,"")
} }
} }
content.value = md.render(content.value.trim())
}) })
</script> </script>
@ -92,10 +148,58 @@ export default defineComponent({
.chat-item { .chat-item {
width 100% width 100%
position: relative;
padding: 0 5px 0 0; padding: 0 5px 0 0;
overflow: hidden; overflow: hidden;
.file-list-box {
display flex
flex-flow column
.image {
display flex
flex-flow row
margin-right 10px
position relative
.el-image {
border 1px solid #e3e3e3
border-radius 10px
margin-bottom 10px
}
}
.item {
display flex
flex-flow row
border-radius 10px
background-color #ffffff
border 1px solid #e3e3e3
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 { .content {
word-break break-word; word-break break-word;
padding: 6px 10px; padding: 6px 10px;

View File

@ -0,0 +1,114 @@
<template>
<el-container class="chat-file-list">
<div v-for="file in fileList">
<div class="image" v-if="isImage(file.ext)">
<el-image :src="file.url" fit="cover"/>
<div class="action">
<el-icon @click="removeFile(file)"><CircleCloseFilled /></el-icon>
</div>
</div>
<div class="item" v-else>
<div class="icon">
<el-image :src="GetFileIcon(file.ext)" fit="cover" />
</div>
<div class="body">
<div class="title">{{substr(file.name, 30)}}</div>
<div class="info">
<span>{{GetFileType(file.ext)}}</span>
<span>{{FormatFileSize(file.size)}}</span>
</div>
</div>
<div class="action">
<el-icon @click="removeFile(file)"><CircleCloseFilled /></el-icon>
</div>
</div>
</div>
</el-container>
</template>
<script setup>
import {ref} from "vue";
import {CircleCloseFilled} from "@element-plus/icons-vue";
import {isImage, removeArrayItem, substr} from "@/utils/libs";
import {FormatFileSize, GetFileIcon, GetFileType} from "@/store/system";
const props = defineProps({
files: {
type: Array,
default:[],
}
})
const emits = defineEmits(['removeFile']);
const fileList = ref(props.files)
const removeFile = (file) => {
fileList.value = removeArrayItem(fileList.value, file, (v1,v2) => v1.url===v2.url)
emits('removeFile', file)
}
</script>
<style scoped lang="stylus">
.chat-file-list {
display flex
flex-flow row
.image {
display flex
flex-flow row
margin-right 10px
position relative
.el-image {
height 56px
width 56px
border 1px solid #e3e3e3
border-radius 10px
}
}
.item {
position relative
display flex
flex-flow row
border-radius 10px
background-color #ffffff
border 1px solid #e3e3e3
padding 6px
margin-right 10px
.icon {
.el-image {
width 40px
height 40px
}
}
.body {
margin-left 5px
font-size 14px
.title {
font-weight bold
line-height 24px
color #0D0D0D
}
.info {
color #B4B4B4
span {
margin-right 10px
}
}
}
}
.action {
position absolute
top -8px
right -8px
color #da0d54
cursor pointer
font-size 20px
}
}
</style>

View File

@ -1,5 +1,5 @@
<template> <template>
<el-container class="file-list-box"> <el-container class="file-select-box">
<a class="file-upload-img" @click="fetchFiles"> <a class="file-upload-img" @click="fetchFiles">
<i class="iconfont icon-attachment-st"></i> <i class="iconfont icon-attachment-st"></i>
</a> </a>
@ -34,8 +34,8 @@
effect="dark" effect="dark"
:content="file.name" :content="file.name"
placement="top"> placement="top">
<el-image :src="file.url" fit="cover" v-if="isImage(file.ext)" @click="insertURL(file.url)"/> <el-image :src="file.url" fit="cover" v-if="isImage(file.ext)" @click="insertURL(file)"/>
<el-image :src="getFileIcon(file.ext)" fit="cover" v-else @click="insertURL(file.url)"/> <el-image :src="GetFileIcon(file.ext)" fit="cover" v-else @click="insertURL(file)"/>
</el-tooltip> </el-tooltip>
<div class="opt"> <div class="opt">
@ -55,6 +55,7 @@ import {ElMessage} from "element-plus";
import {httpGet, httpPost} from "@/utils/http"; import {httpGet, httpPost} from "@/utils/http";
import {Delete, Plus} from "@element-plus/icons-vue"; import {Delete, Plus} from "@element-plus/icons-vue";
import {isImage, removeArrayItem} from "@/utils/libs"; import {isImage, removeArrayItem} from "@/utils/libs";
import {GetFileIcon} from "@/store/system";
const props = defineProps({ const props = defineProps({
userId: Number, userId: Number,
@ -65,30 +66,12 @@ const fileList = ref([])
const fetchFiles = () => { const fetchFiles = () => {
show.value = true show.value = true
httpGet("/api/upload/list").then(res => { httpPost("/api/upload/list").then(res => {
fileList.value = res.data fileList.value = res.data
}).catch(() => { }).catch(() => {
}) })
} }
const getFileIcon = (ext) => {
const files = {
".docx": "doc.png",
".doc": "doc.png",
".xls": "xls.png",
".xlsx": "xls.png",
".ppt": "ppt.png",
".pptx": "ppt.png",
".md": "md.png",
".pdf": "pdf.png",
".sql": "sql.png"
}
if (files[ext]) {
return '/images/ext/' + files[ext]
}
return '/images/ext/file.png'
}
const afterRead = (file) => { const afterRead = (file) => {
const formData = new FormData(); const formData = new FormData();
@ -113,19 +96,19 @@ const removeFile = (file) => {
}) })
} }
const insertURL = (url) => { const insertURL = (file) => {
show.value = false show.value = false
// //
if (url.indexOf("http") === -1) { if (file.url.indexOf("http") === -1) {
url = location.protocol + "//" + location.host + url file.url = location.protocol + "//" + location.host + file.url
} }
emits('selected', url) emits('selected', file)
} }
</script> </script>
<style lang="stylus"> <style lang="stylus">
.file-list-box { .file-select-box {
.file-upload-img { .file-upload-img {
.iconfont { .iconfont {
font-size: 24px; font-size: 24px;

View File

@ -25,3 +25,37 @@ export function getAdminTheme() {
export function setAdminTheme(theme) { export function setAdminTheme(theme) {
Storage.set(ADMIN_THEME, theme) Storage.set(ADMIN_THEME, theme)
} }
export function GetFileIcon(ext) {
const files = {
".docx": "doc.png",
".doc": "doc.png",
".xls": "xls.png",
".xlsx": "xls.png",
".csv": "xls.png",
".ppt": "ppt.png",
".pptx": "ppt.png",
".md": "md.png",
".pdf": "pdf.png",
".sql": "sql.png"
}
if (files[ext]) {
return '/images/ext/' + files[ext]
}
return '/images/ext/file.png'
}
// 获取文件类型
export function GetFileType (ext) {
return ext.replace(".", "").toUpperCase()
}
// 将文件大小转成字符
export function FormatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

View File

@ -223,23 +223,9 @@ export function processContent(content) {
} }
export function processPrompt(prompt) { export function processPrompt(prompt) {
prompt = prompt.replace(/&/g, "&amp;") return prompt.replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;"); .replace(/>/g, "&gt;");
const linkRegex = /(https?:\/\/\S+)/g;
const links = prompt.match(linkRegex);
if (links) {
for (let link of links) {
if (isImage(link)) {
const index = prompt.indexOf(link)
if (prompt.substring(index - 1, 2) !== "]") {
prompt = prompt.replace(link, "\n![](" + link + ")\n")
}
}
}
}
return prompt
} }
// 判断是否为微信浏览器 // 判断是否为微信浏览器
@ -258,3 +244,4 @@ export function showLoginDialog(router) {
// on cancel // on cancel
}); });
} }

View File

@ -129,14 +129,18 @@
<span class="tool-item" v-if="isLogin"> <span class="tool-item" v-if="isLogin">
<el-tooltip class="box-item" effect="dark" content="上传附件"> <el-tooltip class="box-item" effect="dark" content="上传附件">
<file-select v-if="isLogin" :user-id="loginUser.id" @selected="insertURL"/> <file-select v-if="isLogin" :user-id="loginUser.id" @selected="insertFile"/>
</el-tooltip> </el-tooltip>
</span> </span>
<div class="input-body"> <div class="input-body">
<div ref="textHeightRef" class="hide-div">{{prompt}}</div> <div ref="textHeightRef" class="hide-div">{{prompt}}</div>
<div class="input-border"> <div class="input-border">
<textarea <div class="input-inner">
<div class="file-list" v-if="files.length > 0">
<file-list :files="files" @remove-file="removeFile" />
</div>
<textarea
ref="inputRef" ref="inputRef"
class="prompt-input" class="prompt-input"
:rows="row" :rows="row"
@ -146,6 +150,8 @@
placeholder="按 Enter 键发送消息,使用 Ctrl + Enter 换行" placeholder="按 Enter 键发送消息,使用 Ctrl + Enter 换行"
autofocus> autofocus>
</textarea> </textarea>
</div>
<span class="send-btn"> <span class="send-btn">
<el-button type="info" v-if="showStopGenerate" @click="stopGenerate" plain> <el-button type="info" v-if="showStopGenerate" @click="stopGenerate" plain>
<el-icon> <el-icon>
@ -210,6 +216,7 @@ import {checkSession} from "@/action/session";
import Welcome from "@/components/Welcome.vue"; import Welcome from "@/components/Welcome.vue";
import {useSharedStore} from "@/store/sharedata"; import {useSharedStore} from "@/store/sharedata";
import FileSelect from "@/components/FileSelect.vue"; import FileSelect from "@/components/FileSelect.vue";
import FileList from "@/components/FileList.vue";
const title = ref('ChatGPT-智能助手'); const title = ref('ChatGPT-智能助手');
const models = ref([]) const models = ref([])
@ -743,12 +750,17 @@ const sendMessage = function () {
if (prompt.value.trim().length === 0 || canSend.value === false) { if (prompt.value.trim().length === 0 || canSend.value === false) {
return false; return false;
} }
//
let content = prompt.value
if (files.value.length > 0) {
content = files.value.map(file => file.url).join(" ") + " " + content
}
// //
chatData.value.push({ chatData.value.push({
type: "prompt", type: "prompt",
id: randString(32), id: randString(32),
icon: loginUser.value.avatar, icon: loginUser.value.avatar,
content: md.render(processPrompt(prompt.value)), content: content,
created_at: new Date().getTime() / 1000, created_at: new Date().getTime() / 1000,
}); });
@ -758,9 +770,10 @@ const sendMessage = function () {
showHello.value = false showHello.value = false
disableInput(false) disableInput(false)
socket.value.send(JSON.stringify({type: "chat", content: prompt.value})); socket.value.send(JSON.stringify({type: "chat", content: content}));
tmpChatTitle.value = prompt.value tmpChatTitle.value = content
prompt.value = ''; prompt.value = ''
files.value = []
return true; return true;
} }
@ -810,9 +823,11 @@ const loadChatHistory = function (chatId) {
showHello.value = false showHello.value = false
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
data[i].orgContent = data[i].content; data[i].orgContent = data[i].content;
data[i].content = md.render(processContent(data[i].content)) if (data[i].type === 'reply') {
if (i > 0 && data[i].type === 'reply') { data[i].content = md.render(processContent(data[i].content))
data[i].prompt = data[i - 1].orgContent if (i > 0) {
data[i].prompt = data[i - 1].orgContent
}
} }
chatData.value.push(data[i]); chatData.value.push(data[i]);
} }
@ -893,9 +908,13 @@ const notShow = () => {
showNotice.value = false showNotice.value = false
} }
// const files = ref([])
const insertURL = (url) => { //
prompt.value += " " + url + " " const insertFile = (file) => {
files.value.push(file)
}
const removeFile = (file) => {
files.value = removeArrayItem(files.value, file, (v1,v2) => v1.url===v2.url)
} }
</script> </script>