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:
Abhinav Raut
2025-03-01 19:40:18 +05:30
parent 1ff335f772
commit 26d76c966f
12 changed files with 222 additions and 26 deletions

View File

@@ -1,6 +1,14 @@
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
func handleAICompletion(r *fastglue.Request) error {
@@ -27,3 +35,18 @@ func handleGetAIPrompts(r *fastglue.Request) error {
}
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")
}

View File

@@ -174,6 +174,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// AI completion.
g.GET("/api/v1/ai/prompts", auth(handleGetAIPrompts))
g.POST("/api/v1/ai/completion", auth(handleAICompletion))
g.PUT("/api/v1/ai/provider", perm(handleUpdateAIProvider, "ai:manage"))
// WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error {

View File

@@ -265,6 +265,7 @@ const updateView = (id, data) =>
const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
const getAiPrompts = () => http.get('/api/v1/ai/prompts')
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data)
const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data)
export default {
login,
@@ -328,6 +329,7 @@ export default {
updateAutomationRule,
updateAutomationRuleWeights,
updateAutomationRulesExecutionMode,
updateAIProvider,
createAutomationRule,
toggleAutomationRule,
deleteAutomationRule,

View File

@@ -13,7 +13,11 @@
<FormItem>
<FormLabel>Description</FormLabel>
<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>
<FormMessage />
</FormItem>
@@ -24,13 +28,19 @@
<div v-for="entity in permissions" :key="entity.name" class="box p-4">
<p class="text-lg mb-5">{{ entity.name }}</p>
<div class="space-y-4">
<FormField v-for="permission in entity.permissions" :key="permission.name" type="checkbox"
:name="permission.name">
<FormField
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">
<div class="flex space-x-3">
<FormControl>
<Checkbox :checked="selectedPermissions.includes(permission.name)"
@update:checked="(newValue) => handleChange(newValue, permission.name)" />
<Checkbox
:checked="selectedPermissions.includes(permission.name)"
@update:checked="(newValue) => handleChange(newValue, permission.name)"
/>
<FormLabel>{{ permission.label }}</FormLabel>
</FormControl>
</div>
@@ -69,7 +79,7 @@ const props = defineProps({
},
isLoading: {
type: Boolean,
required: false,
required: false
}
})
@@ -77,7 +87,7 @@ const permissions = ref([
{
name: 'Conversation',
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_all', label: 'View all 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: 'messages:read', label: 'View conversation messages' },
{ 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: 'business_hours:manage', label: 'Manage Business Hours' },
{ name: 'sla:manage', label: 'Manage SLA Policies' },
{ name: 'ai:manage', label: 'Manage AI Features' }
]
},
}
])
const selectedPermissions = ref([])

View File

@@ -1,12 +1,12 @@
<template>
<div class="flex flex-wrap">
<div class="flex flex-wrap">
<div class="flex flex-wrap gap-2">
<div
v-for="action in actions"
: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"
>
<div class="flex items-center space-x-2 px-2 ">
<div class="flex items-center space-x-2 px-2">
<component
:is="getIcon(action.type)"
size="16"

View File

@@ -1,4 +1,38 @@
<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">
<!-- Fullscreen editor -->
<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 { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useUserStore } from '@/stores/user'
import api from '@/api'
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 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 emitter = useEmitter()
const userStore = useUserStore()
const openAIKeyPrompt = ref(false)
const isOpenAIKeyUpdating = ref(false)
// Shared state between the two editor components.
const clearEditorContent = ref(false)
@@ -164,6 +227,10 @@ const handleAiPromptSelected = async (key) => {
timestamp: Date.now()
})
} 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, {
title: 'Error',
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.
* Uploads each file to the server and adds them to the conversation's mediaFiles.

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"embed"
"encoding/json"
"errors"
"github.com/abhinavxd/libredesk/internal/ai/models"
"github.com/abhinavxd/libredesk/internal/dbutil"
@@ -16,6 +17,9 @@ import (
var (
//go:embed queries.sql
efs embed.FS
ErrInvalidAPIKey = errors.New("invalid API Key")
ErrApiKeyNotSet = errors.New("api Key not set")
)
// Manager manages LLM providers.
@@ -35,6 +39,7 @@ type queries struct {
GetDefaultProvider *sqlx.Stmt `query:"get-default-provider"`
GetPrompt *sqlx.Stmt `query:"get-prompt"`
GetPrompts *sqlx.Stmt `query:"get-prompts"`
SetOpenAIKey *sqlx.Stmt `query:"set-openai-key"`
}
// 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)
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)
return "", envelope.NewError(envelope.GeneralError, err.Error(), nil)
}
@@ -86,6 +99,26 @@ func (m *Manager) GetPrompts() ([]models.Prompt, error) {
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.
func (m *Manager) getPrompt(k string) (string, error) {
var p models.Prompt

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"time"
"github.com/valyala/fasthttp"
"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.
func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
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"
@@ -48,7 +49,7 @@ func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
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 {
o.lo.Error("error creating request", "error", 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()
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 {
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)
}

View File

@@ -5,4 +5,13 @@ SELECT id, name, provider, config, is_default FROM ai_providers where is_default
SELECT id, key, title, content FROM ai_prompts where key = $1;
-- 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';

View File

@@ -62,6 +62,9 @@ const (
// OpenID Connect SSO
PermOIDCManage = "oidc:manage"
// AI
PermAIManage = "ai:manage"
)
var validPermissions = map[string]struct{}{
@@ -93,6 +96,7 @@ var validPermissions = map[string]struct{}{
PermGeneralSettingsManage: {},
PermNotificationSettingsManage: {},
PermOIDCManage: {},
PermAIManage: {},
}
// IsValidPermission returns true if it's a valid permission.

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

View File

@@ -536,28 +536,30 @@ VALUES
(
'Admin',
'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
INSERT INTO public.templates
INSERT INTO templates
("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>
<div>
Reference number: {{.conversation.reference_number }} <br>
Priority: {{.conversation.priority }}<br>
Subject: {{.conversation.subject }}
Reference number: {{ .Conversation.ReferenceNumber }} <br>
Subject: {{ .Conversation.Subject }}
</div>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .conversation.uuid }}">View Conversation</a>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<div >
<div>
Best regards,<br>
Libredesk
</div>', false, 'Conversation assigned', 'New conversation assigned to you', true);
</div>
', false, 'Conversation assigned', 'New conversation assigned to you', true);