mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-04 05:53:30 +00:00
feat: AI clean up of agent responses, adds new ai package
- feat: full screen tiptap editor, esentially two editors that attempt to keep their state in sync. - moves tiptap editor menu bar to to show up as a bubble once text is selected. - Layout fixes and improvements. - Improves /reports/overview charts and cards.
This commit is contained in:
29
cmd/ai.go
Normal file
29
cmd/ai.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/zerodha/fastglue"
|
||||||
|
|
||||||
|
// handleAICompletion handles AI completion requests
|
||||||
|
func handleAICompletion(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
promptKey = string(r.RequestCtx.PostArgs().Peek("prompt_key"))
|
||||||
|
content = string(r.RequestCtx.PostArgs().Peek("content"))
|
||||||
|
)
|
||||||
|
resp, err := app.ai.SendPrompt(promptKey, content)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
return r.SendEnvelope(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetAIPrompts returns AI prompts
|
||||||
|
func handleGetAIPrompts(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
)
|
||||||
|
resp, err := app.ai.GetPrompts()
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
return r.SendEnvelope(resp)
|
||||||
|
}
|
||||||
@@ -457,6 +457,9 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
|
|||||||
if !allowed {
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
14
cmd/init.go
14
cmd/init.go
@@ -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")
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
import { Icon } from '@iconify/vue'
|
|
||||||
import { buttonVariants } from '@/components/ui/button'
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip'
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
isCollapsed: Boolean,
|
|
||||||
links: Array,
|
|
||||||
bottomLinks: Array
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const getFirstSegment = (path) => {
|
|
||||||
return path.split('/')[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
const getButtonVariant = (to) => {
|
|
||||||
const currentSegment = getFirstSegment(route.path.toLowerCase())
|
|
||||||
const targetSegment = getFirstSegment(to.toLowerCase())
|
|
||||||
return currentSegment === targetSegment ? '' : 'ghost'
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div :data-collapsed="isCollapsed" class="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2 h-full">
|
|
||||||
<nav class="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
|
|
||||||
<template v-for="(link, index) of links">
|
|
||||||
<!-- Collapsed -->
|
|
||||||
<router-link :to="link.to" v-if="isCollapsed" :key="`1-${index}`">
|
|
||||||
<TooltipProvider :delay-duration="10">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger as-child>
|
|
||||||
<span :class="cn(
|
|
||||||
buttonVariants({ variant: getButtonVariant(link.to), size: 'icon' }),
|
|
||||||
'h-9 w-9',
|
|
||||||
link.variant === getButtonVariant(link.to) &&
|
|
||||||
'dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white'
|
|
||||||
)
|
|
||||||
">
|
|
||||||
<Icon :icon="link.icon" class="size-5" />
|
|
||||||
<span class="sr-only">{{ link.title }}</span>
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" class="flex items-center gap-4">
|
|
||||||
{{ link.title }}
|
|
||||||
<span v-if="link.label" class="ml-auto text-muted-foreground">
|
|
||||||
{{ link.label }}
|
|
||||||
</span>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</router-link>
|
|
||||||
|
|
||||||
<!-- Expanded -->
|
|
||||||
<router-link v-else :to="link.to" :key="`2-${index}`" :class="cn(
|
|
||||||
buttonVariants({ variant: getButtonVariant(link.to), size: 'sm' }),
|
|
||||||
link.variant === getButtonVariant(link.to) &&
|
|
||||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
|
||||||
'justify-start'
|
|
||||||
)
|
|
||||||
">
|
|
||||||
<Icon :icon="link.icon" class="mr-2 size-5" />
|
|
||||||
{{ link.title }}
|
|
||||||
<span v-if="link.label" :class="cn(
|
|
||||||
'ml-',
|
|
||||||
link.variant === getButtonVariant(link.to) && 'text-background dark:text-white'
|
|
||||||
)
|
|
||||||
">
|
|
||||||
{{ link.label }}
|
|
||||||
</span>
|
|
||||||
</router-link>
|
|
||||||
|
|
||||||
</template>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Bottom Links -->
|
|
||||||
<div class="mt-auto px-2">
|
|
||||||
<template v-for="(bottomLink, index) in bottomLinks" :key="`bottom-${index}`">
|
|
||||||
<TooltipProvider :delay-duration="10">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger as-child>
|
|
||||||
<a :href="bottomLink.to" :class="cn(
|
|
||||||
buttonVariants({
|
|
||||||
variant: getButtonVariant(bottomLink.to),
|
|
||||||
size: isCollapsed ? 'icon' : 'sm'
|
|
||||||
}),
|
|
||||||
bottomLink.variant === getButtonVariant(bottomLink.to) &&
|
|
||||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
|
||||||
'justify-start'
|
|
||||||
)
|
|
||||||
">
|
|
||||||
<Icon :icon="bottomLink.icon" class="mr-2 size-5" v-if="!isCollapsed" />
|
|
||||||
<span v-if="!isCollapsed">{{ bottomLink.title }}</span>
|
|
||||||
<Icon :icon="bottomLink.icon" class="size-5 mx-auto" v-else />
|
|
||||||
</a>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" class="flex items-center gap-4">
|
|
||||||
{{ bottomLink.title }}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,19 +1,8 @@
|
|||||||
<script setup>
|
<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">
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col space-y-1 border-b pb-3 mb-5 border-gray-200">
|
|
||||||
<span class="font-semibold text-2xl">{{ title }}</span>
|
|
||||||
<p class="text-muted-foreground text-lg">{{ description }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({})
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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({})
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,8 @@
|
|||||||
SELECT * FROM ai_providers where name = $1;
|
-- name: get-default-provider
|
||||||
|
SELECT id, name, provider, config, is_default FROM ai_providers where is_default is true;
|
||||||
|
|
||||||
|
-- name: get-prompt
|
||||||
|
SELECT id, key, title, content FROM ai_prompts where key = $1;
|
||||||
|
|
||||||
|
-- name: get-prompts
|
||||||
|
SELECT id, key, title, content FROM ai_prompts order by title;
|
||||||
@@ -582,10 +582,10 @@ func (c *Manager) GetDashboardChart(userID, teamID int) (json.RawMessage, error)
|
|||||||
cond = " AND assigned_team_id = $1"
|
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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
49
schema.sql
49
schema.sql
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user