From 75c57ad039341e6681cba726de16c35ccbc49e52 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 30 Jul 2025 08:58:27 +0400 Subject: [PATCH 01/15] feat: use captain endpoint config in legacy OpenAI base service (#12060) This PR migrates the legacy OpenAI integration (where users provide their own API keys) from using hardcoded `https://api.openai.com` endpoints to use the configurable `CAPTAIN_OPEN_AI_ENDPOINT` from the captain configuration. This ensures consistency across all OpenAI integrations in the platform. ## Changes - Updated `lib/integrations/openai_base_service.rb` to use captain endpoint config - Updated `enterprise/app/models/enterprise/concerns/article.rb` to use captain endpoint config - Removed unused `enterprise/lib/chat_gpt.rb` class - Added tests for endpoint configuration behavior --- .../app/models/enterprise/concerns/article.rb | 10 ++- enterprise/lib/chat_gpt.rb | 62 ------------------- lib/integrations/openai_base_service.rb | 9 ++- .../openai/processor_service_spec.rb | 47 ++++++++++++++ 4 files changed, 63 insertions(+), 65 deletions(-) delete mode 100644 enterprise/lib/chat_gpt.rb diff --git a/enterprise/app/models/enterprise/concerns/article.rb b/enterprise/app/models/enterprise/concerns/article.rb index b7de767ad..d3a94d7b7 100644 --- a/enterprise/app/models/enterprise/concerns/article.rb +++ b/enterprise/app/models/enterprise/concerns/article.rb @@ -68,8 +68,16 @@ module Enterprise::Concerns::Article headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY', nil)}" } body = { model: 'gpt-4o', messages: messages, response_format: { type: 'json_object' } }.to_json Rails.logger.info "Requesting Chat GPT with body: #{body}" - response = HTTParty.post('https://api.openai.com/v1/chat/completions', headers: headers, body: body) + response = HTTParty.post(openai_api_url, headers: headers, body: body) Rails.logger.info "Chat GPT response: #{response.body}" JSON.parse(response.parsed_response['choices'][0]['message']['content'])['search_terms'] end + + private + + def openai_api_url + endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/' + endpoint = endpoint.chomp('/') + "#{endpoint}/v1/chat/completions" + end end diff --git a/enterprise/lib/chat_gpt.rb b/enterprise/lib/chat_gpt.rb deleted file mode 100644 index 44afbd641..000000000 --- a/enterprise/lib/chat_gpt.rb +++ /dev/null @@ -1,62 +0,0 @@ -class ChatGpt - def self.base_uri - 'https://api.openai.com' - end - - def initialize(context_sections = '') - @model = 'gpt-4o' - @messages = [system_message(context_sections)] - end - - def generate_response(input, previous_messages = [], role = 'user') - @messages += previous_messages - @messages << { 'role': role, 'content': input } if input.present? - - response = request_gpt - JSON.parse(response['choices'][0]['message']['content'].strip) - end - - private - - def system_message(context_sections) - { - 'role': 'system', - 'content': system_content(context_sections) - } - end - - def system_content(context_sections) - <<~SYSTEM_PROMPT_MESSAGE - You are a very enthusiastic customer support representative who loves to help people. - Your answers will always be formatted in valid JSON hash, as shown below. Never respond in non JSON format. - - ``` - { - response: '' , - context_ids: [ids], - } - ``` - - response: will be the next response to the conversation - - context_ids: will be an array of unique context IDs that were used to generate the answer. choose top 3. - - The answers will be generated using the information provided at the end of the prompt under the context sections. You will not respond outside the context of the information provided in context sections. - - If the answer is not provided in context sections, Respond to the customer and ask whether they want to talk to another support agent . If they ask to Chat with another agent, return `conversation_handoff' as the response in JSON response - - ---------------------------------- - Context sections: - #{context_sections} - SYSTEM_PROMPT_MESSAGE - end - - def request_gpt - headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY')}" } - body = { model: @model, messages: @messages, response_format: { type: 'json_object' } }.to_json - Rails.logger.info "Requesting Chat GPT with body: #{body}" - response = HTTParty.post("#{self.class.base_uri}/v1/chat/completions", headers: headers, body: body) - Rails.logger.info "Chat GPT response: #{response.body}" - JSON.parse(response.body) - end -end diff --git a/lib/integrations/openai_base_service.rb b/lib/integrations/openai_base_service.rb index 908e496a7..f06baf5b5 100644 --- a/lib/integrations/openai_base_service.rb +++ b/lib/integrations/openai_base_service.rb @@ -4,7 +4,6 @@ class Integrations::OpenaiBaseService # sticking with 120000 to be safe # 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe) TOKEN_LIMIT = 400_000 - API_URL = 'https://api.openai.com/v1/chat/completions'.freeze GPT_MODEL = ENV.fetch('OPENAI_GPT_MODEL', 'gpt-4o-mini').freeze ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze @@ -81,6 +80,12 @@ class Integrations::OpenaiBaseService self.class::CACHEABLE_EVENTS.include?(event_name) end + def api_url + endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/' + endpoint = endpoint.chomp('/') + "#{endpoint}/v1/chat/completions" + end + def make_api_call(body) headers = { 'Content-Type' => 'application/json', @@ -88,7 +93,7 @@ class Integrations::OpenaiBaseService } Rails.logger.info("OpenAI API request: #{body}") - response = HTTParty.post(API_URL, headers: headers, body: body) + response = HTTParty.post(api_url, headers: headers, body: body) Rails.logger.info("OpenAI API response: #{response.body}") return { error: response.parsed_response, error_code: response.code } unless response.success? diff --git a/spec/lib/integrations/openai/processor_service_spec.rb b/spec/lib/integrations/openai/processor_service_spec.rb index 8bbf5d5fb..a22c8e815 100644 --- a/spec/lib/integrations/openai/processor_service_spec.rb +++ b/spec/lib/integrations/openai/processor_service_spec.rb @@ -253,5 +253,52 @@ RSpec.describe Integrations::Openai::ProcessorService do expect(result).to eq({ :message => 'This is a reply from openai.' }) end end + + context 'when testing endpoint configuration' do + let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } } + + context 'when CAPTAIN_OPEN_AI_ENDPOINT is not configured' do + it 'uses default OpenAI endpoint' do + InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy + + stub_request(:post, 'https://api.openai.com/v1/chat/completions') + .with(body: anything, headers: expected_headers) + .to_return(status: 200, body: openai_response, headers: {}) + + result = subject.perform + expect(result).to eq({ :message => 'This is a reply from openai.' }) + end + end + + context 'when CAPTAIN_OPEN_AI_ENDPOINT is configured' do + before do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/') + end + + it 'uses custom endpoint' do + stub_request(:post, 'https://custom.azure.com/v1/chat/completions') + .with(body: anything, headers: expected_headers) + .to_return(status: 200, body: openai_response, headers: {}) + + result = subject.perform + expect(result).to eq({ :message => 'This is a reply from openai.' }) + end + end + + context 'when CAPTAIN_OPEN_AI_ENDPOINT has trailing slash' do + before do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/') + end + + it 'properly handles trailing slash' do + stub_request(:post, 'https://custom.azure.com/v1/chat/completions') + .with(body: anything, headers: expected_headers) + .to_return(status: 200, body: openai_response, headers: {}) + + result = subject.perform + expect(result).to eq({ :message => 'This is a reply from openai.' }) + end + end + end end end From 62b36d4aec2318e2f4c7b87605bfc9b94dac568e Mon Sep 17 00:00:00 2001 From: Chatwoot Bot <92152627+chatwoot-bot@users.noreply.github.com> Date: Tue, 29 Jul 2025 23:06:32 -0700 Subject: [PATCH 02/15] chore: Update translations (#12056) --- app/javascript/dashboard/i18n/locale/pl/agentBots.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/javascript/dashboard/i18n/locale/pl/agentBots.json b/app/javascript/dashboard/i18n/locale/pl/agentBots.json index d861bdead..6060d7e43 100644 --- a/app/javascript/dashboard/i18n/locale/pl/agentBots.json +++ b/app/javascript/dashboard/i18n/locale/pl/agentBots.json @@ -2,7 +2,7 @@ "AGENT_BOTS": { "HEADER": "Boty", "LOADING_EDITOR": "Ładowanie edytora...", - "DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.", + "DESCRIPTION": "Boty agentów są jak najbardziej fantastyczni członkowie Twojego zespołu. Mogą zajmować się drobnymi sprawami, dzięki czemu Ty możesz skupić się na tym, co naprawdę ważne. Wypróbuj je! Możesz zarządzać swoimi botami z tej strony lub tworzyć nowe za pomocą przycisku 'Dodaj bota'.", "LEARN_MORE": "Learn about agent bots", "GLOBAL_BOT": "System bot", "GLOBAL_BOT_BADGE": "System", @@ -30,10 +30,10 @@ } }, "LIST": { - "404": "No bots found. You can create a bot by clicking the 'Add Bot' button.", + "404": "Nie znaleziono botów. Możesz utworzyć bota klikając przycisk 'Dodaj bota'.", "LOADING": "Pobieranie botów...", "TABLE_HEADER": { - "DETAILS": "Bot Details", + "DETAILS": "Szczegóły bota", "URL": "Adres URL webhooka" } }, From 1230d1f2512e5f2135564a91aff4974d43c26094 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 30 Jul 2025 11:07:18 +0400 Subject: [PATCH 03/15] chore: Added support for inbox variables (#11952) --- .../components/widgets/conversation/ReplyBox.vue | 1 + app/javascript/shared/constants/messages.js | 8 ++++++++ package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 8bf81d0c3..e5243ac04 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -372,6 +372,7 @@ export default { const variables = getMessageVariables({ conversation: this.currentChat, contact: this.currentContact, + inbox: this.inbox, }); return variables; }, diff --git a/app/javascript/shared/constants/messages.js b/app/javascript/shared/constants/messages.js index f5fa834fc..7b7b4f331 100644 --- a/app/javascript/shared/constants/messages.js +++ b/app/javascript/shared/constants/messages.js @@ -157,6 +157,14 @@ export const MESSAGE_VARIABLES = [ label: 'Agent email', key: 'agent.email', }, + { + key: 'inbox.name', + label: 'Inbox name', + }, + { + label: 'Inbox id', + key: 'inbox.id', + }, ]; export const ATTACHMENT_ICONS = { diff --git a/package.json b/package.json index e0fd2cf7b..668ea8b57 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", "@chatwoot/prosemirror-schema": "1.1.6-next", - "@chatwoot/utils": "^0.0.47", + "@chatwoot/utils": "^0.0.48", "@formkit/core": "^1.6.7", "@formkit/vue": "^1.6.7", "@hcaptcha/vue3-hcaptcha": "^1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a88cfc084..16e830a87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,8 +23,8 @@ importers: specifier: 1.1.6-next version: 1.1.6-next '@chatwoot/utils': - specifier: ^0.0.47 - version: 0.0.47 + specifier: ^0.0.48 + version: 0.0.48 '@formkit/core': specifier: ^1.6.7 version: 1.6.7 @@ -406,8 +406,8 @@ packages: '@chatwoot/prosemirror-schema@1.1.6-next': resolution: {integrity: sha512-9lf7FrcED/B5oyGrMmIkbegkhlC/P0NrtXoX8k94YWRosZcx0hGVGhpTud+0Mhm7saAfGerKIwTRVDmmnxPuCA==} - '@chatwoot/utils@0.0.47': - resolution: {integrity: sha512-0z/MY+rBjDnf6zuWbMdzexH+zFDXU/g5fPr/kcUxnqtvPsZIQpL8PvwSPBW0+wS6R7LChndNkdviV1e9H8Yp+Q==} + '@chatwoot/utils@0.0.48': + resolution: {integrity: sha512-67M2lvpBp0Ciczv1uRzabOXSCGiEeJE3wYVoPAxkqI35CJSkotu4tSX2TFOwagUQoRyU6F8YV3xXGfCpDN9WAA==} engines: {node: '>=10'} '@codemirror/commands@6.7.0': @@ -5255,7 +5255,7 @@ snapshots: prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3) prosemirror-view: 1.34.1 - '@chatwoot/utils@0.0.47': + '@chatwoot/utils@0.0.48': dependencies: date-fns: 2.30.0 From df4de508e70deefb8fa88b09bc69add7a0bdb798 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 30 Jul 2025 19:34:27 +0530 Subject: [PATCH 04/15] feat: New Scenarios page (#11975) --- .../dashboard/api/captain/scenarios.js | 36 ++ app/javascript/dashboard/api/captain/tools.js | 16 + .../components-next/Editor/Editor.vue | 2 + .../captain/assistant/AddNewRulesDialog.vue | 6 +- .../assistant/AddNewScenariosDialog.vue | 155 +++++++++ .../captain/assistant/RuleCard.vue | 1 - .../captain/assistant/ScenariosCard.story.vue | 45 +++ .../captain/assistant/ScenariosCard.vue | 218 ++++++++++++ .../captain/assistant/ToolsDropdown.story.vue | 37 ++ .../captain/assistant/ToolsDropdown.vue | 54 +++ .../components/widgets/WootWriter/Editor.vue | 26 +- .../widgets/conversation/TagTools.vue | 56 +++ .../helper/AnalyticsHelper/events.js | 1 + .../dashboard/helper/editorHelper.js | 11 + .../i18n/locale/en/integrations.json | 69 ++++ .../captain/assistants/guardrails/Index.vue | 5 + .../captain/assistants/guidelines/Index.vue | 7 + .../captain/assistants/scenarios/Index.vue | 320 ++++++++++++++++++ .../captain/assistants/settings/Settings.vue | 2 +- .../dashboard/captain/captain.routes.js | 16 + .../dashboard/store/captain/scenarios.js | 38 +++ .../dashboard/store/captain/tools.js | 24 ++ app/javascript/dashboard/store/index.js | 4 + .../captain/scenarios/index.json.jbuilder | 7 +- package.json | 2 +- pnpm-lock.yaml | 10 +- .../captain/scenarios_controller_spec.rb | 8 +- 27 files changed, 1161 insertions(+), 15 deletions(-) create mode 100644 app/javascript/dashboard/api/captain/scenarios.js create mode 100644 app/javascript/dashboard/api/captain/tools.js create mode 100644 app/javascript/dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue create mode 100644 app/javascript/dashboard/components-next/captain/assistant/ScenariosCard.story.vue create mode 100644 app/javascript/dashboard/components-next/captain/assistant/ScenariosCard.vue create mode 100644 app/javascript/dashboard/components-next/captain/assistant/ToolsDropdown.story.vue create mode 100644 app/javascript/dashboard/components-next/captain/assistant/ToolsDropdown.vue create mode 100644 app/javascript/dashboard/components/widgets/conversation/TagTools.vue create mode 100644 app/javascript/dashboard/routes/dashboard/captain/assistants/scenarios/Index.vue create mode 100644 app/javascript/dashboard/store/captain/scenarios.js create mode 100644 app/javascript/dashboard/store/captain/tools.js diff --git a/app/javascript/dashboard/api/captain/scenarios.js b/app/javascript/dashboard/api/captain/scenarios.js new file mode 100644 index 000000000..3e61c28a3 --- /dev/null +++ b/app/javascript/dashboard/api/captain/scenarios.js @@ -0,0 +1,36 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainScenarios extends ApiClient { + constructor() { + super('captain/assistants', { accountScoped: true }); + } + + get({ assistantId, page = 1, searchKey } = {}) { + return axios.get(`${this.url}/${assistantId}/scenarios`, { + params: { page, searchKey }, + }); + } + + show({ assistantId, id }) { + return axios.get(`${this.url}/${assistantId}/scenarios/${id}`); + } + + create({ assistantId, ...data } = {}) { + return axios.post(`${this.url}/${assistantId}/scenarios`, { + scenario: data, + }); + } + + update({ assistantId, id }, data = {}) { + return axios.put(`${this.url}/${assistantId}/scenarios/${id}`, { + scenario: data, + }); + } + + delete({ assistantId, id }) { + return axios.delete(`${this.url}/${assistantId}/scenarios/${id}`); + } +} + +export default new CaptainScenarios(); diff --git a/app/javascript/dashboard/api/captain/tools.js b/app/javascript/dashboard/api/captain/tools.js new file mode 100644 index 000000000..20edaa95e --- /dev/null +++ b/app/javascript/dashboard/api/captain/tools.js @@ -0,0 +1,16 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainTools extends ApiClient { + constructor() { + super('captain/assistants/tools', { accountScoped: true }); + } + + get(params = {}) { + return axios.get(this.url, { + params, + }); + } +} + +export default new CaptainTools(); diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index 9e5ff6ab5..a2f139bdc 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -20,6 +20,7 @@ const props = defineProps({ enableVariables: { type: Boolean, default: false }, enableCannedResponses: { type: Boolean, default: true }, enabledMenuOptions: { type: Array, default: () => [] }, + enableCaptainTools: { type: Boolean, default: false }, }); const emit = defineEmits(['update:modelValue']); @@ -98,6 +99,7 @@ watch( :enable-variables="enableVariables" :enable-canned-responses="enableCannedResponses" :enabled-menu-options="enabledMenuOptions" + :enable-captain-tools="enableCaptainTools" @input="handleInput" @focus="handleFocus" @blur="handleBlur" diff --git a/app/javascript/dashboard/components-next/captain/assistant/AddNewRulesDialog.vue b/app/javascript/dashboard/components-next/captain/assistant/AddNewRulesDialog.vue index ecdb2d654..c1a465c64 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/AddNewRulesDialog.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/AddNewRulesDialog.vue @@ -1,5 +1,6 @@