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
Content interface{} `json:"content"`
}
type WsMsgType string
const (

View File

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

View File

@ -9,6 +9,7 @@ package handler
import (
"geekai/core"
"geekai/core/types"
"geekai/service/oss"
"geekai/store/model"
"geekai/store/vo"
@ -35,6 +36,12 @@ func (h *UploadHandler) Upload(c *gin.Context) {
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)
res := h.DB.Create(&model.File{
UserId: int(userId),
@ -54,10 +61,24 @@ func (h *UploadHandler) Upload(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)
var items []model.File
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 {
for _, v := range items {
var file vo.File

View File

@ -255,7 +255,7 @@ func main() {
}),
fx.Invoke(func(s *core.AppServer, h *handler.UploadHandler) {
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)
}),
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_KEY_PREFIX=ChatPLUS_DEV_
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_KEY_PREFIX=ChatPLUS_
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-radius 10px
padding 10px
background-color #F4F4F4
.prompt-input::-webkit-scrollbar {
width: 0;
height: 0;
}
.prompt-input {
.input-inner {
display flex
flex-flow column
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; /* */
.file-list {
padding-bottom 10px
}
.prompt-input::-webkit-scrollbar {
width: 0;
height: 0;
}
.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 {
width 32px
margin-left 10px

View File

@ -6,6 +6,25 @@
</div>
<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="bar" v-if="createdAt">
<span class="bar-item"><el-icon><Clock/></el-icon> {{ createdAt }}</span>
@ -17,50 +36,87 @@
</div>
</template>
<script>
import {defineComponent} from "vue"
<script setup>
import {onMounted, ref} from "vue"
import {Clock} from "@element-plus/icons-vue";
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({
name: 'ChatPrompt',
components: {Clock},
methods: {},
props: {
content: {
type: String,
default: '',
},
icon: {
type: String,
default: 'images/user-icon.png',
},
createdAt: {
type: String,
default: '',
},
tokens: {
type: Number,
default: 0,
},
model: {
type: String,
default: '',
},
},
data() {
return {
finalTokens: this.tokens
const mathjaxPlugin = require('markdown-it-mathjax3')
const md = require('markdown-it')({
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(lang, str, 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)
const props = defineProps({
content: {
type: String,
default: '',
},
mounted() {
if (!this.finalTokens) {
httpPost("/api/chat/tokens", {text: this.content, model: this.model}).then(res => {
this.finalTokens = res.data;
}).catch(() => {
})
icon: {
type: String,
default: 'images/user-icon.png',
},
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>
@ -92,10 +148,58 @@ export default defineComponent({
.chat-item {
width 100%
position: relative;
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
.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 {
word-break break-word;
padding: 6px 10px;
@ -149,4 +253,4 @@ export default defineComponent({
}
</style>
</style>

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

View File

@ -24,4 +24,38 @@ export function getAdminTheme() {
export function setAdminTheme(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) {
prompt = prompt.replace(/&/g, "&amp;")
return prompt.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.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
});
}

View File

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