mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-12 18:06:19 +00:00
feat: allow setting OpenAI API KEY from the UI.
feat: new `ai:manage` permission for the same Migrations for new role.
This commit is contained in:
25
cmd/ai.go
25
cmd/ai.go
@@ -1,6 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "github.com/zerodha/fastglue"
|
import (
|
||||||
|
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||||
|
"github.com/zerodha/fastglue"
|
||||||
|
)
|
||||||
|
|
||||||
|
type providerUpdateReq struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
}
|
||||||
|
|
||||||
// handleAICompletion handles AI completion requests
|
// handleAICompletion handles AI completion requests
|
||||||
func handleAICompletion(r *fastglue.Request) error {
|
func handleAICompletion(r *fastglue.Request) error {
|
||||||
@@ -27,3 +35,18 @@ func handleGetAIPrompts(r *fastglue.Request) error {
|
|||||||
}
|
}
|
||||||
return r.SendEnvelope(resp)
|
return r.SendEnvelope(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUpdateAIProvider updates the AI provider
|
||||||
|
func handleUpdateAIProvider(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
req providerUpdateReq
|
||||||
|
)
|
||||||
|
if err := r.Decode(&req, "json"); err != nil {
|
||||||
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Error unmarshalling request", nil))
|
||||||
|
}
|
||||||
|
if err := app.ai.UpdateProvider(req.Provider, req.APIKey); err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
return r.SendEnvelope("Provider updated successfully")
|
||||||
|
}
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
|||||||
// AI completion.
|
// AI completion.
|
||||||
g.GET("/api/v1/ai/prompts", auth(handleGetAIPrompts))
|
g.GET("/api/v1/ai/prompts", auth(handleGetAIPrompts))
|
||||||
g.POST("/api/v1/ai/completion", auth(handleAICompletion))
|
g.POST("/api/v1/ai/completion", auth(handleAICompletion))
|
||||||
|
g.PUT("/api/v1/ai/provider", perm(handleUpdateAIProvider, "ai:manage"))
|
||||||
|
|
||||||
// WebSocket.
|
// WebSocket.
|
||||||
g.GET("/ws", auth(func(r *fastglue.Request) error {
|
g.GET("/ws", auth(func(r *fastglue.Request) error {
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ 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 getAiPrompts = () => http.get('/api/v1/ai/prompts')
|
||||||
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data)
|
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data)
|
||||||
|
const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data)
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
login,
|
login,
|
||||||
@@ -328,6 +329,7 @@ export default {
|
|||||||
updateAutomationRule,
|
updateAutomationRule,
|
||||||
updateAutomationRuleWeights,
|
updateAutomationRuleWeights,
|
||||||
updateAutomationRulesExecutionMode,
|
updateAutomationRulesExecutionMode,
|
||||||
|
updateAIProvider,
|
||||||
createAutomationRule,
|
createAutomationRule,
|
||||||
toggleAutomationRule,
|
toggleAutomationRule,
|
||||||
deleteAutomationRule,
|
deleteAutomationRule,
|
||||||
|
|||||||
@@ -13,7 +13,11 @@
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Description</FormLabel>
|
<FormLabel>Description</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" placeholder="This role is for all support agents" v-bind="componentField" />
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="This role is for all support agents"
|
||||||
|
v-bind="componentField"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -24,13 +28,19 @@
|
|||||||
<div v-for="entity in permissions" :key="entity.name" class="box p-4">
|
<div v-for="entity in permissions" :key="entity.name" class="box p-4">
|
||||||
<p class="text-lg mb-5">{{ entity.name }}</p>
|
<p class="text-lg mb-5">{{ entity.name }}</p>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<FormField v-for="permission in entity.permissions" :key="permission.name" type="checkbox"
|
<FormField
|
||||||
:name="permission.name">
|
v-for="permission in entity.permissions"
|
||||||
|
:key="permission.name"
|
||||||
|
type="checkbox"
|
||||||
|
:name="permission.name"
|
||||||
|
>
|
||||||
<FormItem class="flex flex-col gap-y-5 space-y-0 rounded-lg">
|
<FormItem class="flex flex-col gap-y-5 space-y-0 rounded-lg">
|
||||||
<div class="flex space-x-3">
|
<div class="flex space-x-3">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Checkbox :checked="selectedPermissions.includes(permission.name)"
|
<Checkbox
|
||||||
@update:checked="(newValue) => handleChange(newValue, permission.name)" />
|
:checked="selectedPermissions.includes(permission.name)"
|
||||||
|
@update:checked="(newValue) => handleChange(newValue, permission.name)"
|
||||||
|
/>
|
||||||
<FormLabel>{{ permission.label }}</FormLabel>
|
<FormLabel>{{ permission.label }}</FormLabel>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,7 +79,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
isLoading: {
|
isLoading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -77,7 +87,7 @@ const permissions = ref([
|
|||||||
{
|
{
|
||||||
name: 'Conversation',
|
name: 'Conversation',
|
||||||
permissions: [
|
permissions: [
|
||||||
{ name: 'conversations:read', label: 'View conversations' },
|
{ name: 'conversations:read', label: 'View conversation' },
|
||||||
{ name: 'conversations:read_assigned', label: 'View conversations assigned to me' },
|
{ name: 'conversations:read_assigned', label: 'View conversations assigned to me' },
|
||||||
{ name: 'conversations:read_all', label: 'View all conversations' },
|
{ name: 'conversations:read_all', label: 'View all conversations' },
|
||||||
{ name: 'conversations:read_unassigned', label: 'View all unassigned conversations' },
|
{ name: 'conversations:read_unassigned', label: 'View all unassigned conversations' },
|
||||||
@@ -89,7 +99,7 @@ const permissions = ref([
|
|||||||
{ name: 'conversations:update_tags', label: 'Add or remove conversation tags' },
|
{ name: 'conversations:update_tags', label: 'Add or remove conversation tags' },
|
||||||
{ name: 'messages:read', label: 'View conversation messages' },
|
{ name: 'messages:read', label: 'View conversation messages' },
|
||||||
{ name: 'messages:write', label: 'Send messages in conversations' },
|
{ name: 'messages:write', label: 'Send messages in conversations' },
|
||||||
{ name: 'view:manage', label: 'Create and manage conversation views' },
|
{ name: 'view:manage', label: 'Create and manage conversation views' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -110,8 +120,9 @@ const permissions = ref([
|
|||||||
{ name: 'reports:manage', label: 'Manage Reports' },
|
{ name: 'reports:manage', label: 'Manage Reports' },
|
||||||
{ name: 'business_hours:manage', label: 'Manage Business Hours' },
|
{ name: 'business_hours:manage', label: 'Manage Business Hours' },
|
||||||
{ name: 'sla:manage', label: 'Manage SLA Policies' },
|
{ name: 'sla:manage', label: 'Manage SLA Policies' },
|
||||||
|
{ name: 'ai:manage', label: 'Manage AI Features' }
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
const selectedPermissions = ref([])
|
const selectedPermissions = ref([])
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap">
|
<div class="flex flex-wrap">
|
||||||
<div class="flex flex-wrap">
|
<div class="flex flex-wrap gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="action in actions"
|
v-for="action in actions"
|
||||||
:key="action.type"
|
:key="action.type"
|
||||||
class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group gap-2 py-1"
|
class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group gap-2 py-1"
|
||||||
>
|
>
|
||||||
<div class="flex items-center space-x-2 px-2 ">
|
<div class="flex items-center space-x-2 px-2">
|
||||||
<component
|
<component
|
||||||
:is="getIcon(action.type)"
|
:is="getIcon(action.type)"
|
||||||
size="16"
|
size="16"
|
||||||
|
|||||||
@@ -1,4 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<Dialog :open="openAIKeyPrompt" @update:open="openAIKeyPrompt = false">
|
||||||
|
<DialogContent class="sm:max-w-lg">
|
||||||
|
<DialogHeader class="space-y-2">
|
||||||
|
<DialogTitle>Enter OpenAI API Key</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
OpenAI API key is not set or invalid. Please enter a valid API key to use AI features.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form v-slot="{ handleSubmit }" as="" keep-values :validation-schema="formSchema">
|
||||||
|
<form id="apiKeyForm" @submit="handleSubmit($event, updateProvider)">
|
||||||
|
<FormField v-slot="{ componentField }" name="apiKey">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>API Key</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" placeholder="Enter your API key" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</form>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="apiKeyForm"
|
||||||
|
:is-loading="isOpenAIKeyUpdating"
|
||||||
|
:disabled="isOpenAIKeyUpdating"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<div class="text-foreground bg-background">
|
<div class="text-foreground bg-background">
|
||||||
<!-- Fullscreen editor -->
|
<!-- Fullscreen editor -->
|
||||||
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
|
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
|
||||||
@@ -98,15 +132,44 @@ import { ref, onMounted, nextTick, watch, computed } 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 { useUserStore } from '@/stores/user'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
|
||||||
import { useConversationStore } from '@/stores/conversation'
|
import { useConversationStore } from '@/stores/conversation'
|
||||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import { useEmitter } from '@/composables/useEmitter'
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
|
import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import * as z from 'zod'
|
||||||
|
|
||||||
|
const formSchema = toTypedSchema(
|
||||||
|
z.object({
|
||||||
|
apiKey: z.string().min(1, 'API key is required')
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
const emitter = useEmitter()
|
const emitter = useEmitter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const openAIKeyPrompt = ref(false)
|
||||||
|
const isOpenAIKeyUpdating = ref(false)
|
||||||
|
|
||||||
// Shared state between the two editor components.
|
// Shared state between the two editor components.
|
||||||
const clearEditorContent = ref(false)
|
const clearEditorContent = ref(false)
|
||||||
@@ -164,6 +227,10 @@ const handleAiPromptSelected = async (key) => {
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Check if user needs to enter OpenAI API key and has permission to do so.
|
||||||
|
if (error.response?.status === 400 && userStore.can('ai:manage')) {
|
||||||
|
openAIKeyPrompt.value = true
|
||||||
|
}
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
@@ -172,6 +239,30 @@ const handleAiPromptSelected = async (key) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* updateProvider updates the OpenAI API key.
|
||||||
|
* @param {Object} values - The form values containing the API key
|
||||||
|
*/
|
||||||
|
const updateProvider = async (values) => {
|
||||||
|
try {
|
||||||
|
isOpenAIKeyUpdating.value = true
|
||||||
|
await api.updateAIProvider({ api_key: values.apiKey, provider: 'openai' })
|
||||||
|
openAIKeyPrompt.value = false
|
||||||
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Success',
|
||||||
|
description: 'API key saved successfully.'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Error',
|
||||||
|
variant: 'destructive',
|
||||||
|
description: handleHTTPError(error).message
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isOpenAIKeyUpdating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the file upload process when files are selected.
|
* Handles the file upload process when files are selected.
|
||||||
* Uploads each file to the server and adds them to the conversation's mediaFiles.
|
* Uploads each file to the server and adds them to the conversation's mediaFiles.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"github.com/abhinavxd/libredesk/internal/ai/models"
|
"github.com/abhinavxd/libredesk/internal/ai/models"
|
||||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||||
@@ -16,6 +17,9 @@ import (
|
|||||||
var (
|
var (
|
||||||
//go:embed queries.sql
|
//go:embed queries.sql
|
||||||
efs embed.FS
|
efs embed.FS
|
||||||
|
|
||||||
|
ErrInvalidAPIKey = errors.New("invalid API Key")
|
||||||
|
ErrApiKeyNotSet = errors.New("api Key not set")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager manages LLM providers.
|
// Manager manages LLM providers.
|
||||||
@@ -35,6 +39,7 @@ type queries struct {
|
|||||||
GetDefaultProvider *sqlx.Stmt `query:"get-default-provider"`
|
GetDefaultProvider *sqlx.Stmt `query:"get-default-provider"`
|
||||||
GetPrompt *sqlx.Stmt `query:"get-prompt"`
|
GetPrompt *sqlx.Stmt `query:"get-prompt"`
|
||||||
GetPrompts *sqlx.Stmt `query:"get-prompts"`
|
GetPrompts *sqlx.Stmt `query:"get-prompts"`
|
||||||
|
SetOpenAIKey *sqlx.Stmt `query:"set-openai-key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates and returns a new instance of the Manager.
|
// New creates and returns a new instance of the Manager.
|
||||||
@@ -69,6 +74,14 @@ func (m *Manager) Completion(k string, prompt string) (string, error) {
|
|||||||
|
|
||||||
response, err := client.SendPrompt(payload)
|
response, err := client.SendPrompt(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrInvalidAPIKey) {
|
||||||
|
m.lo.Error("error invalid API key", "error", err)
|
||||||
|
return "", envelope.NewError(envelope.InputError, "OpenAI API Key is invalid, Please ask your administrator to set it up", nil)
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrApiKeyNotSet) {
|
||||||
|
m.lo.Error("error API key not set", "error", err)
|
||||||
|
return "", envelope.NewError(envelope.InputError, "OpenAI API Key is not set, Please ask your administrator to set it up", nil)
|
||||||
|
}
|
||||||
m.lo.Error("error sending prompt to provider", "error", err)
|
m.lo.Error("error sending prompt to provider", "error", err)
|
||||||
return "", envelope.NewError(envelope.GeneralError, err.Error(), nil)
|
return "", envelope.NewError(envelope.GeneralError, err.Error(), nil)
|
||||||
}
|
}
|
||||||
@@ -86,6 +99,26 @@ func (m *Manager) GetPrompts() ([]models.Prompt, error) {
|
|||||||
return prompts, nil
|
return prompts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateProvider updates a provider.
|
||||||
|
func (m *Manager) UpdateProvider(provider, apiKey string) error {
|
||||||
|
switch ProviderType(provider) {
|
||||||
|
case ProviderOpenAI:
|
||||||
|
return m.setOpenAIAPIKey(apiKey)
|
||||||
|
default:
|
||||||
|
m.lo.Error("unsupported provider type", "provider", provider)
|
||||||
|
return envelope.NewError(envelope.GeneralError, "Unsupported provider type", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setOpenAIAPIKey sets the OpenAI API key in the database.
|
||||||
|
func (m *Manager) setOpenAIAPIKey(apiKey string) error {
|
||||||
|
if _, err := m.q.SetOpenAIKey.Exec(apiKey); err != nil {
|
||||||
|
m.lo.Error("error setting OpenAI API key", "error", err)
|
||||||
|
return envelope.NewError(envelope.GeneralError, "Error setting OpenAI API key", nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// getPrompt returns a prompt from the database.
|
// getPrompt returns a prompt from the database.
|
||||||
func (m *Manager) getPrompt(k string) (string, error) {
|
func (m *Manager) getPrompt(k string) (string, error) {
|
||||||
var p models.Prompt
|
var p models.Prompt
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/valyala/fasthttp"
|
||||||
"github.com/zerodha/logf"
|
"github.com/zerodha/logf"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ func NewOpenAIClient(apiKey string, lo *logf.Logger) *OpenAIClient {
|
|||||||
// SendPrompt sends a prompt to the OpenAI API and returns the response text.
|
// SendPrompt sends a prompt to the OpenAI API and returns the response text.
|
||||||
func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
|
func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
|
||||||
if o.apikey == "" {
|
if o.apikey == "" {
|
||||||
return "", fmt.Errorf("OpenAI API key is not set, Please ask your administrator to set the key")
|
return "", ErrApiKeyNotSet
|
||||||
}
|
}
|
||||||
|
|
||||||
apiURL := "https://api.openai.com/v1/chat/completions"
|
apiURL := "https://api.openai.com/v1/chat/completions"
|
||||||
@@ -48,7 +49,7 @@ func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
|
|||||||
return "", fmt.Errorf("marshalling request body: %w", err)
|
return "", fmt.Errorf("marshalling request body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(bodyBytes))
|
req, err := http.NewRequest(fasthttp.MethodPost, apiURL, bytes.NewBuffer(bodyBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.lo.Error("error creating request", "error", err)
|
o.lo.Error("error creating request", "error", err)
|
||||||
return "", fmt.Errorf("error creating request: %w", err)
|
return "", fmt.Errorf("error creating request: %w", err)
|
||||||
@@ -65,11 +66,12 @@ func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusUnauthorized {
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
return "", fmt.Errorf("OpenAI API key is invalid, Please ask your administrator to update the key")
|
return "", ErrInvalidAPIKey
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
o.lo.Error("non-ok response received from openai API", "status", resp.Status, "code", resp.StatusCode, "response_text", body)
|
||||||
return "", fmt.Errorf("API error: %s, body: %s", resp.Status, body)
|
return "", fmt.Errorf("API error: %s, body: %s", resp.Status, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,12 @@ SELECT id, key, title, content FROM ai_prompts where key = $1;
|
|||||||
|
|
||||||
-- name: get-prompts
|
-- name: get-prompts
|
||||||
SELECT id, key, title FROM ai_prompts order by title;
|
SELECT id, key, title FROM ai_prompts order by title;
|
||||||
|
|
||||||
|
-- name: set-openai-key
|
||||||
|
UPDATE ai_providers
|
||||||
|
SET config = jsonb_set(
|
||||||
|
COALESCE(config, '{}'::jsonb),
|
||||||
|
'{api_key}',
|
||||||
|
to_jsonb($1::text)
|
||||||
|
)
|
||||||
|
WHERE provider = 'openai';
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ const (
|
|||||||
|
|
||||||
// OpenID Connect SSO
|
// OpenID Connect SSO
|
||||||
PermOIDCManage = "oidc:manage"
|
PermOIDCManage = "oidc:manage"
|
||||||
|
|
||||||
|
// AI
|
||||||
|
PermAIManage = "ai:manage"
|
||||||
)
|
)
|
||||||
|
|
||||||
var validPermissions = map[string]struct{}{
|
var validPermissions = map[string]struct{}{
|
||||||
@@ -93,6 +96,7 @@ var validPermissions = map[string]struct{}{
|
|||||||
PermGeneralSettingsManage: {},
|
PermGeneralSettingsManage: {},
|
||||||
PermNotificationSettingsManage: {},
|
PermNotificationSettingsManage: {},
|
||||||
PermOIDCManage: {},
|
PermOIDCManage: {},
|
||||||
|
PermAIManage: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValidPermission returns true if it's a valid permission.
|
// IsValidPermission returns true if it's a valid permission.
|
||||||
|
|||||||
18
internal/migrations/v0.4.0.go
Normal file
18
internal/migrations/v0.4.0.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/knadh/koanf/v2"
|
||||||
|
"github.com/knadh/stuffbin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// V0_4_0 updates the database schema to V0_4_0.
|
||||||
|
func V0_4_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||||
|
// Admin role gets the new ai:manage permission, as this user is supposed to have all permissions.
|
||||||
|
_, err := db.Exec(`
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = array_append(permissions, 'ai:manage')
|
||||||
|
WHERE name = 'Admin' AND NOT ('ai:manage' = ANY(permissions));
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
20
schema.sql
20
schema.sql
@@ -536,28 +536,30 @@ VALUES
|
|||||||
(
|
(
|
||||||
'Admin',
|
'Admin',
|
||||||
'Role for users who have complete access to everything.',
|
'Role for users who have complete access to everything.',
|
||||||
'{general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
|
'{ai:manage,general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
-- Email notification templates
|
-- Email notification templates
|
||||||
INSERT INTO public.templates
|
INSERT INTO templates
|
||||||
("type", body, is_default, "name", subject, is_builtin)
|
("type", body, is_default, "name", subject, is_builtin)
|
||||||
VALUES('email_notification'::public."template_type", '<p>Hello {{ .agent.full_name }},</p>
|
VALUES('email_notification'::template_type, '
|
||||||
|
<p>Hi {{ .Agent.FirstName }},</p>
|
||||||
|
|
||||||
<p>A new conversation has been assigned to you:</p>
|
<p>A new conversation has been assigned to you:</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
Reference number: {{.conversation.reference_number }} <br>
|
Reference number: {{ .Conversation.ReferenceNumber }} <br>
|
||||||
Priority: {{.conversation.priority }}<br>
|
Subject: {{ .Conversation.Subject }}
|
||||||
Subject: {{.conversation.subject }}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .conversation.uuid }}">View Conversation</a>
|
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div >
|
<div>
|
||||||
Best regards,<br>
|
Best regards,<br>
|
||||||
Libredesk
|
Libredesk
|
||||||
</div>', false, 'Conversation assigned', 'New conversation assigned to you', true);
|
</div>
|
||||||
|
|
||||||
|
', false, 'Conversation assigned', 'New conversation assigned to you', true);
|
||||||
Reference in New Issue
Block a user