From ad41fd90f92ef83a48538d1ffc5bbf088f334aa5 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 19 May 2025 23:48:06 +0530 Subject: [PATCH 01/22] fix: Fix the translation issue on conversation filter reload (#11513) # Pull Request Template ## Description This PR fixes the translation inconsistency in the `` component, where dropdown options would revert to English after page reload. **Cause:** The component used static arrays for chat status and sort options, with translations initialized only once. After a reload, it showed system language (English) until the user's locale was fully loaded. **Solution:** Replaced static arrays with computed properties to make translations reactive. This ensures the options automatically update when the locale changes. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Loom video **Before** https://www.loom.com/share/eeac97c59b21480b95ef74813d3d7fa9?sid=0481994a-8d35-4c44-87d0-c6c5a77a54fd **After** https://www.loom.com/share/c1bdfbdb19ca4e37bda373f0fe12527a?sid=cb5b1d19-272b-48cb-967c-9a82c2a2b028 ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../conversation/ConversationBasicFilter.vue | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue index dc434f6c7..d699923f5 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue @@ -38,7 +38,7 @@ const currentSortBy = computed(() => { ); }); -const chatStatusOptions = [ +const chatStatusOptions = computed(() => [ { label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.open.TEXT'), value: 'open', @@ -59,9 +59,9 @@ const chatStatusOptions = [ label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.all.TEXT'), value: 'all', }, -]; +]); -const chatSortOptions = [ +const chatSortOptions = computed(() => [ { label: t('CHAT_LIST.SORT_ORDER_ITEMS.last_activity_at_asc.TEXT'), value: 'last_activity_at_asc', @@ -94,15 +94,18 @@ const chatSortOptions = [ label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_desc.TEXT'), value: 'waiting_since_desc', }, -]; +]); const activeChatStatusLabel = computed( () => - chatStatusOptions.find(m => m.value === chatStatusFilter.value)?.label || '' + chatStatusOptions.value.find(m => m.value === chatStatusFilter.value) + ?.label || '' ); const activeChatSortLabel = computed( - () => chatSortOptions.find(m => m.value === chatSortFilter.value)?.label || '' + () => + chatSortOptions.value.find(m => m.value === chatSortFilter.value)?.label || + '' ); const saveSelectedFilter = (type, value) => { From d657f35a7680c24f8e9ab3329b9ddc85899f1fc3 Mon Sep 17 00:00:00 2001 From: Pranav Date: Mon, 19 May 2025 15:26:38 -0700 Subject: [PATCH 02/22] feat: Introduce the concept of tool registry within Captain (#11516) This PR introduces the concept of a tool registry. The implementation is straightforward: you can define a tool by creating a class with a function name. The function name gets registered in the registry and can be referenced during LLM calls. When the LLM invokes a tool using the registered name, the registry locates and executes the appropriate tool. If the LLM calls an unregistered tool, the registry returns an error indicating that the tool is not defined. --- enterprise/app/helpers/captain/chat_helper.rb | 67 ++++---------- .../services/captain/copilot/chat_service.rb | 7 ++ .../captain/llm/assistant_chat_service.rb | 6 ++ .../services/captain/tool_registry_service.rb | 27 ++++++ .../services/captain/tools/base_service.rb | 34 ++++++++ .../tools/search_documentation_service.rb | 49 +++++++++++ .../captain/tool_registry_service_spec.rb | 87 +++++++++++++++++++ .../search_documentation_service_spec.rb | 77 ++++++++++++++++ 8 files changed, 303 insertions(+), 51 deletions(-) create mode 100644 enterprise/app/services/captain/tool_registry_service.rb create mode 100644 enterprise/app/services/captain/tools/base_service.rb create mode 100644 enterprise/app/services/captain/tools/search_documentation_service.rb create mode 100644 spec/enterprise/services/captain/tool_registry_service_spec.rb create mode 100644 spec/enterprise/services/captain/tools/search_documentation_service_spec.rb diff --git a/enterprise/app/helpers/captain/chat_helper.rb b/enterprise/app/helpers/captain/chat_helper.rb index 146e8f813..bbe3851f0 100644 --- a/enterprise/app/helpers/captain/chat_helper.rb +++ b/enterprise/app/helpers/captain/chat_helper.rb @@ -1,24 +1,4 @@ module Captain::ChatHelper - def search_documentation_tool - { - type: 'function', - function: { - name: 'search_documentation', - description: "Use this function to get documentation on functionalities you don't know about.", - parameters: { - type: 'object', - properties: { - search_query: { - type: 'string', - description: 'The search query to look up in the documentation.' - } - }, - required: ['search_query'] - } - } - } - end - def request_chat_completion Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{@messages}" } @@ -26,13 +6,12 @@ module Captain::ChatHelper parameters: { model: @model, messages: @messages, - tools: [search_documentation_tool], + tools: @tool_registry&.registered_tools || [], response_format: { type: 'json_object' } } ) handle_response(response) - @response end def handle_response(response) @@ -41,7 +20,7 @@ module Captain::ChatHelper if message['tool_calls'] process_tool_calls(message['tool_calls']) else - @response = JSON.parse(message['content'].strip) + JSON.parse(message['content'].strip) end end @@ -54,38 +33,20 @@ module Captain::ChatHelper end def process_tool_call(tool_call) + arguments = JSON.parse(tool_call['function']['arguments']) + function_name = tool_call['function']['name'] tool_call_id = tool_call['id'] - if tool_call['function']['name'] == 'search_documentation' - query = JSON.parse(tool_call['function']['arguments'])['search_query'] - sections = fetch_documentation(query) - append_tool_response(sections, tool_call_id) + if @tool_registry.respond_to?(function_name) + execute_tool(function_name, arguments, tool_call_id) else - append_tool_response('', tool_call_id) + process_invalid_tool_call(tool_call_id) end end - def fetch_documentation(query) - Rails.logger.debug { "[CAPTAIN][DocumentationSearch] #{query}" } - @assistant - .responses - .approved - .search(query) - .map { |response| format_response(response) }.join - end - - def format_response(response) - formatted_response = " - Question: #{response.question} - Answer: #{response.answer} - " - if response.documentable.present? && response.documentable.try(:external_link) - formatted_response += " - Source: #{response.documentable.external_link} - " - end - - formatted_response + def execute_tool(function_name, arguments, tool_call_id) + result = @tool_registry.send(function_name, arguments) + append_tool_response(result, tool_call_id) end def append_tool_calls(tool_calls) @@ -95,11 +56,15 @@ module Captain::ChatHelper } end - def append_tool_response(sections, tool_call_id) + def process_invalid_tool_call(tool_call_id) + append_tool_response('Tool not available', tool_call_id) + end + + def append_tool_response(content, tool_call_id) @messages << { role: 'tool', tool_call_id: tool_call_id, - content: "Found the following FAQs in the documentation:\n #{sections}" + content: content } end end diff --git a/enterprise/app/services/captain/copilot/chat_service.rb b/enterprise/app/services/captain/copilot/chat_service.rb index 6fd3c4e68..202348825 100644 --- a/enterprise/app/services/captain/copilot/chat_service.rb +++ b/enterprise/app/services/captain/copilot/chat_service.rb @@ -10,6 +10,8 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService @conversation_history = config[:conversation_history] @previous_messages = config[:previous_messages] || [] @language = config[:language] || 'english' + + register_tools @messages = [system_message, conversation_history_context] + @previous_messages @response = '' end @@ -25,6 +27,11 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService private + def register_tools + @tool_registry = Captain::ToolRegistryService.new(@assistant) + @tool_registry.register_tool(Captain::Tools::SearchDocumentationService) + end + def system_message { role: 'system', diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb index 50688f18b..8077b22c9 100644 --- a/enterprise/app/services/captain/llm/assistant_chat_service.rb +++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb @@ -9,6 +9,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService @assistant = assistant @messages = [system_message] @response = '' + register_tools end def generate_response(input, previous_messages = [], role = 'user') @@ -19,6 +20,11 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService private + def register_tools + @tool_registry = Captain::ToolRegistryService.new(@assistant) + @tool_registry.register_tool(Captain::Tools::SearchDocumentationService) + end + def system_message { role: 'system', diff --git a/enterprise/app/services/captain/tool_registry_service.rb b/enterprise/app/services/captain/tool_registry_service.rb new file mode 100644 index 000000000..088d89483 --- /dev/null +++ b/enterprise/app/services/captain/tool_registry_service.rb @@ -0,0 +1,27 @@ +class Captain::ToolRegistryService + attr_reader :registered_tools, :tools + + def initialize(assistant) + @assistant = assistant + @registered_tools = [] + @tools = {} + end + + def register_tool(tool_class) + tool = tool_class.new(@assistant) + @tools[tool.name] = tool + @registered_tools << tool.to_registry_format + end + + def method_missing(method_name, *arguments) + if @tools.key?(method_name.to_s) + @tools[method_name.to_s].execute(*arguments) + else + super + end + end + + def respond_to_missing?(method_name, include_private = false) + @tools.key?(method_name.to_s) || super + end +end diff --git a/enterprise/app/services/captain/tools/base_service.rb b/enterprise/app/services/captain/tools/base_service.rb new file mode 100644 index 000000000..10e44d2f4 --- /dev/null +++ b/enterprise/app/services/captain/tools/base_service.rb @@ -0,0 +1,34 @@ +class Captain::Tools::BaseService + attr_accessor :assistant + + def initialize(assistant) + @assistant = assistant + end + + def name + raise NotImplementedError, "#{self.class} must implement name" + end + + def description + raise NotImplementedError, "#{self.class} must implement description" + end + + def parameters + raise NotImplementedError, "#{self.class} must implement parameters" + end + + def execute(arguments) + raise NotImplementedError, "#{self.class} must implement execute" + end + + def to_registry_format + { + type: 'function', + function: { + name: name, + description: description, + parameters: parameters + } + } + end +end diff --git a/enterprise/app/services/captain/tools/search_documentation_service.rb b/enterprise/app/services/captain/tools/search_documentation_service.rb new file mode 100644 index 000000000..672baf24a --- /dev/null +++ b/enterprise/app/services/captain/tools/search_documentation_service.rb @@ -0,0 +1,49 @@ +class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseService + def name + 'search_documentation' + end + + def description + 'Search and retrieve documentation from knowledge base' + end + + def parameters + { + type: 'object', + properties: { + search_query: { + type: 'string', + description: 'The search query to look up in the documentation.' + } + }, + required: ['search_query'] + } + end + + def execute(arguments) + query = arguments['search_query'] + Rails.logger.info { "#{self.class.name}: #{query}" } + + responses = assistant.responses.approved.search(query) + + return 'No FAQs found for the given query' if responses.empty? + + responses.map { |response| format_response(response) }.join + end + + private + + def format_response(response) + formatted_response = " + Question: #{response.question} + Answer: #{response.answer} + " + if response.documentable.present? && response.documentable.try(:external_link) + formatted_response += " + Source: #{response.documentable.external_link} + " + end + + formatted_response + end +end diff --git a/spec/enterprise/services/captain/tool_registry_service_spec.rb b/spec/enterprise/services/captain/tool_registry_service_spec.rb new file mode 100644 index 000000000..e355dea0a --- /dev/null +++ b/spec/enterprise/services/captain/tool_registry_service_spec.rb @@ -0,0 +1,87 @@ +require 'rails_helper' + +# Test tool implementation +class TestTool < Captain::Tools::BaseService + def name + 'test_tool' + end + + def description + 'A test tool for specs' + end + + def parameters + { + type: 'object', + properties: { + test_param: { + type: 'string' + } + } + } + end + + def execute(*args) + args + end +end + +RSpec.describe Captain::ToolRegistryService do + let(:assistant) { create(:captain_assistant) } + let(:service) { described_class.new(assistant) } + + describe '#initialize' do + it 'initializes with empty tools and registered_tools' do + expect(service.tools).to be_empty + expect(service.registered_tools).to be_empty + end + end + + describe '#register_tool' do + let(:tool_class) { TestTool } + + it 'registers a new tool' do + service.register_tool(tool_class) + + expect(service.tools['test_tool']).to be_a(TestTool) + expect(service.registered_tools).to include( + { + type: 'function', + function: { + name: 'test_tool', + description: 'A test tool for specs', + parameters: { + type: 'object', + properties: { + test_param: { + type: 'string' + } + } + } + } + } + ) + end + end + + describe 'method_missing' do + let(:tool_class) { TestTool } + + before do + service.register_tool(tool_class) + end + + context 'when method corresponds to a registered tool' do + it 'executes the tool with given arguments' do + result = service.test_tool(test_param: 'arg1') + expect(result).to eq([{ test_param: 'arg1' }]) + end + end + + context 'when method does not correspond to a registered tool' do + it 'raises NoMethodError' do + expect { service.unknown_tool }.to raise_error(NoMethodError) + end + end + end +end diff --git a/spec/enterprise/services/captain/tools/search_documentation_service_spec.rb b/spec/enterprise/services/captain/tools/search_documentation_service_spec.rb new file mode 100644 index 000000000..9f5586e6b --- /dev/null +++ b/spec/enterprise/services/captain/tools/search_documentation_service_spec.rb @@ -0,0 +1,77 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::SearchDocumentationService do + let(:assistant) { create(:captain_assistant) } + let(:service) { described_class.new(assistant) } + let(:question) { 'How to create a new account?' } + let(:answer) { 'You can create a new account by clicking on the Sign Up button.' } + let(:external_link) { 'https://example.com/docs/create-account' } + + describe '#name' do + it 'returns the correct service name' do + expect(service.name).to eq('search_documentation') + end + end + + describe '#description' do + it 'returns the service description' do + expect(service.description).to eq('Search and retrieve documentation from knowledge base') + end + end + + describe '#parameters' do + it 'returns the required parameters schema' do + expected_schema = { + type: 'object', + properties: { + search_query: { + type: 'string', + description: 'The search query to look up in the documentation.' + } + }, + required: ['search_query'] + } + + expect(service.parameters).to eq(expected_schema) + end + end + + describe '#execute' do + let!(:response) do + create( + :captain_assistant_response, + assistant: assistant, + question: question, + answer: answer, + status: 'approved' + ) + end + + let(:documentable) { create(:captain_document, external_link: external_link) } + + context 'when matching responses exist' do + before do + response.update(documentable: documentable) + allow(Captain::AssistantResponse).to receive(:search).with(question).and_return([response]) + end + + it 'returns formatted responses for the search query' do + result = service.execute({ 'search_query' => question }) + + expect(result).to include(question) + expect(result).to include(answer) + expect(result).to include(external_link) + end + end + + context 'when no matching responses exist' do + before do + allow(Captain::AssistantResponse).to receive(:search).with(question).and_return([]) + end + + it 'returns an empty string' do + expect(service.execute({ 'search_query' => question })).to eq('No FAQs found for the given query') + end + end + end +end From 16371498ba01ee4c4265bf85d6fc0a8d706a1bee Mon Sep 17 00:00:00 2001 From: Pranav Date: Mon, 19 May 2025 18:00:38 -0700 Subject: [PATCH 03/22] feat: Add a tool to search Linear Issues in copilot (#11518) This PR adds a tool to search Linear issues. If the integration is enabled for the account, the tool will return results as expected. Also introduces support for an `active?` method, which allows third-party Copilot tools to be conditionally enabled based on the status of the integration on the account. --- .../services/captain/tool_registry_service.rb | 2 + .../services/captain/tools/base_service.rb | 4 + .../copilot/search_linear_issues_service.rb | 77 +++++++++++ .../captain/tool_registry_service_spec.rb | 59 ++++++--- .../search_linear_issues_service_spec.rb | 125 ++++++++++++++++++ 5 files changed, 250 insertions(+), 17 deletions(-) create mode 100644 enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb create mode 100644 spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb diff --git a/enterprise/app/services/captain/tool_registry_service.rb b/enterprise/app/services/captain/tool_registry_service.rb index 088d89483..f2c234060 100644 --- a/enterprise/app/services/captain/tool_registry_service.rb +++ b/enterprise/app/services/captain/tool_registry_service.rb @@ -9,6 +9,8 @@ class Captain::ToolRegistryService def register_tool(tool_class) tool = tool_class.new(@assistant) + return unless tool.active? + @tools[tool.name] = tool @registered_tools << tool.to_registry_format end diff --git a/enterprise/app/services/captain/tools/base_service.rb b/enterprise/app/services/captain/tools/base_service.rb index 10e44d2f4..7fadc806b 100644 --- a/enterprise/app/services/captain/tools/base_service.rb +++ b/enterprise/app/services/captain/tools/base_service.rb @@ -31,4 +31,8 @@ class Captain::Tools::BaseService } } end + + def active? + true + end end diff --git a/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb b/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb new file mode 100644 index 000000000..599fc7de9 --- /dev/null +++ b/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb @@ -0,0 +1,77 @@ +class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseService + def name + 'search_linear_issues' + end + + def description + 'Search Linear issues based on a search term' + end + + def parameters + { + type: 'object', + properties: { + term: { + type: 'string', + description: 'The search term to find Linear issues' + } + }, + required: %w[term] + } + end + + def execute(arguments) + return 'Linear integration is not enabled' unless active? + + term = arguments['term'] + + Rails.logger.info "#{self.class.name}: Service called with the search term #{term}" + + return 'Missing required parameters' if term.blank? + + linear_service = Integrations::Linear::ProcessorService.new(account: @assistant.account) + result = linear_service.search_issue(term) + + return result[:error] if result[:error] + + issues = result[:data] + return 'No issues found, I should try another similar search term' if issues.blank? + + total_count = issues.length + + <<~RESPONSE + Total number of issues: #{total_count} + #{issues.map { |issue| format_issue(issue) }.join("\n---\n")} + RESPONSE + end + + def active? + @assistant.account.hooks.find_by(app_id: 'linear').present? + end + + private + + def format_issue(issue) + <<~ISSUE + Title: #{issue['title']} + ID: #{issue['id']} + State: #{issue['state']['name']} + Priority: #{format_priority(issue['priority'])} + #{issue['assignee'] ? "Assignee: #{issue['assignee']['name']}" : 'Assignee: Unassigned'} + #{issue['description'].present? ? "\nDescription: #{issue['description']}" : ''} + ISSUE + end + + def format_priority(priority) + return 'No priority' if priority.nil? + + case priority + when 0 then 'No priority' + when 1 then 'Urgent' + when 2 then 'High' + when 3 then 'Medium' + when 4 then 'Low' + else 'Unknown' + end + end +end diff --git a/spec/enterprise/services/captain/tool_registry_service_spec.rb b/spec/enterprise/services/captain/tool_registry_service_spec.rb index e355dea0a..beb4a6a63 100644 --- a/spec/enterprise/services/captain/tool_registry_service_spec.rb +++ b/spec/enterprise/services/captain/tool_registry_service_spec.rb @@ -2,6 +2,13 @@ require 'rails_helper' # Test tool implementation class TestTool < Captain::Tools::BaseService + attr_accessor :tool_active + + def initialize(*args) + super + @tool_active = true + end + def name 'test_tool' end @@ -24,6 +31,10 @@ class TestTool < Captain::Tools::BaseService def execute(*args) args end + + def active? + @tool_active + end end RSpec.describe Captain::ToolRegistryService do @@ -40,27 +51,41 @@ RSpec.describe Captain::ToolRegistryService do describe '#register_tool' do let(:tool_class) { TestTool } - it 'registers a new tool' do - service.register_tool(tool_class) - - expect(service.tools['test_tool']).to be_a(TestTool) - expect(service.registered_tools).to include( - { - type: 'function', - function: { - name: 'test_tool', - description: 'A test tool for specs', - parameters: { - type: 'object', - properties: { - test_param: { - type: 'string' + context 'when tool is active' do + it 'registers a new tool' do + service.register_tool(tool_class) + expect(service.tools['test_tool']).to be_a(TestTool) + expect(service.registered_tools).to include( + { + type: 'function', + function: { + name: 'test_tool', + description: 'A test tool for specs', + parameters: { + type: 'object', + properties: { + test_param: { + type: 'string' + } } } } } - } - ) + ) + end + end + + context 'when tool is inactive' do + it 'does not register the tool' do + tool = tool_class.new(assistant) + tool.tool_active = false + allow(tool_class).to receive(:new).and_return(tool) + + service.register_tool(tool_class) + + expect(service.tools['test_tool']).to be_nil + expect(service.registered_tools).to be_empty + end end end diff --git a/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb new file mode 100644 index 000000000..f4a5225b1 --- /dev/null +++ b/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb @@ -0,0 +1,125 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:service) { described_class.new(assistant) } + + describe '#name' do + it 'returns the correct service name' do + expect(service.name).to eq('search_linear_issues') + end + end + + describe '#description' do + it 'returns the service description' do + expect(service.description).to eq('Search Linear issues based on a search term') + end + end + + describe '#parameters' do + it 'returns the expected parameter schema' do + expect(service.parameters).to eq( + { + type: 'object', + properties: { + term: { + type: 'string', + description: 'The search term to find Linear issues' + } + }, + required: %w[term] + } + ) + end + end + + describe '#active?' do + context 'when Linear integration is enabled' do + before do + create(:integrations_hook, :linear, account: account) + end + + it 'returns true' do + expect(service.active?).to be true + end + end + + context 'when Linear integration is not enabled' do + it 'returns false' do + expect(service.active?).to be false + end + end + end + + describe '#execute' do + context 'when Linear integration is not enabled' do + it 'returns error message' do + expect(service.execute({ 'term' => 'test' })).to eq('Linear integration is not enabled') + end + end + + context 'when Linear integration is enabled' do + let(:linear_service) { instance_double(Integrations::Linear::ProcessorService) } + + before do + create(:integrations_hook, :linear, account: account) + allow(Integrations::Linear::ProcessorService).to receive(:new).and_return(linear_service) + end + + context 'when term is blank' do + it 'returns error message' do + expect(service.execute({ 'term' => '' })).to eq('Missing required parameters') + end + end + + context 'when search returns error' do + before do + allow(linear_service).to receive(:search_issue).and_return({ error: 'API Error' }) + end + + it 'returns the error message' do + expect(service.execute({ 'term' => 'test' })).to eq('API Error') + end + end + + context 'when search returns no issues' do + before do + allow(linear_service).to receive(:search_issue).and_return({ data: [] }) + end + + it 'returns no issues found message' do + expect(service.execute({ 'term' => 'test' })).to eq('No issues found, I should try another similar search term') + end + end + + context 'when search returns issues' do + let(:issues) do + [{ + 'title' => 'Test Issue', + 'id' => 'TEST-123', + 'state' => { 'name' => 'In Progress' }, + 'priority' => 4, + 'assignee' => { 'name' => 'John Doe' }, + 'description' => 'Test description' + }] + end + + before do + allow(linear_service).to receive(:search_issue).and_return({ data: issues }) + end + + it 'returns formatted issues' do + result = service.execute({ 'term' => 'test' }) + expect(result).to include('Total number of issues: 1') + expect(result).to include('Title: Test Issue') + expect(result).to include('ID: TEST-123') + expect(result).to include('State: In Progress') + expect(result).to include('Priority: Low') + expect(result).to include('Assignee: John Doe') + expect(result).to include('Description: Test description') + end + end + end + end +end From 98a50d12e047a49c863d3287c0946b4cacac14f1 Mon Sep 17 00:00:00 2001 From: Sojan Date: Tue, 20 May 2025 00:11:50 -0700 Subject: [PATCH 04/22] Bump version to 4.2.0 --- config/app.yml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/app.yml b/config/app.yml index 9afc3f7bc..36bbdbcf1 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '4.1.0' + version: '4.2.0' development: <<: *shared diff --git a/package.json b/package.json index 88c775f28..90b7708f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "4.1.0", + "version": "4.2.0", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", From ccb3672ff88c85308750acead257abdae8702aa1 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 20 May 2025 16:04:56 +0530 Subject: [PATCH 05/22] fix: Status not updating when creating a Linear issue (#11523) --- .../api/v1/accounts/integrations/linear_controller.rb | 3 ++- lib/linear.rb | 3 ++- .../api/v1/accounts/integrations/linear_controller_spec.rb | 1 + spec/lib/integrations/linear/processor_service_spec.rb | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index 4e5348e88..c66f06909 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -94,7 +94,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas end def permitted_params - params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: []) + params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, :state_id, + label_ids: []) end def fetch_hook diff --git a/lib/linear.rb b/lib/linear.rb index 9a998c34a..8bf967fc3 100644 --- a/lib/linear.rb +++ b/lib/linear.rb @@ -57,7 +57,8 @@ class Linear assigneeId: params[:assignee_id], priority: params[:priority], labelIds: params[:label_ids], - projectId: params[:project_id] + projectId: params[:project_id], + stateId: params[:state_id] }.compact mutation = Linear::Mutations.issue_create(variables) response = post({ query: mutation }) diff --git a/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb index b1341e65e..0f27e2bd2 100644 --- a/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb @@ -100,6 +100,7 @@ RSpec.describe 'Linear Integration API', type: :request do description: 'This is a sample issue.', assignee_id: 'user1', priority: 'high', + state_id: 'state1', label_ids: ['label1'] } end diff --git a/spec/lib/integrations/linear/processor_service_spec.rb b/spec/lib/integrations/linear/processor_service_spec.rb index 07cf27654..807e93c71 100644 --- a/spec/lib/integrations/linear/processor_service_spec.rb +++ b/spec/lib/integrations/linear/processor_service_spec.rb @@ -76,6 +76,7 @@ describe Integrations::Linear::ProcessorService do description: 'Issue description', assignee_id: 'user1', priority: 2, + state_id: 'state1', label_ids: %w[bug] } end From 27ec791353dadc990a67b10701e19981421f6470 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 21 May 2025 04:09:18 +0530 Subject: [PATCH 06/22] fix: Display message content for CSAT messages in non-widget inboxes (#11528) We made so many improvements for CSAT via https://github.com/chatwoot/chatwoot/pull/11485. However, we missed showing message content in the dashboard for CSAT URLs created in non-widget inboxes. This PR fixes the issue by ensuring that CSAT-configured messages are passed along with CSAT responses, otherwise defaulting to the translation. --- app/models/message.rb | 8 +++++++- spec/models/message_spec.rb | 31 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/models/message.rb b/app/models/message.rb index f5d7712d2..a952e0265 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -185,7 +185,13 @@ class Message < ApplicationRecord # move this to a presenter return self[:content] if !input_csat? || inbox.web_widget? - I18n.t('conversations.survey.response', link: "#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation.uuid}") + survey_link = "#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation.uuid}" + + if inbox.csat_config&.dig('message').present? + "#{inbox.csat_config['message']} #{survey_link}" + else + I18n.t('conversations.survey.response', link: survey_link) + end end def email_notifiable_message? diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index 4c66e37ee..b4be3ac5a 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -475,4 +475,35 @@ RSpec.describe Message do end end end + + describe '#content' do + let(:conversation) { create(:conversation) } + let(:message) { create(:message, conversation: conversation, content_type: 'input_csat', content: 'Original content') } + + it 'returns original content for web widget inbox' do + allow(message.inbox).to receive(:web_widget?).and_return(true) + expect(message.content).to eq('Original content') + end + + context 'when inbox is not a web widget' do + before do + allow(message.inbox).to receive(:web_widget?).and_return(false) + allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('https://app.chatwoot.com') + end + + it 'returns custom message with survey link when csat message is configured' do + allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom survey message:' }) + expected_content = "Custom survey message: https://app.chatwoot.com/survey/responses/#{conversation.uuid}" + expect(message.content).to eq(expected_content) + end + + it 'returns default message with survey link when no custom csat message' do + allow(message.inbox).to receive(:csat_config).and_return(nil) + allow(I18n).to receive(:t).with('conversations.survey.response', link: "https://app.chatwoot.com/survey/responses/#{conversation.uuid}") + .and_return("Please rate your conversation: https://app.chatwoot.com/survey/responses/#{conversation.uuid}") + expected_content = "Please rate your conversation: https://app.chatwoot.com/survey/responses/#{conversation.uuid}" + expect(message.content).to eq(expected_content) + end + end + end end From 2ee63656e24633a8c74f6f0e1e724d8f7fed9a28 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 21 May 2025 06:04:30 +0530 Subject: [PATCH 07/22] feat: Prevent saving preferences and status when impersonating (#11164) This PR will prevent saving user preferences and online status when impersonating. Previously, these settings could be updated during impersonation, causing the user to see a different view or UI settings. Fixes https://linear.app/chatwoot/issue/CW-4163/impersonation-improvements --- .../sidebar/SidebarProfileMenuStatus.vue | 7 + .../components/layout/AvailabilityStatus.vue | 12 ++ .../composables/spec/useImpersonation.spec.js | 37 +++++ .../dashboard/composables/useImpersonation.js | 10 ++ .../dashboard/constants/sessionStorage.js | 3 + .../dashboard/i18n/locale/en/settings.json | 3 +- .../dashboard/store/modules/auth.js | 13 +- app/javascript/dashboard/store/utils/api.js | 7 + .../shared/helpers/sessionStorage.js | 26 ++++ .../helpers/specs/sessionStorage.spec.js | 137 ++++++++++++++++++ app/javascript/v3/views/login/Index.vue | 14 +- app/models/concerns/sso_authenticatable.rb | 4 + app/views/super_admin/users/_impersonate.erb | 2 +- 13 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 app/javascript/dashboard/composables/spec/useImpersonation.spec.js create mode 100644 app/javascript/dashboard/composables/useImpersonation.js create mode 100644 app/javascript/dashboard/constants/sessionStorage.js create mode 100644 app/javascript/shared/helpers/sessionStorage.js create mode 100644 app/javascript/shared/helpers/specs/sessionStorage.spec.js diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue index 0fdf96162..fef196162 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue @@ -4,6 +4,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store'; import wootConstants from 'dashboard/constants/globals'; import { useAlert } from 'dashboard/composables'; import { useI18n } from 'vue-i18n'; +import { useImpersonation } from 'dashboard/composables/useImpersonation'; import { DropdownContainer, @@ -20,6 +21,8 @@ const currentUserAvailability = useMapGetter('getCurrentUserAvailability'); const currentAccountId = useMapGetter('getCurrentAccountId'); const currentUserAutoOffline = useMapGetter('getCurrentUserAutoOffline'); +const { isImpersonating } = useImpersonation(); + const { AVAILABILITY_STATUS_KEYS } = wootConstants; const statusList = computed(() => { return [ @@ -46,6 +49,10 @@ const activeStatus = computed(() => { }); function changeAvailabilityStatus(availability) { + if (isImpersonating.value) { + useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR')); + return; + } try { store.dispatch('updateAvailability', { availability, diff --git a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue index 04190c7a4..a4995aee8 100644 --- a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue +++ b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue @@ -1,6 +1,7 @@ + + diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue index a3b0bf37c..b2b0dbfa0 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue @@ -7,6 +7,7 @@ import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/Contac import Button from 'dashboard/components-next/button/Button.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Flag from 'dashboard/components-next/flag/Flag.vue'; +import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue'; import countries from 'shared/constants/countries'; const props = defineProps({ @@ -149,15 +150,15 @@ const onClickViewDetails = () => emit('showContact', props.id); /> diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue index d9c0deb1b..f43a50883 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue @@ -47,11 +47,7 @@ defineExpose({ dialogRef }); ref="dialogRef" type="alert" :title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')" - :description=" - t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION', { - contactName: props.selectedContact.name, - }) - " + :description="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION')" :confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')" @confirm="handleDialogConfirm" /> diff --git a/app/javascript/dashboard/components-next/button/Button.vue b/app/javascript/dashboard/components-next/button/Button.vue index c54bd395a..dde1d3d9a 100644 --- a/app/javascript/dashboard/components-next/button/Button.vue +++ b/app/javascript/dashboard/components-next/button/Button.vue @@ -117,7 +117,7 @@ const STYLE_CONFIG = { 'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 focus-visible:bg-n-ruby-9/10 outline-n-ruby-8', ghost: 'text-n-ruby-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent', - link: 'text-n-ruby-9 hover:enabled:underline focus-visible:underline outline-transparent', + link: 'text-n-ruby-9 dark:text-n-ruby-11 hover:enabled:underline focus-visible:underline outline-transparent', }, amber: { solid: diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index fc4509809..4c599ffe8 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -458,6 +458,10 @@ "PLACEHOLDER": "Add Twitter" } } + }, + "DELETE_CONTACT": { + "MESSAGE": "This action is permanent and irreversible.", + "BUTTON": "Delete now" } }, "DETAILS": { @@ -467,7 +471,7 @@ "DELETE_CONTACT": "Delete contact", "DELETE_DIALOG": { "TITLE": "Confirm Deletion", - "DESCRIPTION": "Are you sure you want to delete this {contactName} contact?", + "DESCRIPTION": "Are you sure you want to delete this contact?", "CONFIRM": "Yes, Delete", "API": { "SUCCESS_MESSAGE": "Contact deleted successfully", From 1602b071db8079909cb04c13c279959ff6fbe6ac Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 20 May 2025 21:35:29 -0700 Subject: [PATCH 12/22] feat: Add components to show steps in the copilot thinking process (#11530) This PR adds the components for new Copilot UI - Added a Header component - Added a thinking block. - Update the outline on copilot input --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../copilot/CopilotHeader.story.vue | 21 +++++++ .../components-next/copilot/CopilotHeader.vue | 32 ++++++++++ .../components-next/copilot/CopilotInput.vue | 9 +-- .../copilot/CopilotThinkingBlock.vue | 25 ++++++++ .../copilot/CopilotThinkingGroup.story.vue | 34 +++++++++++ .../copilot/CopilotThinkingGroup.vue | 61 +++++++++++++++++++ .../i18n/locale/en/integrations.json | 2 + 7 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 app/javascript/dashboard/components-next/copilot/CopilotHeader.story.vue create mode 100644 app/javascript/dashboard/components-next/copilot/CopilotHeader.vue create mode 100644 app/javascript/dashboard/components-next/copilot/CopilotThinkingBlock.vue create mode 100644 app/javascript/dashboard/components-next/copilot/CopilotThinkingGroup.story.vue create mode 100644 app/javascript/dashboard/components-next/copilot/CopilotThinkingGroup.vue diff --git a/app/javascript/dashboard/components-next/copilot/CopilotHeader.story.vue b/app/javascript/dashboard/components-next/copilot/CopilotHeader.story.vue new file mode 100644 index 000000000..78a345093 --- /dev/null +++ b/app/javascript/dashboard/components-next/copilot/CopilotHeader.story.vue @@ -0,0 +1,21 @@ + + + diff --git a/app/javascript/dashboard/components-next/copilot/CopilotHeader.vue b/app/javascript/dashboard/components-next/copilot/CopilotHeader.vue new file mode 100644 index 000000000..c7a8696f3 --- /dev/null +++ b/app/javascript/dashboard/components-next/copilot/CopilotHeader.vue @@ -0,0 +1,32 @@ + + + diff --git a/app/javascript/dashboard/components-next/copilot/CopilotInput.vue b/app/javascript/dashboard/components-next/copilot/CopilotInput.vue index bf14945b5..c8f1a0056 100644 --- a/app/javascript/dashboard/components-next/copilot/CopilotInput.vue +++ b/app/javascript/dashboard/components-next/copilot/CopilotInput.vue @@ -13,19 +13,16 @@ const sendMessage = () => {