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 {
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)

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.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)

View File

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

View File

@@ -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),
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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({})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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' }
}
]
}

View File

@@ -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) => {

View File

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

View File

@@ -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"`
}

View File

@@ -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")
}

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"
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)

View File

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

View File

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