mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-01 12:33:42 +00:00
feat: AI clean up of agent responses, adds new ai package
- feat: full screen tiptap editor, esentially two editors that attempt to keep their state in sync. - moves tiptap editor menu bar to to show up as a bubble once text is selected. - Layout fixes and improvements. - Improves /reports/overview charts and cards.
This commit is contained in:
29
cmd/ai.go
Normal file
29
cmd/ai.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import "github.com/zerodha/fastglue"
|
||||
|
||||
// handleAICompletion handles AI completion requests
|
||||
func handleAICompletion(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
promptKey = string(r.RequestCtx.PostArgs().Peek("prompt_key"))
|
||||
content = string(r.RequestCtx.PostArgs().Peek("content"))
|
||||
)
|
||||
resp, err := app.ai.SendPrompt(promptKey, content)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(resp)
|
||||
}
|
||||
|
||||
// handleGetAIPrompts returns AI prompts
|
||||
func handleGetAIPrompts(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
resp, err := app.ai.GetPrompts()
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(resp)
|
||||
}
|
||||
@@ -457,6 +457,9 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
|
||||
if !allowed {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
|
||||
}
|
||||
if string(status) == cmodels.StatusResolved && conversation.AssignedUserID.Int == 0 {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Cannot resolve the conversation without an assigned user. Please assign a user before attempting to resolve.", nil))
|
||||
}
|
||||
|
||||
if err := app.conversation.UpdateConversationStatus(uuid, status, snoozedUntil, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
|
||||
@@ -165,6 +165,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
g.PUT("/api/v1/sla/{id}", perm(fastglue.ReqLenRangeParams(handleUpdateSLA, slaReqFields), "sla:manage"))
|
||||
g.DELETE("/api/v1/sla/{id}", perm(handleDeleteSLA, "sla:manage"))
|
||||
|
||||
// AI.
|
||||
g.GET("/api/v1/ai/prompts", auth(handleGetAIPrompts))
|
||||
g.POST("/api/v1/ai/completion", auth(handleAICompletion))
|
||||
|
||||
// WebSocket.
|
||||
g.GET("/ws", auth(func(r *fastglue.Request) error {
|
||||
return handleWS(r, hub)
|
||||
|
||||
14
cmd/init.go
14
cmd/init.go
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"html/template"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/ai"
|
||||
auth_ "github.com/abhinavxd/artemis/internal/auth"
|
||||
"github.com/abhinavxd/artemis/internal/authz"
|
||||
"github.com/abhinavxd/artemis/internal/autoassigner"
|
||||
@@ -701,6 +702,19 @@ func initPriority(db *sqlx.DB) *priority.Manager {
|
||||
return manager
|
||||
}
|
||||
|
||||
// initAI inits AI manager.
|
||||
func initAI(db *sqlx.DB) *ai.Manager {
|
||||
lo := initLogger("ai")
|
||||
m, err := ai.New(ai.Opts{
|
||||
DB: db,
|
||||
Lo: lo,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing AI manager: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// initLogger initializes a logf logger.
|
||||
func initLogger(src string) *logf.Logger {
|
||||
lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/ai"
|
||||
auth_ "github.com/abhinavxd/artemis/internal/auth"
|
||||
"github.com/abhinavxd/artemis/internal/authz"
|
||||
businesshours "github.com/abhinavxd/artemis/internal/business_hours"
|
||||
@@ -73,6 +74,7 @@ type App struct {
|
||||
sla *sla.Manager
|
||||
csat *csat.Manager
|
||||
view *view.Manager
|
||||
ai *ai.Manager
|
||||
notifier *notifier.Service
|
||||
}
|
||||
|
||||
@@ -205,6 +207,7 @@ func main() {
|
||||
priority: initPriority(db),
|
||||
role: initRole(db),
|
||||
tag: initTag(db),
|
||||
ai: initAI(db),
|
||||
cannedResp: initCannedResponse(db),
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<ResizableHandle id="resize-handle-1" />
|
||||
<ResizablePanel id="resize-panel-2">
|
||||
<div class="w-full h-screen">
|
||||
<PageHeader />
|
||||
<RouterView />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
@@ -28,6 +29,7 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { useConversationStore } from './stores/conversation'
|
||||
import PageHeader from './components/common/PageHeader.vue'
|
||||
import ViewForm from '@/components/ViewForm.vue'
|
||||
import api from '@/api'
|
||||
import Sidebar from '@/components/sidebar/Sidebar.vue'
|
||||
|
||||
@@ -239,6 +239,8 @@ const updateView = (id, data) =>
|
||||
}
|
||||
})
|
||||
const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
|
||||
const getAiPrompts = () => http.get('/api/v1/ai/prompts')
|
||||
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data)
|
||||
|
||||
export default {
|
||||
login,
|
||||
@@ -337,5 +339,7 @@ export default {
|
||||
getCurrentUserViews,
|
||||
createView,
|
||||
updateView,
|
||||
deleteView
|
||||
deleteView,
|
||||
getAiPrompts,
|
||||
aiCompletion
|
||||
}
|
||||
|
||||
@@ -8,14 +8,11 @@
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 1rem 1rem;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
|
||||
// Theme.
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
<script setup>
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
defineProps({
|
||||
isCollapsed: Boolean,
|
||||
links: Array,
|
||||
bottomLinks: Array
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const getFirstSegment = (path) => {
|
||||
return path.split('/')[1]
|
||||
}
|
||||
|
||||
const getButtonVariant = (to) => {
|
||||
const currentSegment = getFirstSegment(route.path.toLowerCase())
|
||||
const targetSegment = getFirstSegment(to.toLowerCase())
|
||||
return currentSegment === targetSegment ? '' : 'ghost'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :data-collapsed="isCollapsed" class="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2 h-full">
|
||||
<nav class="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
|
||||
<template v-for="(link, index) of links">
|
||||
<!-- Collapsed -->
|
||||
<router-link :to="link.to" v-if="isCollapsed" :key="`1-${index}`">
|
||||
<TooltipProvider :delay-duration="10">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span :class="cn(
|
||||
buttonVariants({ variant: getButtonVariant(link.to), size: 'icon' }),
|
||||
'h-9 w-9',
|
||||
link.variant === getButtonVariant(link.to) &&
|
||||
'dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white'
|
||||
)
|
||||
">
|
||||
<Icon :icon="link.icon" class="size-5" />
|
||||
<span class="sr-only">{{ link.title }}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" class="flex items-center gap-4">
|
||||
{{ link.title }}
|
||||
<span v-if="link.label" class="ml-auto text-muted-foreground">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</router-link>
|
||||
|
||||
<!-- Expanded -->
|
||||
<router-link v-else :to="link.to" :key="`2-${index}`" :class="cn(
|
||||
buttonVariants({ variant: getButtonVariant(link.to), size: 'sm' }),
|
||||
link.variant === getButtonVariant(link.to) &&
|
||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
||||
'justify-start'
|
||||
)
|
||||
">
|
||||
<Icon :icon="link.icon" class="mr-2 size-5" />
|
||||
{{ link.title }}
|
||||
<span v-if="link.label" :class="cn(
|
||||
'ml-',
|
||||
link.variant === getButtonVariant(link.to) && 'text-background dark:text-white'
|
||||
)
|
||||
">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</router-link>
|
||||
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Bottom Links -->
|
||||
<div class="mt-auto px-2">
|
||||
<template v-for="(bottomLink, index) in bottomLinks" :key="`bottom-${index}`">
|
||||
<TooltipProvider :delay-duration="10">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<a :href="bottomLink.to" :class="cn(
|
||||
buttonVariants({
|
||||
variant: getButtonVariant(bottomLink.to),
|
||||
size: isCollapsed ? 'icon' : 'sm'
|
||||
}),
|
||||
bottomLink.variant === getButtonVariant(bottomLink.to) &&
|
||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
||||
'justify-start'
|
||||
)
|
||||
">
|
||||
<Icon :icon="bottomLink.icon" class="mr-2 size-5" v-if="!isCollapsed" />
|
||||
<span v-if="!isCollapsed">{{ bottomLink.title }}</span>
|
||||
<Icon :icon="bottomLink.icon" class="size-5 mx-auto" v-else />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" class="flex items-center gap-4">
|
||||
{{ bottomLink.title }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,19 +1,8 @@
|
||||
<script setup>
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import SidebarNav from '@/components/common/SidebarNav.vue'
|
||||
|
||||
const sidebarNavItems = [
|
||||
{
|
||||
title: 'Profile',
|
||||
href: '/account/profile',
|
||||
description: 'Update your profile'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4 md:block page-content">
|
||||
<PageHeader title="Account settings" subTitle="Manage your account settings." />
|
||||
<div class="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
|
||||
<div class="flex-1 lg:max-w-3xl admin-main-content min-h-[700px]">
|
||||
<div class="space-y-6">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<template>
|
||||
<PageHeader title="Automation" description="Manage automation rules" />
|
||||
<div class="w-8/12">
|
||||
<div v-if="router.currentRoute.value.path === '/admin/automations'">
|
||||
<div class="flex justify-between mb-5">
|
||||
@@ -19,7 +18,6 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AutomationTabs from '@/components/admin/automation/AutomationTabs.vue'
|
||||
import PageHeader from '../common/PageHeader.vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<PageHeader title="Business hours" description="Manage business hours" />
|
||||
|
||||
<div class="w-8/12">
|
||||
<template v-if="router.currentRoute.value.path === '/admin/business-hours'">
|
||||
<div class="flex justify-between mb-5">
|
||||
@@ -25,7 +25,7 @@ import DataTable from '@/components/admin/DataTable.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import PageHeader from '../common/PageHeader.vue'
|
||||
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { columns } from '@/components/admin/business_hours/dataTableColumns.js'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex-1 rounded-xl px-6 py-4 border border-muted shadow-md hover:shadow-lg transition-transform duration-200 transform hover:scale-105 cursor-pointer bg-white max-w-80"
|
||||
class="flex flex-col p-4 border rounded-lg shadow-sm hover:shadow transition-colors cursor-pointer bg-white max-w-xs"
|
||||
@click="handleClick">
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="flex items-center mb-2">
|
||||
<component :is="icon" size="24" class="mr-2 text-primary" />
|
||||
<p class="text-lg font-semibold text-gray-700">{{ title }}</p>
|
||||
<h3 class="text-lg font-medium text-gray-800">{{ title }}</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">{{ subTitle }}</p>
|
||||
<p class="text-sm text-gray-600">{{ subTitle }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -17,16 +17,13 @@ const props = defineProps({
|
||||
title: String,
|
||||
subTitle: String,
|
||||
icon: Function,
|
||||
onClick: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
onClick: Function
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const handleClick = () => {
|
||||
if (props.onClick) props.onClick()
|
||||
props.onClick?.()
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-1 border-b pb-3 mb-5 border-gray-200">
|
||||
<span class="font-semibold text-2xl">{{ title }}</span>
|
||||
<p class="text-muted-foreground text-lg">{{ description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<PageHeader title="Canned responses" description="Manage canned responses" />
|
||||
|
||||
<div class="w-8/12">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div class="flex justify-end mb-4 w-full">
|
||||
@@ -35,7 +35,7 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import DataTable from '@/components/admin/DataTable.vue'
|
||||
import { columns } from './dataTableColumns.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import PageHeader from '@/components/admin/common/PageHeader.vue'
|
||||
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import {
|
||||
Dialog,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<PageHeader title="Status" description="Manage conversation statuses" />
|
||||
|
||||
<div class="w-8/12">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div class="flex justify-end mb-4 w-full">
|
||||
@@ -35,7 +35,7 @@ import { ref, onMounted } from 'vue'
|
||||
import DataTable from '@/components/admin/DataTable.vue'
|
||||
import { columns } from './dataTableColumns.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import PageHeader from '@/components/admin/common/PageHeader.vue'
|
||||
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import StatusForm from './StatusForm.vue'
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<PageHeader title="Tags" description="Manage conversation tags" />
|
||||
|
||||
<div class="w-8/12">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div class="flex justify-end mb-4 w-full">
|
||||
@@ -36,7 +36,7 @@ import DataTable from '@/components/admin/DataTable.vue'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { columns } from '@/components/admin/conversation/tags/dataTableColumns.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import PageHeader from '@/components/admin/common/PageHeader.vue'
|
||||
|
||||
import TagsForm from './TagsForm.vue'
|
||||
import {
|
||||
Dialog,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="General" description="Manage general app settings" />
|
||||
|
||||
</div>
|
||||
<div class="flex justify-center items-center flex-col w-8/12">
|
||||
<GeneralSettingForm :submitForm="submitForm" :initial-values="initialValues" submitLabel="Save" />
|
||||
@@ -10,7 +10,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import GeneralSettingForm from './GeneralSettingForm.vue'
|
||||
import PageHeader from '../common/PageHeader.vue'
|
||||
|
||||
import api from '@/api'
|
||||
|
||||
const initialValues = ref({})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<PageHeader title="Inboxes" description="Manage your inboxes" />
|
||||
|
||||
<div class="w-8/12">
|
||||
<template v-if="router.currentRoute.value.path === '/admin/inboxes'">
|
||||
<div class="flex justify-between mb-5">
|
||||
@@ -27,7 +27,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import DataTable from '@/components/admin/DataTable.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageHeader from '../common/PageHeader.vue'
|
||||
|
||||
import { format } from 'date-fns'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import api from '@/api'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<PageHeader title="Notifications" description="Manage your email notification settings" />
|
||||
|
||||
<div class="w-8/12">
|
||||
<div>
|
||||
<Spinner v-if="formLoading"></Spinner>
|
||||
@@ -11,7 +11,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '@/api'
|
||||
import PageHeader from '../common/PageHeader.vue'
|
||||
|
||||
import NotificationsForm from './NotificationSettingForm.vue'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<PageHeader title="OpenID Connect" description="Manage OpenID Connect configurations" />
|
||||
|
||||
<div class="w-8/12">
|
||||
<template v-if="router.currentRoute.value.path === '/admin/oidc'">
|
||||
<div class="flex justify-between mb-5">
|
||||
@@ -26,7 +26,7 @@ import { columns } from '@/components/admin/oidc/dataTableColumns.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import PageHeader from '../common/PageHeader.vue'
|
||||
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<PageHeader title="SLA" description="Manage service level agreements" />
|
||||
|
||||
<div class="w-8/12">
|
||||
<template v-if="router.currentRoute.value.path === '/admin/sla'">
|
||||
<div class="flex justify-between mb-5">
|
||||
@@ -26,7 +26,7 @@ import { columns } from './dataTableColumns.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import PageHeader from '../common/PageHeader.vue'
|
||||
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<PageHeader title="Roles" description="Manage roles" />
|
||||
|
||||
<div class="w-8/12">
|
||||
<div v-if="router.currentRoute.value.path === '/admin/teams/roles'">
|
||||
<div class="flex justify-end mb-5">
|
||||
@@ -27,7 +27,7 @@ import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import PageHeader from '@/components/admin/common/PageHeader.vue'
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
const emit = useEmitter()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<PageHeader title="Teams" description="Manage teams" />
|
||||
|
||||
<div class="w-8/12">
|
||||
<div v-if="router.currentRoute.value.path === '/admin/teams/teams'">
|
||||
<div class="flex justify-end mb-5">
|
||||
@@ -27,7 +27,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||
import DataTable from '@/components/admin/DataTable.vue'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/admin/common/PageHeader.vue'
|
||||
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<PageHeader title="Users" description="Manage users" />
|
||||
|
||||
<div class="w-8/12">
|
||||
<div v-if="router.currentRoute.value.path === '/admin/teams/users'">
|
||||
<div class="flex justify-end mb-5">
|
||||
@@ -26,7 +26,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||
import PageHeader from '@/components/admin/common/PageHeader.vue'
|
||||
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<PageHeader title="Email templates" description="Manage email templates" />
|
||||
|
||||
<div class="w-8/12">
|
||||
<template v-if="router.currentRoute.value.path === '/admin/templates'">
|
||||
<div class="flex justify-between mb-5">
|
||||
@@ -35,7 +35,7 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import DataTable from '@/components/admin/DataTable.vue'
|
||||
import { emailNotificationTemplates, outgoingEmailTemplatesColumns } from '@/components/admin/templates/dataTableColumns.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import PageHeader from '@/components/admin/common/PageHeader.vue'
|
||||
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="Uploads" description="Manage file upload settings" />
|
||||
|
||||
</div>
|
||||
<div>
|
||||
<component
|
||||
@@ -18,7 +18,7 @@ import { ref, onMounted, computed } from 'vue'
|
||||
import S3Form from './S3Form.vue'
|
||||
import LocalFsForm from './LocalFsForm.vue'
|
||||
import api from '@/api'
|
||||
import PageHeader from '../common/PageHeader.vue'
|
||||
|
||||
|
||||
const initialValues = ref({})
|
||||
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<span class="text-2xl">
|
||||
{{ title }}
|
||||
</span>
|
||||
<p class="text-sm-muted">
|
||||
{{ subTitle }}
|
||||
</p>
|
||||
<div v-if="!isHidden">
|
||||
<div class="flex items-center space-x-4 p-4">
|
||||
<SidebarTrigger class="cursor-pointer w-5 h-5" />
|
||||
<span class="text-2xl font-semibold">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
<Separator class="my-3" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subTitle: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const title = computed(() => route.meta.title || '')
|
||||
const isHidden = computed(() => route.meta.hidePageHeader === true)
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<div class="relative" v-if="conversationStore.messages.data">
|
||||
|
||||
<div v-if="conversationStore.messages.data">
|
||||
<!-- Header -->
|
||||
<div class="px-4 border-b h-[44px] flex items-center justify-between">
|
||||
<div class="p-3 border-b flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3 text-sm">
|
||||
<div class="font-medium">
|
||||
{{ conversationStore.current.subject }}
|
||||
@@ -27,16 +26,16 @@
|
||||
</div>
|
||||
|
||||
<!-- Messages & reply box -->
|
||||
<div class="flex flex-col h-screen" v-auto-animate>
|
||||
<MessageList class="flex-1" />
|
||||
<ReplyBox class="h-max mb-12" />
|
||||
<div class="flex flex-col h-[calc(100vh-theme(spacing.10))]">
|
||||
<MessageList class="flex-1 overflow-y-auto" />
|
||||
<div class="sticky bottom-0 bg-white">
|
||||
<ReplyBox class="h-max" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
<template>
|
||||
<div class="max-h-[600px] overflow-y-auto">
|
||||
<BubbleMenu :editor="editor" :tippy-options="{ duration: 100 }" v-if="editor">
|
||||
<div class="BubbleMenu">
|
||||
<BubbleMenu :editor="editor" :tippy-options="{ duration: 100 }" v-if="editor" class="bg-white p-1 box rounded-lg">
|
||||
<div class="flex space-x-1 items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button size="sm" variant="outline">
|
||||
AI
|
||||
<ChevronDown class="w-4 h-4 ml-2" />
|
||||
<Button size="sm" variant="ghost" class="flex items-center justify-center">
|
||||
<span class="flex items-center">
|
||||
<span class="text-medium">AI</span>
|
||||
<Bot size="14" class="ml-1" />
|
||||
<ChevronDown class="w-4 h-4 ml-2" />
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Make friendly</DropdownMenuItem>
|
||||
<DropdownMenuItem>Make formal</DropdownMenuItem>
|
||||
<DropdownMenuItem>Make casual</DropdownMenuItem>
|
||||
<DropdownMenuItem v-for="prompt in aiPrompts" :key="prompt.key" @select="emitPrompt(prompt.key)">
|
||||
{{ prompt.title }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button size="sm" variant="ghost" @click="isBold = !isBold" :active="isBold" :class="{ 'bg-gray-200': isBold }">
|
||||
<Bold size="14" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" @click="isItalic = !isItalic" :active="isItalic"
|
||||
:class="{ 'bg-gray-200': isItalic }">
|
||||
<Italic size="14" />
|
||||
</Button>
|
||||
</div>
|
||||
</BubbleMenu>
|
||||
<EditorContent :editor="editor" />
|
||||
@@ -24,7 +34,7 @@
|
||||
<script setup>
|
||||
import { ref, watch, watchEffect, onUnmounted } from 'vue'
|
||||
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/vue-3'
|
||||
import { ChevronDown } from 'lucide-vue-next';
|
||||
import { ChevronDown, Bold, Italic, Bot } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -37,93 +47,87 @@ import Image from '@tiptap/extension-image'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
|
||||
const emit = defineEmits([
|
||||
'send',
|
||||
'editorText',
|
||||
'updateBold',
|
||||
'updateItalic',
|
||||
'contentCleared',
|
||||
'contentSet',
|
||||
'editorReady'
|
||||
])
|
||||
const selectedText = defineModel('selectedText', { default: '' })
|
||||
const textContent = defineModel('textContent')
|
||||
const htmlContent = defineModel('htmlContent')
|
||||
const isBold = defineModel('isBold')
|
||||
const isItalic = defineModel('isItalic')
|
||||
const cursorPosition = defineModel('cursorPosition', {
|
||||
default: 0
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: String,
|
||||
isBold: Boolean,
|
||||
isItalic: Boolean,
|
||||
clearContent: Boolean,
|
||||
contentToSet: String
|
||||
contentToSet: String,
|
||||
aiPrompts: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'send',
|
||||
'editorReady',
|
||||
'aiPromptSelected'
|
||||
])
|
||||
|
||||
function emitPrompt (key) {
|
||||
emit('aiPromptSelected', key)
|
||||
}
|
||||
|
||||
const editor = ref(
|
||||
useEditor({
|
||||
content: '',
|
||||
content: textContent.value,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Image.configure({
|
||||
HTMLAttributes: {
|
||||
// Common class for all inline images.
|
||||
class: 'inline-image',
|
||||
},
|
||||
HTMLAttributes: { class: 'inline-image' }
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: () => {
|
||||
return props.placeholder
|
||||
}
|
||||
placeholder: () => props.placeholder
|
||||
}),
|
||||
Link,
|
||||
Link
|
||||
],
|
||||
autofocus: true,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
// No outline for the editor.
|
||||
class: 'outline-none'
|
||||
},
|
||||
}
|
||||
editorProps: { attributes: { class: 'outline-none' } },
|
||||
onSelectionUpdate: ({ editor }) => {
|
||||
selectedText.value = editor.state.doc.textBetween(
|
||||
editor.state.selection.from,
|
||||
editor.state.selection.to
|
||||
)
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
htmlContent.value = editor.getHTML()
|
||||
textContent.value = editor.getText()
|
||||
cursorPosition.value = editor.state.selection.from
|
||||
},
|
||||
onCreate: ({ editor }) => {
|
||||
if (cursorPosition.value) {
|
||||
editor.commands.setTextSelection(cursorPosition.value)
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
if (editor.value) {
|
||||
// Emit the editor instance when it's ready
|
||||
if (editor.value) {
|
||||
emit('editorReady', editor.value)
|
||||
}
|
||||
|
||||
emit('editorText', {
|
||||
text: editor.value.getText(),
|
||||
html: editor.value.getHTML()
|
||||
})
|
||||
|
||||
// Emit bold and italic state changes
|
||||
emit('updateBold', editor.value.isActive('bold'))
|
||||
emit('updateItalic', editor.value.isActive('italic'))
|
||||
emit('editorReady', editor.value)
|
||||
isBold.value = editor.value.isActive('bold')
|
||||
isItalic.value = editor.value.isActive('italic')
|
||||
}
|
||||
})
|
||||
|
||||
// Watcher for bold and italic changes
|
||||
watchEffect(() => {
|
||||
if (props.isBold !== editor.value?.isActive('bold')) {
|
||||
if (props.isBold) {
|
||||
editor.value?.chain().focus().setBold().run()
|
||||
} else {
|
||||
editor.value?.chain().focus().unsetBold().run()
|
||||
}
|
||||
if (isBold.value !== editor.value?.isActive('bold')) {
|
||||
isBold.value
|
||||
? editor.value?.chain().focus().setBold().run()
|
||||
: editor.value?.chain().focus().unsetBold().run()
|
||||
}
|
||||
if (props.isItalic !== editor.value?.isActive('italic')) {
|
||||
if (props.isItalic) {
|
||||
editor.value?.chain().focus().setItalic().run()
|
||||
} else {
|
||||
editor.value?.chain().focus().unsetItalic().run()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Watcher for clearContent prop
|
||||
watchEffect(() => {
|
||||
if (props.clearContent) {
|
||||
editor.value?.commands.clearContent()
|
||||
emit('contentCleared')
|
||||
if (isItalic.value !== editor.value?.isActive('italic')) {
|
||||
isItalic.value
|
||||
? editor.value?.chain().focus().setItalic().run()
|
||||
: editor.value?.chain().focus().unsetItalic().run()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -131,14 +135,19 @@ watch(
|
||||
() => props.contentToSet,
|
||||
(newContent) => {
|
||||
if (newContent) {
|
||||
// Remove trailing break when setting content
|
||||
console.log('Setting content to -:', newContent)
|
||||
editor.value.commands.setContent(newContent)
|
||||
editor.value.commands.focus()
|
||||
emit('contentSet')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(cursorPosition, (newPos, oldPos) => {
|
||||
if (editor.value && newPos !== oldPos && newPos !== editor.value.state.selection.from) {
|
||||
editor.value.commands.setTextSelection(newPos)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
editor.value.destroy()
|
||||
})
|
||||
@@ -176,4 +185,4 @@ onUnmounted(() => {
|
||||
br.ProseMirror-trailingBreak {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,9 +1,34 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Fullscreen editor -->
|
||||
<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">
|
||||
<div v-if="filteredCannedResponses.length > 0" class="w-full overflow-hidden p-2 border-t backdrop-blur">
|
||||
<ul ref="cannedResponsesRef" class="space-y-2 max-h-96 overflow-y-auto">
|
||||
<li v-for="(response, index) in filteredCannedResponses" :key="response.id" :class="[
|
||||
'cursor-pointer rounded p-1 hover:bg-secondary',
|
||||
{ 'bg-secondary': index === selectedResponseIndex }
|
||||
]" @click="selectCannedResponse(response.content)" @mouseenter="selectedResponseIndex = index">
|
||||
<span class="font-semibold">{{ response.title }}</span> - {{ getTextFromHTML(response.content).slice(0,
|
||||
150)
|
||||
}}...
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<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" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Canned responses -->
|
||||
<div v-if="filteredCannedResponses.length > 0" class="w-full overflow-hidden p-2 border-t backdrop-blur">
|
||||
<ul ref="responsesList" class="space-y-2 max-h-96 overflow-y-auto">
|
||||
<!-- Canned responses on non-fullscreen editor -->
|
||||
<div v-if="filteredCannedResponses.length > 0 && !isEditorFullscreen"
|
||||
class="w-full overflow-hidden p-2 border-t backdrop-blur">
|
||||
<ul ref="cannedResponsesRef" class="space-y-2 max-h-96 overflow-y-auto">
|
||||
<li v-for="(response, index) in filteredCannedResponses" :key="response.id" :class="[
|
||||
'cursor-pointer rounded p-1 hover:bg-secondary',
|
||||
{ 'bg-secondary': index === selectedResponseIndex }
|
||||
@@ -13,24 +38,28 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="border-t">
|
||||
|
||||
<!-- Main Editor non-fullscreen -->
|
||||
<div class="border-t" v-if="!isEditorFullscreen">
|
||||
<!-- Message type toggle -->
|
||||
<div class="flex justify-between px-2 border-b py-2">
|
||||
<Tabs v-model:model-value="messageType">
|
||||
<Tabs v-model="messageType">
|
||||
<TabsList>
|
||||
<TabsTrigger value="reply"> Reply </TabsTrigger>
|
||||
<TabsTrigger value="private_note"> Private note </TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div class="flex items-center mr-2 cursor-pointer" @click="isEditorFullscreen = !isEditorFullscreen">
|
||||
<Fullscreen size="20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Editor -->
|
||||
<Editor @keydown="handleKeydown" @editorText="handleEditorText" :placeholder="editorPlaceholder" :isBold="isBold"
|
||||
:clearContent="clearContent" :isItalic="isItalic" @updateBold="updateBold" @updateItalic="updateItalic"
|
||||
@contentCleared="handleContentCleared" @contentSet="clearContentToSet" @editorReady="onEditorReady"
|
||||
:contentToSet="contentToSet" :cannedResponses="cannedResponses" />
|
||||
|
||||
<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" />
|
||||
|
||||
<!-- Attachments preview -->
|
||||
<AttachmentsPreview :attachments="attachments" :onDelete="handleOnFileDelete"></AttachmentsPreview>
|
||||
@@ -40,58 +69,97 @@
|
||||
:isBold="isBold" :isItalic="isItalic" @toggleBold="toggleBold" @toggleItalic="toggleItalic" :hasText="hasText"
|
||||
:handleSend="handleSend">
|
||||
</ReplyBoxBottomMenuBar>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { transformImageSrcToCID } from '@/utils/strings'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { Fullscreen } from 'lucide-vue-next';
|
||||
import api from '@/api'
|
||||
|
||||
import { getTextFromHTML } from '@/utils/strings'
|
||||
import Editor from './ConversationTextEditor.vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import AttachmentsPreview from '@/components/attachment/AttachmentsPreview.vue'
|
||||
import ReplyBoxBottomMenuBar from '@/components/conversation/ReplyBoxMenuBar.vue'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
const emitter = useEmitter()
|
||||
|
||||
let editorInstance = ref(null)
|
||||
const isEditorFullscreen = ref(false)
|
||||
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)
|
||||
const editorText = ref('')
|
||||
const editorHTML = ref('')
|
||||
const contentToSet = ref('')
|
||||
const conversationStore = useConversationStore()
|
||||
const filteredCannedResponses = ref([])
|
||||
|
||||
const uploadedFiles = ref([])
|
||||
const messageType = ref('reply')
|
||||
|
||||
const filteredCannedResponses = ref([])
|
||||
const selectedResponseIndex = ref(-1)
|
||||
const responsesList = ref(null)
|
||||
let editorInstance = ref(null)
|
||||
const cannedResponsesRef = ref(null)
|
||||
const cannedResponses = ref([])
|
||||
|
||||
const aiPrompts = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchCannedResponses(), fetchAiPrompts()])
|
||||
})
|
||||
|
||||
const fetchAiPrompts = async () => {
|
||||
try {
|
||||
const resp = await api.getAiPrompts()
|
||||
aiPrompts.value = resp.data.data
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCannedResponses = async () => {
|
||||
try {
|
||||
const resp = await api.getCannedResponses()
|
||||
cannedResponses.value = resp.data.data
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const cannedResponses = ref([])
|
||||
|
||||
const updateBold = (newState) => {
|
||||
isBold.value = newState
|
||||
}
|
||||
|
||||
const updateItalic = (newState) => {
|
||||
isItalic.value = newState
|
||||
const handleAiPromptSelected = async (key) => {
|
||||
try {
|
||||
const resp = await api.aiCompletion({
|
||||
prompt_key: key,
|
||||
content: selectedText.value,
|
||||
})
|
||||
contentToSet.value = resp.data.data.replace(/\n/g, '<br>')
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const toggleBold = () => {
|
||||
@@ -110,6 +178,11 @@ const attachments = computed(() => {
|
||||
return uploadedFiles.value.filter(upload => upload.disposition === 'attachment')
|
||||
})
|
||||
|
||||
// Watch for text content changes and filter canned responses
|
||||
watch(textContent, (newVal) => {
|
||||
filterCannedResponses(newVal)
|
||||
})
|
||||
|
||||
const filterCannedResponses = (input) => {
|
||||
// Extract the text after the last `/`
|
||||
const lastSlashIndex = input.lastIndexOf('/')
|
||||
@@ -129,14 +202,8 @@ const filterCannedResponses = (input) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditorText = (text) => {
|
||||
editorText.value = text.text
|
||||
editorHTML.value = text.html
|
||||
filterCannedResponses(text.text)
|
||||
}
|
||||
|
||||
const hasText = computed(() => {
|
||||
return editorText.value.length > 0 ? true : false
|
||||
return textContent.value.trim().length > 0 ? true : false
|
||||
})
|
||||
|
||||
const onEditorReady = (editor) => {
|
||||
@@ -156,7 +223,7 @@ const handleFileUpload = (event) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error uploading file',
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
@@ -182,7 +249,7 @@ const handleInlineImageUpload = (event) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error uploading file',
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
@@ -190,18 +257,14 @@ const handleInlineImageUpload = (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleContentCleared = () => {
|
||||
clearContent.value = false
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
try {
|
||||
// Replace inline image url with cid.
|
||||
const message = transformImageSrcToCID(editorHTML.value)
|
||||
const message = transformImageSrcToCID(htmlContent.value)
|
||||
|
||||
// Check which images are still in editor before sending.
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(editorHTML.value, 'text/html')
|
||||
const doc = parser.parseFromString(htmlContent.value, 'text/html')
|
||||
const inlineImageUUIDs = Array.from(doc.querySelectorAll('img.inline-image'))
|
||||
.map(img => img.getAttribute('title'))
|
||||
.filter(Boolean)
|
||||
@@ -220,7 +283,7 @@ const handleSend = async () => {
|
||||
})
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error sending message',
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
@@ -256,7 +319,7 @@ const handleKeydown = (event) => {
|
||||
}
|
||||
|
||||
const scrollToSelectedItem = () => {
|
||||
const list = responsesList.value
|
||||
const list = cannedResponsesRef.value
|
||||
const selectedItem = list.children[selectedResponseIndex.value]
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({
|
||||
@@ -271,8 +334,4 @@ const selectCannedResponse = (content) => {
|
||||
filteredCannedResponses.value = []
|
||||
selectedResponseIndex.value = -1
|
||||
}
|
||||
|
||||
const clearContentToSet = () => {
|
||||
contentToSet.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div class="flex justify-between items-center border-y h-14 px-2">
|
||||
<div class="flex justify-between items-center border-t h-14 px-2">
|
||||
<div class="flex justify-items-start gap-2">
|
||||
<!-- File inputs -->
|
||||
<input type="file" class="hidden" ref="attachmentInput" multiple @change="handleFileUpload" />
|
||||
<input type="file" class="hidden" ref="inlineImageInput" accept="image/*" @change="handleInlineImageUpload" />
|
||||
<!-- Editor buttons -->
|
||||
<Toggle class="px-2 py-2 border-0" variant="outline" @click="toggleBold" :pressed="isBold">
|
||||
<!-- <Toggle class="px-2 py-2 border-0" variant="outline" @click="toggleBold" :pressed="isBold">
|
||||
<Bold class="h-4 w-4" />
|
||||
</Toggle>
|
||||
<Toggle class="px-2 py-2 border-0" variant="outline" @click="toggleItalic" :pressed="isItalic">
|
||||
<Italic class="h-4 w-4" />
|
||||
</Toggle>
|
||||
</Toggle> -->
|
||||
<Toggle class="px-2 py-2 border-0" variant="outline" @click="triggerFileUpload" :pressed="false">
|
||||
<Paperclip class="h-4 w-4" />
|
||||
</Toggle>
|
||||
@@ -26,11 +26,11 @@
|
||||
import { ref } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Toggle } from '@/components/ui/toggle'
|
||||
import { Paperclip, Bold, Italic } from 'lucide-vue-next'
|
||||
import { Paperclip } from 'lucide-vue-next'
|
||||
|
||||
const attachmentInput = ref(null)
|
||||
const inlineImageInput = ref(null)
|
||||
const emit = defineEmits(['toggleBold', 'toggleItalic'])
|
||||
// const emit = defineEmits(['toggleBold', 'toggleItalic'])
|
||||
defineProps({
|
||||
isBold: Boolean,
|
||||
isItalic: Boolean,
|
||||
@@ -40,13 +40,13 @@ defineProps({
|
||||
handleInlineImageUpload: Function
|
||||
})
|
||||
|
||||
const toggleBold = () => {
|
||||
emit('toggleBold')
|
||||
}
|
||||
// const toggleBold = () => {
|
||||
// emit('toggleBold')
|
||||
// }
|
||||
|
||||
const toggleItalic = () => {
|
||||
emit('toggleItalic')
|
||||
}
|
||||
// const toggleItalic = () => {
|
||||
// emit('toggleItalic')
|
||||
// }
|
||||
|
||||
const triggerFileUpload = () => {
|
||||
attachmentInput.value.click()
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<div class="h-screen flex flex-col">
|
||||
<div class="flex justify-start items-center p-3 w-full space-x-4 border-b">
|
||||
<SidebarTrigger class="cursor-pointer w-5 h-5" />
|
||||
<span class="text-xl font-semibold">{{title}}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between px-2 py-2 w-full">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="cursor-pointer">
|
||||
@@ -84,11 +89,12 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import EmptyList from '@/components/conversation/list/ConversationEmptyList.vue'
|
||||
import ConversationListItem from '@/components/conversation/list/ConversationListItem.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import ConversationListItemSkeleton from '@/components/conversation/list/ConversationListItemSkeleton.vue'
|
||||
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
let reFetchInterval = null
|
||||
|
||||
@@ -100,6 +106,9 @@ onMounted(() => {
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const title = computed(() => route.meta.title || '')
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(reFetchInterval)
|
||||
conversationStore.clearListReRenderInterval()
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
<template>
|
||||
<div class="flex">
|
||||
<div class="flex flex-col gap-x-5 box p-5 rounded-md space-y-5">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="text-2xl">{{title}}</p>
|
||||
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
|
||||
<span class="blinking-dot"></span>
|
||||
<strong class="uppercase tracking-wider">Live</strong>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-x-5 box p-5 rounded-md space-y-5">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="text-2xl">{{ title }}</p>
|
||||
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
|
||||
<span class="blinking-dot"></span>
|
||||
<p class="uppercase text-xs">Live</p>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div v-for="(value, key) in counts" :key="key" class="flex flex-col items-center gap-y-2">
|
||||
<span class="text-muted-foreground">{{ labels[key] }}</span>
|
||||
<span class="text-2xl font-medium">{{ value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between pr-32">
|
||||
<div v-for="(value, key) in counts" :key="key" class="flex flex-col items-center gap-y-2">
|
||||
<span class="text-muted-foreground">{{ labels[key] }}</span>
|
||||
<span class="text-2xl font-medium">{{ value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<template>
|
||||
<LineChart :data="chartData" index="date" :categories="['New conversations']" :y-formatter="(tick) => {
|
||||
return tick
|
||||
}
|
||||
" />
|
||||
<LineChart :data="data" index="date" :categories="['New conversations', 'Resolved conversations', 'Messages sent']"
|
||||
:x-formatter="xFormatter" :y-formatter="yFormatter" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { LineChart } from '@/components/ui/chart-line'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
@@ -16,10 +12,12 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const chartData = computed(() =>
|
||||
props.data.map(item => ({
|
||||
date: item.date,
|
||||
"New conversations": item.new_conversations
|
||||
}))
|
||||
)
|
||||
const xFormatter = (tick) => {
|
||||
return props.data[tick]?.date ?? ''
|
||||
}
|
||||
|
||||
const yFormatter = (tick) => {
|
||||
return Number.isInteger(tick) ? tick : ''
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<!-- Sticky container for the scroll arrow -->
|
||||
<div v-show="!isAtBottom" class="sticky bottom-6 flex justify-end px-6">
|
||||
<div class="relative">
|
||||
<button @click="handleScrollToBottom" class="w-11 h-11 rounded-full flex items-center justify-center shadow">
|
||||
<button @click="handleScrollToBottom" class="w-8 h-8 rounded-full flex items-center justify-center shadow">
|
||||
<ArrowDown size="20" />
|
||||
</button>
|
||||
<span v-if="unReadMessages > 0"
|
||||
|
||||
@@ -55,46 +55,46 @@ const routes = [
|
||||
path: '/inboxes',
|
||||
name: 'inboxes',
|
||||
redirect: '/inboxes/assigned',
|
||||
meta: { title: 'Inboxes' },
|
||||
meta: { title: 'Inbox', hidePageHeader: true },
|
||||
children: [
|
||||
{
|
||||
path: ':type(assigned|unassigned|all)',
|
||||
name: 'inbox',
|
||||
component: ConversationsView,
|
||||
props: route => ({ type: route.params.type, uuid: route.params.uuid }),
|
||||
meta: route => ({ title: `${route.params.type.charAt(0).toUpperCase()}${route.params.type.slice(1)} inbox` }),
|
||||
meta: { title: 'Inbox' },
|
||||
children: [
|
||||
{
|
||||
path: 'conversation/:uuid',
|
||||
name: 'inbox-conversation',
|
||||
component: ConversationsView,
|
||||
props: true,
|
||||
meta: { title: 'Conversation' }
|
||||
meta: { title: 'Inbox', hidePageHeader: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/teams',
|
||||
name: 'teams',
|
||||
redirect: '/teams/:teamID',
|
||||
meta: { title: 'Teams' },
|
||||
meta: { title: 'Team inbox', hidePageHeader: true },
|
||||
children: [
|
||||
{
|
||||
path: ':teamID',
|
||||
name: 'team-inbox',
|
||||
props: true,
|
||||
component: ConversationsView,
|
||||
meta: route => ({ title: `Team ${route.params.teamID} inbox` }),
|
||||
meta: { title: `Team inbox` },
|
||||
children: [
|
||||
{
|
||||
path: 'conversation/:uuid',
|
||||
name: 'team-inbox-conversation',
|
||||
component: ConversationsView,
|
||||
props: true,
|
||||
meta: { title: 'Conversation' }
|
||||
meta: { title: 'Team inbox', hidePageHeader: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -104,21 +104,21 @@ const routes = [
|
||||
path: '/views',
|
||||
name: 'views',
|
||||
redirect: '/views/:viewID',
|
||||
meta: { title: 'Views' },
|
||||
meta: { title: 'View', hidePageHeader: true },
|
||||
children: [
|
||||
{
|
||||
path: ':viewID',
|
||||
name: 'view-inbox',
|
||||
props: true,
|
||||
component: ConversationsView,
|
||||
meta: route => ({ title: `View ${route.params.viewID} inbox` }),
|
||||
meta: { title: `View` },
|
||||
children: [
|
||||
{
|
||||
path: 'conversation/:uuid',
|
||||
name: 'view-inbox-conversation',
|
||||
component: ConversationsView,
|
||||
props: true,
|
||||
meta: { title: 'Conversation' }
|
||||
meta: { title: 'View', hidePageHeader: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -148,119 +148,119 @@ const routes = [
|
||||
{
|
||||
path: 'business-hours',
|
||||
component: () => import('@/components/admin/business_hours/BusinessHours.vue'),
|
||||
meta: { title: 'Admin - Business Hours' },
|
||||
meta: { title: 'Business Hours' },
|
||||
children: [
|
||||
{
|
||||
path: 'new',
|
||||
component: () => import('@/components/admin/business_hours/CreateOrEditBusinessHours.vue'),
|
||||
meta: { title: 'Admin - Add Business Hours' }
|
||||
meta: { title: 'Add Business Hours' }
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
name: 'edit-business-hours',
|
||||
props: true,
|
||||
component: () => import('@/components/admin/business_hours/CreateOrEditBusinessHours.vue'),
|
||||
meta: { title: 'Admin - Edit Business Hours' }
|
||||
meta: { title: 'Edit Business Hours' }
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'sla',
|
||||
component: () => import('@/components/admin/sla/SLA.vue'),
|
||||
meta: { title: 'Admin - SLA' },
|
||||
meta: { title: 'SLA' },
|
||||
children: [
|
||||
{
|
||||
path: 'new',
|
||||
component: () => import('@/components/admin/sla/CreateEditSLA.vue'),
|
||||
meta: { title: 'Admin - Add SLA' }
|
||||
meta: { title: 'Add SLA' }
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
props: true,
|
||||
component: () => import('@/components/admin/sla/CreateEditSLA.vue'),
|
||||
meta: { title: 'Admin - Edit SLA' }
|
||||
meta: { title: 'Edit SLA' }
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'inboxes',
|
||||
component: () => import('@/components/admin/inbox/Inbox.vue'),
|
||||
meta: { title: 'Admin - Inboxes' },
|
||||
meta: { title: 'Inboxes' },
|
||||
children: [
|
||||
{
|
||||
path: 'new',
|
||||
component: () => import('@/components/admin/inbox/NewInbox.vue'),
|
||||
meta: { title: 'Admin - New Inbox' }
|
||||
meta: { title: 'New Inbox' }
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
props: true,
|
||||
component: () => import('@/components/admin/inbox/EditInbox.vue'),
|
||||
meta: { title: 'Admin - Edit Inbox' }
|
||||
meta: { title: 'Edit Inbox' }
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'notification',
|
||||
component: () => import('@/components/admin/notification/NotificationSetting.vue'),
|
||||
meta: { title: 'Admin - Notification Settings' }
|
||||
meta: { title: 'Notification Settings' }
|
||||
},
|
||||
{
|
||||
path: 'teams',
|
||||
meta: { title: 'Admin - Teams' },
|
||||
meta: { title: 'Teams' },
|
||||
children: [
|
||||
{
|
||||
path: 'users',
|
||||
component: () => import('@/components/admin/team/users/UsersCard.vue'),
|
||||
meta: { title: 'Admin - Users' },
|
||||
meta: { title: 'Users' },
|
||||
children: [
|
||||
{
|
||||
path: 'new',
|
||||
component: () => import('@/components/admin/team/users/AddUserForm.vue'),
|
||||
meta: { title: 'Admin - Create User' }
|
||||
meta: { title: 'Create User' }
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
props: true,
|
||||
component: () => import('@/components/admin/team/users/EditUserForm.vue'),
|
||||
meta: { title: 'Admin - Edit User' }
|
||||
meta: { title: 'Edit User' }
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'teams',
|
||||
component: () => import('@/components/admin/team/teams/Teams.vue'),
|
||||
meta: { title: 'Admin - Teams Management' },
|
||||
meta: { title: 'Teams Management' },
|
||||
children: [
|
||||
|
||||
{
|
||||
path: 'new',
|
||||
component: () => import('@/components/admin/team/teams/CreateTeamForm.vue'),
|
||||
meta: { title: 'Admin - Create Team' }
|
||||
meta: { title: 'Create Team' }
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
props: true,
|
||||
component: () => import('@/components/admin/team/teams/EditTeamForm.vue'),
|
||||
meta: { title: 'Admin - Edit Team' }
|
||||
meta: { title: 'Edit Team' }
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'roles',
|
||||
component: () => import('@/components/admin/team/roles/Roles.vue'),
|
||||
meta: { title: 'Admin - Roles' },
|
||||
meta: { title: 'Roles' },
|
||||
children: [
|
||||
{
|
||||
path: 'new',
|
||||
component: () => import('@/components/admin/team/roles/NewRole.vue'),
|
||||
meta: { title: 'Admin - Create Role' }
|
||||
meta: { title: 'Create Role' }
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
props: true,
|
||||
component: () => import('@/components/admin/team/roles/EditRole.vue'),
|
||||
meta: { title: 'Admin - Edit Role' }
|
||||
meta: { title: 'Edit Role' }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -269,81 +269,81 @@ const routes = [
|
||||
{
|
||||
path: 'automations',
|
||||
component: () => import('@/components/admin/automation/Automation.vue'),
|
||||
meta: { title: 'Admin - Automations' },
|
||||
meta: { title: 'Automations' },
|
||||
children: [
|
||||
{
|
||||
path: 'new',
|
||||
props: true,
|
||||
component: () => import('@/components/admin/automation/CreateOrEditRule.vue'),
|
||||
meta: { title: 'Admin - Create Automation' }
|
||||
meta: { title: 'Create Automation' }
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
props: true,
|
||||
component: () => import('@/components/admin/automation/CreateOrEditRule.vue'),
|
||||
meta: { title: 'Admin - Edit Automation' }
|
||||
meta: { title: 'Edit Automation' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'general',
|
||||
component: () => import('@/components/admin/general/General.vue'),
|
||||
meta: { title: 'Admin - General Settings' }
|
||||
meta: { title: 'General Settings' }
|
||||
},
|
||||
{
|
||||
path: 'templates',
|
||||
component: () => import('@/components/admin/templates/Templates.vue'),
|
||||
meta: { title: 'Admin - Templates' },
|
||||
meta: { title: 'Templates' },
|
||||
children: [
|
||||
{
|
||||
path: ':id/edit',
|
||||
props: true,
|
||||
component: () => import('@/components/admin/templates/AddEditTemplate.vue'),
|
||||
meta: { title: 'Admin - Edit Template' }
|
||||
meta: { title: 'Edit Template' }
|
||||
},
|
||||
{
|
||||
path: 'new',
|
||||
component: () => import('@/components/admin/templates/AddEditTemplate.vue'),
|
||||
meta: { title: 'Admin - Add Template' }
|
||||
meta: { title: 'Add Template' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'oidc',
|
||||
component: () => import('@/components/admin/oidc/OIDC.vue'),
|
||||
meta: { title: 'Admin - OIDC' },
|
||||
meta: { title: 'OIDC' },
|
||||
children: [
|
||||
{
|
||||
path: ':id/edit',
|
||||
props: true,
|
||||
component: () => import('@/components/admin/oidc/AddEditOIDC.vue'),
|
||||
meta: { title: 'Admin - Edit OIDC' }
|
||||
meta: { title: 'Edit OIDC' }
|
||||
},
|
||||
{
|
||||
path: 'new',
|
||||
component: () => import('@/components/admin/oidc/AddEditOIDC.vue'),
|
||||
meta: { title: 'Admin - Add OIDC' }
|
||||
meta: { title: 'Add OIDC' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'conversations',
|
||||
meta: { title: 'Admin - Conversations' },
|
||||
meta: { title: 'Conversations' },
|
||||
children: [
|
||||
{
|
||||
path: 'tags',
|
||||
component: () => import('@/components/admin/conversation/tags/Tags.vue'),
|
||||
meta: { title: 'Admin - Conversation Tags' }
|
||||
meta: { title: 'Conversation Tags' }
|
||||
},
|
||||
{
|
||||
path: 'statuses',
|
||||
component: () => import('@/components/admin/conversation/status/Status.vue'),
|
||||
meta: { title: 'Admin - Conversation Statuses' }
|
||||
meta: { title: 'Conversation Statuses' }
|
||||
},
|
||||
{
|
||||
path: 'canned-responses',
|
||||
component: () => import('@/components/admin/conversation/canned_responses/CannedResponses.vue'),
|
||||
meta: { title: 'Admin - Canned Responses' }
|
||||
meta: { title: 'Canned Responses' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<PageHeader title="Overview" />
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<div class="mt-7 flex w-full space-x-4" v-auto-animate>
|
||||
<Card class="flex-1" title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" />
|
||||
<Card class="flex-1" title="Agent status" :counts="sampleAgentStatusCounts" :labels="sampleAgentStatusLabels" />
|
||||
</div>
|
||||
<div class="w-11/12" :class="{ 'soft-fade': isLoading }">
|
||||
<div class="flex my-7 justify-between items-center space-x-5">
|
||||
<div class="dashboard-card p-5">
|
||||
<LineChart :data="chartData.new_conversations"></LineChart>
|
||||
</div>
|
||||
<div class="dashboard-card p-5">
|
||||
<BarChart :data="chartData.status_summary"></BarChart>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<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" />
|
||||
</div>
|
||||
<div class="dashboard-card p-5">
|
||||
<LineChart :data="chartData.processedData"></LineChart>
|
||||
</div>
|
||||
<div class="dashboard-card p-5">
|
||||
<BarChart :data="chartData.status_summary"></BarChart>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,7 +25,7 @@ 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 PageHeader from '@/components/admin/common/PageHeader.vue'
|
||||
|
||||
import Spinner from '@/components/ui/spinner/Spinner.vue'
|
||||
|
||||
const { toast } = useToast()
|
||||
@@ -36,10 +33,10 @@ const isLoading = ref(false)
|
||||
const cardCounts = ref({})
|
||||
const chartData = ref({})
|
||||
const agentCountCardsLabels = {
|
||||
total_count: 'Total',
|
||||
resolved_count: 'Resolved',
|
||||
unresolved_count: 'Unresolved',
|
||||
awaiting_response_count: 'Awaiting Response'
|
||||
open: 'Total',
|
||||
awaiting_response: 'Awaiting Response',
|
||||
unassigned: 'Unassigned',
|
||||
pending: 'Pending'
|
||||
}
|
||||
const sampleAgentStatusLabels = {
|
||||
online: 'Online',
|
||||
@@ -65,7 +62,7 @@ const getDashboardData = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const getCardStats = () => {
|
||||
const getCardStats = async () => {
|
||||
return api.getOverviewCounts()
|
||||
.then((resp) => {
|
||||
cardCounts.value = resp.data.data
|
||||
@@ -79,10 +76,18 @@ const getCardStats = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const getDashboardCharts = () => {
|
||||
const getDashboardCharts = async () => {
|
||||
return api.getOverviewCharts()
|
||||
.then((resp) => {
|
||||
chartData.value.new_conversations = resp.data.data.new_conversations || []
|
||||
chartData.value.resolved_conversations = resp.data.data.resolved_conversations || []
|
||||
chartData.value.messages_sent = resp.data.data.messages_sent || []
|
||||
chartData.value.processedData = resp.data.data.new_conversations.map(item => ({
|
||||
date: item.date,
|
||||
'New conversations': item.count,
|
||||
'Resolved conversations': resp.data.data.resolved_conversations.find(r => r.date === item.date)?.count || 0,
|
||||
'Messages sent': resp.data.data.messages_sent.find(r => r.date === item.date)?.count || 0
|
||||
}))
|
||||
chartData.value.status_summary = resp.data.data.status_summary || []
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
|
||||
@@ -14,18 +15,7 @@ import (
|
||||
|
||||
var (
|
||||
//go:embed queries.sql
|
||||
efs embed.FS
|
||||
systemPrompts = map[string]string{
|
||||
"make_friendly": "Modify the text to make it more friendly and approachable.",
|
||||
"make_professional": "Rephrase the text to make it sound more formal and professional.",
|
||||
"make_concise": "Simplify the text to make it more concise and to the point.",
|
||||
"add_empathy": "Add empathy to the text while retaining the original meaning.",
|
||||
"adjust_positive_tone": "Adjust the tone of the text to make it sound more positive and reassuring.",
|
||||
"provide_clear_explanation": "Rewrite the text to provide a clearer explanation of the issue or solution.",
|
||||
"add_urgency": "Modify the text to convey a sense of urgency without being rude.",
|
||||
"make_actionable": "Rephrase the text to clearly specify the next steps for the customer.",
|
||||
"adjust_neutral_tone": "Adjust the tone to make it neutral and unbiased.",
|
||||
}
|
||||
efs embed.FS
|
||||
)
|
||||
|
||||
// Manager manages LLM providers.
|
||||
@@ -42,7 +32,9 @@ type Opts struct {
|
||||
|
||||
// queries contains prepared SQL queries.
|
||||
type queries struct {
|
||||
GetProvider *sqlx.Stmt `query:"get-provider"`
|
||||
GetDefaultProvider *sqlx.Stmt `query:"get-default-provider"`
|
||||
GetPrompt *sqlx.Stmt `query:"get-prompt"`
|
||||
GetPrompts *sqlx.Stmt `query:"get-prompts"`
|
||||
}
|
||||
|
||||
// New creates and returns a new instance of the Manager.
|
||||
@@ -57,19 +49,17 @@ func New(opts Opts) (*Manager, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SendPromptToProvider sends a prompt to the specified provider and returns the response.
|
||||
func (m *Manager) SendPromptToProvider(provider, k string, prompt string) (string, error) {
|
||||
|
||||
// Fetch the system prompt.
|
||||
systemPrompt, ok := systemPrompts[k]
|
||||
if !ok {
|
||||
m.lo.Error("invalid system prompt key", "key", k)
|
||||
return "", envelope.NewError(envelope.InputError, "Invalid system prompt key", nil)
|
||||
// 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
|
||||
}
|
||||
|
||||
client, err := m.getProviderClient(provider)
|
||||
client, err := m.getDefaultProviderClient()
|
||||
if err != nil {
|
||||
m.lo.Error("error getting provider client", "provider", provider, "error", err)
|
||||
m.lo.Error("error getting provider client", "error", err)
|
||||
return "", envelope.NewError(envelope.GeneralError, "Error getting provider client", nil)
|
||||
}
|
||||
|
||||
@@ -80,19 +70,43 @@ func (m *Manager) SendPromptToProvider(provider, k string, prompt string) (strin
|
||||
|
||||
response, err := client.SendPrompt(payload)
|
||||
if err != nil {
|
||||
m.lo.Error("error sending prompt to provider", "provider", provider, "error", err)
|
||||
m.lo.Error("error sending prompt to provider", "error", err)
|
||||
return "", envelope.NewError(envelope.GeneralError, "Error sending prompt to provider", nil)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// getProviderClient retrieves a ProviderClient for the specified provider.
|
||||
func (m *Manager) getProviderClient(providerName string) (ProviderClient, error) {
|
||||
// GetPrompts returns a list of prompts from the database.
|
||||
func (m *Manager) GetPrompts() ([]models.Prompt, error) {
|
||||
var prompts = make([]models.Prompt, 0)
|
||||
if err := m.q.GetPrompts.Select(&prompts); err != nil {
|
||||
m.lo.Error("error fetching prompts", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, "Error fetching prompts", nil)
|
||||
}
|
||||
return prompts, nil
|
||||
}
|
||||
|
||||
// getPrompt returns a prompt from the database.
|
||||
func (m *Manager) getPrompt(k string) (string, error) {
|
||||
var p models.Prompt
|
||||
if err := m.q.GetPrompt.Get(&p, k); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
m.lo.Error("error prompt not found", "key", k)
|
||||
return "", envelope.NewError(envelope.InputError, "Prompt not found", nil)
|
||||
}
|
||||
m.lo.Error("error fetching prompt", "error", err)
|
||||
return "", envelope.NewError(envelope.GeneralError, "Error fetching prompt", nil)
|
||||
}
|
||||
return p.Content, nil
|
||||
}
|
||||
|
||||
// getDefaultProviderClient returns a ProviderClient for the default provider.
|
||||
func (m *Manager) getDefaultProviderClient() (ProviderClient, error) {
|
||||
var p models.Provider
|
||||
|
||||
if err := m.q.GetProvider.Get(&p, providerName); err != nil {
|
||||
m.lo.Error("error fetching provider details", "provider", providerName, "error", err)
|
||||
if err := m.q.GetDefaultProvider.Get(&p); err != nil {
|
||||
m.lo.Error("error fetching provider details", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, "Error fetching provider details", nil)
|
||||
}
|
||||
|
||||
@@ -102,7 +116,7 @@ func (m *Manager) getProviderClient(providerName string) (ProviderClient, error)
|
||||
APIKey string `json:"api_key"`
|
||||
}{}
|
||||
if err := json.Unmarshal([]byte(p.Config), &config); err != nil {
|
||||
m.lo.Error("error parsing provider config", "provider", providerName, "error", err)
|
||||
m.lo.Error("error parsing provider config", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, "Error parsing provider config", nil)
|
||||
}
|
||||
return NewOpenAIClient(config.APIKey), nil
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Provider struct {
|
||||
ID string `db:"id"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
Name string `db:"name"`
|
||||
Provider string `db:"provider"`
|
||||
Config string `db:"config"`
|
||||
IsDefault bool `db:"is_default"`
|
||||
ID string `db:"id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
Name string `db:"name"`
|
||||
Provider string `db:"provider"`
|
||||
Config string `db:"config"`
|
||||
IsDefault bool `db:"is_default"`
|
||||
}
|
||||
|
||||
type Prompt struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Key string `db:"key" json:"key"`
|
||||
Content string `db:"content" json:"content"`
|
||||
}
|
||||
|
||||
@@ -22,11 +22,13 @@ func NewOpenAIClient(apiKey string) *OpenAIClient {
|
||||
}
|
||||
|
||||
func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
|
||||
apiURL := "https://api.openai.com/v1/completions"
|
||||
|
||||
apiURL := "https://api.openai.com/v1/chat/completions"
|
||||
requestBody := map[string]interface{}{
|
||||
"model": "text-davinci-003",
|
||||
"prompt": fmt.Sprintf("%s\n\n%s", payload.SystemPrompt, payload.UserPrompt),
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": []map[string]string{
|
||||
{"role": "system", "content": payload.SystemPrompt},
|
||||
{"role": "user", "content": payload.UserPrompt},
|
||||
},
|
||||
"max_tokens": 200,
|
||||
"temperature": 0.7,
|
||||
}
|
||||
@@ -57,7 +59,9 @@ func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
|
||||
|
||||
var responseBody struct {
|
||||
Choices []struct {
|
||||
Text string `json:"text"`
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&responseBody); err != nil {
|
||||
@@ -65,7 +69,7 @@ func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
|
||||
}
|
||||
|
||||
if len(responseBody.Choices) > 0 {
|
||||
return responseBody.Choices[0].Text, nil
|
||||
return responseBody.Choices[0].Message.Content, nil
|
||||
}
|
||||
return "", fmt.Errorf("no response text found")
|
||||
}
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
SELECT * FROM ai_providers where name = $1;
|
||||
-- name: get-default-provider
|
||||
SELECT id, name, provider, config, is_default FROM ai_providers where is_default is true;
|
||||
|
||||
-- name: get-prompt
|
||||
SELECT id, key, title, content FROM ai_prompts where key = $1;
|
||||
|
||||
-- name: get-prompts
|
||||
SELECT id, key, title, content FROM ai_prompts order by title;
|
||||
@@ -582,10 +582,10 @@ func (c *Manager) GetDashboardChart(userID, teamID int) (json.RawMessage, error)
|
||||
cond = " AND assigned_team_id = $1"
|
||||
qArgs = append(qArgs, teamID)
|
||||
}
|
||||
cond += " AND c.created_at >= NOW() - INTERVAL '30 days'"
|
||||
cond += " AND c.created_at >= NOW() - INTERVAL '90 days'"
|
||||
|
||||
// Apply the same condition across queries.
|
||||
query := fmt.Sprintf(c.q.GetDashboardCharts, cond, cond)
|
||||
query := fmt.Sprintf(c.q.GetDashboardCharts, cond, cond, cond, cond)
|
||||
if err := tx.Get(&stats, query, qArgs...); err != nil {
|
||||
c.lo.Error("error fetching dashboard charts", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, "Error fetching dashboard charts", nil)
|
||||
|
||||
@@ -227,14 +227,14 @@ WHERE assigned_user_id IS NULL AND assigned_team_id IS NOT NULL;
|
||||
|
||||
-- name: get-dashboard-counts
|
||||
SELECT json_build_object(
|
||||
'resolved_count', COUNT(CASE WHEN s.name = 'Resolved' THEN 1 END),
|
||||
'unresolved_count', COUNT(CASE WHEN s.name NOT IN ('Resolved', 'Closed') THEN 1 END),
|
||||
'awaiting_response_count', COUNT(CASE WHEN first_reply_at IS NULL THEN 1 END),
|
||||
'total_count', COUNT(*)
|
||||
'open', COUNT(*),
|
||||
'awaiting_response', COUNT(CASE WHEN c.first_reply_at IS NULL THEN 1 END),
|
||||
'unassigned', COUNT(CASE WHEN c.assigned_user_id IS NULL THEN 1 END),
|
||||
'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END)
|
||||
)
|
||||
FROM conversations c
|
||||
LEFT JOIN conversation_statuses s ON c.status_id = s.id
|
||||
WHERE 1=1 %s
|
||||
INNER JOIN conversation_statuses s ON c.status_id = s.id
|
||||
WHERE s.name = 'Open' AND 1=1 %s;
|
||||
|
||||
-- name: get-dashboard-charts
|
||||
WITH new_conversations AS (
|
||||
@@ -242,7 +242,7 @@ WITH new_conversations AS (
|
||||
FROM (
|
||||
SELECT
|
||||
TO_CHAR(created_at::date, 'YYYY-MM-DD') AS date,
|
||||
COUNT(*) AS new_conversations
|
||||
COUNT(*) AS count
|
||||
FROM
|
||||
conversations c
|
||||
WHERE 1=1 %s
|
||||
@@ -252,6 +252,21 @@ WITH new_conversations AS (
|
||||
date
|
||||
) agg
|
||||
),
|
||||
resolved_conversations AS (
|
||||
SELECT json_agg(row_to_json(agg)) AS data
|
||||
FROM (
|
||||
SELECT
|
||||
TO_CHAR(resolved_at::date, 'YYYY-MM-DD') AS date,
|
||||
COUNT(*) AS count
|
||||
FROM
|
||||
conversations c
|
||||
WHERE c.resolved_at IS NOT NULL AND 1=1 %s
|
||||
GROUP BY
|
||||
date
|
||||
ORDER BY
|
||||
date
|
||||
) agg
|
||||
),
|
||||
status_summary AS (
|
||||
SELECT json_agg(row_to_json(agg)) AS data
|
||||
FROM (
|
||||
@@ -264,13 +279,30 @@ status_summary AS (
|
||||
conversations c
|
||||
LEFT join conversation_statuses s on s.id = c.status_id
|
||||
LEFT join conversation_priorities p on p.id = c.priority_id
|
||||
WHERE 1=1 %s
|
||||
WHERE 1=1 AND s.name > '' %s
|
||||
GROUP BY
|
||||
s.name
|
||||
) agg
|
||||
),
|
||||
messages_sent as (
|
||||
SELECT json_agg(row_to_json(agg)) AS data
|
||||
FROM (
|
||||
SELECT
|
||||
TO_CHAR(created_at::date, 'YYYY-MM-DD') AS date,
|
||||
COUNT(*) AS count
|
||||
FROM
|
||||
conversation_messages c
|
||||
WHERE status = 'sent' AND 1=1 %s
|
||||
GROUP BY
|
||||
date
|
||||
ORDER BY
|
||||
date
|
||||
) agg
|
||||
)
|
||||
SELECT json_build_object(
|
||||
'new_conversations', (SELECT data FROM new_conversations),
|
||||
'resolved_conversations', (SELECT data FROM resolved_conversations),
|
||||
'messages_sent', (SELECT data FROM messages_sent),
|
||||
'status_summary', (SELECT data FROM status_summary)
|
||||
) AS result;
|
||||
|
||||
|
||||
49
schema.sql
49
schema.sql
@@ -348,15 +348,48 @@ CREATE TABLE views (
|
||||
|
||||
DROP TABLE IF EXISTS ai_providers CASCADE;
|
||||
CREATE TABLE ai_providers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
name TEXT NOT NULL,
|
||||
provider ai_provider NOT NULL,
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
CONSTRAINT constraint_ai_providers_on_name CHECK (length(name) <= 140)
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
name TEXT NOT NULL,
|
||||
provider ai_provider NOT NULL,
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
CONSTRAINT constraint_ai_providers_on_name CHECK (length(name) <= 140)
|
||||
);
|
||||
CREATE UNIQUE INDEX unique_index_ai_providers_on_is_default_when_is_default_is_true ON ai_providers USING btree (is_default)
|
||||
WHERE (is_default = true);
|
||||
CREATE INDEX index_ai_providers_on_name ON ai_providers USING btree (name);
|
||||
|
||||
CREATE TABLE ai_prompts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
title TEXT NOT NULL,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
CONSTRAINT constraint_prompts_on_title CHECK (length(title) <= 140),
|
||||
CONSTRAINT constraint_prompts_on_key CHECK (length(key) <= 140)
|
||||
);
|
||||
CREATE INDEX index_ai_prompts_on_key ON ai_prompts USING btree (key);
|
||||
|
||||
INSERT INTO ai_providers
|
||||
("name", provider, config, is_default)
|
||||
VALUES('openai', 'openai', '{"api_key": ""}'::jsonb, true);
|
||||
|
||||
-- Default AI prompts
|
||||
-- TODO: Narrow down the list of prompts.
|
||||
INSERT INTO public.ai_prompts ("key", "content", title)
|
||||
VALUES
|
||||
('make_friendly', 'Modify the text to make it more friendly and approachable.', 'Make Friendly'),
|
||||
('make_concise', 'Simplify the text to make it more concise and to the point.', 'Make Concise'),
|
||||
('add_empathy', 'Add empathy to the text while retaining the original meaning.', 'Add Empathy'),
|
||||
('adjust_positive_tone', 'Adjust the tone of the text to make it sound more positive and reassuring.', 'Adjust Positive Tone'),
|
||||
('provide_clear_explanation', 'Rewrite the text to provide a clearer explanation of the issue or solution.', 'Provide Clear Explanation'),
|
||||
('add_urgency', 'Modify the text to convey a sense of urgency without being rude.', 'Add Urgency'),
|
||||
('make_actionable', 'Rephrase the text to clearly specify the next steps for the customer.', 'Make Actionable'),
|
||||
('adjust_neutral_tone', 'Adjust the tone to make it neutral and unbiased.', 'Adjust Neutral Tone'),
|
||||
('make_professional', 'Rephrase the text to make it sound more formal and professional and to the point.', 'Make Professional');
|
||||
|
||||
-- Default settings
|
||||
INSERT INTO settings ("key", value)
|
||||
|
||||
Reference in New Issue
Block a user