mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-10-23 16:14:12 +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
|
||||
|
||||
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")
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
@@ -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,
|
||||
|
@@ -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([])
|
||||
|
@@ -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"
|
||||
|
@@ -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.
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
}
|
||||
|
||||
|
@@ -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';
|
||||
|
@@ -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.
|
||||
|
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',
|
||||
'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);
|
Reference in New Issue
Block a user