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/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) => { 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/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..f2c234060 --- /dev/null +++ b/enterprise/app/services/captain/tool_registry_service.rb @@ -0,0 +1,29 @@ +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) + return unless tool.active? + + @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..7fadc806b --- /dev/null +++ b/enterprise/app/services/captain/tools/base_service.rb @@ -0,0 +1,38 @@ +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 + + 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/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/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/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}", 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/enterprise/services/captain/tool_registry_service_spec.rb b/spec/enterprise/services/captain/tool_registry_service_spec.rb new file mode 100644 index 000000000..beb4a6a63 --- /dev/null +++ b/spec/enterprise/services/captain/tool_registry_service_spec.rb @@ -0,0 +1,112 @@ +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 + + def description + 'A test tool for specs' + end + + def parameters + { + type: 'object', + properties: { + test_param: { + type: 'string' + } + } + } + end + + def execute(*args) + args + end + + def active? + @tool_active + 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 } + + 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 + + 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/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 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 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