mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-10-23 05:11:57 +00:00
feat: keyboard shortcuts to send message in a conversations (CTRL + Enter) to send, enter for newline.
- feat: button to show/hide quoted text of incoming emails. - fix: logout redirect - remove hardcoded user login info. - add libredesk.io to webtemplate footer
This commit is contained in:
@@ -193,13 +193,8 @@ func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `team_id`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Check if user belongs to the team.
|
||||
exists, err := app.team.UserBelongsToTeam(teamID, user.ID)
|
||||
exists, err := app.team.UserBelongsToTeam(teamID, auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
@@ -50,5 +50,5 @@ func handleLogout(r *fastglue.Request) error {
|
||||
"no-store, no-cache, must-revalidate, post-check=0, pre-check=0")
|
||||
r.RequestCtx.Response.Header.Add("Pragma", "no-cache")
|
||||
r.RequestCtx.Response.Header.Add("Expires", "-1")
|
||||
return r.RedirectURI("dashboard", fasthttp.StatusFound, nil, "")
|
||||
return r.RedirectURI("/", fasthttp.StatusFound, nil, "")
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
amodels "github.com/abhinavxd/artemis/internal/auth/models"
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
@@ -73,49 +74,49 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
var (
|
||||
// app = r.Context.(*App)
|
||||
// cookieToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
|
||||
// hdrToken = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
|
||||
app = r.Context.(*App)
|
||||
cookieToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
|
||||
hdrToken = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
|
||||
)
|
||||
|
||||
// if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
|
||||
// app.lo.Error("csrf token mismatch", "cookie_token", cookieToken, "header_token", hdrToken)
|
||||
// return r.SendErrorEnvelope(http.StatusForbidden, "Invalid CSRF token", nil, envelope.PermissionError)
|
||||
// }
|
||||
if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
|
||||
app.lo.Error("csrf token mismatch", "cookie_token", cookieToken, "header_token", hdrToken)
|
||||
return r.SendErrorEnvelope(http.StatusForbidden, "Invalid CSRF token", nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
// Validate session and fetch user.
|
||||
// sessUser, err := app.auth.ValidateSession(r)
|
||||
// if err != nil || sessUser.ID <= 0 {
|
||||
// app.lo.Error("error validating session", "error", err)
|
||||
// return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
|
||||
// }
|
||||
sessUser, err := app.auth.ValidateSession(r)
|
||||
if err != nil || sessUser.ID <= 0 {
|
||||
app.lo.Error("error validating session", "error", err)
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
// // Get user from DB.
|
||||
// user, err := app.user.Get(sessUser.ID)
|
||||
// if err != nil {
|
||||
// return sendErrorEnvelope(r, err)
|
||||
// }
|
||||
// Get user from DB.
|
||||
user, err := app.user.Get(sessUser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Split the permission string into object and action and enforce it.
|
||||
parts := strings.Split(perm, ":")
|
||||
if len(parts) != 2 {
|
||||
return r.SendErrorEnvelope(http.StatusInternalServerError, "Invalid permission format", nil, envelope.GeneralError)
|
||||
}
|
||||
object, action := parts[0], parts[1]
|
||||
ok, err := app.authz.Enforce(user, object, action)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error checking permissions", nil, envelope.GeneralError)
|
||||
}
|
||||
if !ok {
|
||||
return r.SendErrorEnvelope(http.StatusForbidden, "Permission denied", nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
// // Split the permission string into object and action and enforce it.
|
||||
// parts := strings.Split(perm, ":")
|
||||
// if len(parts) != 2 {
|
||||
// return r.SendErrorEnvelope(http.StatusInternalServerError, "Invalid permission format", nil, envelope.GeneralError)
|
||||
// }
|
||||
// object, action := parts[0], parts[1]
|
||||
// ok, err := app.authz.Enforce(user, object, action)
|
||||
// if err != nil {
|
||||
// return r.SendErrorEnvelope(http.StatusInternalServerError, "Error checking permissions", nil, envelope.GeneralError)
|
||||
// }
|
||||
// if !ok {
|
||||
// return r.SendErrorEnvelope(http.StatusForbidden, "Permission denied", nil, envelope.PermissionError)
|
||||
// }
|
||||
|
||||
// Set user in the request context.
|
||||
r.RequestCtx.SetUserValue("user", amodels.User{
|
||||
ID: 1,
|
||||
Email: "sample@example.com",
|
||||
FirstName: "Sample",
|
||||
LastName: "User",
|
||||
ID: user.ID,
|
||||
Email: user.Email.String,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
})
|
||||
|
||||
return handler(r)
|
||||
|
@@ -287,11 +287,7 @@ func handleGetCurrentUser(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
u, err := app.user.Get(user.ID)
|
||||
u, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
@@ -20,6 +20,7 @@
|
||||
"@radix-icons/vue": "^1.0.0",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@tanstack/vue-table": "^8.19.2",
|
||||
"@tiptap/extension-hard-break": "^2.11.0",
|
||||
"@tiptap/extension-image": "^2.5.9",
|
||||
"@tiptap/extension-link": "^2.9.1",
|
||||
"@tiptap/extension-list-item": "^2.4.0",
|
||||
|
@@ -12,11 +12,14 @@
|
||||
padding: 1rem 1rem;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
padding-bottom: 50px;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Theme.
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
@@ -145,7 +148,6 @@
|
||||
border
|
||||
rounded-xl;
|
||||
box-shadow: 1px 1px 1px 0px rgba(0, 0, 0, 0.1);
|
||||
// To make email tables fit.
|
||||
table {
|
||||
width: 100% !important;
|
||||
}
|
||||
@@ -266,3 +268,15 @@ a[data-active="false"]:hover {
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.show-quoted-text {
|
||||
blockquote {
|
||||
@apply block;
|
||||
}
|
||||
}
|
||||
|
||||
.hide-quoted-text {
|
||||
blockquote {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="overflow-y-auto ">
|
||||
<div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="max-h-[600px] overflow-y-auto">
|
||||
<BubbleMenu :editor="editor" :tippy-options="{ duration: 100 }" v-if="editor" class="bg-white p-1 box rounded-lg">
|
||||
<BubbleMenu :editor="editor" :tippy-options="{ duration: 100 }" v-if="editor"
|
||||
class="bg-white p-1 box rounded-lg will-change-transform">
|
||||
<div class="flex space-x-1 items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import HardBreak from '@tiptap/extension-hard-break'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
|
||||
@@ -52,9 +54,7 @@ const textContent = defineModel('textContent')
|
||||
const htmlContent = defineModel('htmlContent')
|
||||
const isBold = defineModel('isBold')
|
||||
const isItalic = defineModel('isItalic')
|
||||
const cursorPosition = defineModel('cursorPosition', {
|
||||
default: 0
|
||||
})
|
||||
const cursorPosition = defineModel('cursorPosition', { default: 0 })
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: String,
|
||||
@@ -66,36 +66,52 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'send',
|
||||
'editorReady',
|
||||
'aiPromptSelected'
|
||||
])
|
||||
const emit = defineEmits(['send', 'editorReady', 'aiPromptSelected'])
|
||||
|
||||
function emitPrompt (key) {
|
||||
emit('aiPromptSelected', key)
|
||||
const emitPrompt = (key) => emit('aiPromptSelected', key)
|
||||
|
||||
const getSelectionText = (from, to, doc) => doc.textBetween(from, to)
|
||||
|
||||
const editorConfig = {
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
hardBreak: false,
|
||||
}),
|
||||
HardBreak.extend({
|
||||
addKeyboardShortcuts () {
|
||||
return {
|
||||
Enter: () => {
|
||||
if (this.editor.isActive('orderedList') || this.editor.isActive('bulletList')) {
|
||||
return this.editor.chain().createParagraphNear().run();
|
||||
}
|
||||
return this.editor.commands.setHardBreak();
|
||||
},
|
||||
}
|
||||
}
|
||||
}),
|
||||
Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
|
||||
Placeholder.configure({ placeholder: () => props.placeholder }),
|
||||
Link
|
||||
],
|
||||
autofocus: true,
|
||||
editorProps: {
|
||||
attributes: { class: 'outline-none' },
|
||||
handleKeyDown: (view, event) => {
|
||||
if (event.ctrlKey && event.key === 'Enter') {
|
||||
emit('send')
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const editor = ref(
|
||||
useEditor({
|
||||
...editorConfig,
|
||||
content: textContent.value,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Image.configure({
|
||||
HTMLAttributes: { class: 'inline-image' }
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: () => props.placeholder
|
||||
}),
|
||||
Link
|
||||
],
|
||||
autofocus: true,
|
||||
editorProps: { attributes: { class: 'outline-none' } },
|
||||
onSelectionUpdate: ({ editor }) => {
|
||||
selectedText.value = editor.state.doc.textBetween(
|
||||
editor.state.selection.from,
|
||||
editor.state.selection.to
|
||||
)
|
||||
const { from, to } = editor.state.selection
|
||||
selectedText.value = getSelectionText(from, to, editor.state.doc)
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
htmlContent.value = editor.getHTML()
|
||||
@@ -106,41 +122,44 @@ const editor = ref(
|
||||
if (cursorPosition.value) {
|
||||
editor.commands.setTextSelection(cursorPosition.value)
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
if (editor.value) {
|
||||
emit('editorReady', editor.value)
|
||||
isBold.value = editor.value.isActive('bold')
|
||||
isItalic.value = editor.value.isActive('italic')
|
||||
}
|
||||
const editorInstance = editor.value
|
||||
if (!editorInstance) return
|
||||
|
||||
emit('editorReady', editorInstance)
|
||||
isBold.value = editorInstance.isActive('bold')
|
||||
isItalic.value = editorInstance.isActive('italic')
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
if (isBold.value !== editor.value?.isActive('bold')) {
|
||||
const editorInstance = editor.value
|
||||
if (!editorInstance) return
|
||||
|
||||
if (isBold.value !== editorInstance.isActive('bold')) {
|
||||
isBold.value
|
||||
? editor.value?.chain().focus().setBold().run()
|
||||
: editor.value?.chain().focus().unsetBold().run()
|
||||
? editorInstance.chain().focus().setBold().run()
|
||||
: editorInstance.chain().focus().unsetBold().run()
|
||||
}
|
||||
if (isItalic.value !== editor.value?.isActive('italic')) {
|
||||
if (isItalic.value !== editorInstance.isActive('italic')) {
|
||||
isItalic.value
|
||||
? editor.value?.chain().focus().setItalic().run()
|
||||
: editor.value?.chain().focus().unsetItalic().run()
|
||||
? editorInstance.chain().focus().setItalic().run()
|
||||
: editorInstance.chain().focus().unsetItalic().run()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.contentToSet,
|
||||
(newContent) => {
|
||||
if (newContent) {
|
||||
console.log('Setting content to -:', newContent)
|
||||
editor.value.commands.setContent(newContent)
|
||||
editor.value.commands.focus()
|
||||
}
|
||||
watch(() => props.contentToSet, (newContent) => {
|
||||
console.log('newContent', newContent)
|
||||
if (newContent === '') {
|
||||
editor.value?.commands.clearContent()
|
||||
} else {
|
||||
editor.value?.commands.setContent(newContent, true)
|
||||
}
|
||||
)
|
||||
editor.value?.commands.focus()
|
||||
})
|
||||
|
||||
watch(cursorPosition, (newPos, oldPos) => {
|
||||
if (editor.value && newPos !== oldPos && newPos !== editor.value.state.selection.from) {
|
||||
@@ -149,7 +168,7 @@ watch(cursorPosition, (newPos, oldPos) => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
editor.value.destroy()
|
||||
editor.value?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Fullscreen editor -->
|
||||
<!-- TODO: Has to be a better way to do this than creating two separate editor components -->
|
||||
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = $event">
|
||||
<DialogContent class="max-w-[70%] max-h-[70%] h-[70%] m-0 p-6">
|
||||
<div v-if="isEditorFullscreen">
|
||||
@@ -19,8 +20,7 @@
|
||||
<Editor v-model:selectedText="selectedText" v-model:isBold="isBold" v-model:isItalic="isItalic"
|
||||
v-model:htmlContent="htmlContent" v-model:textContent="textContent" :placeholder="editorPlaceholder"
|
||||
:aiPrompts="aiPrompts" @keydown="handleKeydown" @aiPromptSelected="handleAiPromptSelected"
|
||||
@editorReady="onEditorReady" @clearContent="clearContent" :contentToSet="contentToSet"
|
||||
v-model:cursorPosition="cursorPosition" />
|
||||
@editorReady="onEditorReady" :contentToSet="contentToSet" v-model:cursorPosition="cursorPosition" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -58,7 +58,7 @@
|
||||
<Editor v-model:selectedText="selectedText" v-model:isBold="isBold" v-model:isItalic="isItalic"
|
||||
v-model:htmlContent="htmlContent" v-model:textContent="textContent" :placeholder="editorPlaceholder"
|
||||
:aiPrompts="aiPrompts" @keydown="handleKeydown" @aiPromptSelected="handleAiPromptSelected"
|
||||
@editorReady="onEditorReady" @clearContent="clearContent" :contentToSet="contentToSet"
|
||||
@editorReady="onEditorReady" :contentToSet="contentToSet" @send="handleSend"
|
||||
v-model:cursorPosition="cursorPosition" />
|
||||
|
||||
<!-- Attachments preview -->
|
||||
@@ -101,7 +101,6 @@ const cursorPosition = ref(0)
|
||||
const selectedText = ref('')
|
||||
const htmlContent = ref('')
|
||||
const textContent = ref('')
|
||||
const clearContent = ref(false)
|
||||
const contentToSet = ref('')
|
||||
const isBold = ref(false)
|
||||
const isItalic = ref(false)
|
||||
@@ -116,6 +115,8 @@ const cannedResponses = ref([])
|
||||
|
||||
const aiPrompts = ref([])
|
||||
|
||||
const editorPlaceholder = "Press Enter to add a new line; Press '/' to select a Canned Response; Press Ctrl + Enter to send."
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchCannedResponses(), fetchAiPrompts()])
|
||||
})
|
||||
@@ -170,10 +171,6 @@ const toggleItalic = () => {
|
||||
isItalic.value = !isItalic.value
|
||||
}
|
||||
|
||||
const editorPlaceholder = computed(() => {
|
||||
return "Press enter to add a new line; Press '/' to select a Canned Response."
|
||||
})
|
||||
|
||||
const attachments = computed(() => {
|
||||
return uploadedFiles.value.filter(upload => upload.disposition === 'attachment')
|
||||
})
|
||||
@@ -287,10 +284,9 @@ const handleSend = async () => {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
clearContent.value = true
|
||||
uploadedFiles.value = []
|
||||
}
|
||||
editorInstance.value.commands.clearContent()
|
||||
uploadedFiles.value = []
|
||||
api.updateAssigneeLastSeen(conversationStore.current.uuid)
|
||||
}
|
||||
|
||||
|
@@ -12,9 +12,17 @@
|
||||
{{ avatarFallback }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex flex-col justify-end message-bubble !rounded-tl-none contact-message-bubble">
|
||||
<Letter :html="messageContent" :allowedSchemas="['cid', 'https', 'http']" class="mb-1"
|
||||
<div class="flex flex-col justify-end message-bubble !rounded-tl-none" :class="{
|
||||
'show-quoted-text': showQuotedText,
|
||||
'hide-quoted-text': !showQuotedText
|
||||
}">
|
||||
<Letter :html="sanitizedMessageContent" :allowedSchemas="['cid', 'https', 'http']" class="mb-1"
|
||||
:class="{ 'mb-3': message.attachments.length > 0 }" />
|
||||
<div v-if="hasQuotedContent" @click="toggleQuote"
|
||||
class="text-xs cursor-pointer text-muted-foreground px-2 py-1 w-max hover:bg-muted hover:text-primary rounded-md transition-all">
|
||||
{{ showQuotedText ? 'Hide quoted text' : 'Show quoted text' }}
|
||||
</div>
|
||||
|
||||
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,10 +44,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { format } from 'date-fns'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Letter } from 'vue-letter'
|
||||
@@ -48,28 +56,39 @@ import MessageAttachmentPreview from '@/components/attachment/MessageAttachmentP
|
||||
const props = defineProps({
|
||||
message: Object
|
||||
})
|
||||
|
||||
const convStore = useConversationStore()
|
||||
const showQuotedText = ref(false)
|
||||
|
||||
const getAvatar = computed(() => {
|
||||
return convStore.current.avatar_url ? convStore.conversation.avatar_url : ''
|
||||
return convStore.current.avatar_url || ''
|
||||
})
|
||||
|
||||
const messageContent = computed(() =>
|
||||
props.message.attachments.reduce((content, { content_id, url }) =>
|
||||
content.replace(new RegExp(`cid:${content_id}`, 'g'), url),
|
||||
props.message.content
|
||||
const sanitizedMessageContent = computed(() => {
|
||||
const content = props.message.content || ''
|
||||
return props.message.attachments.reduce((acc, { content_id, url }) =>
|
||||
acc.replace(new RegExp(`cid:${content_id}`, 'g'), url),
|
||||
content
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const nonInlineAttachments = computed(() =>
|
||||
const hasQuotedContent = computed(() => sanitizedMessageContent.value.includes('<blockquote'))
|
||||
|
||||
const toggleQuote = () => {
|
||||
showQuotedText.value = !showQuotedText.value
|
||||
}
|
||||
|
||||
const nonInlineAttachments = computed(() =>
|
||||
props.message.attachments.filter(attachment => attachment.disposition !== 'inline')
|
||||
)
|
||||
|
||||
const getFullName = computed(() => {
|
||||
return convStore.current.contact.first_name + ' ' + convStore.current.contact.last_name
|
||||
const contact = convStore.current.contact || {}
|
||||
return `${contact.first_name || ''} ${contact.last_name || ''}`.trim()
|
||||
})
|
||||
|
||||
const avatarFallback = computed(() => {
|
||||
return convStore.current.contact.first_name.toUpperCase().substring(0, 2)
|
||||
const contact = convStore.current.contact || {}
|
||||
return (contact.first_name || '').toUpperCase().substring(0, 2)
|
||||
})
|
||||
</script>
|
||||
|
@@ -4,42 +4,35 @@
|
||||
<SidebarMenuButton size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0">
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src="https://avatars.githubusercontent.com/u/48166553?v=4" alt="Abhinav" />
|
||||
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
|
||||
<AvatarFallback class="rounded-lg">
|
||||
AR
|
||||
{{ userStore.getInitials }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">Abhinav Raut</span>
|
||||
<span class="truncate text-xs">abhinavrautcs@gmail.com</span>
|
||||
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
|
||||
<span class="truncate text-xs">{{ userStore.email }}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" side="bottom"
|
||||
align="end" :side-offset="4">
|
||||
:side-offset="4">
|
||||
<DropdownMenuLabel class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src="https://avatars.githubusercontent.com/u/48166553?v=4" alt="Abhinav" />
|
||||
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
|
||||
<AvatarFallback class="rounded-lg">
|
||||
AR
|
||||
{{ userStore.getInitials }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">Abhinav Raut!</span>
|
||||
<span class="truncate text-xs">abhinavrautcs@gmail.com</span>
|
||||
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
|
||||
<span class="truncate text-xs">{{ userStore.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<!-- <DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Sparkles size="18" class="mr-2" />
|
||||
Upgrade to Pro
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator /> -->
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<router-link to="/account" class="flex items-center">
|
||||
@@ -47,19 +40,14 @@
|
||||
Account
|
||||
</router-link>
|
||||
</DropdownMenuItem>
|
||||
<!-- <DropdownMenuItem>
|
||||
<Bell size="18" class="mr-2" />
|
||||
Notifications
|
||||
</DropdownMenuItem> -->
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click.stop="window.location.href = '/logout'">
|
||||
<DropdownMenuItem @click="logout">
|
||||
<LogOut size="18" class="mr-2" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -81,37 +69,14 @@ import {
|
||||
AvatarImage,
|
||||
} from '@/components/ui/avatar'
|
||||
import {
|
||||
AudioWaveform,
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
Users,
|
||||
CircleUserRound,
|
||||
Bot,
|
||||
Inbox,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
Command,
|
||||
CreditCard,
|
||||
SlidersHorizontal,
|
||||
Folder,
|
||||
Shield,
|
||||
FileLineChart,
|
||||
Forward,
|
||||
EllipsisVertical,
|
||||
MessageCircleHeart,
|
||||
Plus,
|
||||
BarChart,
|
||||
MessageCircle,
|
||||
Frame,
|
||||
GalleryVerticalEnd,
|
||||
CircleUserRound,
|
||||
LogOut,
|
||||
Map,
|
||||
MoreHorizontal,
|
||||
PieChart,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
ListFilter,
|
||||
SquareTerminal,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
const userStore = useUserStore()
|
||||
|
||||
const logout = () => {
|
||||
window.location.href = '/logout'
|
||||
}
|
||||
</script>
|
@@ -2,6 +2,9 @@
|
||||
<div class="page-content">
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-gray-500 text-right">
|
||||
Last updated: {{ new Date(lastUpdate).toLocaleTimeString() }}
|
||||
</div>
|
||||
<div class="mt-7 flex w-full space-x-4" v-auto-animate>
|
||||
<Card title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" />
|
||||
<Card class="w-8/12" title="Agent status" :counts="sampleAgentStatusCounts" :labels="sampleAgentStatusLabels" />
|
||||
@@ -17,43 +20,61 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import api from '@/api'
|
||||
|
||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
import Card from '@/components/dashboard/DashboardCard.vue'
|
||||
import LineChart from '@/components/dashboard/DashboardLineChart.vue'
|
||||
import BarChart from '@/components/dashboard/DashboardBarChart.vue'
|
||||
|
||||
import Spinner from '@/components/ui/spinner/Spinner.vue'
|
||||
|
||||
const { toast } = useToast()
|
||||
const isLoading = ref(false)
|
||||
const cardCounts = ref({})
|
||||
const chartData = ref({})
|
||||
const lastUpdate = ref(new Date())
|
||||
let updateInterval
|
||||
|
||||
const agentCountCardsLabels = {
|
||||
open: 'Total',
|
||||
awaiting_response: 'Awaiting Response',
|
||||
unassigned: 'Unassigned',
|
||||
pending: 'Pending'
|
||||
}
|
||||
|
||||
// TODO: Build agent status feature.
|
||||
const sampleAgentStatusLabels = {
|
||||
online: 'Online',
|
||||
offline: 'Offline',
|
||||
away: 'Away',
|
||||
away: 'Away'
|
||||
}
|
||||
|
||||
const sampleAgentStatusCounts = {
|
||||
online: 5,
|
||||
offline: 2,
|
||||
away: 1,
|
||||
away: 1
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getDashboardData()
|
||||
startRealtimeUpdates()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopRealtimeUpdates()
|
||||
})
|
||||
|
||||
const startRealtimeUpdates = () => {
|
||||
updateInterval = setInterval(() => {
|
||||
getDashboardData()
|
||||
lastUpdate.value = new Date()
|
||||
}, 60000)
|
||||
}
|
||||
|
||||
const stopRealtimeUpdates = () => {
|
||||
clearInterval(updateInterval)
|
||||
}
|
||||
|
||||
const getDashboardData = () => {
|
||||
isLoading.value = true
|
||||
Promise.all([getCardStats(), getDashboardCharts()])
|
||||
|
@@ -1380,6 +1380,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.4.0.tgz#2a738509d40f5f856492c11e32b10e4462f71216"
|
||||
integrity sha512-F4y/0J2lseohkFUw9P2OpKhrJ6dHz69ZScABUvcHxjznJLd6+0Zt7014Lw5PA8/m2d/w0fX8LZQ88pZr4quZPQ==
|
||||
|
||||
"@tiptap/extension-hard-break@^2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.11.0.tgz#0e199a1c58171fe1879e730b87666e759fe55c04"
|
||||
integrity sha512-7pMgPNk2FnPT0LcWaWNNxOLK3LQnRSYFgrdBGMXec3sy+y3Lit3hM+EZhbZcHpTIQTbWWs+eskh1waRMIt0ZaQ==
|
||||
|
||||
"@tiptap/extension-hard-break@^2.4.0":
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.4.0.tgz#b5bf5b065827280e450fba8f53d137654509d836"
|
||||
|
@@ -1,4 +1,4 @@
|
||||
// Package ai handles management of LLM providers.
|
||||
// Package ai manages AI prompts and integrates with LLM providers.
|
||||
package ai
|
||||
|
||||
import (
|
||||
@@ -51,7 +51,6 @@ func New(opts Opts) (*Manager, error) {
|
||||
|
||||
// SendPrompt sends a prompt to the default provider and returns the response.
|
||||
func (m *Manager) SendPrompt(k string, prompt string) (string, error) {
|
||||
// return "Hello Abhinav,\n\nI wanted to let you know that we have successfully added a top-up of Rs 20,000 to your account. You can expect to receive the funds by the end of the day.\n\nIf you have any concerns or questions, please don't hesitate to update this ticket. We are always happy to assist you in any way we can.\n\nPlease keep in mind that if we do not hear back from you within 24 hours, the ticket will be marked as <i>closed</i>. However, you can easily reopen it if you need further assistance. Additionally, you can always reach out to us through our support portal for any help you may need.\n\nThank you for choosing our services. We are here to make your experience as smooth and hassle-free as possible.\n\nBest regards, \n[Your Name]", nil
|
||||
systemPrompt, err := m.getPrompt(k)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
|
||||
<footer class="container">
|
||||
Powered by <a target="_blank" rel="noreferrer" href="#">Artemis</a>
|
||||
Powered by <a target="_blank" rel="noreferrer" href="https://libredesk.io/">LibreDesk</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
Reference in New Issue
Block a user