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:
Abhinav Raut
2025-01-09 05:01:33 +05:30
parent d8e29b569f
commit 98537825e9
45 changed files with 582 additions and 495 deletions

29
cmd/ai.go Normal file
View 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)
}

View File

@@ -457,6 +457,9 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
if !allowed { if !allowed {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil)) 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 { if err := app.conversation.UpdateConversationStatus(uuid, status, snoozedUntil, user); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)

View File

@@ -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.PUT("/api/v1/sla/{id}", perm(fastglue.ReqLenRangeParams(handleUpdateSLA, slaReqFields), "sla:manage"))
g.DELETE("/api/v1/sla/{id}", perm(handleDeleteSLA, "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. // WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error { g.GET("/ws", auth(func(r *fastglue.Request) error {
return handleWS(r, hub) return handleWS(r, hub)

View File

@@ -12,6 +12,7 @@ import (
"html/template" "html/template"
"github.com/abhinavxd/artemis/internal/ai"
auth_ "github.com/abhinavxd/artemis/internal/auth" auth_ "github.com/abhinavxd/artemis/internal/auth"
"github.com/abhinavxd/artemis/internal/authz" "github.com/abhinavxd/artemis/internal/authz"
"github.com/abhinavxd/artemis/internal/autoassigner" "github.com/abhinavxd/artemis/internal/autoassigner"
@@ -701,6 +702,19 @@ func initPriority(db *sqlx.DB) *priority.Manager {
return 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. // initLogger initializes a logf logger.
func initLogger(src string) *logf.Logger { func initLogger(src string) *logf.Logger {
lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env") lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")

View File

@@ -8,6 +8,7 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
"github.com/abhinavxd/artemis/internal/ai"
auth_ "github.com/abhinavxd/artemis/internal/auth" auth_ "github.com/abhinavxd/artemis/internal/auth"
"github.com/abhinavxd/artemis/internal/authz" "github.com/abhinavxd/artemis/internal/authz"
businesshours "github.com/abhinavxd/artemis/internal/business_hours" businesshours "github.com/abhinavxd/artemis/internal/business_hours"
@@ -73,6 +74,7 @@ type App struct {
sla *sla.Manager sla *sla.Manager
csat *csat.Manager csat *csat.Manager
view *view.Manager view *view.Manager
ai *ai.Manager
notifier *notifier.Service notifier *notifier.Service
} }
@@ -205,6 +207,7 @@ func main() {
priority: initPriority(db), priority: initPriority(db),
role: initRole(db), role: initRole(db),
tag: initTag(db), tag: initTag(db),
ai: initAI(db),
cannedResp: initCannedResponse(db), cannedResp: initCannedResponse(db),
} }

View File

@@ -7,6 +7,7 @@
<ResizableHandle id="resize-handle-1" /> <ResizableHandle id="resize-handle-1" />
<ResizablePanel id="resize-panel-2"> <ResizablePanel id="resize-panel-2">
<div class="w-full h-screen"> <div class="w-full h-screen">
<PageHeader />
<RouterView /> <RouterView />
</div> </div>
</ResizablePanel> </ResizablePanel>
@@ -28,6 +29,7 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http' import { handleHTTPError } from '@/utils/http'
import { useConversationStore } from './stores/conversation' import { useConversationStore } from './stores/conversation'
import PageHeader from './components/common/PageHeader.vue'
import ViewForm from '@/components/ViewForm.vue' import ViewForm from '@/components/ViewForm.vue'
import api from '@/api' import api from '@/api'
import Sidebar from '@/components/sidebar/Sidebar.vue' import Sidebar from '@/components/sidebar/Sidebar.vue'

View File

@@ -239,6 +239,8 @@ const updateView = (id, data) =>
} }
}) })
const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`) 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 { export default {
login, login,
@@ -337,5 +339,7 @@ export default {
getCurrentUserViews, getCurrentUserViews,
createView, createView,
updateView, updateView,
deleteView deleteView,
getAiPrompts,
aiCompletion
} }

View File

@@ -8,14 +8,11 @@
font-size: 16px; font-size: 16px;
} }
body {
overflow-x: hidden;
overflow-y: hidden;
}
.page-content { .page-content {
padding: 1rem 1rem; padding: 1rem 1rem;
height: 100%; height: 100%;
overflow-y: scroll;
padding-bottom: 50px;
} }
// Theme. // Theme.

View File

@@ -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>

View File

@@ -1,19 +1,8 @@
<script setup> <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> </script>
<template> <template>
<div class="space-y-4 md:block page-content"> <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 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="flex-1 lg:max-w-3xl admin-main-content min-h-[700px]">
<div class="space-y-6"> <div class="space-y-6">

View File

@@ -1,5 +1,4 @@
<template> <template>
<PageHeader title="Automation" description="Manage automation rules" />
<div class="w-8/12"> <div class="w-8/12">
<div v-if="router.currentRoute.value.path === '/admin/automations'"> <div v-if="router.currentRoute.value.path === '/admin/automations'">
<div class="flex justify-between mb-5"> <div class="flex justify-between mb-5">
@@ -19,7 +18,6 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import AutomationTabs from '@/components/admin/automation/AutomationTabs.vue' import AutomationTabs from '@/components/admin/automation/AutomationTabs.vue'
import PageHeader from '../common/PageHeader.vue'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
const router = useRouter() const router = useRouter()

View File

@@ -1,5 +1,5 @@
<template> <template>
<PageHeader title="Business hours" description="Manage business hours" />
<div class="w-8/12"> <div class="w-8/12">
<template v-if="router.currentRoute.value.path === '/admin/business-hours'"> <template v-if="router.currentRoute.value.path === '/admin/business-hours'">
<div class="flex justify-between mb-5"> <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 { Button } from '@/components/ui/button'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import PageHeader from '../common/PageHeader.vue'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { columns } from '@/components/admin/business_hours/dataTableColumns.js' import { columns } from '@/components/admin/business_hours/dataTableColumns.js'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'

View File

@@ -1,12 +1,12 @@
<template> <template>
<div <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"> @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" /> <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> </div>
<p class="text-sm text-gray-500">{{ subTitle }}</p> <p class="text-sm text-gray-600">{{ subTitle }}</p>
</div> </div>
</template> </template>
@@ -17,16 +17,13 @@ const props = defineProps({
title: String, title: String,
subTitle: String, subTitle: String,
icon: Function, icon: Function,
onClick: { onClick: Function
type: Function,
default: null
}
}) })
const emit = defineEmits(['click']) const emit = defineEmits(['click'])
const handleClick = () => { const handleClick = () => {
if (props.onClick) props.onClick() props.onClick?.()
emit('click') emit('click')
} }
</script> </script>

View File

@@ -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>

View File

@@ -1,5 +1,5 @@
<template> <template>
<PageHeader title="Canned responses" description="Manage canned responses" />
<div class="w-8/12"> <div class="w-8/12">
<div class="flex justify-between mb-5"> <div class="flex justify-between mb-5">
<div class="flex justify-end mb-4 w-full"> <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 DataTable from '@/components/admin/DataTable.vue'
import { columns } from './dataTableColumns.js' import { columns } from './dataTableColumns.js'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import PageHeader from '@/components/admin/common/PageHeader.vue'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { import {
Dialog, Dialog,

View File

@@ -1,5 +1,5 @@
<template> <template>
<PageHeader title="Status" description="Manage conversation statuses" />
<div class="w-8/12"> <div class="w-8/12">
<div class="flex justify-between mb-5"> <div class="flex justify-between mb-5">
<div class="flex justify-end mb-4 w-full"> <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 DataTable from '@/components/admin/DataTable.vue'
import { columns } from './dataTableColumns.js' import { columns } from './dataTableColumns.js'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import PageHeader from '@/components/admin/common/PageHeader.vue'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import StatusForm from './StatusForm.vue' import StatusForm from './StatusForm.vue'
import { import {

View File

@@ -1,5 +1,5 @@
<template> <template>
<PageHeader title="Tags" description="Manage conversation tags" />
<div class="w-8/12"> <div class="w-8/12">
<div class="flex justify-between mb-5"> <div class="flex justify-between mb-5">
<div class="flex justify-end mb-4 w-full"> <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 { Spinner } from '@/components/ui/spinner'
import { columns } from '@/components/admin/conversation/tags/dataTableColumns.js' import { columns } from '@/components/admin/conversation/tags/dataTableColumns.js'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import PageHeader from '@/components/admin/common/PageHeader.vue'
import TagsForm from './TagsForm.vue' import TagsForm from './TagsForm.vue'
import { import {
Dialog, Dialog,

View File

@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<PageHeader title="General" description="Manage general app settings" />
</div> </div>
<div class="flex justify-center items-center flex-col w-8/12"> <div class="flex justify-center items-center flex-col w-8/12">
<GeneralSettingForm :submitForm="submitForm" :initial-values="initialValues" submitLabel="Save" /> <GeneralSettingForm :submitForm="submitForm" :initial-values="initialValues" submitLabel="Save" />
@@ -10,7 +10,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import GeneralSettingForm from './GeneralSettingForm.vue' import GeneralSettingForm from './GeneralSettingForm.vue'
import PageHeader from '../common/PageHeader.vue'
import api from '@/api' import api from '@/api'
const initialValues = ref({}) const initialValues = ref({})

View File

@@ -1,5 +1,5 @@
<template> <template>
<PageHeader title="Inboxes" description="Manage your inboxes" />
<div class="w-8/12"> <div class="w-8/12">
<template v-if="router.currentRoute.value.path === '/admin/inboxes'"> <template v-if="router.currentRoute.value.path === '/admin/inboxes'">
<div class="flex justify-between mb-5"> <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 { Button } from '@/components/ui/button'
import DataTable from '@/components/admin/DataTable.vue' import DataTable from '@/components/admin/DataTable.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import PageHeader from '../common/PageHeader.vue'
import { format } from 'date-fns' import { format } from 'date-fns'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import api from '@/api' import api from '@/api'

View File

@@ -1,5 +1,5 @@
<template> <template>
<PageHeader title="Notifications" description="Manage your email notification settings" />
<div class="w-8/12"> <div class="w-8/12">
<div> <div>
<Spinner v-if="formLoading"></Spinner> <Spinner v-if="formLoading"></Spinner>
@@ -11,7 +11,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import api from '@/api' import api from '@/api'
import PageHeader from '../common/PageHeader.vue'
import NotificationsForm from './NotificationSettingForm.vue' import NotificationsForm from './NotificationSettingForm.vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'

View File

@@ -1,5 +1,5 @@
<template> <template>
<PageHeader title="OpenID Connect" description="Manage OpenID Connect configurations" />
<div class="w-8/12"> <div class="w-8/12">
<template v-if="router.currentRoute.value.path === '/admin/oidc'"> <template v-if="router.currentRoute.value.path === '/admin/oidc'">
<div class="flex justify-between mb-5"> <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 { Button } from '@/components/ui/button'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import PageHeader from '../common/PageHeader.vue'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api' import api from '@/api'

View File

@@ -1,5 +1,5 @@
<template> <template>
<PageHeader title="SLA" description="Manage service level agreements" />
<div class="w-8/12"> <div class="w-8/12">
<template v-if="router.currentRoute.value.path === '/admin/sla'"> <template v-if="router.currentRoute.value.path === '/admin/sla'">
<div class="flex justify-between mb-5"> <div class="flex justify-between mb-5">
@@ -26,7 +26,7 @@ import { columns } from './dataTableColumns.js'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import PageHeader from '../common/PageHeader.vue'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api' import api from '@/api'

View File

@@ -1,5 +1,5 @@
<template> <template>
<PageHeader title="Roles" description="Manage roles" />
<div class="w-8/12"> <div class="w-8/12">
<div v-if="router.currentRoute.value.path === '/admin/teams/roles'"> <div v-if="router.currentRoute.value.path === '/admin/teams/roles'">
<div class="flex justify-end mb-5"> <div class="flex justify-end mb-5">
@@ -27,7 +27,7 @@ import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import PageHeader from '@/components/admin/common/PageHeader.vue'
const { toast } = useToast() const { toast } = useToast()
const emit = useEmitter() const emit = useEmitter()

View File

@@ -1,5 +1,5 @@
<template> <template>
<PageHeader title="Teams" description="Manage teams" />
<div class="w-8/12"> <div class="w-8/12">
<div v-if="router.currentRoute.value.path === '/admin/teams/teams'"> <div v-if="router.currentRoute.value.path === '/admin/teams/teams'">
<div class="flex justify-end mb-5"> <div class="flex justify-end mb-5">
@@ -27,7 +27,7 @@ import { Button } from '@/components/ui/button'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb' import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import DataTable from '@/components/admin/DataTable.vue' import DataTable from '@/components/admin/DataTable.vue'
import api from '@/api' import api from '@/api'
import PageHeader from '@/components/admin/common/PageHeader.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'

View File

@@ -1,5 +1,5 @@
<template> <template>
<PageHeader title="Users" description="Manage users" />
<div class="w-8/12"> <div class="w-8/12">
<div v-if="router.currentRoute.value.path === '/admin/teams/users'"> <div v-if="router.currentRoute.value.path === '/admin/teams/users'">
<div class="flex justify-end mb-5"> <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 { useRouter } from 'vue-router'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb' import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import PageHeader from '@/components/admin/common/PageHeader.vue'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api' import api from '@/api'

View File

@@ -1,5 +1,5 @@
<template> <template>
<PageHeader title="Email templates" description="Manage email templates" />
<div class="w-8/12"> <div class="w-8/12">
<template v-if="router.currentRoute.value.path === '/admin/templates'"> <template v-if="router.currentRoute.value.path === '/admin/templates'">
<div class="flex justify-between mb-5"> <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 DataTable from '@/components/admin/DataTable.vue'
import { emailNotificationTemplates, outgoingEmailTemplatesColumns } from '@/components/admin/templates/dataTableColumns.js' import { emailNotificationTemplates, outgoingEmailTemplatesColumns } from '@/components/admin/templates/dataTableColumns.js'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import PageHeader from '@/components/admin/common/PageHeader.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'

View File

@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<PageHeader title="Uploads" description="Manage file upload settings" />
</div> </div>
<div> <div>
<component <component
@@ -18,7 +18,7 @@ import { ref, onMounted, computed } from 'vue'
import S3Form from './S3Form.vue' import S3Form from './S3Form.vue'
import LocalFsForm from './LocalFsForm.vue' import LocalFsForm from './LocalFsForm.vue'
import api from '@/api' import api from '@/api'
import PageHeader from '../common/PageHeader.vue'
const initialValues = ref({}) const initialValues = ref({})

View File

@@ -1,25 +1,22 @@
<template> <template>
<div> <div v-if="!isHidden">
<span class="text-2xl"> <div class="flex items-center space-x-4 p-4">
{{ title }} <SidebarTrigger class="cursor-pointer w-5 h-5" />
</span> <span class="text-2xl font-semibold">
<p class="text-sm-muted"> {{ title }}
{{ subTitle }} </span>
</p> </div>
<Separator />
</div> </div>
<Separator class="my-3" />
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
defineProps({ import { SidebarTrigger } from '@/components/ui/sidebar'
title: { import { useRoute } from 'vue-router'
type: String,
required: true const route = useRoute()
}, const title = computed(() => route.meta.title || '')
subTitle: { const isHidden = computed(() => route.meta.hidePageHeader === true)
type: String,
required: true
}
})
</script> </script>

View File

@@ -1,8 +1,7 @@
<template> <template>
<div class="relative" v-if="conversationStore.messages.data"> <div v-if="conversationStore.messages.data">
<!-- Header --> <!-- 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="flex items-center space-x-3 text-sm">
<div class="font-medium"> <div class="font-medium">
{{ conversationStore.current.subject }} {{ conversationStore.current.subject }}
@@ -27,16 +26,16 @@
</div> </div>
<!-- Messages & reply box --> <!-- Messages & reply box -->
<div class="flex flex-col h-screen" v-auto-animate> <div class="flex flex-col h-[calc(100vh-theme(spacing.10))]">
<MessageList class="flex-1" /> <MessageList class="flex-1 overflow-y-auto" />
<ReplyBox class="h-max mb-12" /> <div class="sticky bottom-0 bg-white">
<ReplyBox class="h-max" />
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import { import {
DropdownMenu, DropdownMenu,

View File

@@ -1,20 +1,30 @@
<template> <template>
<div class="max-h-[600px] overflow-y-auto"> <div class="max-h-[600px] overflow-y-auto">
<BubbleMenu :editor="editor" :tippy-options="{ duration: 100 }" v-if="editor"> <BubbleMenu :editor="editor" :tippy-options="{ duration: 100 }" v-if="editor" class="bg-white p-1 box rounded-lg">
<div class="BubbleMenu"> <div class="flex space-x-1 items-center">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<Button size="sm" variant="outline"> <Button size="sm" variant="ghost" class="flex items-center justify-center">
AI <span class="flex items-center">
<ChevronDown class="w-4 h-4 ml-2" /> <span class="text-medium">AI</span>
<Bot size="14" class="ml-1" />
<ChevronDown class="w-4 h-4 ml-2" />
</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem>Make friendly</DropdownMenuItem> <DropdownMenuItem v-for="prompt in aiPrompts" :key="prompt.key" @select="emitPrompt(prompt.key)">
<DropdownMenuItem>Make formal</DropdownMenuItem> {{ prompt.title }}
<DropdownMenuItem>Make casual</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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> </div>
</BubbleMenu> </BubbleMenu>
<EditorContent :editor="editor" /> <EditorContent :editor="editor" />
@@ -24,7 +34,7 @@
<script setup> <script setup>
import { ref, watch, watchEffect, onUnmounted } from 'vue' import { ref, watch, watchEffect, onUnmounted } from 'vue'
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/vue-3' 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 { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
@@ -37,93 +47,87 @@ import Image from '@tiptap/extension-image'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
const emit = defineEmits([ const selectedText = defineModel('selectedText', { default: '' })
'send', const textContent = defineModel('textContent')
'editorText', const htmlContent = defineModel('htmlContent')
'updateBold', const isBold = defineModel('isBold')
'updateItalic', const isItalic = defineModel('isItalic')
'contentCleared', const cursorPosition = defineModel('cursorPosition', {
'contentSet', default: 0
'editorReady' })
])
const props = defineProps({ const props = defineProps({
placeholder: String, placeholder: String,
isBold: Boolean,
isItalic: Boolean,
clearContent: 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( const editor = ref(
useEditor({ useEditor({
content: '', content: textContent.value,
extensions: [ extensions: [
StarterKit, StarterKit,
Image.configure({ Image.configure({
HTMLAttributes: { HTMLAttributes: { class: 'inline-image' }
// Common class for all inline images.
class: 'inline-image',
},
}), }),
Placeholder.configure({ Placeholder.configure({
placeholder: () => { placeholder: () => props.placeholder
return props.placeholder
}
}), }),
Link, Link
], ],
autofocus: true, autofocus: true,
editorProps: { editorProps: { attributes: { class: 'outline-none' } },
attributes: { onSelectionUpdate: ({ editor }) => {
// No outline for the editor. selectedText.value = editor.state.doc.textBetween(
class: 'outline-none' 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(() => { watchEffect(() => {
if (editor.value) { if (editor.value) {
// Emit the editor instance when it's ready emit('editorReady', editor.value)
if (editor.value) { isBold.value = editor.value.isActive('bold')
emit('editorReady', editor.value) isItalic.value = editor.value.isActive('italic')
}
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'))
} }
}) })
// Watcher for bold and italic changes
watchEffect(() => { watchEffect(() => {
if (props.isBold !== editor.value?.isActive('bold')) { if (isBold.value !== editor.value?.isActive('bold')) {
if (props.isBold) { isBold.value
editor.value?.chain().focus().setBold().run() ? editor.value?.chain().focus().setBold().run()
} else { : editor.value?.chain().focus().unsetBold().run()
editor.value?.chain().focus().unsetBold().run()
}
} }
if (props.isItalic !== editor.value?.isActive('italic')) { if (isItalic.value !== editor.value?.isActive('italic')) {
if (props.isItalic) { isItalic.value
editor.value?.chain().focus().setItalic().run() ? editor.value?.chain().focus().setItalic().run()
} else { : editor.value?.chain().focus().unsetItalic().run()
editor.value?.chain().focus().unsetItalic().run()
}
}
})
// Watcher for clearContent prop
watchEffect(() => {
if (props.clearContent) {
editor.value?.commands.clearContent()
emit('contentCleared')
} }
}) })
@@ -131,14 +135,19 @@ watch(
() => props.contentToSet, () => props.contentToSet,
(newContent) => { (newContent) => {
if (newContent) { if (newContent) {
// Remove trailing break when setting content console.log('Setting content to -:', newContent)
editor.value.commands.setContent(newContent) editor.value.commands.setContent(newContent)
editor.value.commands.focus() 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(() => { onUnmounted(() => {
editor.value.destroy() editor.value.destroy()
}) })
@@ -176,4 +185,4 @@ onUnmounted(() => {
br.ProseMirror-trailingBreak { br.ProseMirror-trailingBreak {
display: none; display: none;
} }
</style> </style>

View File

@@ -1,9 +1,34 @@
<template> <template>
<div> <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 --> <!-- Canned responses on non-fullscreen editor -->
<div v-if="filteredCannedResponses.length > 0" class="w-full overflow-hidden p-2 border-t backdrop-blur"> <div v-if="filteredCannedResponses.length > 0 && !isEditorFullscreen"
<ul ref="responsesList" class="space-y-2 max-h-96 overflow-y-auto"> 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="[ <li v-for="(response, index) in filteredCannedResponses" :key="response.id" :class="[
'cursor-pointer rounded p-1 hover:bg-secondary', 'cursor-pointer rounded p-1 hover:bg-secondary',
{ 'bg-secondary': index === selectedResponseIndex } { 'bg-secondary': index === selectedResponseIndex }
@@ -13,24 +38,28 @@
</li> </li>
</ul> </ul>
</div> </div>
<div class="border-t">
<!-- Main Editor non-fullscreen -->
<div class="border-t" v-if="!isEditorFullscreen">
<!-- Message type toggle --> <!-- Message type toggle -->
<div class="flex justify-between px-2 border-b py-2"> <div class="flex justify-between px-2 border-b py-2">
<Tabs v-model:model-value="messageType"> <Tabs v-model="messageType">
<TabsList> <TabsList>
<TabsTrigger value="reply"> Reply </TabsTrigger> <TabsTrigger value="reply"> Reply </TabsTrigger>
<TabsTrigger value="private_note"> Private note </TabsTrigger> <TabsTrigger value="private_note"> Private note </TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
<div class="flex items-center mr-2 cursor-pointer" @click="isEditorFullscreen = !isEditorFullscreen">
<Fullscreen size="20" />
</div>
</div> </div>
<!-- Main Editor --> <!-- Main Editor -->
<Editor @keydown="handleKeydown" @editorText="handleEditorText" :placeholder="editorPlaceholder" :isBold="isBold" <Editor v-model:selectedText="selectedText" v-model:isBold="isBold" v-model:isItalic="isItalic"
:clearContent="clearContent" :isItalic="isItalic" @updateBold="updateBold" @updateItalic="updateItalic" v-model:htmlContent="htmlContent" v-model:textContent="textContent" :placeholder="editorPlaceholder"
@contentCleared="handleContentCleared" @contentSet="clearContentToSet" @editorReady="onEditorReady" :aiPrompts="aiPrompts" @keydown="handleKeydown" @aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet" :cannedResponses="cannedResponses" /> @editorReady="onEditorReady" @clearContent="clearContent" :contentToSet="contentToSet"
v-model:cursorPosition="cursorPosition" />
<!-- Attachments preview --> <!-- Attachments preview -->
<AttachmentsPreview :attachments="attachments" :onDelete="handleOnFileDelete"></AttachmentsPreview> <AttachmentsPreview :attachments="attachments" :onDelete="handleOnFileDelete"></AttachmentsPreview>
@@ -40,58 +69,97 @@
:isBold="isBold" :isItalic="isItalic" @toggleBold="toggleBold" @toggleItalic="toggleItalic" :hasText="hasText" :isBold="isBold" :isItalic="isItalic" @toggleBold="toggleBold" @toggleItalic="toggleItalic" :hasText="hasText"
:handleSend="handleSend"> :handleSend="handleSend">
</ReplyBoxBottomMenuBar> </ReplyBoxBottomMenuBar>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, watch } from 'vue'
import { transformImageSrcToCID } from '@/utils/strings' import { transformImageSrcToCID } from '@/utils/strings'
import { handleHTTPError } from '@/utils/http' import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { Fullscreen } from 'lucide-vue-next';
import api from '@/api' import api from '@/api'
import { getTextFromHTML } from '@/utils/strings' import { getTextFromHTML } from '@/utils/strings'
import Editor from './ConversationTextEditor.vue' import Editor from './ConversationTextEditor.vue'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import AttachmentsPreview from '@/components/attachment/AttachmentsPreview.vue' import AttachmentsPreview from '@/components/attachment/AttachmentsPreview.vue'
import ReplyBoxBottomMenuBar from '@/components/conversation/ReplyBoxMenuBar.vue' import ReplyBoxBottomMenuBar from '@/components/conversation/ReplyBoxMenuBar.vue'
const conversationStore = useConversationStore()
const emitter = useEmitter() 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 clearContent = ref(false)
const contentToSet = ref('')
const isBold = ref(false) const isBold = ref(false)
const isItalic = 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 uploadedFiles = ref([])
const messageType = ref('reply') const messageType = ref('reply')
const filteredCannedResponses = ref([])
const selectedResponseIndex = ref(-1) const selectedResponseIndex = ref(-1)
const responsesList = ref(null) const cannedResponsesRef = ref(null)
let editorInstance = ref(null) const cannedResponses = ref([])
const aiPrompts = ref([])
onMounted(async () => { 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 { try {
const resp = await api.getCannedResponses() const resp = await api.getCannedResponses()
cannedResponses.value = resp.data.data cannedResponses.value = resp.data.data
} catch (error) { } 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) => { const handleAiPromptSelected = async (key) => {
isItalic.value = newState 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 = () => { const toggleBold = () => {
@@ -110,6 +178,11 @@ const attachments = computed(() => {
return uploadedFiles.value.filter(upload => upload.disposition === 'attachment') 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) => { const filterCannedResponses = (input) => {
// Extract the text after the last `/` // Extract the text after the last `/`
const lastSlashIndex = input.lastIndexOf('/') 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(() => { const hasText = computed(() => {
return editorText.value.length > 0 ? true : false return textContent.value.trim().length > 0 ? true : false
}) })
const onEditorReady = (editor) => { const onEditorReady = (editor) => {
@@ -156,7 +223,7 @@ const handleFileUpload = (event) => {
}) })
.catch((error) => { .catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error uploading file', title: 'Error',
variant: 'destructive', variant: 'destructive',
description: handleHTTPError(error).message description: handleHTTPError(error).message
}) })
@@ -182,7 +249,7 @@ const handleInlineImageUpload = (event) => {
}) })
.catch((error) => { .catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error uploading file', title: 'Error',
variant: 'destructive', variant: 'destructive',
description: handleHTTPError(error).message description: handleHTTPError(error).message
}) })
@@ -190,18 +257,14 @@ const handleInlineImageUpload = (event) => {
} }
} }
const handleContentCleared = () => {
clearContent.value = false
}
const handleSend = async () => { const handleSend = async () => {
try { try {
// Replace inline image url with cid. // 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. // Check which images are still in editor before sending.
const parser = new DOMParser() 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')) const inlineImageUUIDs = Array.from(doc.querySelectorAll('img.inline-image'))
.map(img => img.getAttribute('title')) .map(img => img.getAttribute('title'))
.filter(Boolean) .filter(Boolean)
@@ -220,7 +283,7 @@ const handleSend = async () => {
}) })
} catch (error) { } catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error sending message', title: 'Error',
variant: 'destructive', variant: 'destructive',
description: handleHTTPError(error).message description: handleHTTPError(error).message
}) })
@@ -256,7 +319,7 @@ const handleKeydown = (event) => {
} }
const scrollToSelectedItem = () => { const scrollToSelectedItem = () => {
const list = responsesList.value const list = cannedResponsesRef.value
const selectedItem = list.children[selectedResponseIndex.value] const selectedItem = list.children[selectedResponseIndex.value]
if (selectedItem) { if (selectedItem) {
selectedItem.scrollIntoView({ selectedItem.scrollIntoView({
@@ -271,8 +334,4 @@ const selectCannedResponse = (content) => {
filteredCannedResponses.value = [] filteredCannedResponses.value = []
selectedResponseIndex.value = -1 selectedResponseIndex.value = -1
} }
const clearContentToSet = () => {
contentToSet.value = null
}
</script> </script>

View File

@@ -1,16 +1,16 @@
<template> <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"> <div class="flex justify-items-start gap-2">
<!-- File inputs --> <!-- File inputs -->
<input type="file" class="hidden" ref="attachmentInput" multiple @change="handleFileUpload" /> <input type="file" class="hidden" ref="attachmentInput" multiple @change="handleFileUpload" />
<input type="file" class="hidden" ref="inlineImageInput" accept="image/*" @change="handleInlineImageUpload" /> <input type="file" class="hidden" ref="inlineImageInput" accept="image/*" @change="handleInlineImageUpload" />
<!-- Editor buttons --> <!-- 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" /> <Bold class="h-4 w-4" />
</Toggle> </Toggle>
<Toggle class="px-2 py-2 border-0" variant="outline" @click="toggleItalic" :pressed="isItalic"> <Toggle class="px-2 py-2 border-0" variant="outline" @click="toggleItalic" :pressed="isItalic">
<Italic class="h-4 w-4" /> <Italic class="h-4 w-4" />
</Toggle> </Toggle> -->
<Toggle class="px-2 py-2 border-0" variant="outline" @click="triggerFileUpload" :pressed="false"> <Toggle class="px-2 py-2 border-0" variant="outline" @click="triggerFileUpload" :pressed="false">
<Paperclip class="h-4 w-4" /> <Paperclip class="h-4 w-4" />
</Toggle> </Toggle>
@@ -26,11 +26,11 @@
import { ref } from 'vue' import { ref } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Toggle } from '@/components/ui/toggle' 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 attachmentInput = ref(null)
const inlineImageInput = ref(null) const inlineImageInput = ref(null)
const emit = defineEmits(['toggleBold', 'toggleItalic']) // const emit = defineEmits(['toggleBold', 'toggleItalic'])
defineProps({ defineProps({
isBold: Boolean, isBold: Boolean,
isItalic: Boolean, isItalic: Boolean,
@@ -40,13 +40,13 @@ defineProps({
handleInlineImageUpload: Function handleInlineImageUpload: Function
}) })
const toggleBold = () => { // const toggleBold = () => {
emit('toggleBold') // emit('toggleBold')
} // }
const toggleItalic = () => { // const toggleItalic = () => {
emit('toggleItalic') // emit('toggleItalic')
} // }
const triggerFileUpload = () => { const triggerFileUpload = () => {
attachmentInput.value.click() attachmentInput.value.click()

View File

@@ -1,5 +1,10 @@
<template> <template>
<div class="h-screen flex flex-col"> <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"> <div class="flex justify-between px-2 py-2 w-full">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger class="cursor-pointer"> <DropdownMenuTrigger class="cursor-pointer">
@@ -84,11 +89,12 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { SidebarTrigger } from '@/components/ui/sidebar'
import EmptyList from '@/components/conversation/list/ConversationEmptyList.vue' import EmptyList from '@/components/conversation/list/ConversationEmptyList.vue'
import ConversationListItem from '@/components/conversation/list/ConversationListItem.vue' import ConversationListItem from '@/components/conversation/list/ConversationListItem.vue'
import { useRoute } from 'vue-router'
import ConversationListItemSkeleton from '@/components/conversation/list/ConversationListItemSkeleton.vue' import ConversationListItemSkeleton from '@/components/conversation/list/ConversationListItemSkeleton.vue'
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
let reFetchInterval = null let reFetchInterval = null
@@ -100,6 +106,9 @@ onMounted(() => {
}, 30000) }, 30000)
}) })
const route = useRoute()
const title = computed(() => route.meta.title || '')
onUnmounted(() => { onUnmounted(() => {
clearInterval(reFetchInterval) clearInterval(reFetchInterval)
conversationStore.clearListReRenderInterval() conversationStore.clearListReRenderInterval()

View File

@@ -1,18 +1,16 @@
<template> <template>
<div class="flex"> <div class="flex flex-1 flex-col gap-x-5 box p-5 rounded-md space-y-5">
<div class="flex flex-col gap-x-5 box p-5 rounded-md space-y-5"> <div class="flex items-center space-x-2">
<div class="flex items-center space-x-2"> <p class="text-2xl">{{ title }}</p>
<p class="text-2xl">{{title}}</p> <div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded"> <span class="blinking-dot"></span>
<span class="blinking-dot"></span> <p class="uppercase text-xs">Live</p>
<strong class="uppercase tracking-wider">Live</strong>
</div>
</div> </div>
<div class="flex"> </div>
<div v-for="(value, key) in counts" :key="key" class="flex flex-col items-center gap-y-2"> <div class="flex justify-between pr-32">
<span class="text-muted-foreground">{{ labels[key] }}</span> <div v-for="(value, key) in counts" :key="key" class="flex flex-col items-center gap-y-2">
<span class="text-2xl font-medium">{{ value }}</span> <span class="text-muted-foreground">{{ labels[key] }}</span>
</div> <span class="text-2xl font-medium">{{ value }}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,14 +1,10 @@
<template> <template>
<LineChart :data="chartData" index="date" :categories="['New conversations']" :y-formatter="(tick) => { <LineChart :data="data" index="date" :categories="['New conversations', 'Resolved conversations', 'Messages sent']"
return tick :x-formatter="xFormatter" :y-formatter="yFormatter" />
}
" />
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'
import { LineChart } from '@/components/ui/chart-line' import { LineChart } from '@/components/ui/chart-line'
const props = defineProps({ const props = defineProps({
data: { data: {
type: Array, type: Array,
@@ -16,10 +12,12 @@ const props = defineProps({
} }
}) })
const chartData = computed(() => const xFormatter = (tick) => {
props.data.map(item => ({ return props.data[tick]?.date ?? ''
date: item.date, }
"New conversations": item.new_conversations
})) const yFormatter = (tick) => {
) return Number.isInteger(tick) ? tick : ''
}
</script> </script>

View File

@@ -30,7 +30,7 @@
<!-- Sticky container for the scroll arrow --> <!-- Sticky container for the scroll arrow -->
<div v-show="!isAtBottom" class="sticky bottom-6 flex justify-end px-6"> <div v-show="!isAtBottom" class="sticky bottom-6 flex justify-end px-6">
<div class="relative"> <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" /> <ArrowDown size="20" />
</button> </button>
<span v-if="unReadMessages > 0" <span v-if="unReadMessages > 0"

View File

@@ -55,46 +55,46 @@ const routes = [
path: '/inboxes', path: '/inboxes',
name: 'inboxes', name: 'inboxes',
redirect: '/inboxes/assigned', redirect: '/inboxes/assigned',
meta: { title: 'Inboxes' }, meta: { title: 'Inbox', hidePageHeader: true },
children: [ children: [
{ {
path: ':type(assigned|unassigned|all)', path: ':type(assigned|unassigned|all)',
name: 'inbox', name: 'inbox',
component: ConversationsView, component: ConversationsView,
props: route => ({ type: route.params.type, uuid: route.params.uuid }), 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: [ children: [
{ {
path: 'conversation/:uuid', path: 'conversation/:uuid',
name: 'inbox-conversation', name: 'inbox-conversation',
component: ConversationsView, component: ConversationsView,
props: true, props: true,
meta: { title: 'Conversation' } meta: { title: 'Inbox', hidePageHeader: true }
} }
] ]
} }
] ]
}, },
{ {
path: '/teams', path: '/teams',
name: 'teams', name: 'teams',
redirect: '/teams/:teamID', redirect: '/teams/:teamID',
meta: { title: 'Teams' }, meta: { title: 'Team inbox', hidePageHeader: true },
children: [ children: [
{ {
path: ':teamID', path: ':teamID',
name: 'team-inbox', name: 'team-inbox',
props: true, props: true,
component: ConversationsView, component: ConversationsView,
meta: route => ({ title: `Team ${route.params.teamID} inbox` }), meta: { title: `Team inbox` },
children: [ children: [
{ {
path: 'conversation/:uuid', path: 'conversation/:uuid',
name: 'team-inbox-conversation', name: 'team-inbox-conversation',
component: ConversationsView, component: ConversationsView,
props: true, props: true,
meta: { title: 'Conversation' } meta: { title: 'Team inbox', hidePageHeader: true }
} }
] ]
} }
@@ -104,21 +104,21 @@ const routes = [
path: '/views', path: '/views',
name: 'views', name: 'views',
redirect: '/views/:viewID', redirect: '/views/:viewID',
meta: { title: 'Views' }, meta: { title: 'View', hidePageHeader: true },
children: [ children: [
{ {
path: ':viewID', path: ':viewID',
name: 'view-inbox', name: 'view-inbox',
props: true, props: true,
component: ConversationsView, component: ConversationsView,
meta: route => ({ title: `View ${route.params.viewID} inbox` }), meta: { title: `View` },
children: [ children: [
{ {
path: 'conversation/:uuid', path: 'conversation/:uuid',
name: 'view-inbox-conversation', name: 'view-inbox-conversation',
component: ConversationsView, component: ConversationsView,
props: true, props: true,
meta: { title: 'Conversation' } meta: { title: 'View', hidePageHeader: true }
} }
] ]
} }
@@ -148,119 +148,119 @@ const routes = [
{ {
path: 'business-hours', path: 'business-hours',
component: () => import('@/components/admin/business_hours/BusinessHours.vue'), component: () => import('@/components/admin/business_hours/BusinessHours.vue'),
meta: { title: 'Admin - Business Hours' }, meta: { title: 'Business Hours' },
children: [ children: [
{ {
path: 'new', path: 'new',
component: () => import('@/components/admin/business_hours/CreateOrEditBusinessHours.vue'), component: () => import('@/components/admin/business_hours/CreateOrEditBusinessHours.vue'),
meta: { title: 'Admin - Add Business Hours' } meta: { title: 'Add Business Hours' }
}, },
{ {
path: ':id/edit', path: ':id/edit',
name: 'edit-business-hours', name: 'edit-business-hours',
props: true, props: true,
component: () => import('@/components/admin/business_hours/CreateOrEditBusinessHours.vue'), component: () => import('@/components/admin/business_hours/CreateOrEditBusinessHours.vue'),
meta: { title: 'Admin - Edit Business Hours' } meta: { title: 'Edit Business Hours' }
}, },
] ]
}, },
{ {
path: 'sla', path: 'sla',
component: () => import('@/components/admin/sla/SLA.vue'), component: () => import('@/components/admin/sla/SLA.vue'),
meta: { title: 'Admin - SLA' }, meta: { title: 'SLA' },
children: [ children: [
{ {
path: 'new', path: 'new',
component: () => import('@/components/admin/sla/CreateEditSLA.vue'), component: () => import('@/components/admin/sla/CreateEditSLA.vue'),
meta: { title: 'Admin - Add SLA' } meta: { title: 'Add SLA' }
}, },
{ {
path: ':id/edit', path: ':id/edit',
props: true, props: true,
component: () => import('@/components/admin/sla/CreateEditSLA.vue'), component: () => import('@/components/admin/sla/CreateEditSLA.vue'),
meta: { title: 'Admin - Edit SLA' } meta: { title: 'Edit SLA' }
}, },
] ]
}, },
{ {
path: 'inboxes', path: 'inboxes',
component: () => import('@/components/admin/inbox/Inbox.vue'), component: () => import('@/components/admin/inbox/Inbox.vue'),
meta: { title: 'Admin - Inboxes' }, meta: { title: 'Inboxes' },
children: [ children: [
{ {
path: 'new', path: 'new',
component: () => import('@/components/admin/inbox/NewInbox.vue'), component: () => import('@/components/admin/inbox/NewInbox.vue'),
meta: { title: 'Admin - New Inbox' } meta: { title: 'New Inbox' }
}, },
{ {
path: ':id/edit', path: ':id/edit',
props: true, props: true,
component: () => import('@/components/admin/inbox/EditInbox.vue'), component: () => import('@/components/admin/inbox/EditInbox.vue'),
meta: { title: 'Admin - Edit Inbox' } meta: { title: 'Edit Inbox' }
}, },
], ],
}, },
{ {
path: 'notification', path: 'notification',
component: () => import('@/components/admin/notification/NotificationSetting.vue'), component: () => import('@/components/admin/notification/NotificationSetting.vue'),
meta: { title: 'Admin - Notification Settings' } meta: { title: 'Notification Settings' }
}, },
{ {
path: 'teams', path: 'teams',
meta: { title: 'Admin - Teams' }, meta: { title: 'Teams' },
children: [ children: [
{ {
path: 'users', path: 'users',
component: () => import('@/components/admin/team/users/UsersCard.vue'), component: () => import('@/components/admin/team/users/UsersCard.vue'),
meta: { title: 'Admin - Users' }, meta: { title: 'Users' },
children: [ children: [
{ {
path: 'new', path: 'new',
component: () => import('@/components/admin/team/users/AddUserForm.vue'), component: () => import('@/components/admin/team/users/AddUserForm.vue'),
meta: { title: 'Admin - Create User' } meta: { title: 'Create User' }
}, },
{ {
path: ':id/edit', path: ':id/edit',
props: true, props: true,
component: () => import('@/components/admin/team/users/EditUserForm.vue'), component: () => import('@/components/admin/team/users/EditUserForm.vue'),
meta: { title: 'Admin - Edit User' } meta: { title: 'Edit User' }
}, },
] ]
}, },
{ {
path: 'teams', path: 'teams',
component: () => import('@/components/admin/team/teams/Teams.vue'), component: () => import('@/components/admin/team/teams/Teams.vue'),
meta: { title: 'Admin - Teams Management' }, meta: { title: 'Teams Management' },
children: [ children: [
{ {
path: 'new', path: 'new',
component: () => import('@/components/admin/team/teams/CreateTeamForm.vue'), component: () => import('@/components/admin/team/teams/CreateTeamForm.vue'),
meta: { title: 'Admin - Create Team' } meta: { title: 'Create Team' }
}, },
{ {
path: ':id/edit', path: ':id/edit',
props: true, props: true,
component: () => import('@/components/admin/team/teams/EditTeamForm.vue'), component: () => import('@/components/admin/team/teams/EditTeamForm.vue'),
meta: { title: 'Admin - Edit Team' } meta: { title: 'Edit Team' }
}, },
] ]
}, },
{ {
path: 'roles', path: 'roles',
component: () => import('@/components/admin/team/roles/Roles.vue'), component: () => import('@/components/admin/team/roles/Roles.vue'),
meta: { title: 'Admin - Roles' }, meta: { title: 'Roles' },
children: [ children: [
{ {
path: 'new', path: 'new',
component: () => import('@/components/admin/team/roles/NewRole.vue'), component: () => import('@/components/admin/team/roles/NewRole.vue'),
meta: { title: 'Admin - Create Role' } meta: { title: 'Create Role' }
}, },
{ {
path: ':id/edit', path: ':id/edit',
props: true, props: true,
component: () => import('@/components/admin/team/roles/EditRole.vue'), 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', path: 'automations',
component: () => import('@/components/admin/automation/Automation.vue'), component: () => import('@/components/admin/automation/Automation.vue'),
meta: { title: 'Admin - Automations' }, meta: { title: 'Automations' },
children: [ children: [
{ {
path: 'new', path: 'new',
props: true, props: true,
component: () => import('@/components/admin/automation/CreateOrEditRule.vue'), component: () => import('@/components/admin/automation/CreateOrEditRule.vue'),
meta: { title: 'Admin - Create Automation' } meta: { title: 'Create Automation' }
}, },
{ {
path: ':id/edit', path: ':id/edit',
props: true, props: true,
component: () => import('@/components/admin/automation/CreateOrEditRule.vue'), component: () => import('@/components/admin/automation/CreateOrEditRule.vue'),
meta: { title: 'Admin - Edit Automation' } meta: { title: 'Edit Automation' }
} }
] ]
}, },
{ {
path: 'general', path: 'general',
component: () => import('@/components/admin/general/General.vue'), component: () => import('@/components/admin/general/General.vue'),
meta: { title: 'Admin - General Settings' } meta: { title: 'General Settings' }
}, },
{ {
path: 'templates', path: 'templates',
component: () => import('@/components/admin/templates/Templates.vue'), component: () => import('@/components/admin/templates/Templates.vue'),
meta: { title: 'Admin - Templates' }, meta: { title: 'Templates' },
children: [ children: [
{ {
path: ':id/edit', path: ':id/edit',
props: true, props: true,
component: () => import('@/components/admin/templates/AddEditTemplate.vue'), component: () => import('@/components/admin/templates/AddEditTemplate.vue'),
meta: { title: 'Admin - Edit Template' } meta: { title: 'Edit Template' }
}, },
{ {
path: 'new', path: 'new',
component: () => import('@/components/admin/templates/AddEditTemplate.vue'), component: () => import('@/components/admin/templates/AddEditTemplate.vue'),
meta: { title: 'Admin - Add Template' } meta: { title: 'Add Template' }
} }
] ]
}, },
{ {
path: 'oidc', path: 'oidc',
component: () => import('@/components/admin/oidc/OIDC.vue'), component: () => import('@/components/admin/oidc/OIDC.vue'),
meta: { title: 'Admin - OIDC' }, meta: { title: 'OIDC' },
children: [ children: [
{ {
path: ':id/edit', path: ':id/edit',
props: true, props: true,
component: () => import('@/components/admin/oidc/AddEditOIDC.vue'), component: () => import('@/components/admin/oidc/AddEditOIDC.vue'),
meta: { title: 'Admin - Edit OIDC' } meta: { title: 'Edit OIDC' }
}, },
{ {
path: 'new', path: 'new',
component: () => import('@/components/admin/oidc/AddEditOIDC.vue'), component: () => import('@/components/admin/oidc/AddEditOIDC.vue'),
meta: { title: 'Admin - Add OIDC' } meta: { title: 'Add OIDC' }
} }
] ]
}, },
{ {
path: 'conversations', path: 'conversations',
meta: { title: 'Admin - Conversations' }, meta: { title: 'Conversations' },
children: [ children: [
{ {
path: 'tags', path: 'tags',
component: () => import('@/components/admin/conversation/tags/Tags.vue'), component: () => import('@/components/admin/conversation/tags/Tags.vue'),
meta: { title: 'Admin - Conversation Tags' } meta: { title: 'Conversation Tags' }
}, },
{ {
path: 'statuses', path: 'statuses',
component: () => import('@/components/admin/conversation/status/Status.vue'), component: () => import('@/components/admin/conversation/status/Status.vue'),
meta: { title: 'Admin - Conversation Statuses' } meta: { title: 'Conversation Statuses' }
}, },
{ {
path: 'canned-responses', path: 'canned-responses',
component: () => import('@/components/admin/conversation/canned_responses/CannedResponses.vue'), component: () => import('@/components/admin/conversation/canned_responses/CannedResponses.vue'),
meta: { title: 'Admin - Canned Responses' } meta: { title: 'Canned Responses' }
} }
] ]
} }

View File

@@ -1,19 +1,16 @@
<template> <template>
<div class="page-content"> <div class="page-content">
<PageHeader title="Overview" />
<Spinner v-if="isLoading"></Spinner> <Spinner v-if="isLoading"></Spinner>
<div class="mt-7 flex w-full space-x-4" v-auto-animate> <div class="space-y-4">
<Card class="flex-1" title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" /> <div class="mt-7 flex w-full space-x-4" v-auto-animate>
<Card class="flex-1" title="Agent status" :counts="sampleAgentStatusCounts" :labels="sampleAgentStatusLabels" /> <Card title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" />
</div> <Card class="w-8/12" title="Agent status" :counts="sampleAgentStatusCounts" :labels="sampleAgentStatusLabels" />
<div class="w-11/12" :class="{ 'soft-fade': isLoading }"> </div>
<div class="flex my-7 justify-between items-center space-x-5"> <div class="dashboard-card p-5">
<div class="dashboard-card p-5"> <LineChart :data="chartData.processedData"></LineChart>
<LineChart :data="chartData.new_conversations"></LineChart> </div>
</div> <div class="dashboard-card p-5">
<div class="dashboard-card p-5"> <BarChart :data="chartData.status_summary"></BarChart>
<BarChart :data="chartData.status_summary"></BarChart>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -28,7 +25,7 @@ import { vAutoAnimate } from '@formkit/auto-animate/vue'
import Card from '@/components/dashboard/DashboardCard.vue' import Card from '@/components/dashboard/DashboardCard.vue'
import LineChart from '@/components/dashboard/DashboardLineChart.vue' import LineChart from '@/components/dashboard/DashboardLineChart.vue'
import BarChart from '@/components/dashboard/DashboardBarChart.vue' import BarChart from '@/components/dashboard/DashboardBarChart.vue'
import PageHeader from '@/components/admin/common/PageHeader.vue'
import Spinner from '@/components/ui/spinner/Spinner.vue' import Spinner from '@/components/ui/spinner/Spinner.vue'
const { toast } = useToast() const { toast } = useToast()
@@ -36,10 +33,10 @@ const isLoading = ref(false)
const cardCounts = ref({}) const cardCounts = ref({})
const chartData = ref({}) const chartData = ref({})
const agentCountCardsLabels = { const agentCountCardsLabels = {
total_count: 'Total', open: 'Total',
resolved_count: 'Resolved', awaiting_response: 'Awaiting Response',
unresolved_count: 'Unresolved', unassigned: 'Unassigned',
awaiting_response_count: 'Awaiting Response' pending: 'Pending'
} }
const sampleAgentStatusLabels = { const sampleAgentStatusLabels = {
online: 'Online', online: 'Online',
@@ -65,7 +62,7 @@ const getDashboardData = () => {
}) })
} }
const getCardStats = () => { const getCardStats = async () => {
return api.getOverviewCounts() return api.getOverviewCounts()
.then((resp) => { .then((resp) => {
cardCounts.value = resp.data.data cardCounts.value = resp.data.data
@@ -79,10 +76,18 @@ const getCardStats = () => {
}) })
} }
const getDashboardCharts = () => { const getDashboardCharts = async () => {
return api.getOverviewCharts() return api.getOverviewCharts()
.then((resp) => { .then((resp) => {
chartData.value.new_conversations = resp.data.data.new_conversations || [] 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 || [] chartData.value.status_summary = resp.data.data.status_summary || []
}) })
.catch((err) => { .catch((err) => {

View File

@@ -2,6 +2,7 @@
package ai package ai
import ( import (
"database/sql"
"embed" "embed"
"encoding/json" "encoding/json"
@@ -14,18 +15,7 @@ import (
var ( var (
//go:embed queries.sql //go:embed queries.sql
efs embed.FS 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.",
}
) )
// Manager manages LLM providers. // Manager manages LLM providers.
@@ -42,7 +32,9 @@ type Opts struct {
// queries contains prepared SQL queries. // queries contains prepared SQL queries.
type queries struct { 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. // New creates and returns a new instance of the Manager.
@@ -57,19 +49,17 @@ func New(opts Opts) (*Manager, error) {
}, nil }, nil
} }
// SendPromptToProvider sends a prompt to the specified provider and returns the response. // SendPrompt sends a prompt to the default provider and returns the response.
func (m *Manager) SendPromptToProvider(provider, k string, prompt string) (string, error) { 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
// Fetch the system prompt. systemPrompt, err := m.getPrompt(k)
systemPrompt, ok := systemPrompts[k] if err != nil {
if !ok { return "", err
m.lo.Error("invalid system prompt key", "key", k)
return "", envelope.NewError(envelope.InputError, "Invalid system prompt key", nil)
} }
client, err := m.getProviderClient(provider) client, err := m.getDefaultProviderClient()
if err != nil { 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) 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) response, err := client.SendPrompt(payload)
if err != nil { 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 "", envelope.NewError(envelope.GeneralError, "Error sending prompt to provider", nil)
} }
return response, nil return response, nil
} }
// getProviderClient retrieves a ProviderClient for the specified provider. // GetPrompts returns a list of prompts from the database.
func (m *Manager) getProviderClient(providerName string) (ProviderClient, error) { 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 var p models.Provider
if err := m.q.GetProvider.Get(&p, providerName); err != nil { if err := m.q.GetDefaultProvider.Get(&p); err != nil {
m.lo.Error("error fetching provider details", "provider", providerName, "error", err) m.lo.Error("error fetching provider details", "error", err)
return nil, envelope.NewError(envelope.GeneralError, "Error fetching provider details", nil) 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"` APIKey string `json:"api_key"`
}{} }{}
if err := json.Unmarshal([]byte(p.Config), &config); err != nil { 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 nil, envelope.NewError(envelope.GeneralError, "Error parsing provider config", nil)
} }
return NewOpenAIClient(config.APIKey), nil return NewOpenAIClient(config.APIKey), nil

View File

@@ -1,11 +1,22 @@
package models package models
import "time"
type Provider struct { type Provider struct {
ID string `db:"id"` ID string `db:"id"`
CreatedAt string `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt string `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
Name string `db:"name"` Name string `db:"name"`
Provider string `db:"provider"` Provider string `db:"provider"`
Config string `db:"config"` Config string `db:"config"`
IsDefault bool `db:"is_default"` 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"`
} }

View File

@@ -22,11 +22,13 @@ func NewOpenAIClient(apiKey string) *OpenAIClient {
} }
func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) { 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{}{ requestBody := map[string]interface{}{
"model": "text-davinci-003", "model": "gpt-4o-mini",
"prompt": fmt.Sprintf("%s\n\n%s", payload.SystemPrompt, payload.UserPrompt), "messages": []map[string]string{
{"role": "system", "content": payload.SystemPrompt},
{"role": "user", "content": payload.UserPrompt},
},
"max_tokens": 200, "max_tokens": 200,
"temperature": 0.7, "temperature": 0.7,
} }
@@ -57,7 +59,9 @@ func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
var responseBody struct { var responseBody struct {
Choices []struct { Choices []struct {
Text string `json:"text"` Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"` } `json:"choices"`
} }
if err := json.NewDecoder(resp.Body).Decode(&responseBody); err != nil { 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 { 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") return "", fmt.Errorf("no response text found")
} }

View File

@@ -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;

View File

@@ -582,10 +582,10 @@ func (c *Manager) GetDashboardChart(userID, teamID int) (json.RawMessage, error)
cond = " AND assigned_team_id = $1" cond = " AND assigned_team_id = $1"
qArgs = append(qArgs, teamID) 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. // 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 { if err := tx.Get(&stats, query, qArgs...); err != nil {
c.lo.Error("error fetching dashboard charts", "error", err) c.lo.Error("error fetching dashboard charts", "error", err)
return nil, envelope.NewError(envelope.GeneralError, "Error fetching dashboard charts", nil) return nil, envelope.NewError(envelope.GeneralError, "Error fetching dashboard charts", nil)

View File

@@ -227,14 +227,14 @@ WHERE assigned_user_id IS NULL AND assigned_team_id IS NOT NULL;
-- name: get-dashboard-counts -- name: get-dashboard-counts
SELECT json_build_object( SELECT json_build_object(
'resolved_count', COUNT(CASE WHEN s.name = 'Resolved' THEN 1 END), 'open', COUNT(*),
'unresolved_count', COUNT(CASE WHEN s.name NOT IN ('Resolved', 'Closed') THEN 1 END), 'awaiting_response', COUNT(CASE WHEN c.first_reply_at IS NULL THEN 1 END),
'awaiting_response_count', COUNT(CASE WHEN first_reply_at IS NULL THEN 1 END), 'unassigned', COUNT(CASE WHEN c.assigned_user_id IS NULL THEN 1 END),
'total_count', COUNT(*) 'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END)
) )
FROM conversations c FROM conversations c
LEFT JOIN conversation_statuses s ON c.status_id = s.id INNER JOIN conversation_statuses s ON c.status_id = s.id
WHERE 1=1 %s WHERE s.name = 'Open' AND 1=1 %s;
-- name: get-dashboard-charts -- name: get-dashboard-charts
WITH new_conversations AS ( WITH new_conversations AS (
@@ -242,7 +242,7 @@ WITH new_conversations AS (
FROM ( FROM (
SELECT SELECT
TO_CHAR(created_at::date, 'YYYY-MM-DD') AS date, TO_CHAR(created_at::date, 'YYYY-MM-DD') AS date,
COUNT(*) AS new_conversations COUNT(*) AS count
FROM FROM
conversations c conversations c
WHERE 1=1 %s WHERE 1=1 %s
@@ -252,6 +252,21 @@ WITH new_conversations AS (
date date
) agg ) 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 ( status_summary AS (
SELECT json_agg(row_to_json(agg)) AS data SELECT json_agg(row_to_json(agg)) AS data
FROM ( FROM (
@@ -264,13 +279,30 @@ status_summary AS (
conversations c conversations c
LEFT join conversation_statuses s on s.id = c.status_id LEFT join conversation_statuses s on s.id = c.status_id
LEFT join conversation_priorities p on p.id = c.priority_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 GROUP BY
s.name s.name
) agg ) 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( SELECT json_build_object(
'new_conversations', (SELECT data FROM new_conversations), '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) 'status_summary', (SELECT data FROM status_summary)
) AS result; ) AS result;

View File

@@ -348,15 +348,48 @@ CREATE TABLE views (
DROP TABLE IF EXISTS ai_providers CASCADE; DROP TABLE IF EXISTS ai_providers CASCADE;
CREATE TABLE ai_providers ( CREATE TABLE ai_providers (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
name TEXT NOT NULL, name TEXT NOT NULL,
provider ai_provider NOT NULL, provider ai_provider NOT NULL,
config JSONB NOT NULL DEFAULT '{}', config JSONB NOT NULL DEFAULT '{}',
is_default BOOLEAN NOT NULL DEFAULT FALSE, is_default BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT constraint_ai_providers_on_name CHECK (length(name) <= 140) 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 -- Default settings
INSERT INTO settings ("key", value) INSERT INTO settings ("key", value)