├── .devcontainer ├── Dockerfile ├── create-db-user.sql ├── devcontainer.json ├── docker-compose.yml ├── install-ble.sh ├── plugin_generator.sh ├── post-create.sh └── redmine.code-workspace ├── .github ├── build-scripts │ ├── build.sh │ ├── cleanup.sh │ ├── database.yml │ ├── env.sh │ └── install.sh ├── copilot-instructions.md ├── release.yml └── workflows │ ├── base_branch_check.yml │ ├── build.yml │ └── release.yml ├── .gitignore ├── .qlty └── qlty.toml ├── Gemfile ├── LICENSE ├── README.md ├── app ├── controllers │ ├── ai_helper_controller.rb │ ├── ai_helper_model_profiles_controller.rb │ └── ai_helper_settings_controller.rb ├── helpers │ ├── ai_helper_helper.rb │ ├── ai_helper_model_profiles_helper.rb │ └── ai_helper_settings_helper.rb ├── models │ ├── ai_helper_conversation.rb │ ├── ai_helper_message.rb │ ├── ai_helper_model_profile.rb │ ├── ai_helper_setting.rb │ ├── ai_helper_summary_cache.rb │ └── ai_helper_vector_data.rb └── views │ ├── ai_helper │ ├── _chat.html.erb │ ├── _chat_form.html.erb │ ├── _history.html.erb │ ├── _html_header.html.erb │ ├── _issue_bottom.html.erb │ ├── _issue_form.html.erb │ ├── _issue_reply.html.erb │ ├── _issue_summary.html.erb │ └── _sidebar.html.erb │ ├── ai_helper_model_profiles │ ├── _form.html.erb │ ├── _show.html.erb │ ├── edit.html.erb │ └── new.html.erb │ └── ai_helper_settings │ └── index.html.erb ├── assets ├── images │ └── icons.svg ├── javascripts │ ├── ai_helper.js │ └── ai_helper_markdown_parser.js ├── prompt_templates │ ├── base_agent │ │ ├── system_prompt.yml │ │ └── system_prompt_ja.yml │ ├── board_agent │ │ ├── backstory.yml │ │ └── backstory_ja.yml │ ├── issue_agent │ │ ├── backstory.yml │ │ ├── backstory_ja.yml │ │ ├── generate_reply.yml │ │ ├── generate_reply_ja.yml │ │ ├── summary.yml │ │ └── summary_ja.yml │ ├── issue_update_agent │ │ ├── backstory.yml │ │ └── backstory_ja.yml │ ├── leader_agent │ │ ├── backstory.yml │ │ ├── backstory_ja.yml │ │ ├── generate_steps.yml │ │ ├── generate_steps_ja.yml │ │ ├── goal.yml │ │ ├── goal_ja.yml │ │ ├── system_prompt.yml │ │ └── system_prompt_ja.yml │ ├── mcp_agent │ │ ├── backstory.yml │ │ └── backstory_ja.yml │ ├── project_agent │ │ ├── backstory.yml │ │ └── backstory_ja.yml │ ├── repository_agent │ │ ├── backstory.yml │ │ └── backstory_ja.yml │ ├── system_agent │ │ ├── backstory.yml │ │ └── backstory_ja.yml │ ├── user_agent │ │ ├── backstory.yml │ │ └── backstory_ja.yml │ ├── version_agent │ │ ├── backstory.yml │ │ └── backstory_ja.yml │ └── wiki_agent │ │ ├── backstory.yml │ │ └── backstory_ja.yml └── stylesheets │ └── ai_helper.css ├── config ├── config.yaml.example ├── icon_source.yml ├── locales │ ├── en.yml │ └── ja.yml └── routes.rb ├── db └── migrate │ ├── 20250112041436_create_ai_helper_conversations.rb │ ├── 20250112041940_create_ai_helper_messages.rb │ ├── 20250112120759_add_project_id_to_ai_helper_conversations.rb │ ├── 20250129221712_remove_project_id_from_ai_helper_conversations.rb │ ├── 20250130045041_rename_update_at_helper_conversations.rb │ ├── 20250130142054_change_date_to_datetime_for_conversation.rb │ ├── 20250405080724_create_ai_helper_model_profiles.rb │ ├── 20250405080829_create_ai_helper_settings.rb │ ├── 20250405122505_add_model_name_to_ai_helper_model_profile.rb │ ├── 20250405134714_change_type_to_llm_type.rb │ ├── 20250414221050_add_fields_for_vector_search.rb │ ├── 20250415220808_create_ai_helper_vector_data.rb │ ├── 20250419035418_add_emmbeding_model_to_setting.rb │ ├── 20250517141147_add_temperature_to_ai_helper_model_profiles.rb │ ├── 20250519133924_create_ai_helper_summary_caches.rb │ ├── 20250529133107_add_dimention_to_setting.rb │ ├── 20250603101602_add_max_tokens_to_model_profile.rb │ └── 20250604122130_add_embedding_url_to_ai_helper_setting.rb ├── example └── redmine_fortune │ ├── fortune_agent.rb │ ├── fortune_tools.rb │ └── init.rb ├── init.rb ├── lib ├── redmine_ai_helper │ ├── agents │ │ ├── board_agent.rb │ │ ├── issue_agent.rb │ │ ├── issue_update_agent.rb │ │ ├── leader_agent.rb │ │ ├── mcp_agent.rb │ │ ├── project_agent.rb │ │ ├── repository_agent.rb │ │ ├── system_agent.rb │ │ ├── user_agent.rb │ │ ├── version_agent.rb │ │ └── wiki_agent.rb │ ├── assistant.rb │ ├── assistant_provider.rb │ ├── assistants │ │ └── gemini_assistant.rb │ ├── base_agent.rb │ ├── base_tools.rb │ ├── chat_room.rb │ ├── langfuse_util │ │ ├── anthropic.rb │ │ ├── azure_open_ai.rb │ │ ├── gemini.rb │ │ ├── langfuse_wrapper.rb │ │ └── open_ai.rb │ ├── llm.rb │ ├── llm_client │ │ ├── anthropic_provider.rb │ │ ├── azure_open_ai_provider.rb │ │ ├── base_provider.rb │ │ ├── gemini_provider.rb │ │ ├── open_ai_compatible_provider.rb │ │ └── open_ai_provider.rb │ ├── llm_provider.rb │ ├── logger.rb │ ├── tool_response.rb │ ├── tools │ │ ├── board_tools.rb │ │ ├── issue_search_tools.rb │ │ ├── issue_tools.rb │ │ ├── issue_update_tools.rb │ │ ├── mcp_tools.rb │ │ ├── project_tools.rb │ │ ├── repository_tools.rb │ │ ├── system_tools.rb │ │ ├── user_tools.rb │ │ ├── vector_tools.rb │ │ ├── version_tools.rb │ │ └── wiki_tools.rb │ ├── util │ │ ├── config_file.rb │ │ ├── issue_json.rb │ │ ├── langchain_patch.rb │ │ ├── mcp_tools_loader.rb │ │ ├── prompt_loader.rb │ │ ├── system_prompt.rb │ │ └── wiki_json.rb │ ├── vector │ │ ├── issue_vector_db.rb │ │ ├── qdrant.rb │ │ ├── vector_db.rb │ │ └── wiki_vector_db.rb │ └── view_hook.rb └── tasks │ ├── scm.rake │ └── vector.rake └── test ├── functional ├── ai_helper_controller_test.rb ├── ai_helper_model_profiles_controller_test.rb └── ai_helper_settings_controller_test.rb ├── model_factory.rb ├── redmine_ai_helper_test_repo.git.tgz ├── test_config.json ├── test_helper.rb └── unit ├── agents ├── agents_test.rb ├── issue_agent_test.rb └── leader_agent_test.rb ├── ai_helper_helper_test.rb ├── ai_helper_summary_cache_test.rb ├── base_agent_test.rb ├── base_tools_test.rb ├── chat_room_test.rb ├── langfuse_util ├── anthropic_test.rb ├── azure_open_ai_test.rb ├── gemini_test.rb ├── langfuse_wrapper_test.rb └── open_ai_test.rb ├── llm_client ├── anthropic_provider_test.rb ├── azure_open_ai_provider_test.rb ├── base_provider_test.rb ├── gemini_provider_test.rb └── open_ai_compatible_provider_test.rb ├── llm_provider_test.rb ├── llm_test.rb ├── logger_test.rb ├── models ├── ai_helper_conversation_test.rb ├── ai_helper_message_test.rb ├── ai_helper_model_profile_test.rb ├── ai_helper_setting_test.rb └── ai_helper_vector_data_test.rb ├── tool_response_test.rb ├── tools ├── board_tools_test.rb ├── issue_search_tools_test.rb ├── issue_tools_test.rb ├── issue_update_tools_test.rb ├── mcp_tools_test.rb ├── project_tools_test.rb ├── repository_tools_test.rb ├── system_tools_test.rb ├── user_tools_test.rb ├── vector_tools_test.rb ├── version_tool_provider_test.rb └── wiki_tool_provider_test.rb ├── util ├── config_file_test.rb ├── issue_json_test.rb ├── mcp_tools_loader_test.rb └── system_prompt_test.rb └── vector ├── issue_vector_db_test.rb ├── qdrant_test.rb ├── vector_db_test.rb └── wiki_vector_db_test.rb /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG RUBY_VERSION=3.2 2 | ARG REDMINE_VERSION=master 3 | FROM haru/redmine_devcontainer:${REDMINE_VERSION}-ruby${RUBY_VERSION} 4 | COPY .devcontainer/post-create.sh /post-create.sh 5 | COPY .devcontainer/install-ble.sh /install-ble.sh 6 | 7 | RUN gem install ruby-lsp 8 | RUN gem install htmlbeautifier 9 | RUN gem install rubocop 10 | RUN gem install logger 11 | RUN apt-get update && apt-get install -y npm --no-install-recommends 12 | USER vscode 13 | RUN bash -x /install-ble.sh 14 | -------------------------------------------------------------------------------- /.devcontainer/create-db-user.sql: -------------------------------------------------------------------------------- 1 | CREATE USER vscode CREATEDB; 2 | CREATE DATABASE vscode WITH OWNER vscode; 3 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // Redmine plugin boilerplate 2 | // version: 1.0.0 3 | { 4 | "name": "Redmine plugin", 5 | "dockerComposeFile": "docker-compose.yml", 6 | "service": "app", 7 | "mounts": [ 8 | "source=${localWorkspaceFolder},target=/workspaces/${localWorkspaceFolderBasename},type=bind" 9 | ], 10 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", 11 | // Set *default* container specific settings.json values on container create. 12 | "settings": { 13 | "sqltools.connections": [ 14 | { 15 | "name": "Rails Development Database", 16 | "driver": "PostgreSQL", 17 | "previewLimit": 50, 18 | "server": "localhost", 19 | "port": 5432, 20 | // update this to match config/database.yml 21 | "database": "app_development", 22 | "username": "vscode" 23 | }, 24 | { 25 | "name": "Rails Test Database", 26 | "driver": "PostgreSQL", 27 | "previewLimit": 50, 28 | "server": "localhost", 29 | "port": 5432, 30 | // update this to match config/database.yml 31 | "database": "app_test", 32 | "username": "vscode" 33 | } 34 | ] 35 | }, 36 | // Add the IDs of extensions you want installed when the container is created. 37 | "extensions": [ 38 | "mtxr.sqltools", 39 | "mtxr.sqltools-driver-pg", 40 | "craigmaslowski.erb", 41 | "hridoy.rails-snippets", 42 | "misogi.ruby-rubocop", 43 | "jnbt.vscode-rufo", 44 | "donjayamanne.git-extension-pack", 45 | "ms-azuretools.vscode-docker", 46 | "KoichiSasada.vscode-rdbg", 47 | "Serhioromano.vscode-gitflow", 48 | "github.vscode-github-actions", 49 | "Shopify.ruby-extensions-pack", 50 | "ritwickdey.LiveServer", 51 | "aliariff.vscode-erb-beautify", 52 | "bysabi.prettier-vscode-standard", 53 | "GitHub.copilot", 54 | "Shunqian.prettier-plus", 55 | "Gruntfuggly.todo-tree" 56 | ], 57 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 58 | // "forwardPorts": [3000, 5432], 59 | // Use 'postCreateCommand' to run commands after the container is created. 60 | "postCreateCommand": "sh -x /post-create.sh", 61 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 62 | "remoteUser": "vscode", 63 | "features": { 64 | // "git": "latest" 65 | }, 66 | "containerEnv": { 67 | "PLUGIN_NAME": "${localWorkspaceFolderBasename}" 68 | }, 69 | "forwardPorts": [ 70 | 3000 71 | ] 72 | } -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | build: 6 | context: .. 7 | dockerfile: .devcontainer/Dockerfile 8 | args: 9 | # Update 'VARIANT' to pick a version of Ruby: 3, 3.0, 2, 2.7, 2.6 10 | # Append -bullseye or -buster to pin to an OS version. 11 | # Use -bullseye variants on local arm64/Apple Silicon. 12 | RUBY_VERSION: "3.2" 13 | # Optional Node.js version to install 14 | NODE_VERSION: "lts/*" 15 | REDMINE_VERSION: "6.0-stable" 16 | 17 | # Overrides default command so things don't shut down after the process ends. 18 | command: sleep infinity 19 | volumes: 20 | - ..:/usr/local/redmine/plugins/redmine_ai_helper 21 | # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. 22 | # network_mode: service:postgres 23 | # Uncomment the next line to use a non-root user for all processes. 24 | # user: vscode 25 | 26 | # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. 27 | # (Adding the "ports" property to this file will not forward from a Codespace.) 28 | 29 | postgres: 30 | image: postgres:latest 31 | restart: unless-stopped 32 | volumes: 33 | - postgres-data:/var/lib/postgresql/data 34 | - ./create-db-user.sql:/docker-entrypoint-initdb.d/create-db-user.sql 35 | environment: 36 | POSTGRES_USER: postgres 37 | POSTGRES_DB: redmine 38 | POSTGRES_PASSWORD: postgres 39 | # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. 40 | # (Adding the "ports" property to this file will not forward from a Codespace.) 41 | 42 | mysql: 43 | image: mysql:latest 44 | restart: unless-stopped 45 | volumes: 46 | - mysql-data:/var/lib/mysql 47 | # network_mode: service:postgres 48 | command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci 49 | environment: 50 | MYSQL_ROOT_PASSWORD: root 51 | MYSQL_USER: redmine 52 | MYSQL_DB: redmine 53 | MYSQL_PASSWORD: remine 54 | qdrant: 55 | image: qdrant/qdrant 56 | ports: 57 | - 6333:6333 58 | volumes: 59 | - ./qdrant/storage:/qdrant/storage 60 | # weaviate: 61 | # command: 62 | # - --host 63 | # - 0.0.0.0 64 | # - --port 65 | # - "8080" 66 | # - --scheme 67 | # - http 68 | # image: cr.weaviate.io/semitechnologies/weaviate:1.30.0 69 | # # ports: 70 | # # - 8080:8080 71 | # # - 50051:50051 72 | # volumes: 73 | # - ./weaviate_data:/var/lib/weaviate 74 | # restart: on-failure:0 75 | # environment: 76 | # QUERY_DEFAULTS_LIMIT: 25 77 | # AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: "true" 78 | # PERSISTENCE_DATA_PATH: "/var/lib/weaviate" 79 | # DEFAULT_VECTORIZER_MODULE: "text2vec-openai" 80 | # ENABLE_MODULES: "text2vec-openai,ref2vec-centroid,generative-openai,generative-aws,reranker-cohere" 81 | # CLUSTER_HOSTNAME: "node1" 82 | volumes: 83 | postgres-data: null 84 | mysql-data: null 85 | -------------------------------------------------------------------------------- /.devcontainer/install-ble.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd /tmp 4 | git clone --recursive --depth 1 --shallow-submodules https://github.com/akinomyoga/ble.sh.git 5 | make -C ble.sh install PREFIX=~/.local 6 | echo 'source ~/.local/share/blesh/ble.sh' >> ~/.bashrc 7 | -------------------------------------------------------------------------------- /.devcontainer/plugin_generator.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | cd `dirname $0` 5 | cd .. 6 | BASEDIR=`pwd` 7 | PLUGIN_NAME=`basename $BASEDIR` 8 | echo $PLUGIN_NAME 9 | 10 | cd $REDMINE_ROOT 11 | 12 | export RAILS_ENV="development" 13 | 14 | bundle exec rails generate redmine_plugin $PLUGIN_NAME -------------------------------------------------------------------------------- /.devcontainer/post-create.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd $REDMINE_ROOT 3 | 4 | if [ -d .git.sv ] 5 | then 6 | mv -s .git.sv .git 7 | git pull 8 | rm .git 9 | fi 10 | 11 | # ln -s /workspaces/${PLUGIN_NAME} plugins/${PLUGIN_NAME} 12 | if [ -f plugins/${PLUGIN_NAME}/Gemfile_for_test ] 13 | then 14 | cp plugins/${PLUGIN_NAME}/Gemfile_for_test plugins/${PLUGIN_NAME}/Gemfile 15 | fi 16 | 17 | 18 | bundle install 19 | bundle exec rake redmine:plugins:ai_helper:setup_scm 20 | 21 | initdb() { 22 | if [ $DB != "sqlite3" ] 23 | then 24 | bundle exec rake db:create 25 | fi 26 | bundle exec rake db:migrate 27 | bundle exec rake redmine:plugins:migrate 28 | 29 | if [ $DB != "sqlite3" ] 30 | then 31 | bundle exec rake db:drop RAILS_ENV=test 32 | bundle exec rake db:create RAILS_ENV=test 33 | fi 34 | 35 | bundle exec rake db:migrate RAILS_ENV=test 36 | bundle exec rake redmine:plugins:migrate RAILS_ENV=test 37 | } 38 | 39 | export DB=mysql2 40 | export DB_NAME=redmine 41 | export DB_USERNAME=root 42 | export DB_PASSWORD=root 43 | export DB_HOST=mysql 44 | export DB_PORT=3306 45 | 46 | initdb 47 | 48 | export DB=postgresql 49 | export DB_NAME=redmine 50 | export DB_USERNAME=postgres 51 | export DB_PASSWORD=postgres 52 | export DB_HOST=postgres 53 | export DB_PORT=5432 54 | 55 | initdb 56 | 57 | rm -f db/redmine.sqlite3_test 58 | export DB=sqlite3 59 | initdb 60 | -------------------------------------------------------------------------------- /.devcontainer/redmine.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "/usr/local/redmine/plugins/redmine_ai_helper" 5 | }, 6 | { 7 | "path": "/usr/local/redmine" 8 | } 9 | ], 10 | "settings": { 11 | "editor.formatOnSave": true, 12 | "editor.wordWrap": "on", 13 | "files.trimFinalNewlines": true, 14 | "files.insertFinalNewline": true, 15 | "files.trimTrailingWhitespace": true, 16 | "git.ignoredRepositories": [ 17 | "/workspace/redmine_ai_helper" 18 | ], 19 | "liveServer.settings.multiRootWorkspaceName": "redmine_ai_helper", 20 | "[ruby]": { 21 | "editor.defaultFormatter": "jnbt.vscode-rufo", 22 | "editor.formatOnSave": true 23 | }, 24 | "files.associations": { 25 | "*.html.erb": "erb" 26 | }, 27 | "[erb]": { 28 | "editor.defaultFormatter": "aliariff.vscode-erb-beautify" 29 | }, 30 | "vscode-erb-beautify.useBundler": true, 31 | "github.copilot.nextEditSuggestions.enabled": false, 32 | "rubyLsp.formatter": "auto" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/build-scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | cd `dirname $0` 6 | . ./env.sh 7 | cd ../.. 8 | 9 | if [ "$NAME_OF_PLUGIN" == "" ] 10 | then 11 | export NAME_OF_PLUGIN=`basename $PATH_TO_PLUGIN` 12 | fi 13 | 14 | cd $PATH_TO_REDMINE 15 | 16 | # create scms for test 17 | bundle exec rake test:scm:setup:all 18 | 19 | # run tests 20 | # bundle exec rake TEST=test/unit/role_test.rb 21 | bundle exec rake redmine:plugins:test NAME=$NAME_OF_PLUGIN 22 | 23 | cp -pr plugins/$NAME_OF_PLUGIN/coverage $PATH_TO_PLUGIN 24 | -------------------------------------------------------------------------------- /.github/build-scripts/cleanup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | cd `dirname $0` 6 | . env.sh 7 | cd ../.. 8 | 9 | rm -rf $TESTSPACE -------------------------------------------------------------------------------- /.github/build-scripts/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite version 3.x 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | sqlite3: &sqlite3 8 | adapter: sqlite3 9 | pool: 5 10 | timeout: 5000 11 | database: db/redmine.sqlite3 12 | 13 | mysql: &mysql 14 | adapter: mysql2 15 | encoding: utf8 16 | database: <%= ENV['DB_NAME'] || 'redmine' %> 17 | username: <%= ENV['DB_USERNAME'] %> 18 | password: <%= ENV['DB_PASSWORD'] %> 19 | host: <%= ENV['DB_HOST'] %> 20 | port: <%= ENV['DB_PORT'] || 3306 %> 21 | 22 | postgres: &postgres 23 | adapter: postgresql 24 | encoding: utf8 25 | database: <%= ENV['DB_NAME'] || 'redmine' %> 26 | username: <%= ENV['DB_USERNAME'] %> 27 | password: <%= ENV['DB_PASSWORD'] %> 28 | host: <%= ENV['DB_HOST'] %> 29 | port: <%= ENV['DB_PORT'] || 5432 %> 30 | 31 | development: 32 | <<: *<%= ENV['DB'] || 'sqlite3' %> 33 | 34 | # Warning: The database defined as "test" will be erased and 35 | # re-generated from your development database when you run "rake". 36 | # Do not set this db to the same as development or production. 37 | test: 38 | <<: *<%= ENV['DB'] || 'sqlite3' %> 39 | 40 | production: 41 | <<: *<%= ENV['DB'] || 'sqlite3' %> 42 | 43 | 44 | -------------------------------------------------------------------------------- /.github/build-scripts/env.sh: -------------------------------------------------------------------------------- 1 | GITHUB_DIR=$(dirname $(pwd)) 2 | export PATH_TO_PLUGIN=`dirname ${GITHUB_DIR}` 3 | export TESTSPACE_NAME=testspace 4 | export TESTSPACE=$PATH_TO_PLUGIN/$TESTSPACE_NAME 5 | export PATH_TO_REDMINE=$TESTSPACE/redmine 6 | export RAILS_ENV=test 7 | -------------------------------------------------------------------------------- /.github/build-scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | cd `dirname $0` 6 | . env.sh 7 | SCRIPTDIR=$(pwd) 8 | cd ../.. 9 | 10 | if [[ ! "$TESTSPACE" = /* ]] || 11 | [[ ! "$PATH_TO_REDMINE" = /* ]] || 12 | [[ ! "$PATH_TO_PLUGIN" = /* ]]; 13 | then 14 | echo "You should set"\ 15 | " TESTSPACE, PATH_TO_REDMINE,"\ 16 | " PATH_TO_PLUGIN"\ 17 | " environment variables" 18 | echo "You set:"\ 19 | "$TESTSPACE"\ 20 | "$PATH_TO_REDMINE"\ 21 | "$PATH_TO_PLUGIN" 22 | exit 1; 23 | fi 24 | 25 | apt-get update 26 | apt-get install -y clang 27 | 28 | if [ "$REDMINE_VER" = "" ] 29 | then 30 | export REDMINE_VER=master 31 | fi 32 | 33 | if [ "$NAME_OF_PLUGIN" == "" ] 34 | then 35 | export NAME_OF_PLUGIN=`basename $PATH_TO_PLUGIN` 36 | fi 37 | 38 | mkdir -p $TESTSPACE 39 | 40 | export REDMINE_GIT_REPO=https://github.com/redmine/redmine.git 41 | export REDMINE_GIT_TAG=$REDMINE_VER 42 | export BUNDLE_GEMFILE=$PATH_TO_REDMINE/Gemfile 43 | 44 | if [ -f Gemfile_for_test ] 45 | then 46 | cp Gemfile_for_test Gemfile 47 | fi 48 | 49 | # checkout redmine 50 | git clone $REDMINE_GIT_REPO $PATH_TO_REDMINE 51 | if [ -d test/fixtures ] 52 | then 53 | if [ -n "$(ls -A test/fixtures/ 2>/dev/null)" ]; then 54 | cp -f test/fixtures/* ${PATH_TO_REDMINE}/test/fixtures/ 55 | fi 56 | fi 57 | 58 | cd $PATH_TO_REDMINE 59 | if [ ! "$REDMINE_GIT_TAG" = "master" ]; 60 | then 61 | git checkout -b $REDMINE_GIT_TAG origin/$REDMINE_GIT_TAG 62 | fi 63 | 64 | 65 | mkdir -p plugins/$NAME_OF_PLUGIN 66 | find $PATH_TO_PLUGIN -mindepth 1 -maxdepth 1 ! -name $TESTSPACE_NAME -exec cp -r {} plugins/$NAME_OF_PLUGIN/ \; 67 | 68 | cp "$SCRIPTDIR/database.yml" config/database.yml 69 | 70 | 71 | 72 | # install gems 73 | bundle install 74 | 75 | # run redmine database migrations 76 | bundle exec rake db:create 77 | bundle exec rake db:migrate 78 | 79 | # run plugin database migrations 80 | bundle exec rake redmine:plugins:migrate 81 | 82 | bundle exec rake redmine:plugins:ai_helper:setup_scm 83 | 84 | apt-get update && apt-get install -y npm 85 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | Please write comments in the source code in English. 2 | 3 | Text and messages displayed on the screen must support i18n (internationalization). 4 | 5 | Please write test code using shoulda. 6 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | authors: 6 | - octocat 7 | categories: 8 | - title: Breaking Changes 🛠 9 | labels: 10 | - Semver-Major 11 | - breaking-change 12 | - title: New Features 🎉 13 | labels: 14 | - Semver-Minor 15 | - enhancement 16 | - title: Bug Fixes 🐛 17 | labels: 18 | - Semver-Patch 19 | - bug 20 | - title: Other Changes 📝 21 | labels: 22 | - "*" 23 | -------------------------------------------------------------------------------- /.github/workflows/base_branch_check.yml: -------------------------------------------------------------------------------- 1 | name: Check Base Branch for Pull Requests 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | 8 | jobs: 9 | check_base_branch: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: checkout repository 14 | uses: actions/checkout@v2 15 | 16 | - name: Check if base branch is develop 17 | run: | 18 | if [[ "${{ github.actor }}" == "github-actions[bot]" ]]; then 19 | echo "Skipping base branch check for GitHub Actions bot." 20 | exit 0 21 | elif [[ "${{ github.base_ref }}" != "develop" ]]; then 22 | echo "The base branch of this pull request is not 'develop'. Please update the base branch to 'develop'." 23 | COMMENT_BODY="◤◢◤◢ Base branch must be a develop!! @${{ github.actor }} ◤◢◤◢" 24 | COMMENT_PAYLOAD=$(echo '{}' | jq --arg body "$COMMENT_BODY" '.body = $body') 25 | curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" -d "$COMMENT_PAYLOAD" "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" 26 | exit 0 27 | fi 28 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - '**' 6 | pull_request: 7 | branches: 8 | - '**' 9 | workflow_dispatch: 10 | env: 11 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | # Supported Matrix: 16 | # Redmine 6.0-stable: Ruby 3.1, 3.2, 3.3 17 | # Redmine master: Ruby 3.2, 3.3, 3.4 18 | strategy: 19 | max-parallel: 10 20 | matrix: 21 | db: [sqlite3, mysql, postgres] 22 | ruby_version: ["3.1", "3.2", "3.3", "3.4"] 23 | redmine_version: [6.0-stable, master] 24 | exclude: 25 | - ruby_version: "3.4" 26 | redmine_version: 6.0-stable 27 | - ruby_version: "3.1" 28 | redmine_version: master 29 | services: 30 | mysql: 31 | image: mysql:5.7 32 | options: --health-cmd "mysqladmin ping -h localhost" --health-interval 20s --health-timeout 10s --health-retries 10 33 | env: 34 | MYSQL_ROOT_PASSWORD: dbpassword 35 | postgres: 36 | image: postgres 37 | env: 38 | POSTGRES_USER: root 39 | POSTGRES_PASSWORD: dbpassword 40 | options: >- 41 | --health-cmd pg_isready 42 | --health-interval 10s 43 | --health-timeout 5s 44 | --health-retries 5 45 | container: 46 | image: ruby:${{ matrix.ruby_version }} 47 | env: 48 | DB: ${{ matrix.db }} 49 | DB_USERNAME: root 50 | DB_PASSWORD: dbpassword 51 | DB_HOST: ${{ matrix.db }} 52 | REDMINE_VER: ${{ matrix.redmine_version }} 53 | steps: 54 | - uses: actions/checkout@v4 55 | - name: Install 56 | run: bash -x ./.github/build-scripts/install.sh 57 | - name: Build 58 | run: bash -x ./.github/build-scripts/build.sh 59 | - name: Clean 60 | run: bash -x ./.github/build-scripts/cleanup.sh 61 | - uses: codecov/codecov-action@v5 62 | with: 63 | token: ${{ secrets.CODECOV_TOKEN }} 64 | fail_ci_if_error: true 65 | - name: Slack Notification on Failure 66 | uses: rtCamp/action-slack-notify@v2 67 | if: failure() 68 | env: 69 | SLACK_CHANNEL: general 70 | SLACK_TITLE: Test Failure 71 | SLACK_COLOR: danger 72 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: build_archive 2 | on: 3 | push: 4 | branches-ignore: 5 | - '**' 6 | tags: 7 | - '**' 8 | workflow_dispatch: 9 | permissions: 10 | contents: write 11 | env: 12 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} 13 | jobs: 14 | archive: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Set version 18 | id: version 19 | run: | 20 | REPOSITORY=$(echo ${{ github.repository }} | sed -e "s#.*/##") 21 | VERSION=$(echo ${{ github.ref }} | sed -e "s#refs/tags/##g") 22 | echo ::set-output name=version::$VERSION 23 | echo ::set-output name=filename::$REPOSITORY-$VERSION 24 | echo ::set-output name=plugin::$REPOSITORY 25 | - uses: actions/checkout@v4 26 | - name: Archive 27 | run: | 28 | cd ..; zip -r ${{ steps.version.outputs.filename }}.zip ${{ steps.version.outputs.plugin }}/ -x "*.git*"; mv ${{ steps.version.outputs.filename }}.zip ${{ steps.version.outputs.plugin }}/ 29 | - name: Create Release 30 | id: create_release 31 | uses: actions/create-release@v1 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | with: 35 | tag_name: ${{ steps.version.outputs.version }} 36 | release_name: ${{ steps.version.outputs.version }} 37 | body: '' 38 | draft: false 39 | prerelease: true 40 | - name: Upload Release Asset 41 | id: upload-release-asset 42 | uses: actions/upload-release-asset@v1 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | with: 46 | upload_url: ${{ steps.create_release.outputs.upload_url }} 47 | asset_path: ${{ steps.version.outputs.filename }}.zip 48 | asset_name: ${{ steps.version.outputs.filename }}.zip 49 | asset_content_type: application/zip 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.rbc 2 | capybara-*.html 3 | .rspec 4 | /db/*.sqlite3 5 | /db/*.sqlite3-journal 6 | /db/*.sqlite3-[0-9]* 7 | /public/system 8 | /coverage/ 9 | /spec/tmp 10 | *.orig 11 | rerun.txt 12 | pickle-email-*.html 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/* 16 | /tmp/* 17 | !/log/.keep 18 | !/tmp/.keep 19 | 20 | # TODO Comment out this rule if you are OK with secrets being uploaded to the repo 21 | config/initializers/secret_token.rb 22 | config/master.key 23 | 24 | # Only include if you have production secrets in this file, which is no longer a Rails default 25 | # config/secrets.yml 26 | 27 | # dotenv, dotenv-rails 28 | # TODO Comment out these rules if environment variables can be committed 29 | .env 30 | .env*.local 31 | 32 | ## Environment normalization: 33 | /.bundle 34 | /vendor/bundle 35 | 36 | # these should all be checked in to normalize the environment: 37 | # Gemfile.lock, .ruby-version, .ruby-gemset 38 | 39 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 40 | .rvmrc 41 | 42 | # if using bower-rails ignore default bower_components path bower.json files 43 | /vendor/assets/bower_components 44 | *.bowerrc 45 | bower.json 46 | 47 | # Ignore pow environment settings 48 | .powenv 49 | 50 | # Ignore Byebug command history file. 51 | .byebug_history 52 | 53 | # Ignore node_modules 54 | node_modules/ 55 | 56 | # Ignore precompiled javascript packs 57 | /public/packs 58 | /public/packs-test 59 | /public/assets 60 | 61 | # Ignore yarn files 62 | /yarn-error.log 63 | yarn-debug.log* 64 | .yarn-integrity 65 | 66 | # Ignore uploaded files in development 67 | /storage/* 68 | !/storage/.keep 69 | /public/uploads 70 | Gemfile.lock 71 | config/config.yaml 72 | .devcontainer/weaviate_data/* 73 | .devcontainer/qdrant/* 74 | -------------------------------------------------------------------------------- /.qlty/qlty.toml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by `qlty init`. 2 | # You can modify it to suit your needs. 3 | # We recommend you to commit this file to your repository. 4 | # 5 | # This configuration is used by both Qlty CLI and Qlty Cloud. 6 | # 7 | # Qlty CLI -- Code quality toolkit for developers 8 | # Qlty Cloud -- Fully automated Code Health Platform 9 | # 10 | # Try Qlty Cloud: https://qlty.sh 11 | # 12 | # For a guide to configuration, visit https://qlty.sh/d/config 13 | # Or for a full reference, visit https://qlty.sh/d/qlty-toml 14 | config_version = "0" 15 | 16 | exclude_patterns = [ 17 | "*_min.*", 18 | "*-min.*", 19 | "*.min.*", 20 | "**/*.d.ts", 21 | "**/.yarn/**", 22 | "**/bower_components/**", 23 | "**/build/**", 24 | "**/cache/**", 25 | "**/config/**", 26 | "**/db/**", 27 | "**/deps/**", 28 | "**/dist/**", 29 | "**/extern/**", 30 | "**/external/**", 31 | "**/generated/**", 32 | "**/Godeps/**", 33 | "**/gradlew/**", 34 | "**/mvnw/**", 35 | "**/node_modules/**", 36 | "**/protos/**", 37 | "**/seed/**", 38 | "**/target/**", 39 | "**/testdata/**", 40 | "**/vendor/**", 41 | "**/assets/**", 42 | ] 43 | 44 | test_patterns = [ 45 | "**/test/**", 46 | "**/spec/**", 47 | "**/*.test.*", 48 | "**/*.spec.*", 49 | "**/*_test.*", 50 | "**/*_spec.*", 51 | "**/test_*.*", 52 | "**/spec_*.*", 53 | ] 54 | 55 | [smells] 56 | mode = "comment" 57 | 58 | [[source]] 59 | name = "default" 60 | default = true 61 | 62 | [[plugin]] 63 | name = "actionlint" 64 | 65 | [[plugin]] 66 | name = "brakeman" 67 | 68 | [[plugin]] 69 | name = "checkov" 70 | 71 | [[plugin]] 72 | name = "golangci-lint" 73 | 74 | [[plugin]] 75 | name = "hadolint" 76 | 77 | [[plugin]] 78 | name = "markdownlint" 79 | version = "0.41.0" 80 | 81 | [[plugin]] 82 | name = "prettier" 83 | 84 | [[plugin]] 85 | name = "reek" 86 | 87 | [[plugin]] 88 | name = "ripgrep" 89 | 90 | [[plugin]] 91 | name = "rubocop" 92 | 93 | [[plugin]] 94 | name = "shellcheck" 95 | 96 | [[plugin]] 97 | name = "shfmt" 98 | 99 | [[plugin]] 100 | name = "trivy" 101 | drivers = [ 102 | "config", 103 | "fs-vuln", 104 | ] 105 | 106 | [[plugin]] 107 | name = "trufflehog" 108 | 109 | [[plugin]] 110 | name = "yamllint" 111 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gem "ruby-openai", "~> 8.0.0" 3 | gem "langchainrb", "~> 0.19.5" 4 | gem "ruby-anthropic", "~> 0.4.2" 5 | # gem "weaviate-ruby", "~> 0.9.2" 6 | gem "qdrant-ruby", "~> 0.9.9" 7 | gem "langfuse", "~> 0.1.1" 8 | 9 | group :test do 10 | gem "simplecov-cobertura" 11 | gem "factory_bot_rails" 12 | gem "shoulda" 13 | gem "rails-controller-testing" 14 | end 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Haruyuki Iida 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/controllers/ai_helper_model_profiles_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Controller for performing CRUD operations on ModelProfile 3 | class AiHelperModelProfilesController < ApplicationController 4 | layout "admin" 5 | before_action :require_admin 6 | before_action :find_model_profile, only: [:show, :edit, :update, :destroy] 7 | self.main_menu = false 8 | 9 | DUMMY_ACCESS_KEY = "___DUMMY_ACCESS_KEY___" 10 | 11 | # Display the model profile 12 | def show 13 | render partial: "ai_helper_model_profiles/show" 14 | end 15 | 16 | # Display the form for creating a new model profile 17 | def new 18 | @title = l("ai_helper.model_profiles.create_profile_title") 19 | @model_profile = AiHelperModelProfile.new 20 | end 21 | 22 | # Create a new model profile 23 | def create 24 | @model_profile = AiHelperModelProfile.new 25 | @model_profile.safe_attributes = params[:ai_helper_model_profile] 26 | if @model_profile.save 27 | flash[:notice] = l(:notice_successful_create) 28 | redirect_to ai_helper_setting_path 29 | else 30 | render action: :new 31 | end 32 | end 33 | 34 | # Display the form for editing an existing model profile 35 | def edit 36 | end 37 | 38 | # Update an existing model profile 39 | def update 40 | original_access_key = @model_profile.access_key 41 | @model_profile.safe_attributes = params[:ai_helper_model_profile] 42 | @model_profile.access_key = original_access_key if @model_profile.access_key == DUMMY_ACCESS_KEY 43 | if @model_profile.save 44 | flash[:notice] = l(:notice_successful_update) 45 | redirect_to ai_helper_setting_path 46 | else 47 | render action: :edit 48 | end 49 | end 50 | 51 | # Delete an existing model profile 52 | def destroy 53 | if @model_profile.destroy 54 | flash[:notice] = l(:notice_successful_delete) 55 | redirect_to ai_helper_setting_path 56 | else 57 | flash[:error] = l(:error_failed_delete) 58 | redirect_to ai_helper_setting_path 59 | end 60 | rescue ActiveRecord::RecordNotFound 61 | render_404 62 | end 63 | 64 | private 65 | 66 | # Find the model profile based on the provided ID 67 | def find_model_profile 68 | id = params[:id] # TODO: remove this line 69 | return if params[:id].blank? 70 | @model_profile = AiHelperModelProfile.find(params[:id]) 71 | rescue ActiveRecord::RecordNotFound 72 | render_404 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /app/controllers/ai_helper_settings_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # AiHelperSetting Controller for managing AI Helper settings 3 | class AiHelperSettingsController < ApplicationController 4 | layout "admin" 5 | before_action :require_admin, :find_setting 6 | self.main_menu = false 7 | 8 | # Display the settings page 9 | def index 10 | end 11 | 12 | # Update the settings 13 | def update 14 | @setting.safe_attributes = params[:ai_helper_setting] 15 | if @setting.save 16 | flash[:notice] = l(:notice_successful_update) 17 | redirect_to action: :index 18 | else 19 | render action: :index 20 | end 21 | end 22 | 23 | private 24 | 25 | # Find or create the AI Helper setting and load model profiles 26 | def find_setting 27 | @setting = AiHelperSetting.find_or_create 28 | @model_profiles = AiHelperModelProfile.order(:name) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/helpers/ai_helper_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # AiHelperHelper module for AI Helper plugin 3 | module AiHelperHelper 4 | include Redmine::WikiFormatting::CommonMark 5 | 6 | # Converts a given Markdown text to HTML using the Markdown pipeline. 7 | def md_to_html(text) 8 | text = text.encode("UTF-8", invalid: :replace, undef: :replace, replace: "") 9 | MarkdownPipeline.call(text)[:output].to_s.html_safe 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/helpers/ai_helper_model_profiles_helper.rb: -------------------------------------------------------------------------------- 1 | module AiHelperModelProfilesHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/ai_helper_settings_helper.rb: -------------------------------------------------------------------------------- 1 | module AiHelperSettingsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/models/ai_helper_conversation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # AiHelperConversation model for managing AI Helper conversations 3 | class AiHelperConversation < ApplicationRecord 4 | has_many :messages, class_name: "AiHelperMessage", foreign_key: "conversation_id", dependent: :destroy 5 | belongs_to :user 6 | validates :title, presence: true 7 | validates :user_id, presence: true 8 | 9 | # Returns the last message in the conversation 10 | def messages_for_openai 11 | messages.map do |message| 12 | { 13 | role: message.role, 14 | content: message.content, 15 | } 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/models/ai_helper_message.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # AiHelperMessage model for managing AI Helper messages 3 | class AiHelperMessage < ApplicationRecord 4 | belongs_to :conversation, class_name: "AiHelperConversation", foreign_key: "conversation_id", touch: true 5 | validates :content, presence: true 6 | validates :role, presence: true 7 | validates :conversation_id, presence: true 8 | end 9 | -------------------------------------------------------------------------------- /app/models/ai_helper_model_profile.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # AiHelperModelProfile model for managing AI Helper model profiles 3 | class AiHelperModelProfile < ApplicationRecord 4 | include Redmine::SafeAttributes 5 | validates :name, presence: true, uniqueness: true 6 | validates :llm_type, presence: true 7 | validates :access_key, presence: true, if: :access_key_required? 8 | validates :llm_model, presence: true 9 | validates :base_uri, presence: true, if: :base_uri_required? 10 | validates :base_uri, format: { with: URI::regexp(%w[http https]), message: l("ai_helper.model_profiles.messages.must_be_valid_url") }, if: :base_uri_required? 11 | validates :temperature, presence: true, numericality: { greater_than_or_equal_to: 0.0 } 12 | 13 | safe_attributes "name", "llm_type", "access_key", "organization_id", "base_uri", "version", "llm_model", "temperature", "max_tokens" 14 | 15 | # Replace all characters after the 4th with * 16 | def masked_access_key 17 | return access_key if access_key.blank? || access_key.length <= 4 18 | masked_key = access_key.dup 19 | masked_key[4..-1] = "*" * (masked_key.length - 4) 20 | masked_key 21 | end 22 | 23 | # Returns the String which is displayed in the select box 24 | def display_name 25 | "#{name} (#{llm_type}: #{llm_model})" 26 | end 27 | 28 | # returns true if base_uri is required. 29 | def base_uri_required? 30 | # Check if the llm_type is OpenAICompatible 31 | llm_type == RedmineAiHelper::LlmProvider::LLM_OPENAI_COMPATIBLE || 32 | llm_type == RedmineAiHelper::LlmProvider::LLM_AZURE_OPENAI 33 | end 34 | 35 | # returns true if access_key is required. 36 | def access_key_required? 37 | llm_type != RedmineAiHelper::LlmProvider::LLM_OPENAI_COMPATIBLE 38 | end 39 | 40 | # Returns the LLM type name for display 41 | def display_llm_type 42 | names = RedmineAiHelper::LlmProvider.option_for_select 43 | name = names.find { |n| n[1] == llm_type } 44 | name ? name[0] : "" 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /app/models/ai_helper_setting.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # 3 | # AiHelperSetting model for storing settings related to AI helper 4 | class AiHelperSetting < ApplicationRecord 5 | include Redmine::SafeAttributes 6 | belongs_to :model_profile, class_name: "AiHelperModelProfile" 7 | validates :vector_search_uri, :presence => true, if: :vector_search_enabled? 8 | validates :vector_search_uri, :format => { with: URI::regexp(%w[http https]), message: l("ai_helper.model_profiles.messages.must_be_valid_url") }, if: :vector_search_enabled? 9 | 10 | safe_attributes "model_profile_id", "additional_instructions", "version", "vector_search_enabled", "vector_search_uri", "vector_search_api_key", "embedding_model", "dimension", "vector_search_index_name", "vector_search_index_type", "embedding_url" 11 | 12 | class << self 13 | # This method is used to find or create an AiHelperSetting record. 14 | # It first tries to find the first record in the AiHelperSetting table. 15 | def find_or_create 16 | data = AiHelperSetting.order(:id).first 17 | data || AiHelperSetting.create! 18 | end 19 | 20 | def setting 21 | find_or_create 22 | end 23 | 24 | def vector_search_enabled? 25 | setting.vector_search_enabled 26 | end 27 | end 28 | 29 | # returns true if embedding_url is required. 30 | def embedding_url_enabled? 31 | model_profile&.llm_type == RedmineAiHelper::LlmProvider::LLM_AZURE_OPENAI 32 | end 33 | 34 | def max_tokens 35 | return nil unless model_profile&.max_tokens 36 | return nil if model_profile.max_tokens <= 0 37 | model_profile.max_tokens 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/models/ai_helper_summary_cache.rb: -------------------------------------------------------------------------------- 1 | class AiHelperSummaryCache < ApplicationRecord 2 | validates :object_class, presence: true 3 | validates :object_id, presence: true, uniqueness: { scope: :object_class } 4 | validates :content, presence: true 5 | 6 | def self.issue_cache(issue_id:) 7 | AiHelperSummaryCache.find_by(object_class: "Issue", object_id: issue_id) 8 | end 9 | 10 | def self.update_issue_cache(issue_id:, content:) 11 | cache = issue_cache(issue_id: issue_id) 12 | if cache 13 | cache.update(content: content) 14 | cache.save! 15 | else 16 | cache = AiHelperSummaryCache.new(object_class: "Issue", object_id: issue_id, content: content) 17 | cache.save! 18 | end 19 | cache 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/models/ai_helper_vector_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # AiHelperVectorData model for storing vector data related to Issue data 3 | class AiHelperVectorData < ApplicationRecord 4 | validates :object_id, presence: true 5 | validates :index, presence: true 6 | validates :uuid, presence: true 7 | end 8 | -------------------------------------------------------------------------------- /app/views/ai_helper/_chat.html.erb: -------------------------------------------------------------------------------- 1 | <% @conversation.messages.each do |message| %> 2 |
3 | <%if message.role == 'user' %> 4 |
5 | <%= avatar(@user) %> 6 | <%= link_to_user(@user) %> 7 |
8 | <% end %> 9 | <% if message.role == 'user' %> 10 |
<%= message.content %>
11 | <% else %> 12 |
13 | <%= md_to_html(message.content) %> 14 |
15 | <% end %> 16 |
17 | <% end %> 18 |
19 |
20 |
21 | 22 | <% unless @conversation.messages.empty? %> 23 | 26 | <% else %> 27 | 31 | <% end %> 32 | 35 | -------------------------------------------------------------------------------- /app/views/ai_helper/_chat_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_with model: @message, url: ai_helper_chat_path(@project), id: 'ai_helper_chat_form', local: true do |f| %> 2 |

3 | <%= f.text_area :content, rows: 5, placeholder: t(:label_chat_placeholder), id: 'ai_helper_chat_input', style: 'width: 100%;' %> 4 |

5 | <%= hidden_field_tag :controller_name, "", id: 'ai_helper_controller_name' %> 6 | <%= hidden_field_tag :action_name, "", id: 'ai_helper_action_name' %> 7 | <%= hidden_field_tag :content_id, "", id: 'ai_helper_content_id' %> 8 | <%= f.submit t(:button_submit), id: 'aihelper-chat-submit' %> 9 | <% end %> 10 | -------------------------------------------------------------------------------- /app/views/ai_helper/_history.html.erb: -------------------------------------------------------------------------------- 1 | 2 | <%= sprite_icon('history', t(:label_history)) %> 3 | 4 | -------------------------------------------------------------------------------- /app/views/ai_helper/_html_header.html.erb: -------------------------------------------------------------------------------- 1 | 2 | <% if @project and @project.module_enabled?(:ai_helper) and User.current.allowed_to?({controller: :ai_helper, action: :chat_form}, @project)%> 3 | <%= stylesheet_link_tag('ai_helper.css', plugin: :redmine_ai_helper) %> 4 | <%= javascript_include_tag('ai_helper.js', plugin: :redmine_ai_helper) %> 5 | <%= javascript_include_tag('ai_helper_markdown_parser.js', plugin: :redmine_ai_helper) %> 6 | <% end %> -------------------------------------------------------------------------------- /app/views/ai_helper/_issue_reply.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= l('ai_helper.generate_issue_reply.generated_reply') %> 3 |
 4 | <%= reply %>
 5 |     
6 | <%= button_tag l('ai_helper.generate_issue_reply.apply'), onclick: "ai_helper.apply_generated_issue_reply(); return false;"%> 7 | 8 | <%= link_to_function( 9 | sprite_icon("copy-link", l('ai_helper.generate_issue_reply.copy_to_clipboard')), "copyTextToClipboard(this);", 10 | class: "icon icon-copy-link", 11 | data: { "clipboard-text" => reply } ) 12 | %> 13 | 14 |
15 | 16 | 21 | -------------------------------------------------------------------------------- /app/views/ai_helper/_issue_summary.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | <%= l(:ai_helper_text_summary_generated_time, time: format_time(summary.updated_at)) %> 4 | <%= link_to sprite_icon("reload", l(:button_update)), "#", onclick: "getSummary(true); return false;"%> 5 | 6 |
7 |
8 | <%= md_to_html(summary.content) %> 9 |
10 |
11 | -------------------------------------------------------------------------------- /app/views/ai_helper_model_profiles/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= error_messages_for "model_profile" %> 2 |
3 |

<%= f.select :llm_type, RedmineAiHelper::LlmProvider.option_for_select %>

4 |

5 | <%= f.text_field :name, size: 60, required: true, autocomplete: "off" %> 6 |

7 | <% 8 | access_key = "" 9 | access_key = AiHelperModelProfilesController::DUMMY_ACCESS_KEY unless @model_profile.new_record? 10 | %> 11 |

<%= f.password_field :access_key, value: access_key, size: 60, required: true %>

12 |

13 | <%= f.text_field :llm_model, size: 60, required: true %> 14 |

15 |

16 | <%= f.text_field :temperature, size: 60, required: true %> 17 |

18 |

19 | <%= f.text_field :max_tokens, size: 60 %> 20 |

21 |

22 | <%= f.text_field :organization_id, size: 60 %> 23 |

24 |

25 | <%= f.text_field :base_uri, size: 90, required: true %> 26 |

27 | 28 | 29 |
30 | 31 | 77 | -------------------------------------------------------------------------------- /app/views/ai_helper_model_profiles/_show.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | <%= link_to(sprite_icon('edit', l(:button_edit)), ai_helper_model_profiles_edit_path(@model_profile), :class => 'icon icon-edit') %> 4 | 5 | <%= link_to(sprite_icon('del', l(:button_delete)), ai_helper_model_profiles_destroy_url(id: @model_profile), method: :delete, data: { confirm: l('ai_helper.model_profiles.messages.confirm_delete') }, :class => 'icon icon-del') %> 6 | 7 |
8 |

9 | <%= label_tag AiHelperModelProfile.human_attribute_name(:llm_type) %> 10 | <%= @model_profile.display_llm_type %> 11 |

12 |

13 | <%= label_tag AiHelperModelProfile.human_attribute_name(:access_key) %> 14 | <%= @model_profile.masked_access_key %> 15 |

16 |

17 | <%= label_tag AiHelperModelProfile.human_attribute_name(:llm_model) %> 18 | <%= @model_profile.llm_model %> 19 |

20 |

21 | <%= label_tag AiHelperModelProfile.human_attribute_name(:temperature) %> 22 | <%= @model_profile.temperature %> 23 |

24 |

25 | <%= label_tag AiHelperModelProfile.human_attribute_name(:max_tokens) %> 26 | <%= @model_profile.max_tokens %> 27 |

28 | <% if@model_profile.llm_type == RedmineAiHelper::LlmProvider::LLM_OPENAI %> 29 |

30 | <%= label_tag AiHelperModelProfile.human_attribute_name(:organization_id) %> 31 | <%= @model_profile.organization_id %> 32 |

33 | <% end %> 34 | <% if @model_profile.base_uri_required? %> 35 |

36 | <%= label_tag AiHelperModelProfile.human_attribute_name(:base_uri) %> 37 | <% unless @model_profile.base_uri.blank? %> 38 | <%= link_to @model_profile.base_uri, @model_profile.base_uri %> 39 | <% end %> 40 |

41 | <% end %> 42 | 43 | -------------------------------------------------------------------------------- /app/views/ai_helper_model_profiles/edit.html.erb: -------------------------------------------------------------------------------- 1 | <% html_title l('ai_helper.model_profiles.edit_profile_title')%> 2 |

<%= l('ai_helper.model_profiles.edit_profile_title') %>

3 | <%= labelled_form_for @model_profile, url: ai_helper_model_profiles_update_path(id: @model_profile), method: :post, html: {autocomplete: "off"} do |f| %> 4 | <%= render partial: "form", locals: {f: f} %> 5 | 6 | <%= f.submit t(:button_submit) %> 7 | <%= link_to l(:button_cancel), ai_helper_setting_path %> 8 | <% end %> 9 | -------------------------------------------------------------------------------- /app/views/ai_helper_model_profiles/new.html.erb: -------------------------------------------------------------------------------- 1 | <% html_title l('ai_helper.model_profiles.create_profile_title')%> 2 |

<%= l('ai_helper.model_profiles.create_profile_title') %>

3 | <%= labelled_form_for @model_profile, html: {autocomplete: "off"} do |f| %> 4 | <%= render partial: "form", locals: {f: f} %> 5 | <%= f.submit t(:button_submit) %> 6 | <%= link_to l(:button_cancel), ai_helper_setting_path %> 7 | <% end %> 8 | -------------------------------------------------------------------------------- /assets/images/icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /assets/prompt_templates/base_agent/system_prompt.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: 4 | - role 5 | - time 6 | - backstory 7 | - lang 8 | template: |- 9 | You are an agent of the RedmineAIHelper plugin. 10 | The RedmineAIHelper plugin answers questions from Redmine users about Redmine features, projects, issues, and more. 11 | 12 | The final answers created by your team of agents will be displayed within the user's Redmine site. If your answer includes links to pages within Redmine, do not include the hostname in the URL; only specify the path starting with "/". 13 | 14 | **Your role is {role}. This is very important. Do not forget it.** 15 | 16 | There are multiple agent roles in RedmineAIHelper. 17 | You will work together with other agents to provide services to RedmineAIHelper users. 18 | You will receive instructions from the agent with the <> role. 19 | 20 | The current time is {time}. 21 | 22 | - You can speak various languages such as Japanese, English, and Chinese, but unless otherwise specified by the user, you should answer in {lang}. 23 | 24 | ---- 25 | 26 | Your backstory is as follows. 27 | {backstory} 28 | -------------------------------------------------------------------------------- /assets/prompt_templates/base_agent/system_prompt_ja.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: 4 | - role 5 | - time 6 | - backstory 7 | - lang 8 | template: |- 9 | あなたは RedmineAIHelper プラグインのエージェントです。 10 | RedmineAIHelper プラグインは、Redmine のユーザーにRedmine の機能やプロジェクト、チケットなどに関する問い合わせに答えます。 11 | 12 | あなた方エージェントのチームが作成した最終回答はユーザーのRedmineサイト内に表示さます。もし回答の中にRedmine内のページへのリンクが含まれる場合、そのURLにはホスト名は含めず、"/"から始まるパスのみを記載してください。 13 | 14 | **あなたのロールは {role} です。これはとても重要です。忘れないでください。** 15 | 16 | RedmineAIHelperには複数のロールのエージェントが存在します。 17 | あなたは他のエージェントと協力して、RedmineAIHelper のユーザーにサービスを提供します。 18 | あなたへの指示は <> ロールのエージェントから受け取ります。 19 | 20 | 現在の時刻は{time}です。 21 | 22 | - あなたは日本語、英語、中国語などいろいろな国の言語を話すことができますが、あなたが回答する際の言語は、特にユーザーからの指定が無い限りは{lang}で話します。 23 | 24 | ---- 25 | 26 | あなたのバックストーリーは以下の通りです。 27 | {backstory} 28 | -------------------------------------------------------------------------------- /assets/prompt_templates/board_agent/backstory.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | You are the forum agent of the RedmineAIHelper plugin. You answer inquiries about Redmine forums and messages posted on the forums. 6 | -------------------------------------------------------------------------------- /assets/prompt_templates/board_agent/backstory_ja.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | あなたは RedmineAIHelper プラグインのフォオーラムエージェントです。Redmine のフォーラムやフォーラムに投稿されているメッセージに関する問い合わせに答えます。 6 | -------------------------------------------------------------------------------- /assets/prompt_templates/issue_agent/backstory.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: 4 | - issue_properties 5 | - search_answer_instruction 6 | template: |- 7 | You are a issue agent for the RedmineAIHelper plugin. You answer inquiries about Redmine issues. 8 | You can also create or suggest updates for issues and validate them before they are actually registered in the database. However, you cannot create or update issues yourself. 9 | 10 | When returning Issue IDs or URLs, make sure to use hyperlinks. 11 | The hyperlink should be in the format [Issue ID](/issues/12345), so that clicking on the issue ID navigates to the issue's URL. The URL should start with "/" and should not include the protocol or hostname. 12 | 13 | Issue IDs are very important information for users. Always include the issue ID when providing issue information. 14 | 15 | {search_answer_instruction} 16 | 17 | {issue_properties} 18 | -------------------------------------------------------------------------------- /assets/prompt_templates/issue_agent/backstory_ja.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: 4 | - issue_properties 5 | - search_answer_instruction 6 | template: |- 7 | あなたは RedmineAIHelper プラグインのチケットエージェントです。Redmine のチケットに関する問い合わせに答えます。 8 | また、チケットの作成案や更新案などを作成し、実際にデータベースに登録する前に検証することもできます。ただしチケットの作成やチケットの更新をすることはできません。 9 | 10 | なお、チケットのIDやURLを返す時は、ハイパーリンクにしてください。 11 | ハイパーリンクは [チケットID](/issues/12345) のように、チケットIDをクリックするとチケットのURLに飛ぶようにしてください。URLにはプロトコルやホスト名は含めずに"/"から始まるようにしてください 12 | 13 | ユーザーにとってチケットIDはとても重要な情報です。チケットの情報を返す際には必ずIDを含めてください。 14 | 15 | {search_answer_instruction} 16 | 17 | {issue_properties} 18 | -------------------------------------------------------------------------------- /assets/prompt_templates/issue_agent/generate_reply.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: 4 | - issue 5 | - instructions 6 | template: |- 7 | Please draft a reply to the following issue. 8 | This draft will be displayed in the Redmine issue edit screen. 9 | 10 | - Please refer to the issue details, past comments, and update history. 11 | - Write the reply as if it will be posted directly to the issue. Do not include phrases like "I have drafted the following reply." 12 | 13 | ---- 14 | 15 | Instructions for drafting the reply: 16 | 17 | {instructions} 18 | 19 | ---- 20 | 21 | Issue: 22 | 23 | {issue} 24 | -------------------------------------------------------------------------------- /assets/prompt_templates/issue_agent/generate_reply_ja.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: 4 | - issue 5 | - instructions 6 | - format 7 | template: |- 8 | 以下のチケットに対する回答案を作成してください。 9 | この回答案はRedmineのチケット編集画面内に表示されます。 10 | 11 | - チケットの内容や過去のコメント、更新履歴も参考にしてください。 12 | - 回答内容はそのままチケットに投稿されることを想定して作成してください。「以下の回答を作成しました」といった表現は不要です。 13 | - テキストのフォーマットは{format}でお願いします。 14 | 15 | ---- 16 | 17 | 回答案の作成にあたっての指示: 18 | 19 | {instructions} 20 | 21 | ---- 22 | 23 | チケット: 24 | 25 | {issue} 26 | -------------------------------------------------------------------------------- /assets/prompt_templates/issue_agent/summary.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: 4 | - issue 5 | template: |- 6 | Please summarize the content of the following issue. 7 | This summary will be displayed on the Redmine issue screen. 8 | Therefore, information such as the subject, issue number, status, tracker, priority, and other attribute data of this issue is not necessary. 9 | 10 | - Summarize only the content, comments, and update history of the issue. 11 | - Write an overall summary in one line, then use bullet points to make it easy to understand. 12 | - Include only the summary text in your answer. Do not add explanations such as "Here is the summary". 13 | 14 | ---- 15 | 16 | Issue: 17 | 18 | {issue} 19 | -------------------------------------------------------------------------------- /assets/prompt_templates/issue_agent/summary_ja.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: 4 | - issue 5 | template: |- 6 | 以下のチケットの内容を要約してください。 7 | この要約はRedmineのチケット画面内に表示されます。 8 | そのためこのチケット自体のサブジェクトやチケット番号、ステータスやトラッカー、優先度などの属性情報は不要です。 9 | 10 | - チケットの内容やコメント、更新履歴のみを要約してください。 11 | - 全体のまとめを1行で書いた後、箇条書きを使って分かりやすく書いてください。 12 | - 回答には要約文のみを記載してください。「要約します」などの説明は不要です。 13 | 14 | ---- 15 | 16 | チケット: 17 | 18 | {issue} 19 | -------------------------------------------------------------------------------- /assets/prompt_templates/issue_update_agent/backstory.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: 4 | - issue_properties 5 | template: |- 6 | You are the issue update agent of the RedmineAIHelper plugin. You create and update Redmine issues. Additionally, you add comments to issues. You do not retrieve issue information. 7 | -- 8 | Notes: 9 | When updating issues or adding comments, you can update as instructed, but you cannot create response proposals or update proposals. 10 | 11 | {issue_properties} 12 | -------------------------------------------------------------------------------- /assets/prompt_templates/issue_update_agent/backstory_ja.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: 4 | - issue_properties 5 | template: |- 6 | あなたは RedmineAIHelper プラグインのチケットアップデートエージェントです。Redmine のチケットの作成や、更新を行います。また、チケットのコメントの追加も行います。チケットの情報取得は行いません。 7 | -- 8 | 注意事項: 9 | チケットの更新やコメントの追加においては、指示された通りに更新をすることはできますが、回答案や更新案を作成することはできません。 10 | 11 | {issue_properties} 12 | -------------------------------------------------------------------------------- /assets/prompt_templates/leader_agent/backstory.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | You are the leader agent of the RedmineAIHelper plugin. Your role is to give instructions to other agents, summarize their responses, and provide the final answer to the user. 6 | Additionally, if there are tasks that other agents cannot perform, you may execute the tasks yourself. 7 | -------------------------------------------------------------------------------- /assets/prompt_templates/leader_agent/backstory_ja.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | あなたは RedmineAIHelper プラグインのリーダーエージェントです。他のエージェントに指示を出し、彼らが答えた内容をまとめ、最終回答をユーザーに返すことがあなたの役割です。 6 | また、他のエージェントが実行できないタスクの場合には、自らタスクを実行することもあります。 7 | -------------------------------------------------------------------------------- /assets/prompt_templates/leader_agent/generate_steps.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: 4 | - goal 5 | - agent_list 6 | - format_instructions 7 | - json_examples 8 | - lang 9 | template: |- 10 | Please create instructions for other agents to solve the user's goal. The goal is as follows: 11 | 12 | ---- 13 | 14 | {goal} 15 | 16 | ---- 17 | 18 | Create the instructions step by step. 19 | For each step, consider how to utilize the results obtained from the previous step. 20 | Select the appropriate agent by considering the agent's backstory. If the backstory fits the goal, you may assign the agent even if the question is not directly related to Redmine. 21 | 22 | - Limit the steps to a maximum of 3. 23 | - Write the instructions for the agents in JSON format. 24 | - If there is no suitable agent, give the instruction to the mcp_agent. 25 | - Write the instructions for the agents in {lang}. 26 | 27 | **If the goal is to confirm something with the user, do not give instructions to other agents to create or update data. In that case, you may only request other agents to retrieve information.** 28 | 29 | ---- 30 | 31 | List of agents: 32 | ```json 33 | {agent_list} 34 | ``` 35 | 36 | ---- 37 | 38 | {format_instructions} 39 | 40 | {json_examples} 41 | -------------------------------------------------------------------------------- /assets/prompt_templates/leader_agent/generate_steps_ja.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: 4 | - goal 5 | - agent_list 6 | - format_instructions 7 | - json_examples 8 | - lang 9 | template: |- 10 | ユーザーからのゴールを解決するために、他のエージェントに与える指示を作成してください。今回のゴールは以下です。 11 | 12 | ---- 13 | 14 | {goal} 15 | 16 | ---- 17 | 18 | 指示はstep by step 作ってください。 19 | 各ステップでは、前のステップの実行で得られた結果をどのように利用するかを考慮してください。 20 | エージェントの backstory を考慮して、適切なエージェントを選択してください。backstoryがgoalに適合していれば、Redmineに直接関係の無い質問でもエージェントにアサインしても構いません。 21 | 22 | - ステップは最大で3ステップまでにしてください。 23 | - エージェントへの指示は、JSON形式で記述してください。 24 | - 適したエージェントがいない場合には、mcp_agentに対して指示を出してください。 25 | - エージェントへの指示は、言語は {lang} で記述してください。 26 | 27 | **ユーザーへの確認を行うゴールが設定されている場合には、他のエージェントに対してデータを作成したり更新したりする指示を出してはいけません。その場合には他のエージェントには情報を取得する依頼のみ行うことができます。** 28 | 29 | ---- 30 | 31 | エージェントの一覧: 32 | ```json 33 | {agent_list} 34 | ``` 35 | 36 | ---- 37 | 38 | {format_instructions} 39 | 40 | {json_examples} 41 | -------------------------------------------------------------------------------- /assets/prompt_templates/leader_agent/goal.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | As a leader agent, you collaborate with other agents to respond to user requests. 6 | First, clarify the user's request and set a clear goal. 7 | 8 | This goal will be shared with each agent, enabling them to execute their tasks smoothly. 9 | Describe the goal specifically so that each agent can understand the objective without referring to past conversation history. 10 | When agents perform tasks, various ID information is very important. Clearly state and share any known information such as project ID, issue ID, user ID, repository ID, etc. 11 | For tasks that update data, specify whether user confirmation has been obtained or not. If user confirmation has been received, quote the content as-is and provide update instructions. 12 | 13 | ** Note: Never set a goal such as "Respond to the user that it cannot be done." The goal should always be what the user wants to achieve. Even tasks that seem impossible or unrelated to Redmine can be accomplished using the mcp_agent. ** 14 | 15 | There may be cases where the user has not specifically requested anything. If it is just a conversation, no instructions to other agents are necessary. Please respond to the conversation. 16 | 17 | Note that this goal is shared only with other agents. It will not be visible to the user. Therefore, no explanation for the user is necessary. 18 | 19 | ---- 20 | 21 | Example 1: 22 | Summarize the content of issue ID:2 in project "my_project" (ID: 1). 23 | 24 | ---- 25 | 26 | Example 2: 27 | For issue ID:3, please create a draft response for the customer including the following content: 28 | "We have identified how to fix the bug. We will release the fix by next week." 29 | User confirmation for updating the issue has not been obtained. 30 | 31 | ---- 32 | 33 | Example 3: 34 | The user greeted with "Hello." Please create a friendly response. 35 | 36 | ---- 37 | 38 | Example 4: 39 | User confirmation has been obtained to respond to issue ID:4 with the following solution. 40 | Here is the content. Please update the issue as is. 41 | "Thank you for your continued support. 42 | 43 | We would like to report that a 404 error occurs when clicking the 'Create New' button. We have identified the solution and will release a new version by next week. 44 | We apologize for the inconvenience and appreciate your understanding." 45 | -------------------------------------------------------------------------------- /assets/prompt_templates/leader_agent/goal_ja.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | あなたはリーダーエージェントなので、他のエージェントと協力してユーザーからの依頼に答えます。 6 | まずは、ユーザーから依頼を明確にし、ゴールを定めてください。 7 | 8 | このゴールは各エージェントに共有されます。これにより各エージェントが円滑にタスクを実行できます。 9 | 各エージェントが過去の会話履歴を参照しなくても目的を理解できるように具体的に記述してください。 10 | 各エージェントがタスクを実行する際には、各種ID情報が非常に重要です。プロジェクトID、チケットID、ユーザーID、リポジトリIDなど、すでに分かっているものは明記して共有してください。 11 | データを更新するタスクの場合には、ユーザーの確認が済んでいるのかまだなのかを明確にしてください。また、ユーザーの確認が取れている場合には、その内容をそのまま引用して更新の指示をしてください。 12 | 13 | ** 注意: ゴールに「ユーザーに、できないと回答する」というような設定を絶対にしないでください。ゴールはあくまで、ユーザーがして欲しいことを書いてください。実現不可能に思えるタスクやRedmineとは無関係のタスクもmcp_agentを使えば実現できます ** 14 | 15 | ユーザーが特に何かを依頼していない場合もあります。単に会話をしているだけであれば、他のエージェントへの指示は必要ありません。会話に対する返答をしてください。 16 | 17 | なお、このゴールは他のエージェントとのみ共有されます。ユーザーには表示されません。なのでユーザー向けの説明は必要ありません。 18 | 19 | ---- 20 | 21 | 例1: 22 | プロジェクト"my_project"(ID: 1)のチケットID:2の内容を要約してください。 23 | 24 | ---- 25 | 26 | 例2: 27 | チケットID:3に対し、以下の内容を盛り込んでお客様向けの回答案を作成してください。 28 | 「バグの修正方法がわかりました。来週中に修正物件をリリースします。」 29 | ユーザーからはチケットの更新確認は取れていません。 30 | 31 | ---- 32 | 33 | 例3: 34 | ユーザーが「こんにちは」と挨拶をしています。親しみを込めて返答を作成してください。 35 | 36 | ---- 37 | 38 | 例4: 39 | チケットID:4に対し、以下の解答案で回答することをユーザーに確認しました。 40 | 以下がその内容です。このままの内容でチケットを更新してください。 41 | 「お世話になっております。 42 | 43 | 新規作成ボタンをクリックすると404エラーが発生する件について、ご報告いたします。修正方法がわかりましたので、来週中に新バージョンをリリースさせていただきます。 44 | ご迷惑をおかけし、誠に申し訳ございませんが、何卒よろしくお願い申し上げます。」 45 | -------------------------------------------------------------------------------- /assets/prompt_templates/leader_agent/system_prompt.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: 4 | - lang 5 | - time 6 | - site_info 7 | - current_page_info 8 | - current_user 9 | - current_user_info 10 | - additional_system_prompt 11 | template: |- 12 | You are the Redmine AI Helper plugin. You are installed in Redmine and answer inquiries from Redmine users. 13 | The main topics of inquiries are related to Redmine's features, projects, issues, and other data registered in this Redmine. 14 | In particular, you answer questions about the currently displayed project or page information. 15 | 16 | Notes: 17 | - When providing a link to a page within this Redmine site, include only the path in the URL, without the hostname or port number. (e.g., /projects/redmine_ai_helper/issues/1) 18 | - You can speak various languages such as Japanese, English, and Chinese, but unless otherwise specified by the user, respond in {lang}. 19 | - When a user refers to "my issues," it means "issues assigned to me," not "issues I created." 20 | - Try to summarize your answers in bullet points as much as possible. 21 | - **When performing operations such as creating or updating issues or Wiki pages, or responding to issues (i.e., creating or modifying Redmine data), always confirm with the user first.** 22 | - **If you are asked to propose ideas for creating, updating, or responding to data, only provide suggestions and do not actually create or update the data.** 23 | 24 | {additional_system_prompt} 25 | 26 | The following is reference information for you. 27 | 28 | ---- 29 | 30 | Reference information: 31 | The current time is {time}. However, when discussing time with the user, consider the user's time zone. If the user's time zone is unknown, try to infer it from the user's language or conversation. 32 | The information about this Redmine site defined in JSON is as follows. 33 | In the JSON, current_project is the project currently displayed to the user. If the user refers to "the project" without specifying one, it means this project. 34 | 35 | {site_info} 36 | 37 | {current_page_info} 38 | 39 | ---- 40 | 41 | The user you are talking to is "{current_user}". 42 | The user's information is shown below. 43 | {current_user_info} 44 | -------------------------------------------------------------------------------- /assets/prompt_templates/leader_agent/system_prompt_ja.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: 4 | - lang 5 | - time 6 | - site_info 7 | - current_page_info 8 | - current_user 9 | - current_user_info 10 | - additional_system_prompt 11 | template: |- 12 | あなたはRedmine AI Helperプラグインです。Redmineにインストールされており、Redmineのユーザーからの問い合わせに答えます。 13 | 問い合わせの内容はRedmineの機能やプロジェクト、チケットなどこのRedmineに登録されているデータに関するものが主になります。 14 | 特に、現在表示しているプロジェクトやページの情報についての問い合わせに答えます。 15 | 16 | 注意事項: 17 | - あなたがこのRedmineのサイト内のページを示すURLへのリンクを回答する際には、URLにはホスト名やポート番号は含めず、パスのみを含めてください。(例: /projects/redmine_ai_helper/issues/1) 18 | - あなたは日本語、英語、中国語などいろいろな国の言語を話すことができますが、あなたが回答する際の言語は、特にユーザーからの指定が無い限りは{lang}で話します。 19 | - ユーザーが「私のチケット」といった場合には、それは「私が作成したチケット」ではなく、「私が担当するチケット」を指します。 20 | - ユーザーへの回答は要点をまとめてなるべく箇条書きにする様心がけてください。 21 | - **チケットやWikiページの作成や更新、チケットへの回答など、Redmineのデータを作成したり変更する操作を行う際には、ユーザーに必ず確認を取るようにしてください。** 22 | - ** データの作成や更新や回答の案を考えてくださいという依頼の場合には、データの作成や更新はせずに、案を提示するだけにしてください** 23 | 24 | {additional_system_prompt} 25 | 26 | 以下はあなたの参考知識です。 27 | 28 | ---- 29 | 30 | 参考情報: 31 | 現在の時刻は{time}です。ただしユーザと時間について会話する場合は、ユーザのタイムゾーンを考慮してください。ユーザーのタイムゾーンがわからない場合には、ユーザーが話している言語や会話から推測してください。 32 | JSONで定義したこのRedmineのサイト情報は以下になります。 33 | JSONの中のcurrent_projectが現在ユーザーが表示している、このプロジェクトです。ユーザが特にプロジェクトを指定せずにただ「プロジェクト」といった場合にはこのプロジェクトのことです。 34 | 35 | {site_info} 36 | 37 | {current_page_info} 38 | 39 | ---- 40 | 41 | あなたと話しているユーザーは"{current_user}"です。 42 | ユーザーの情報を以下に示します。 43 | {current_user_info} 44 | -------------------------------------------------------------------------------- /assets/prompt_templates/mcp_agent/backstory.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | You are the MCP agent of the RedmineAIHelper plugin. 6 | This RedmineAIHelper plugin uses the MCP (Model Context Protocol) to utilize various tools. 7 | MCP is a protocol that allows AI models to interact with external tools and services. 8 | By using MCP, you can perform various tasks that are not related to Redmine. 9 | -------------------------------------------------------------------------------- /assets/prompt_templates/mcp_agent/backstory_ja.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | あなたは RedmineAIHelper プラグインの MCP エージェントです。 6 | このRedmineAIHelper プラグインは、MCP (Model Context Protocol) を使用して、さまざまなツールを利用することができます。 7 | MCPは、AIモデルが外部のツールやサービスと連携するためのプロトコルです。 8 | MCPを使用することで、あなたはRedmineとは関係のない様々たタスクを実行することができます。 9 | -------------------------------------------------------------------------------- /assets/prompt_templates/project_agent/backstory.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | You are the project agent of the RedmineAIHelper plugin. You answer inquiries about Redmine projects. 6 | -------------------------------------------------------------------------------- /assets/prompt_templates/project_agent/backstory_ja.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | あなたは RedmineAIHelper プラグインのプロジェクトエージェントです。Redmine のプロジェクトに関する問い合わせに答えます。 6 | -------------------------------------------------------------------------------- /assets/prompt_templates/repository_agent/backstory.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | You are the repository agent of the RedmineAIHelper plugin. You answer inquiries about the repositories in Redmine. 6 | -------------------------------------------------------------------------------- /assets/prompt_templates/repository_agent/backstory_ja.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | あなたは RedmineAIHelper プラグインのリポジトリトエージェントです。Redmine のリポジトリに関する問い合わせに答えます。 6 | -------------------------------------------------------------------------------- /assets/prompt_templates/system_agent/backstory.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | You are the system agent of the RedmineAIHelper plugin. You answer inquiries about the Redmine system, which includes information such as: 6 | - Redmine settings 7 | - Installed plugins 8 | and more. 9 | -------------------------------------------------------------------------------- /assets/prompt_templates/system_agent/backstory_ja.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | あなたは RedmineAIHelper プラグインのシステムエージェントです。Redmine のシステムに関する問い合わせに答えます。システムとは、 6 | - Redmine の設定 7 | - インストールされているプラグイン 8 | などの情報が含まれます。 9 | -------------------------------------------------------------------------------- /assets/prompt_templates/user_agent/backstory.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | You are the user agent of the RedmineAIHelper plugin. You answer inquiries related to Redmine users. 6 | 7 | When searching for tickets, wiki pages, etc., in Redmine by user, it is often more common to use the user ID rather than the username. 8 | Therefore, you may also assist other agents by searching for user IDs. 9 | -------------------------------------------------------------------------------- /assets/prompt_templates/user_agent/backstory_ja.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | あなたは RedmineAIHelper プラグインのユーザーエージェントです。Redmine のユーザーに関する問い合わせに答えます。 6 | 7 | Redmineでチケットやwikiページなどをユーザーをキーに検索する場合にはユーザー名ではなくユーザーIDを使うことが多いです。 8 | よって他のエージェントのためにユーザIDを検索してあげることもあります。 9 | -------------------------------------------------------------------------------- /assets/prompt_templates/version_agent/backstory.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | You are the version agent of the RedmineAIHelper plugin. You answer inquiries about the roadmap and versions of Redmine projects. 6 | -------------------------------------------------------------------------------- /assets/prompt_templates/version_agent/backstory_ja.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | あなたは RedmineAIHelper プラグインのバージョンエージェントです。Redmine のプロジェクトのロードマップやバージョンに関する問い合わせに答えます。 6 | -------------------------------------------------------------------------------- /assets/prompt_templates/wiki_agent/backstory.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | You are the Wiki Agent of the RedmineAIHelper plugin. You answer inquiries related to the Wiki in Redmine. 6 | When presenting a Wiki page to the user, please include the URL of the Wiki page. The URL should start with "/" and should not include the protocol or hostname. 7 | -------------------------------------------------------------------------------- /assets/prompt_templates/wiki_agent/backstory_ja.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _type: prompt 3 | input_variables: [] 4 | template: |- 5 | あなたは RedmineAIHelper プラグインのWikiエージェントです。Redmine のWikiに関する問い合わせに答えます。 6 | ユーザーにwikiのページを提示する際には、wikiのURLを含めてください。URLにはプロトコルやホスト名は含めずに"/"から始まるようにしてください。 7 | -------------------------------------------------------------------------------- /config/config.yaml.example: -------------------------------------------------------------------------------- 1 | logger: 2 | log_level: debug 3 | -------------------------------------------------------------------------------- /config/icon_source.yml: -------------------------------------------------------------------------------- 1 | - name: ai-helper-robot 2 | svg: message-chatbot 3 | -------------------------------------------------------------------------------- /config/locales/ja.yml: -------------------------------------------------------------------------------- 1 | ja: 2 | project_module_ai_helper: "AIヘルパー" 3 | label_ai_helper: "AI ヘルパー" 4 | label_chat_placeholder: "メッセージを入力してください..." 5 | label_ai_helper_input_newline: "Shift + Enter で改行します。" 6 | label_ai_helper_start_new_chat: "新しいチャットを開始" 7 | ai_helper_summary: "AIヘルパーで要約" 8 | ai_helper_text_summary_generated_time: "%{time} に生成" 9 | ai_helper_loading: "読み込み中..." 10 | ai_helper_error_occurred: "エラーが発生しました" 11 | permission_view_ai_helper: "AIヘルパーの表示" 12 | ai_helper: 13 | generate_issue_reply: 14 | title: "AIヘルパーでコメント案を作成" 15 | instructions: "回答内容の要点や指示を記入してください。" 16 | generated_reply: "生成されたコメント" 17 | apply: "適用" 18 | copy_to_clipboard: "クリップボードにコピー" 19 | model_profiles: 20 | create_profile_title: "新しいモデルプロファイル" 21 | edit_profile_title: "モデルプロファイルの編集" 22 | messages: 23 | confirm_delete: "このモデルプロファイルを削除してもよろしいですか?" 24 | must_be_valid_url: "は有効なURLを入力してください" 25 | prompts: 26 | current_page_info: 27 | project_page: "プロジェクト「%{project_name}」の情報ページです" 28 | issue_detail_page: "チケット #%{issue_id} の詳細\nユーザが特にIDや名前を指定せずにただ「チケット」といった場合にはこのチケットのことです。" 29 | issue_list_page: "チケット一覧" 30 | issue_with_action_page: "チケットの%{action_name}ページです" 31 | wiki_page: "「%{page_title}」というタイトルのWikiページを表示しています。\nユーザが特にタイトルを指定せずにただ「Wikiページ」や「ページ」といった場合にはこのWikiページのことです。" 32 | repository_page: "リポジトリ「%{repo_name}」の情報ページです。リポジトリのIDは %{repo_id} です。" 33 | repository_file_page: "リポジトリのファイル情報のページです。表示しているファイルパスは %{path} です。リビジョンは %{rev} です。リポジトリは「 %{repo_name}」です。リポジトリのIDは %{repo_id} です。" 34 | repository_diff: 35 | page: "リポジトリ「%{repo_name}」の変更差分ページです。リポジトリのIDは %{repo_id} です。" 36 | rev: "リビジョンは %{rev} です。" 37 | rev_to: "リビジョンは %{rev} から %{rev_to} です。" 38 | path: "ファイルパスは %{path} です。" 39 | repository_revision_page: "リポジトリ「%{repo_name}」のリビジョン情報ページです。リビジョンは %{rev}} です。リポジトリのIDは %{repo_id} です。" 40 | repository_other_page: "リポジトリの情報ページです" 41 | boards: 42 | show: "フォーラム「%{board_name}」のページです。フォーラムのIDは %{board_id} です。" 43 | index: "フォーラム一覧のページです。" 44 | other: "フォーラムのページです。" 45 | messages: 46 | show: "メッセージ「%{subject}」のページです。メッセージのIDは %{message_id}です。" 47 | other: "メッセージのページです。" 48 | versions: 49 | show: "バージョン「%{version_name}」のページです。バージョンのIDは %{version_id} です。" 50 | other: "バージョンのページです。" 51 | other_page: "%{controller_name}の%{action_name}ページです" 52 | issue_agent: 53 | search_answer_instruction: "なお、「条件に合ったチケットを探して」「こういう条件のチケット見せて」の様な複数のチケット探すタスクの場合には、チケット検索のURLを返してください。" 54 | leader_agent: 55 | generate_final_response: "全てのエージェントがタスクを完了しました。ユーザーへの最終的な回答を作成してください。" 56 | activerecord: 57 | models: 58 | ai_helper_model_profile: "モデルプロファイル" 59 | ai_helper_setting: "AIヘルパー設定" 60 | attributes: 61 | ai_helper_model_profile: 62 | llm_type: "タイプ" 63 | llm_model: "モデル名" 64 | access_key: "アクセスキー" 65 | temperature: "Temperature" 66 | organization_id: "Organization ID" 67 | base_uri: "ベースURI" 68 | max_tokens: "MAXトークン数" 69 | ai_helper_setting: 70 | model_profile_id: "モデル定義" 71 | additional_instructions: "追加の指示" 72 | vector_search_enabled: "ベクトル検索を有効にする" 73 | vector_search_uri: "URI" 74 | vector_search_api_key: "APIキー" 75 | embedding_model: "埋め込みモデル" 76 | dimension: "次元数" 77 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # Plugin's routes 2 | # See: http://guides.rubyonrails.org/routing.html 3 | RedmineApp::Application.routes.draw do 4 | post "projects/:id/ai_helper/chat", to: "ai_helper#chat", as: "ai_helper_chat" 5 | get "projects/:id/ai_helper/chat_form", to: "ai_helper#chat_form", as: "ai_helper_chat_form" 6 | get "projects/:id/ai_helper/reload", to: "ai_helper#reload", as: "ai_helper_reload" 7 | get "projects/:id/ai_helper/clear", to: "ai_helper#clear", as: "ai_helper_clear" 8 | get "projects/:id/ai_helper/history", to: "ai_helper#history", as: "ai_helper_history" 9 | get "projects/:id/ai_helper/conversation/:conversation_id", to: "ai_helper#conversation", as: "ai_helper_conversation" 10 | delete "projects/:id/ai_helper/conversation/:conversation_id", to: "ai_helper#conversation", as: "ai_helper_delete_conversation" 11 | post "projects/:id/ai_helper/call_llm", to: "ai_helper#call_llm", as: "ai_helper_call_llm" 12 | get "ai_helper/issue/:id/summary", to: "ai_helper#issue_summary", as: "ai_helper_issue_summary" 13 | post "ai_helper/issue/:id/generate_reply", to: "ai_helper#generate_issue_reply", as: "ai_helper_generate_issue_reply" 14 | 15 | get "ai_helper_settings/index", to: "ai_helper_settings#index", as: "ai_helper_setting" 16 | post "ai_helper_settings/index", to: "ai_helper_settings#update", as: "ai_helper_setting_update" 17 | 18 | get "ai_helper_model_profiles", to: "ai_helper_model_profiles#index", as: "ai_helper_model_profiles" 19 | get "ai_helper_model_profiles/:id", to: "ai_helper_model_profiles#show", as: "ai_helper_model_profiles_show" 20 | get "ai_helper_model_profiles/:id/edit", to: "ai_helper_model_profiles#edit", as: "ai_helper_model_profiles_edit" 21 | get "ai_helper_model_profiles_new", to: "ai_helper_model_profiles#new", as: "ai_helper_model_profiles_new" 22 | post "ai_helper_model_profiles", to: "ai_helper_model_profiles#create", as: "ai_helper_model_profiles_create" 23 | post "ai_helper_model_profiles/:id/edit", to: "ai_helper_model_profiles#update", as: "ai_helper_model_profiles_update" 24 | delete "ai_helper_model_profiles/:id", to: "ai_helper_model_profiles#destroy", as: "ai_helper_model_profiles_destroy" 25 | end 26 | -------------------------------------------------------------------------------- /db/migrate/20250112041436_create_ai_helper_conversations.rb: -------------------------------------------------------------------------------- 1 | class CreateAiHelperConversations < ActiveRecord::Migration[7.2] 2 | def change 3 | create_table :ai_helper_conversations do |t| 4 | t.string :title 5 | t.integer :user_id 6 | t.integer :version_id 7 | t.date :created_at 8 | t.date :update_at 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20250112041940_create_ai_helper_messages.rb: -------------------------------------------------------------------------------- 1 | class CreateAiHelperMessages < ActiveRecord::Migration[7.2] 2 | def change 3 | create_table :ai_helper_messages do |t| 4 | t.integer :conversation_id 5 | t.string :role 6 | t.text :content 7 | t.date :created_at 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20250112120759_add_project_id_to_ai_helper_conversations.rb: -------------------------------------------------------------------------------- 1 | class AddProjectIdToAiHelperConversations < ActiveRecord::Migration[7.2] 2 | def change 3 | add_column :ai_helper_conversations, :project_id, :integer 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20250129221712_remove_project_id_from_ai_helper_conversations.rb: -------------------------------------------------------------------------------- 1 | class RemoveProjectIdFromAiHelperConversations < ActiveRecord::Migration[7.2] 2 | def change 3 | remove_column :ai_helper_conversations, :project_id 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20250130045041_rename_update_at_helper_conversations.rb: -------------------------------------------------------------------------------- 1 | class RenameUpdateAtHelperConversations < ActiveRecord::Migration[7.2] 2 | def change 3 | rename_column :ai_helper_conversations, :update_at, :updated_at 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20250130142054_change_date_to_datetime_for_conversation.rb: -------------------------------------------------------------------------------- 1 | class ChangeDateToDatetimeForConversation < ActiveRecord::Migration[7.2] 2 | def up 3 | change_column :ai_helper_conversations, :updated_at, :datetime 4 | change_column :ai_helper_conversations, :created_at, :datetime 5 | change_column :ai_helper_messages, :created_at, :datetime 6 | end 7 | 8 | def down 9 | change_column :ai_helper_conversations, :updated_at, :date 10 | change_column :ai_helper_conversations, :created_at, :date 11 | change_column :ai_helper_messages, :created_at, :date 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20250405080724_create_ai_helper_model_profiles.rb: -------------------------------------------------------------------------------- 1 | class CreateAiHelperModelProfiles < ActiveRecord::Migration[7.2] 2 | def change 3 | create_table :ai_helper_model_profiles do |t| 4 | t.string :type 5 | t.string :name 6 | t.string :access_key 7 | t.string :organization_id 8 | t.string :base_uri 9 | t.integer :version 10 | t.datetime :created_at 11 | t.datetime :updated_at 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20250405080829_create_ai_helper_settings.rb: -------------------------------------------------------------------------------- 1 | class CreateAiHelperSettings < ActiveRecord::Migration[7.2] 2 | def change 3 | create_table :ai_helper_settings do |t| 4 | t.integer :model_profile_id 5 | t.text :additional_instructions 6 | t.integer :version 7 | t.datetime :created_at 8 | t.datetime :updated_at 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20250405122505_add_model_name_to_ai_helper_model_profile.rb: -------------------------------------------------------------------------------- 1 | class AddModelNameToAiHelperModelProfile < ActiveRecord::Migration[7.2] 2 | def change 3 | add_column :ai_helper_model_profiles, :llm_model, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20250405134714_change_type_to_llm_type.rb: -------------------------------------------------------------------------------- 1 | class ChangeTypeToLlmType < ActiveRecord::Migration[7.2] 2 | def change 3 | rename_column :ai_helper_model_profiles, :type, :llm_type 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20250414221050_add_fields_for_vector_search.rb: -------------------------------------------------------------------------------- 1 | class AddFieldsForVectorSearch < ActiveRecord::Migration[7.2] 2 | def change 3 | add_column :ai_helper_settings, :vector_search_enabled, :boolean, default: false 4 | add_column :ai_helper_settings, :vector_search_uri, :string 5 | add_column :ai_helper_settings, :vector_search_api_key, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20250415220808_create_ai_helper_vector_data.rb: -------------------------------------------------------------------------------- 1 | class CreateAiHelperVectorData < ActiveRecord::Migration[7.2] 2 | def change 3 | create_table :ai_helper_vector_data do |t| 4 | t.integer :object_id 5 | t.string :index 6 | t.string :uuid 7 | t.datetime :created_at 8 | t.datetime :updated_at 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20250419035418_add_emmbeding_model_to_setting.rb: -------------------------------------------------------------------------------- 1 | class AddEmmbedingModelToSetting < ActiveRecord::Migration[7.2] 2 | def change 3 | add_column :ai_helper_settings, :embedding_model, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20250517141147_add_temperature_to_ai_helper_model_profiles.rb: -------------------------------------------------------------------------------- 1 | class AddTemperatureToAiHelperModelProfiles < ActiveRecord::Migration[7.2] 2 | def change 3 | add_column :ai_helper_model_profiles, :temperature, :float, default: 0.5 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20250519133924_create_ai_helper_summary_caches.rb: -------------------------------------------------------------------------------- 1 | class CreateAiHelperSummaryCaches < ActiveRecord::Migration[7.2] 2 | def change 3 | create_table :ai_helper_summary_caches do |t| 4 | t.string :object_class 5 | t.integer :object_id 6 | t.text :content 7 | t.datetime :created_at 8 | t.datetime :updated_at 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20250529133107_add_dimention_to_setting.rb: -------------------------------------------------------------------------------- 1 | class AddDimentionToSetting < ActiveRecord::Migration[7.2] 2 | def change 3 | add_column :ai_helper_settings, :dimension, :integer, null: true, default: nil 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20250603101602_add_max_tokens_to_model_profile.rb: -------------------------------------------------------------------------------- 1 | class AddMaxTokensToModelProfile < ActiveRecord::Migration[7.2] 2 | def change 3 | add_column :ai_helper_model_profiles, :max_tokens, :integer 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20250604122130_add_embedding_url_to_ai_helper_setting.rb: -------------------------------------------------------------------------------- 1 | class AddEmbeddingUrlToAiHelperSetting < ActiveRecord::Migration[7.2] 2 | def change 3 | add_column :ai_helper_settings, :embedding_url, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /example/redmine_fortune/fortune_agent.rb: -------------------------------------------------------------------------------- 1 | require "redmine_ai_helper/base_agent" 2 | require_relative "./fortune_tools" 3 | 4 | class FortuneAgent < RedmineAiHelper::BaseAgent 5 | def backstory 6 | "You are a fortune-telling agent of the Redmine AI Helper plugin. You can predict the fortunes of Redmine users. You provide Japanise-omikuji and horoscope readings." 7 | end 8 | 9 | def available_tool_providers 10 | [FortuneTools] 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /example/redmine_fortune/fortune_tools.rb: -------------------------------------------------------------------------------- 1 | require "redmine_ai_helper/base_tools" 2 | 3 | # This class provides fortune-telling features such as omikuji (Japanese fortune-telling) and horoscope predictions. 4 | # Based on langchainrb's ToolDefinition. The format of define_function follows the specifications of langchainrb. 5 | # @see https://github.com/patterns-ai-core/langchainrb#creating-custom-tools 6 | class FortuneTools < RedmineAiHelper::BaseTools 7 | # Definition of the Omikuji fortune-telling feature 8 | define_function :omikuji, description: "Draw a fortune by Japanese-OMIKUJI for the specified date." do 9 | property :date, type: "string", description: "Specify the date to draw the fortune in 'YYYY-MM-DD' format.", required: true 10 | end 11 | 12 | # Omikuji fortune-telling method 13 | # @param date [String] The date for which to draw the fortune. 14 | # @return [String] The fortune result. 15 | # @example 16 | # omikuji(date: "2023-10-01") 17 | # => "DAI-KICHI/Great blessing" 18 | # 19 | # @note The date parameter is not used in the fortune drawing process. 20 | def omikuji(date:) 21 | ["DAI-KICHI/Great blessing", "CHU-KICHI/Middle blessing", "SHOU-KICHI/Small blessing", "SUE-KICHI/Future blessing", "KYOU/Curse", "DAI-KYOU/Great curse"].sample 22 | end 23 | 24 | # Definition of the horoscope fortune-telling feature 25 | define_function :horoscope, description: "Predict the monthly horoscope for the person with the specified birthday." do 26 | property :birthday, type: "string", description: "Specify the birthday of the person to predict in 'YYYY-MM-DD' format.", required: true 27 | end 28 | 29 | # Horoscope fortune-telling method 30 | # @param birthday [String] The birthday of the person to predict. 31 | # @return [String] The horoscope result. 32 | # @example 33 | # horoscope(birthday: "1990-01-01") 34 | # => "This month's fortune is excellent. Everything you do will go well." 35 | # 36 | # @note The birthday parameter is not used in the horoscope prediction process. 37 | def horoscope(birthday:) 38 | fortune1 = "This month's fortune is excellent. Everything you do will go well." 39 | fortune2 = "This month's fortune is so-so. There are no particular problems." 40 | fortune3 = "This month's fortune is not very good. Caution is required." 41 | fortune4 = "This month's fortune is the worst. Nothing you do will go well." 42 | [fortune1, fortune2, fortune3, fortune4].sample 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /example/redmine_fortune/init.rb: -------------------------------------------------------------------------------- 1 | require_relative "./fortune_agent" # Don't forget to require the agent class 2 | 3 | Redmine::Plugin.register :redmine_fortune do 4 | name "Redmine Fortune plugin" 5 | author "Haruyuki Iida" 6 | description "This is a example plugin of AI Agent for Redmine AI Helper" 7 | version "0.0.1" 8 | url "https://github.com/haru/redmine_ai_helper" 9 | author_url "https://github.com/haru" 10 | end 11 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require "langfuse" 2 | $LOAD_PATH.unshift "#{File.dirname(__FILE__)}/lib" 3 | require "redmine_ai_helper/util/config_file" 4 | require_dependency "redmine_ai_helper/view_hook" 5 | Dir[File.join(File.dirname(__FILE__), "lib/redmine_ai_helper/agents", "*_agent.rb")].each do |file| 6 | require file 7 | end 8 | Redmine::Plugin.register :redmine_ai_helper do 9 | name "Redmine Ai Helper plugin" 10 | author "Haruyuki Iida" 11 | description "This plugin adds an AI assistant to Redmine." 12 | url "https://github.com/haru/redmine_ai_helper" 13 | author_url "https://github.com/haru" 14 | requires_redmine :version_or_higher => "6.0.0" 15 | 16 | version "1.1.0" 17 | 18 | project_module :ai_helper do 19 | permission :view_ai_helper, { ai_helper: [:chat, :chat_form, :reload, :clear, :call_llm, :history, :issue_summary, :conversation, :generate_issue_reply] } 20 | end 21 | 22 | menu :admin_menu, "icon ah_helper", { 23 | controller: "ai_helper_settings", action: "index", 24 | }, caption: :label_ai_helper, :icon => "ai-helper-robot", 25 | :plugin => :redmine_ai_helper 26 | end 27 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/agents/board_agent.rb: -------------------------------------------------------------------------------- 1 | require_relative "../base_agent" 2 | 3 | module RedmineAiHelper 4 | module Agents 5 | # BoardAgent is a specialized agent for handling Redmine board-related queries. 6 | class BoardAgent < RedmineAiHelper::BaseAgent 7 | def backstory 8 | prompt = load_prompt("board_agent/backstory") 9 | content = prompt.format 10 | content 11 | end 12 | 13 | def available_tool_providers 14 | [RedmineAiHelper::Tools::BoardTools] 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/agents/issue_agent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../base_agent" 3 | 4 | module RedmineAiHelper 5 | module Agents 6 | # IssueAgent is a specialized agent for handling Redmine issue-related queries. 7 | class IssueAgent < RedmineAiHelper::BaseAgent 8 | include RedmineAiHelper::Util::IssueJson 9 | include Rails.application.routes.url_helpers 10 | 11 | def backstory 12 | search_answer_instruction = I18n.t("ai_helper.prompts.issue_agent.search_answer_instruction") 13 | search_answer_instruction = "" if AiHelperSetting.vector_search_enabled? 14 | prompt = load_prompt("issue_agent/backstory") 15 | prompt.format(issue_properties: issue_properties, search_answer_instruction: search_answer_instruction) 16 | end 17 | 18 | def available_tool_providers 19 | base_tools = [ 20 | RedmineAiHelper::Tools::IssueTools, 21 | RedmineAiHelper::Tools::ProjectTools, 22 | RedmineAiHelper::Tools::UserTools, 23 | RedmineAiHelper::Tools::IssueSearchTools, 24 | ] 25 | if AiHelperSetting.vector_search_enabled? 26 | base_tools.unshift(RedmineAiHelper::Tools::VectorTools) 27 | end 28 | 29 | base_tools 30 | end 31 | 32 | def issue_summary(issue:) 33 | return "Permission denied" unless issue.visible? 34 | 35 | prompt = load_prompt("issue_agent/summary") 36 | issue_json = generate_issue_data(issue) 37 | prompt_text = prompt.format(issue: JSON.pretty_generate(issue_json)) 38 | message = { role: "user", content: prompt_text } 39 | messages = [message] 40 | chat(messages) 41 | end 42 | 43 | def generate_issue_reply(issue:, instructions:) 44 | return "Permission denied" unless issue.visible? 45 | 46 | prompt = load_prompt("issue_agent/generate_reply") 47 | issue_json = generate_issue_data(issue) 48 | prompt_text = prompt.format(issue: JSON.pretty_generate(issue_json), instructions: instructions, format: Setting.text_formatting) 49 | message = { role: "user", content: prompt_text } 50 | messages = [message] 51 | chat(messages) 52 | end 53 | 54 | private 55 | 56 | # Generate a available issue properties string 57 | def issue_properties 58 | return "" unless @project 59 | provider = RedmineAiHelper::Tools::IssueTools.new 60 | properties = provider.capable_issue_properties(project_id: @project.id) 61 | content = <<~EOS 62 | 63 | ---- 64 | 65 | The following issue properties are available for Project ID: #{@project.id}. 66 | 67 | ```json 68 | #{JSON.pretty_generate(properties)} 69 | ``` 70 | EOS 71 | content 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/agents/issue_update_agent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../base_agent" 3 | 4 | module RedmineAiHelper 5 | module Agents 6 | # IssueUpdateAgent is a specialized agent for handling Redmine issue updates. 7 | class IssueUpdateAgent < RedmineAiHelper::BaseAgent 8 | def backstory 9 | prompt = load_prompt("issue_update_agent/backstory") 10 | content = prompt.format(issue_properties: issue_properties) 11 | content 12 | end 13 | 14 | def available_tool_providers 15 | [ 16 | RedmineAiHelper::Tools::IssueTools, 17 | RedmineAiHelper::Tools::IssueUpdateTools, 18 | RedmineAiHelper::Tools::ProjectTools, 19 | RedmineAiHelper::Tools::UserTools, 20 | ] 21 | end 22 | 23 | private 24 | 25 | def issue_properties 26 | return "" unless @project 27 | provider = RedmineAiHelper::Tools::IssueTools.new 28 | properties = provider.capable_issue_properties(project_id: @project.id) 29 | content = <<~EOS 30 | 31 | ---- 32 | 33 | The following issue properties are available for Project ID: #{@project.id}. 34 | 35 | ```json 36 | #{JSON.pretty_generate(properties)} 37 | ``` 38 | EOS 39 | content 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/agents/mcp_agent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "redmine_ai_helper/base_agent" 3 | 4 | module RedmineAiHelper 5 | module Agents 6 | # MCPAgent is a specialized agent for handling tasks using the Model Context Protocol (MCP). 7 | class McpAgent < RedmineAiHelper::BaseAgent 8 | def role 9 | "mcp_agent" 10 | end 11 | 12 | def available_tool_providers 13 | list = Util::McpToolsLoader.load 14 | list 15 | end 16 | 17 | def backstory 18 | prompt = load_prompt("mcp_agent/backstory") 19 | content = prompt.format 20 | content 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/agents/project_agent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../base_agent" 3 | 4 | module RedmineAiHelper 5 | module Agents 6 | # ProjectAgent is a specialized agent for handling Redmine project-related queries. 7 | class ProjectAgent < RedmineAiHelper::BaseAgent 8 | def backstory 9 | prompt = load_prompt("project_agent/backstory") 10 | content = prompt.format 11 | content 12 | end 13 | 14 | def available_tool_providers 15 | [RedmineAiHelper::Tools::ProjectTools] 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/agents/repository_agent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../base_agent" 3 | 4 | module RedmineAiHelper 5 | module Agents 6 | # RepositoryAgent is a specialized agent for handling Redmine repository-related queries. 7 | class RepositoryAgent < RedmineAiHelper::BaseAgent 8 | def backstory 9 | prompt = load_prompt("repository_agent/backstory") 10 | content = prompt.format 11 | content 12 | end 13 | 14 | def available_tool_providers 15 | [RedmineAiHelper::Tools::RepositoryTools] 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/agents/system_agent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../base_agent" 3 | 4 | module RedmineAiHelper 5 | module Agents 6 | # SystemAgent is a specialized agent for handling Redmine system-related queries. 7 | class SystemAgent < RedmineAiHelper::BaseAgent 8 | def backstory 9 | prompt = load_prompt("system_agent/backstory") 10 | content = prompt.format 11 | content 12 | end 13 | 14 | def available_tool_providers 15 | [RedmineAiHelper::Tools::SystemTools] 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/agents/user_agent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../base_agent" 3 | 4 | module RedmineAiHelper 5 | module Agents 6 | # UserAgent is a specialized agent for handling Redmine user-related queries. 7 | class UserAgent < RedmineAiHelper::BaseAgent 8 | def backstory 9 | prompt = load_prompt("user_agent/backstory") 10 | content = prompt.format 11 | content 12 | end 13 | 14 | def available_tool_providers 15 | [RedmineAiHelper::Tools::UserTools] 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/agents/version_agent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../base_agent" 3 | 4 | module RedmineAiHelper 5 | module Agents 6 | # VersionAgent is a specialized agent for handling Redmine version-related queries. 7 | class VersionAgent < RedmineAiHelper::BaseAgent 8 | def backstory 9 | prompt = load_prompt("version_agent/backstory") 10 | content = prompt.format 11 | content 12 | end 13 | 14 | def available_tool_providers 15 | [RedmineAiHelper::Tools::VersionTools] 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/agents/wiki_agent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../base_agent" 3 | 4 | module RedmineAiHelper 5 | module Agents 6 | # WikiAgent is a specialized agent for handling Redmine wiki-related queries. 7 | class WikiAgent < RedmineAiHelper::BaseAgent 8 | def backstory 9 | prompt = load_prompt("wiki_agent/backstory") 10 | content = prompt.format 11 | content 12 | end 13 | 14 | def available_tool_providers 15 | base_tools = [RedmineAiHelper::Tools::WikiTools] 16 | if AiHelperSetting.vector_search_enabled? 17 | base_tools.unshift(RedmineAiHelper::Tools::VectorTools) 18 | end 19 | base_tools 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/assistant.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # TODO: Move this to assistants directory. 3 | require "langchain" 4 | 5 | module RedmineAiHelper 6 | # Base class for all assistants. 7 | class Assistant < Langchain::Assistant 8 | attr_accessor :llm_provider 9 | @llm_provider = nil 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/assistant_provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module RedmineAiHelper 3 | class AssistantProvider 4 | # This class is responsible for providing the appropriate assistant based on the LLM type. 5 | class << self 6 | # Returns an instance of the appropriate assistant based on the LLM type. 7 | # @param llm_type [String] The type of LLM (e.g., LLM_GEMINI). 8 | # @param llm [Object] The LLM client to use. 9 | # @param instructions [String] The instructions for the assistant. 10 | # @param tools [Array] The tools to be used by the assistant. 11 | # @return [Object] An instance of the appropriate assistant. 12 | def get_assistant(llm_type:, llm:, instructions:, tools: []) 13 | case llm_type 14 | when LlmProvider::LLM_GEMINI 15 | return RedmineAiHelper::Assistants::GeminiAssistant.new( 16 | llm: llm, 17 | instructions: instructions, 18 | tools: tools, 19 | ) 20 | else 21 | return RedmineAiHelper::Assistant.new( 22 | llm: llm, 23 | instructions: instructions, 24 | tools: tools, 25 | ) 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/assistants/gemini_assistant.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module RedmineAiHelper 3 | module Assistants 4 | # GeminiAssistant is a specialized assistant for handling Gemini messages. 5 | class GeminiAssistant < RedmineAiHelper::Assistant 6 | # Adjust the message format to match Gemini. Convert "assistant" role to "model". 7 | def add_message(role: "user", content: nil, image_url: nil, tool_calls: [], tool_call_id: nil) 8 | new_role = role 9 | case role 10 | when "assistant" 11 | new_role = "model" 12 | end 13 | super( 14 | role: new_role, 15 | content: content, 16 | image_url: image_url, 17 | tool_calls: tool_calls, 18 | tool_call_id: tool_call_id, 19 | ) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/base_tools.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "langchain" 3 | require "redmine_ai_helper/logger" 4 | 5 | module RedmineAiHelper 6 | 7 | # Base class for all tools. 8 | class BaseTools 9 | extend Langchain::ToolDefinition 10 | 11 | include RedmineAiHelper::Logger 12 | include Rails.application.routes.url_helpers 13 | 14 | # Check if the specified project is accessible 15 | # @param project [Project] The project 16 | # @return [Boolean] true if accessible, false otherwise 17 | def accessible_project?(project) 18 | return false unless project.visible? 19 | return false unless project.module_enabled?(:ai_helper) 20 | User.current.allowed_to?({ controller: :ai_helper, action: :chat_form }, project) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/chat_room.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "redmine_ai_helper/logger" 3 | 4 | module RedmineAiHelper 5 | # A class representing a chat room where the LeaderAgent and other agents collaborate to achieve the user's goal. 6 | # The chat room is created by the LeaderAgent, and additional agents are added as needed. 7 | class ChatRoom 8 | include RedmineAiHelper::Logger 9 | attr_accessor :agents, :messages 10 | 11 | # @param goal [String] The user's goal. 12 | def initialize(goal) 13 | @agents = [] 14 | @goal = goal 15 | @messages = [] 16 | end 17 | 18 | # Share the user's goal with all agents in the chat room. 19 | def share_goal 20 | first_message = <<~EOS 21 | The user's goal is as follows. Collaborate with all agents to achieve this goal. 22 | 23 | ---- 24 | 25 | goal: 26 | #{goal} 27 | EOS 28 | add_message("user", "leader", first_message, "all") 29 | end 30 | 31 | # @return [String] The user's goal. 32 | def goal 33 | @goal 34 | end 35 | 36 | # Add an agent to the chat room. 37 | # @param agent [BaseAgent] The agent to be added to the chat room. 38 | # @return [Array] The list of agents in the chat room. 39 | def add_agent(agent) 40 | @agents << agent 41 | end 42 | 43 | # Add a message to the chat room. 44 | # @param role [String] The role of the agent sending the message. 45 | # @param message [String] The message content. 46 | # @param to [String] The recipient of the message. 47 | # @return [Array] The list of messages in the chat room. 48 | def add_message(llm_role, from, message, to) 49 | ai_helper_logger.debug "from: #{from}\n @#{to}, #{message}" 50 | @messages ||= [] 51 | content = "From: #{from}\n\n----\n\nTo: #{to}\n#{message}" 52 | @messages << { role: llm_role, content: content } 53 | @agents.each do |agent| 54 | agent.add_message(role: llm_role, content: content) 55 | end 56 | end 57 | 58 | # Get an agent by its role. 59 | # @param [String] role The role of the agent to be retrieved. 60 | # @return [BaseAgent] The agent with the specified role. 61 | def get_agent(role) 62 | @agents.find { |agent| agent.role == role } 63 | end 64 | 65 | # Send a task from one agent to another. The task is saved in the messages. 66 | # @param [String] from the role of the agent sending the task 67 | # @param [String] to the role of the agent receiving the task 68 | # @param [String] task the task to be sent 69 | # @param [Hash] option options for the task 70 | # @param [Proc] proc a block to be executed after the task is sent 71 | # @return [String] the response from the agent 72 | def send_task(from, to, task, option = {}, proc = nil) 73 | add_message("user", from, task, to) 74 | agent = get_agent(to) 75 | unless agent 76 | error = "Agent not found: #{to}" 77 | ai_helper_logger.error error 78 | raise error 79 | end 80 | answer = agent.perform_task(option, proc) 81 | add_message("assistant", to, answer, from) 82 | answer 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/langfuse_util/anthropic.rb: -------------------------------------------------------------------------------- 1 | module RedmineAiHelper 2 | module LangfuseUtil 3 | # Wrapper for Anthropic. 4 | class Anthropic < Langchain::LLM::Anthropic 5 | attr_accessor :langfuse 6 | 7 | # Override the chat method to handle tool calls. 8 | # @param [Hash] params Parameters for the chat request. 9 | # @param [Proc] block Block to handle the response. 10 | # @return The response from the chat. 11 | def chat(params = {}, &block) 12 | generation = nil 13 | if @langfuse&.current_span 14 | parameters = chat_parameters.to_params(params) 15 | span = @langfuse.current_span 16 | max_tokens = parameters[:max_tokens] || @defaults[:max_tokens] 17 | new_messages = [] 18 | new_messages << { role: "system", content: params[:system] } if params[:system] 19 | new_messages = new_messages + params[:messages] 20 | generation = span.create_generation(name: "chat", messages: new_messages, model: parameters[:model], temperature: parameters[:temperature], max_tokens: max_tokens) 21 | end 22 | response = super(params, &block) 23 | if generation 24 | usage = { 25 | prompt_tokens: response.prompt_tokens, 26 | completion_tokens: response.completion_tokens, 27 | total_tokens: response.total_tokens, 28 | } 29 | generation.finish(output: response.chat_completion, usage: usage) 30 | end 31 | response 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/langfuse_util/azure_open_ai.rb: -------------------------------------------------------------------------------- 1 | module RedmineAiHelper 2 | module LangfuseUtil 3 | # Wrapper for OpenAI. 4 | class AzureOpenAi < Langchain::LLM::Azure 5 | attr_accessor :langfuse 6 | 7 | # Override the chat method to handle tool calls. 8 | # @param [Hash] params Parameters for the chat request. 9 | # @param [Proc] block Block to handle the response. 10 | # @return The response from the chat. 11 | def chat(params = {}, &block) 12 | generation = nil 13 | if @langfuse&.current_span 14 | parameters = chat_parameters.to_params(params) 15 | span = @langfuse.current_span 16 | max_tokens = parameters[:max_tokens] || @defaults[:max_tokens] 17 | generation = span.create_generation(name: "chat", messages: params[:messages], model: parameters[:model], temperature: parameters[:temperature], max_tokens: max_tokens) 18 | end 19 | response = super(params, &block) 20 | if generation 21 | usage = { 22 | prompt_tokens: response.prompt_tokens, 23 | completion_tokens: response.completion_tokens, 24 | total_tokens: response.total_tokens, 25 | } 26 | generation.finish(output: response.chat_completion, usage: usage) 27 | end 28 | response 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/langfuse_util/gemini.rb: -------------------------------------------------------------------------------- 1 | module RedmineAiHelper 2 | module LangfuseUtil 3 | # Wrapper for GoogleGemini. 4 | class Gemini < Langchain::LLM::GoogleGemini 5 | attr_accessor :langfuse 6 | 7 | # Override the chat method to handle tool calls. 8 | # @param [Hash] params Parameters for the chat request. 9 | # @param [Proc] block Block to handle the response. 10 | # @return The response from the chat. 11 | def chat(params = {}) 12 | generation = nil 13 | if @langfuse&.current_span 14 | parameters = chat_parameters.to_params(params) 15 | span = @langfuse.current_span 16 | max_tokens = parameters[:max_tokens] || @defaults[:max_tokens] 17 | new_messages = [] 18 | new_messages << { role: "system", content: params[:system] } if params[:system] 19 | params[:messages].each do |message| 20 | new_messages << { role: message[:role], content: message.dig(:parts, 0, :text) } 21 | end 22 | generation = span.create_generation(name: "chat", messages: new_messages, model: parameters[:model], temperature: parameters[:temperature], max_tokens: max_tokens) 23 | end 24 | response = super(params) 25 | if generation 26 | usage = { 27 | prompt_tokens: response.prompt_tokens, 28 | completion_tokens: response.completion_tokens, 29 | total_tokens: response.total_tokens, 30 | } 31 | generation.finish(output: response.chat_completion, usage: usage) 32 | end 33 | response 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/langfuse_util/open_ai.rb: -------------------------------------------------------------------------------- 1 | module RedmineAiHelper 2 | module LangfuseUtil 3 | # Wrapper for OpenAI. 4 | class OpenAi < Langchain::LLM::OpenAI 5 | attr_accessor :langfuse 6 | 7 | # Override the chat method to handle tool calls. 8 | # @param [Hash] params Parameters for the chat request. 9 | # @param [Proc] block Block to handle the response. 10 | # @return The response from the chat. 11 | def chat(params = {}, &block) 12 | generation = nil 13 | if @langfuse&.current_span 14 | parameters = chat_parameters.to_params(params) 15 | span = @langfuse.current_span 16 | max_tokens = parameters[:max_tokens] || @defaults[:max_tokens] 17 | generation = span.create_generation(name: "chat", messages: params[:messages], model: parameters[:model], temperature: parameters[:temperature], max_tokens: max_tokens) 18 | end 19 | response = super(params, &block) 20 | if generation 21 | usage = { 22 | prompt_tokens: response.prompt_tokens, 23 | completion_tokens: response.completion_tokens, 24 | total_tokens: response.total_tokens, 25 | } 26 | generation.finish(output: response.chat_completion, usage: usage) 27 | end 28 | response 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/llm.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "logger" 3 | require_relative "base_agent" 4 | require_relative "langfuse_util/langfuse_wrapper" 5 | 6 | module RedmineAiHelper 7 | 8 | # A class that is directly called from the controller to interact with AI using LLM. 9 | # TODO: クラス名を変えたい 10 | class Llm 11 | include RedmineAiHelper::Logger 12 | attr_accessor :model 13 | 14 | def initialize(params = {}) 15 | end 16 | 17 | # chat with the AI 18 | # @param conversation [Conversation] The conversation object 19 | # @param proc [Proc] A block to be executed after the task is sent 20 | # @param option [Hash] Options for the task 21 | # @return [AiHelperMessage] The AI's response 22 | def chat(conversation, proc, option = {}) 23 | task = conversation.messages.last.content 24 | ai_helper_logger.debug "#### ai_helper: chat start ####" 25 | ai_helper_logger.info "user:#{User.current}, task: #{task}, option: #{option}" 26 | begin 27 | langfuse = RedmineAiHelper::LangfuseUtil::LangfuseWrapper.new(input: task) 28 | option[:langfuse] = langfuse 29 | agent = RedmineAiHelper::Agents::LeaderAgent.new(option) 30 | langfuse.create_span(name: "user_request", input: task) 31 | answer = agent.perform_user_request(conversation.messages_for_openai, option, proc) 32 | langfuse.finish_current_span(output: answer) 33 | langfuse.flush 34 | rescue => e 35 | ai_helper_logger.error "error: #{e.full_message}" 36 | answer = e.message 37 | end 38 | ai_helper_logger.info "answer: #{answer}" 39 | AiHelperMessage.new(role: "assistant", content: answer, conversation: conversation) 40 | end 41 | 42 | # IssueAgentを使用して、issueの要約を取得する 43 | # @param issue [Issue] The issue object 44 | # return [String] The summary of the issue 45 | def issue_summary(issue:) 46 | begin 47 | prompt = "Please summarize the issue #{issue.id}." 48 | langfuse = RedmineAiHelper::LangfuseUtil::LangfuseWrapper.new(input: prompt) 49 | agent = RedmineAiHelper::Agents::IssueAgent.new(project: issue.project, langfuse: langfuse) 50 | langfuse.create_span(name: "user_request", input: prompt) 51 | answer = agent.issue_summary(issue: issue) 52 | langfuse.finish_current_span(output: answer) 53 | langfuse.flush 54 | rescue => e 55 | ai_helper_logger.error "error: #{e.full_message}" 56 | answer = e.message 57 | end 58 | ai_helper_logger.info "answer: #{answer}" 59 | answer 60 | end 61 | 62 | def generate_issue_reply(issue:, instructions:) 63 | begin 64 | prompt = "Please generate a reply to the issue #{issue.id} with the instructions.\n\n#{instructions}" 65 | langfuse = RedmineAiHelper::LangfuseUtil::LangfuseWrapper.new(input: prompt) 66 | agent = RedmineAiHelper::Agents::IssueAgent.new(project: issue.project, langfuse: langfuse) 67 | langfuse.create_span(name: "user_request", input: prompt) 68 | answer = agent.generate_issue_reply(issue: issue, instructions: instructions) 69 | langfuse.finish_current_span(output: answer) 70 | langfuse.flush 71 | rescue => e 72 | ai_helper_logger.error "error: #{e.full_message}" 73 | answer = e.message 74 | end 75 | ai_helper_logger.info "answer: #{answer}" 76 | answer 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/llm_client/anthropic_provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module RedmineAiHelper 3 | module LlmClient 4 | ## AnthropicProvider is a specialized provider for handling requests to the Anthropic LLM. 5 | class AnthropicProvider < RedmineAiHelper::LlmClient::BaseProvider 6 | # generate a client for the Anthropic LLM 7 | # @return [Langchain::LLM::Anthropic] client 8 | def generate_client 9 | setting = AiHelperSetting.find_or_create 10 | model_profile = setting.model_profile 11 | raise "Model Profile not found" unless model_profile 12 | default_options = { 13 | chat_model: model_profile.llm_model, 14 | temperature: model_profile.temperature, 15 | max_tokens: 2000, 16 | } 17 | default_options[:max_tokens] = setting.max_tokens if setting.max_tokens 18 | client = RedmineAiHelper::LangfuseUtil::Anthropic.new( 19 | api_key: model_profile.access_key, 20 | default_options: default_options, 21 | ) 22 | raise "Anthropic LLM Create Error" unless client 23 | client 24 | end 25 | 26 | # Generate a chat completion request 27 | # @param [Hash] system_prompt 28 | # @param [Array] messages 29 | # @return [Hash] chat_params 30 | def create_chat_param(system_prompt, messages) 31 | new_messages = messages.dup 32 | chat_params = { 33 | messages: new_messages, 34 | } 35 | chat_params[:system] = system_prompt[:content] 36 | chat_params 37 | end 38 | 39 | # Extract a message from the chunk 40 | # @param [Hash] chunk 41 | # @return [String] message 42 | def chunk_converter(chunk) 43 | chunk.dig("delta", "text") 44 | end 45 | 46 | # Clear the messages held by the Assistant, set the system prompt, and add messages 47 | # @param [RedmineAiHelper::Assistant] assistant 48 | # @param [Hash] system_prompt 49 | # @param [Array] messages 50 | # @return [void] 51 | def reset_assistant_messages(assistant:, system_prompt:, messages:) 52 | assistant.clear_messages! 53 | assistant.instructions = system_prompt 54 | messages.each do |message| 55 | assistant.add_message(role: message[:role], content: message[:content]) 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/llm_client/azure_open_ai_provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "base_provider" 4 | 5 | module RedmineAiHelper 6 | module LlmClient 7 | # OpenAiProvider is a specialized provider for handling OpenAI-related queries. 8 | class AzureOpenAiProvider < RedmineAiHelper::LlmClient::BaseProvider 9 | # Generate a client for OpenAI LLM 10 | # @return [Langchain::LLM::OpenAI] the OpenAI client 11 | def generate_client 12 | setting = AiHelperSetting.find_or_create 13 | model_profile = setting.model_profile 14 | raise "Model Profile not found" unless model_profile 15 | llm_options = { 16 | api_type: :azure, 17 | chat_deployment_url: model_profile.base_uri, 18 | embedding_deployment_url: setting.embedding_url, 19 | api_version: "2024-12-01-preview", 20 | } 21 | llm_options[:organization_id] = model_profile.organization_id if model_profile.organization_id 22 | llm_options[:embedding_model] = setting.embedding_model unless setting.embedding_model.blank? 23 | llm_options[:organization_id] = model_profile.organization_id if model_profile.organization_id 24 | llm_options[:max_tokens] = setting.max_tokens if setting.max_tokens 25 | default_options = { 26 | model: model_profile.llm_model, 27 | chat_model: model_profile.llm_model, 28 | temperature: model_profile.temperature, 29 | embedding_deployment_url: setting.embedding_url, 30 | } 31 | default_options[:embedding_model] = setting.embedding_model unless setting.embedding_model.blank? 32 | default_options[:max_tokens] = setting.max_tokens if setting.max_tokens 33 | client = RedmineAiHelper::LangfuseUtil::AzureOpenAi.new( 34 | api_key: model_profile.access_key, 35 | llm_options: llm_options, 36 | default_options: default_options, 37 | chat_deployment_url: model_profile.base_uri, 38 | embedding_deployment_url: setting.embedding_url, 39 | ) 40 | raise "OpenAI LLM Create Error" unless client 41 | client 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/llm_client/base_provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module RedmineAiHelper 3 | module LlmClient 4 | # BaseProvider is an abstract class that defines the interface for LLM providers. 5 | class BaseProvider 6 | 7 | # @return LLM Client of the LLM provider. 8 | def generate_client 9 | raise NotImplementedError, "LLM provider not found" 10 | end 11 | 12 | # @return [Hash] The system prompt for the LLM provider. 13 | def create_chat_param(system_prompt, messages) 14 | new_messages = messages.dup 15 | new_messages.unshift(system_prompt) 16 | { 17 | messages: new_messages, 18 | } 19 | end 20 | 21 | # Extracts a message from the chunk 22 | # @param [Hash] chunk 23 | # @return [String] message 24 | def chunk_converter(chunk) 25 | chunk.dig("delta", "content") 26 | end 27 | 28 | # Clears the messages held by the Assistant, sets the system prompt, and adds messages 29 | # @param [RedmineAiHelper::Assistant] assistant 30 | # @param [Hash] system_prompt 31 | # @param [Array] messages 32 | # @return [void] 33 | def reset_assistant_messages(assistant:, system_prompt:, messages:) 34 | assistant.clear_messages! 35 | assistant.add_message(role: "system", content: system_prompt[:content]) 36 | messages.each do |message| 37 | assistant.add_message(role: message[:role], content: message[:content]) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/llm_client/gemini_provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module RedmineAiHelper 3 | module LlmClient 4 | # GeminiProvider is a specialized provider for handling Google Gemini LLM requests. 5 | class GeminiProvider < RedmineAiHelper::LlmClient::BaseProvider 6 | # Generate a new Gemini client using the provided API key and model profile. 7 | # @return [Langchain::LLM::GoogleGemini] client 8 | def generate_client 9 | setting = AiHelperSetting.find_or_create 10 | model_profile = setting.model_profile 11 | raise "Model Profile not found" unless model_profile 12 | default_options = { 13 | chat_model: model_profile.llm_model, 14 | temperature: model_profile.temperature, 15 | } 16 | default_options[:max_tokens] = setting.max_tokens if setting.max_tokens 17 | client = RedmineAiHelper::LangfuseUtil::Gemini.new( 18 | api_key: model_profile.access_key, 19 | default_options: default_options, 20 | ) 21 | raise "Gemini LLM Create Error" unless client 22 | client 23 | end 24 | 25 | # Generate a parameter for chat completion request for the Gemini LLM. 26 | # @param [Hash] system_prompt 27 | # @param [Array] messages 28 | # @return [Hash] chat_params 29 | def create_chat_param(system_prompt, messages) 30 | new_messages = messages.map do |message| 31 | { 32 | role: message[:role], 33 | parts: [ 34 | { 35 | text: message[:content], 36 | }, 37 | ], 38 | } 39 | end 40 | chat_params = { 41 | messages: new_messages, 42 | system: system_prompt[:content], 43 | } 44 | chat_params 45 | end 46 | 47 | # Reset the assistant's messages, set the system prompt, and add messages. 48 | # @param [RedmineAiHelper::Assistant] assistant 49 | # @param [Hash] system_prompt 50 | # @param [Array] messages 51 | # @return [void] 52 | def reset_assistant_messages(assistant:, system_prompt:, messages:) 53 | assistant.clear_messages! 54 | assistant.instructions = system_prompt 55 | messages.each do |message| 56 | role = message[:role] 57 | role = "model" if role == "assistant" 58 | assistant.add_message(role: role, content: message[:content]) 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/llm_client/open_ai_provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "redmine_ai_helper/langfuse_util/open_ai" 3 | require_relative "base_provider" 4 | 5 | module RedmineAiHelper 6 | module LlmClient 7 | # OpenAiProvider is a specialized provider for handling OpenAI-related queries. 8 | class OpenAiProvider < RedmineAiHelper::LlmClient::BaseProvider 9 | # Generate a client for OpenAI LLM 10 | # @return [Langchain::LLM::OpenAI] the OpenAI client 11 | def generate_client 12 | setting = AiHelperSetting.find_or_create 13 | model_profile = setting.model_profile 14 | raise "Model Profile not found" unless model_profile 15 | llm_options = {} 16 | llm_options[:organization_id] = model_profile.organization_id if model_profile.organization_id 17 | llm_options[:embedding_model] = setting.embedding_model unless setting.embedding_model.blank? 18 | llm_options[:organization_id] = model_profile.organization_id if model_profile.organization_id 19 | llm_options[:max_tokens] = setting.max_tokens if setting.max_tokens 20 | default_options = { 21 | model: model_profile.llm_model, 22 | chat_model: model_profile.llm_model, 23 | temperature: model_profile.temperature, 24 | } 25 | default_options[:embedding_model] = setting.embedding_model unless setting.embedding_model.blank? 26 | default_options[:max_tokens] = setting.max_tokens if setting.max_tokens 27 | client = RedmineAiHelper::LangfuseUtil::OpenAi.new( 28 | api_key: model_profile.access_key, 29 | llm_options: llm_options, 30 | default_options: default_options, 31 | ) 32 | raise "OpenAI LLM Create Error" unless client 33 | client 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/llm_provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "llm_client/open_ai_provider" 3 | require_relative "llm_client/anthropic_provider" 4 | 5 | module RedmineAiHelper 6 | # This class is responsible for providing the appropriate LLM client based on the LLM type. 7 | class LlmProvider 8 | LLM_OPENAI = "OpenAI".freeze 9 | LLM_OPENAI_COMPATIBLE = "OpenAICompatible".freeze 10 | LLM_GEMINI = "Gemini".freeze 11 | LLM_ANTHROPIC = "Anthropic".freeze 12 | LLM_AZURE_OPENAI = "AzureOpenAi".freeze 13 | class << self 14 | # Returns an instance of the appropriate LLM client based on the system settings. 15 | # @return [Object] An instance of the appropriate LLM client. 16 | def get_llm_provider 17 | case type 18 | when LLM_OPENAI 19 | return RedmineAiHelper::LlmClient::OpenAiProvider.new 20 | when LLM_OPENAI_COMPATIBLE 21 | return RedmineAiHelper::LlmClient::OpenAiCompatibleProvider.new 22 | when LLM_GEMINI 23 | return RedmineAiHelper::LlmClient::GeminiProvider.new 24 | when LLM_ANTHROPIC 25 | return RedmineAiHelper::LlmClient::AnthropicProvider.new 26 | when LLM_AZURE_OPENAI 27 | return RedmineAiHelper::LlmClient::AzureOpenAiProvider.new 28 | else 29 | raise NotImplementedError, "LLM provider not found" 30 | end 31 | end 32 | 33 | # Returns the LLM type based on the system settings. 34 | # @return [String] The LLM type (e.g., LLM_OPENAI). 35 | def type 36 | setting = AiHelperSetting.find_or_create 37 | setting.model_profile.llm_type 38 | end 39 | 40 | # Returns the options to display in the settings screen's dropdown menu 41 | # @return [Array] An array of options for the select menu. 42 | def option_for_select 43 | [ 44 | ["OpenAI", LLM_OPENAI], 45 | ["OpenAI Compatible(Experimental)", LLM_OPENAI_COMPATIBLE], 46 | ["Gemini(Experimental)", LLM_GEMINI], 47 | ["Anthropic(Experimental)", LLM_ANTHROPIC], 48 | ["Azure OpenAI(Experimental)", LLM_AZURE_OPENAI], 49 | ] 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module RedmineAiHelper 3 | module Logger 4 | def self.included(base) 5 | base.extend(ClassMethods) 6 | end 7 | 8 | module ClassMethods 9 | def ai_helper_logger 10 | @ai_helper_logger ||= begin 11 | RedmineAiHelper::CustomLogger.instance 12 | end 13 | end 14 | 15 | def debug(message) 16 | ai_helper_logger.debug("[#{self.name}] #{message}") 17 | end 18 | 19 | def info(message) 20 | ai_helper_logger.info("[#{self.name}] #{message}") 21 | end 22 | 23 | def warn(message) 24 | ai_helper_logger.warn("[#{self.name}] #{message}") 25 | end 26 | 27 | def error(message) 28 | ai_helper_logger.error("[#{self.name}] #{message}") 29 | end 30 | end 31 | 32 | def ai_helper_logger 33 | self.class.ai_helper_logger 34 | end 35 | 36 | def debug(message) 37 | ai_helper_logger.debug("[#{self.class.name}] #{message}") 38 | end 39 | 40 | def info(message) 41 | ai_helper_logger.info("[#{self.class.name}] #{message}") 42 | end 43 | 44 | def warn(message) 45 | ai_helper_logger.warn("[#{self.class.name}] #{message}") 46 | end 47 | 48 | def error(message) 49 | ai_helper_logger.error("[#{self.class.name}] #{message}") 50 | end 51 | end 52 | 53 | class CustomLogger 54 | include Singleton 55 | 56 | def initialize 57 | log_file_path = Rails.root.join("log", "ai_helper.log") 58 | 59 | config = RedmineAiHelper::Util::ConfigFile.load_config 60 | 61 | logger = config[:logger] 62 | unless logger 63 | @logger = Rails.logger 64 | return 65 | end 66 | log_level = "info" 67 | log_level = logger[:level] if logger[:level] 68 | log_file_path = Rails.root.join("log", logger[:file]) if logger[:file] 69 | @logger = ::Logger.new(log_file_path, "daily") 70 | @logger.formatter = proc do |severity, datetime, progname, msg| 71 | "[#{datetime}] #{severity} -- #{msg}\n" 72 | end 73 | set_log_level(log_level) 74 | end 75 | 76 | def debug(message) 77 | @logger.debug(message) 78 | end 79 | 80 | def info(message) 81 | @logger.info(message) 82 | end 83 | 84 | def warn(message) 85 | @logger.warn(message) 86 | end 87 | 88 | def error(message) 89 | @logger.error(message) 90 | end 91 | 92 | def set_log_level(log_level) 93 | level = case log_level.to_s 94 | when "debug" 95 | ::Logger::DEBUG 96 | when "info" 97 | ::Logger::INFO 98 | when "warn" 99 | ::Logger::WARN 100 | when "error" 101 | ::Logger::ERROR 102 | else 103 | ::Logger::INFO 104 | end 105 | @logger.level = level 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/tool_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module RedmineAiHelper 3 | # ツールからのレスポンスを格納するクラス 4 | # TODO: 不要かも 5 | class ToolResponse 6 | attr_reader :status, :value, :error 7 | ToolResponse::STATUS_SUCCESS = "success" 8 | ToolResponse::STATUS_ERROR = "error" 9 | 10 | def initialize(response = {}) 11 | @status = response[:status] || response["status"] 12 | @value = response[:value] || response["value"] 13 | @error = response[:error] || response["error"] 14 | end 15 | 16 | def to_json 17 | to_hash().to_json 18 | end 19 | 20 | def to_hash 21 | { status: status, value: value, error: error } 22 | end 23 | 24 | def to_h 25 | to_hash 26 | end 27 | 28 | def to_s 29 | to_hash.to_s 30 | end 31 | 32 | def is_success? 33 | status == ToolResponse::STATUS_SUCCESS 34 | end 35 | 36 | def is_error? 37 | !is_success? 38 | end 39 | 40 | def self.create_error(error) 41 | ToolResponse.new(status: ToolResponse::STATUS_ERROR, error: error) 42 | end 43 | 44 | def self.create_success(value) 45 | ToolResponse.new(status: ToolResponse::STATUS_SUCCESS, value: value) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/tools/system_tools.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "redmine_ai_helper/base_tools" 3 | 4 | module RedmineAiHelper 5 | module Tools 6 | # SystemTools is a specialized tool provider for handling system-related queries in Redmine. 7 | class SystemTools < RedmineAiHelper::BaseTools 8 | define_function :list_plugins, description: "Returns a list of all plugins installed in Redmine." do 9 | property :dummy, type: "string", description: "Dummy property. No need to specify.", required: false 10 | end 11 | 12 | # Returns a list of all plugins installed in Redmine. 13 | # A dummy property is defined because at least one property is required in the tool 14 | # definition for langchainrb. 15 | # @param dummy [String] Dummy property to satisfy the tool definition requirement. 16 | # @return [Array] An array of hashes containing plugin information. 17 | def list_plugins(dummy: nil) 18 | plugins = Redmine::Plugin.all 19 | plugin_list = [] 20 | plugins.map do |plugin| 21 | plugin_list << 22 | { 23 | name: plugin.name, 24 | version: plugin.version, 25 | author: plugin.author, 26 | url: plugin.url, 27 | author_url: plugin.author_url, 28 | } 29 | end 30 | json = { plugins: plugin_list } 31 | return json 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/util/config_file.rb: -------------------------------------------------------------------------------- 1 | module RedmineAiHelper 2 | module Util 3 | class ConfigFile 4 | def self.load_config 5 | unless File.exist?(config_file_path) 6 | return {} 7 | end 8 | 9 | yaml = YAML.load_file(config_file_path) 10 | yaml.deep_symbolize_keys 11 | end 12 | 13 | def self.config_file_path 14 | Rails.root.join("config", "ai_helper", "config.yml") 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/util/langchain_patch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "langchain" 3 | 4 | module RedmineAiHelper 5 | module Util 6 | module LangchainPatch 7 | # A patch to enable recursive calls for creating Object properties when automatically 8 | # generating Tool definitions in MCPTools 9 | refine Langchain::ToolDefinition::ParameterBuilder do 10 | def build_properties_from_json(json) 11 | properties = json["properties"] || {} 12 | items = json["items"] 13 | properties.each do |key, value| 14 | type = value["type"] 15 | case type 16 | when "object", "array" 17 | property key.to_sym, type: type, description: value["description"] do 18 | build_properties_from_json(value) 19 | end 20 | else 21 | property key.to_sym, type: type, description: value["description"] 22 | end 23 | end 24 | if items 25 | @parent_type = "array" 26 | type = items["type"] 27 | description = items["description"] 28 | case type 29 | when "object", "array" 30 | item type: type, description: description do 31 | @parent_type = type 32 | build_properties_from_json(items) 33 | end 34 | else 35 | item type: type 36 | end 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/util/mcp_tools_loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "redmine_ai_helper/logger" 3 | 4 | module RedmineAiHelper 5 | module Util 6 | # A class that reads config.json and generates tool classes for MCPTools. 7 | # The Singleton pattern is adopted to avoid generating the same class multiple times. 8 | class McpToolsLoader 9 | include Singleton 10 | include RedmineAiHelper::Logger 11 | 12 | # Load the configuration file and generate tool classes. 13 | # @return [Array] An array of tool classes generated from the configuration file. 14 | def self.load 15 | loader.generate_tools_instances 16 | end 17 | 18 | # Retrieves the singleton instance of this class. 19 | # @return [McpToolsLoader] The singleton instance of this class. 20 | def self.loader 21 | McpToolsLoader.instance 22 | end 23 | 24 | # Generate instances of all MCPTools 25 | # @return [Array] An array of tool classes generated from the configuration file. 26 | def generate_tools_instances 27 | return @list if @list and @list.length > 0 28 | # Check if the config file exists 29 | unless File.exist?(config_file) 30 | return [] 31 | end 32 | 33 | # Load the configuration file 34 | config = JSON.parse(File.read(config_file)) 35 | 36 | mcp_servers = config["mcpServers"] 37 | return [] unless mcp_servers 38 | 39 | list = [] 40 | # Generate tool classes based on the configuration 41 | mcp_servers.each do |name, json| 42 | begin 43 | tool_class = RedmineAiHelper::Tools::McpTools.generate_tool_class(name: name, json: json) 44 | list << tool_class 45 | rescue => e 46 | ai_helper_logger.error "Error generating tool class for #{name}: #{e.message}" 47 | throw "Error generating tool class for #{name}: #{e.message}" 48 | end 49 | end 50 | @list = list 51 | end 52 | 53 | # Returns the path to the configuration file. 54 | def config_file 55 | @config_file ||= Rails.root.join("config", "ai_helper", "config.json").to_s 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/util/prompt_loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "langchain" 3 | 4 | module RedmineAiHelper 5 | module Util 6 | # A class that loads prompt templates from YAML files. 7 | # The templates are stored in the assets/prompt_templates directory. 8 | class PromptLoader 9 | class << self 10 | # Loads a prompt template from a YAML file. 11 | # @param name [String] The name of the template file (without extension). 12 | # @return [Langchain::Prompt] The loaded prompt template. 13 | def load_template(name) 14 | tepmlate_base_dir = File.dirname(__FILE__) + "/../../../assets/prompt_templates" 15 | locale_string = I18n.locale.to_s 16 | template_file = "#{tepmlate_base_dir}/#{name}_#{locale_string}.yml" 17 | # Check if the locale-specific template file exists 18 | unless File.exist?(template_file) 19 | template_file = "#{tepmlate_base_dir}/#{name}.yml" 20 | end 21 | Langchain::Prompt.load_from_path(file_path: template_file) 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/util/wiki_json.rb: -------------------------------------------------------------------------------- 1 | module RedmineAiHelper 2 | module Util 3 | # This module provides methods to generate JSON data for wiki pages. 4 | module WikiJson 5 | # Generates a JSON representation of a wiki page. 6 | # @param page [WikiPage] The wiki page to be represented in JSON. 7 | # @return [Hash] A hash representing the wiki page in JSON format. 8 | def generate_wiki_data(page) 9 | json = { 10 | title: page.title, 11 | text: page.text, 12 | page_url: project_wiki_page_path(page.wiki.project, page.title), 13 | author: { 14 | id: page.content.author.id, 15 | name: page.content.author.name, 16 | }, 17 | version: page.version, 18 | created_on: page.created_on, 19 | updated_on: page.updated_on, 20 | children: page.children.filter(&:visible?).map do |child| 21 | { 22 | title: child.title, 23 | } 24 | end, 25 | parent: page.parent ? { title: page.parent.title } : nil, 26 | attachments: page.attachments.map do |attachment| 27 | { 28 | filename: attachment.filename, 29 | filesize: attachment.filesize, 30 | content_type: attachment.content_type, 31 | description: attachment.description, 32 | created_on: attachment.created_on, 33 | attachment_url: attachment_path(attachment), 34 | } 35 | end, 36 | } 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/vector/issue_vector_db.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "json" 3 | 4 | module RedmineAiHelper 5 | module Vector 6 | # This class is responsible for managing the vector database for issues in Redmine. 7 | class IssueVectorDb < VectorDb 8 | include Rails.application.routes.url_helpers 9 | 10 | def index_name 11 | "RedmineIssue" 12 | end 13 | 14 | # Checks whether an Issue with the specified ID exists. 15 | # @param object_id [Integer] The ID of the issue to check. 16 | def data_exists?(object_id) 17 | Issue.exists?(id: object_id) 18 | end 19 | 20 | # A method to generate content and payload for registering an issue into the vector database 21 | # @param issue [Issue] The issue to be registered. 22 | # @return [Hash] A hash containing the content and payload for the issue. 23 | # @note This method is used to prepare the data for vector database registration. 24 | def data_to_json(issue) 25 | payload = { 26 | issue_id: issue.id, 27 | project_id: issue.project.id, 28 | project_name: issue.project.name, 29 | author_id: issue.author&.id, 30 | author_name: issue.author&.name, 31 | subject: issue.subject, 32 | description: issue.description, 33 | status_id: issue.status.id, 34 | status: issue.status.name, 35 | priority_id: issue.priority.id, 36 | priority: issue.priority.name, 37 | assigned_to_id: issue.assigned_to&.id, 38 | assigned_to_name: issue.assigned_to&.name, 39 | created_on: issue.created_on, 40 | updated_on: issue.updated_on, 41 | due_date: issue.due_date, 42 | tracker_id: issue.tracker.id, 43 | tracker_name: issue.tracker.name, 44 | version_id: issue.fixed_version&.id, 45 | version_name: issue.fixed_version&.name, 46 | category_name: issue.category&.name, 47 | issue_url: issue_url(issue, only_path: true), 48 | } 49 | content = "#{issue.subject} #{issue.description}" 50 | content += " " + issue.journals.map { |journal| journal.notes.to_s }.join(" ") 51 | 52 | return { content: content, payload: payload } 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/vector/qdrant.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "langchain" 3 | 4 | module RedmineAiHelper 5 | module Vector 6 | # Langchainrb's Qdrant does not support payload filtering, 7 | # so it is implemented independently by inheritance 8 | class Qdrant < Langchain::Vectorsearch::Qdrant 9 | 10 | # search data from vector db with filter for payload. 11 | # @param query [String] The query string to search for. 12 | # @param filter [Hash] The filter to apply to the search. 13 | # @param k [Integer] The number of results to return. 14 | # @return [Array] An array of issues that match the query and filter. 15 | def ask_with_filter(query:, k: 20, filter: nil) 16 | return [] unless client 17 | 18 | embedding = llm.embed(text: query).embedding 19 | 20 | response = client.points.search( 21 | collection_name: index_name, 22 | limit: k, 23 | vector: embedding, 24 | with_payload: true, 25 | with_vector: true, 26 | filter: filter, 27 | ) 28 | results = response.dig("result") 29 | return [] unless results.is_a?(Array) 30 | 31 | results.map do |result| 32 | result.dig("payload") 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/vector/wiki_vector_db.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "json" 3 | 4 | module RedmineAiHelper 5 | module Vector 6 | # This class is responsible for managing the vector database for issues in Redmine. 7 | class WikiVectorDb < VectorDb 8 | include Rails.application.routes.url_helpers 9 | 10 | def index_name 11 | "RedmineWiki" 12 | end 13 | 14 | # Checks whether an Issue with the specified ID exists. 15 | # @param object_id [Integer] The ID of the issue to check. 16 | def data_exists?(object_id) 17 | WikiPage.exists?(id: object_id) 18 | end 19 | 20 | # A method to generate content and payload for registering an issue into the vector database 21 | # @param issue [Issue] The issue to be registered. 22 | # @return [Hash] A hash containing the content and payload for the issue. 23 | # @note This method is used to prepare the data for vector database registration. 24 | def data_to_json(wiki) 25 | payload = { 26 | wiki_id: wiki.id, 27 | project_id: wiki.project&.id, 28 | project_name: wiki.project&.name, 29 | created_on: wiki.created_on, 30 | updated_on: wiki.updated_on, 31 | parent_id: wiki.parent_id, 32 | parent_title: wiki.parent_title, 33 | page_url: "#{project_wiki_page_path(wiki.project, wiki.title)}", 34 | } 35 | content = "#{wiki.title} #{wiki.content.text}" 36 | 37 | return { content: content, payload: payload } 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/redmine_ai_helper/view_hook.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module RedmineAiHelper 3 | # Hook to display the chat screen in the sidebar 4 | class ViewHook < Redmine::Hook::ViewListener 5 | render_on :view_layouts_base_html_head, :partial => "ai_helper/html_header" 6 | render_on :view_layouts_base_body_top, :partial => "ai_helper/sidebar" 7 | render_on :view_issues_show_details_bottom, :partial => "ai_helper/issue_bottom" 8 | render_on :view_issues_edit_notes_bottom, :partial => "ai_helper/issue_form" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/tasks/scm.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # generate git repository for Redmine AI Helper tests 3 | namespace :redmine do 4 | namespace :plugins do 5 | namespace :ai_helper do 6 | desc "Setup SCM for Redmine AI Helper tests" 7 | task :setup_scm => :environment do 8 | plugin_dir = Rails.root.join("plugins/redmine_ai_helper").to_s 9 | scm_archive = "#{plugin_dir}/test/redmine_ai_helper_test_repo.git.tgz" 10 | puts scm_archive 11 | plugin_tmp = "#{plugin_dir}/tmp" 12 | puts plugin_tmp 13 | system("mkdir -p #{plugin_tmp}") 14 | Dir.chdir(plugin_tmp) do 15 | system("rm -rf redmine_ai_helper_test_repo.git") 16 | system("tar xvfz #{scm_archive}") 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/tasks/vector.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | namespace :redmine do 3 | namespace :plugins do 4 | namespace :ai_helper do 5 | # Rake tasks for initializing, registering, and deleting the vector DB 6 | namespace :vector do 7 | desc "Register vector data for Redmine AI Helper" 8 | task :regist => :environment do 9 | if enabled? 10 | issue_vector_db.generate_schema 11 | wiki_vector_db.generate_schema 12 | puts "Registering vector data for Redmine AI Helper..." 13 | issues = Issue.order(:id).all 14 | puts "Issues:" 15 | issue_vector_db.add_datas(datas: issues) 16 | wikis = WikiPage.order(:id).all 17 | puts "Wiki Pages:" 18 | wiki_vector_db.add_datas(datas: wikis) 19 | puts "Vector data registration completed." 20 | else 21 | puts "Vector search is not enabled. Skipping registration." 22 | end 23 | end 24 | 25 | desc "generate" 26 | task :generate => :environment do 27 | if enabled? 28 | puts "Generating vector index for Redmine AI Helper..." 29 | issue_vector_db.generate_schema 30 | wiki_vector_db.generate_schema 31 | else 32 | puts "Vector search is not enabled. Skipping generation." 33 | end 34 | end 35 | 36 | desc "Destroy vector data for Redmine AI Helper" 37 | task :destroy => :environment do 38 | if enabled? 39 | puts "Destroying vector data for Redmine AI Helper..." 40 | issue_vector_db.destroy_schema 41 | wiki_vector_db.destroy_schema 42 | else 43 | puts "Vector search is not enabled. Skipping destruction." 44 | end 45 | end 46 | 47 | def issue_vector_db 48 | return nil unless enabled? 49 | @issue_vector_db ||= RedmineAiHelper::Vector::IssueVectorDb.new(llm: llm) 50 | end 51 | 52 | def wiki_vector_db 53 | return nil unless enabled? 54 | @wiki_vector_db ||= RedmineAiHelper::Vector::WikiVectorDb.new(llm: llm) 55 | end 56 | 57 | def llm 58 | @llm ||= RedmineAiHelper::LlmProvider.get_llm_provider.generate_client 59 | end 60 | 61 | def enabled? 62 | setting = AiHelperSetting.find_or_create 63 | setting.vector_search_enabled 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/functional/ai_helper_model_profiles_controller_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class AiHelperModelProfilesControllerTest < ActionController::TestCase 4 | setup do 5 | AiHelperModelProfile.delete_all 6 | @request.session[:user_id] = 1 # Assuming user with ID 1 is an admin 7 | @model_profile = AiHelperModelProfile.create!(name: 'Test Profile', access_key: 'test_key', llm_type: "OpenAI", llm_model: "gpt-3.5-turbo") 8 | 9 | end 10 | 11 | should "show model profile" do 12 | get :show, params: { id: @model_profile.id } 13 | assert_response :success 14 | assert_template partial: '_show' 15 | assert_not_nil assigns(:model_profile) 16 | end 17 | 18 | should "get new model profile form" do 19 | get :new 20 | assert_response :success 21 | assert_template :new 22 | assert_not_nil assigns(:model_profile) 23 | end 24 | 25 | should "create model profile with valid attributes" do 26 | assert_difference('AiHelperModelProfile.count', 1) do 27 | post :create, params: { ai_helper_model_profile: { name: 'New Profile', access_key: 'new_key', llm_type: "OpenAI", llm_model: "model" } } 28 | end 29 | assert_redirected_to ai_helper_setting_path 30 | end 31 | 32 | should "not create model profile with invalid attributes" do 33 | assert_no_difference('AiHelperModelProfile.count') do 34 | post :create, params: { ai_helper_model_profile: { name: '', access_key: '' } } 35 | end 36 | assert_response :success 37 | assert_template :new 38 | end 39 | 40 | should "get edit model profile form" do 41 | get :edit, params: { id: @model_profile.id } 42 | assert_response :success 43 | assert_template :edit 44 | assert_not_nil assigns(:model_profile) 45 | end 46 | 47 | should "update model profile with valid attributes" do 48 | patch :update, params: { id: @model_profile.id, ai_helper_model_profile: { name: 'Updated Profile' } } 49 | assert_redirected_to ai_helper_setting_path 50 | @model_profile.reload 51 | assert_equal 'Updated Profile', @model_profile.name 52 | end 53 | 54 | should "not update model profile with invalid attributes" do 55 | patch :update, params: { id: @model_profile.id, ai_helper_model_profile: { name: '' } } 56 | assert_response :success 57 | assert_template :edit 58 | @model_profile.reload 59 | assert_not_equal '', @model_profile.name 60 | end 61 | 62 | should "destroy model profile" do 63 | assert_difference('AiHelperModelProfile.count', -1) do 64 | delete :destroy, params: { id: @model_profile.id } 65 | end 66 | assert_redirected_to ai_helper_setting_path 67 | end 68 | 69 | should "handle destroy for non-existent model profile" do 70 | assert_no_difference('AiHelperModelProfile.count') do 71 | delete :destroy, params: { id: 9999 } # Non-existent ID 72 | end 73 | assert_response :not_found 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /test/functional/ai_helper_settings_controller_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class AiHelperSettingsControllerTest < ActionController::TestCase 4 | setup do 5 | AiHelperSetting.delete_all 6 | AiHelperModelProfile.delete_all 7 | @request.session[:user_id] = 1 # Assuming user with ID 1 is an admin 8 | 9 | @model_profile = AiHelperModelProfile.create!(name: 'Test Profile', access_key: 'test_key', llm_type: "OpenAI", llm_model: "gpt-3.5-turbo") 10 | @model_profile.reload 11 | @ai_helper_setting = AiHelperSetting.find_or_create 12 | end 13 | 14 | should "get index" do 15 | get :index 16 | assert_response :success 17 | assert_template :index 18 | assert_not_nil assigns(:setting) 19 | assert_not_nil assigns(:model_profiles) 20 | end 21 | 22 | should "update setting with valid attributes" do 23 | post :update, params: { ai_helper_setting: { model_profile_id: @model_profile.id } } 24 | assert_redirected_to action: :index 25 | @ai_helper_setting.reload 26 | assert_equal @model_profile.id, @ai_helper_setting.model_profile_id 27 | end 28 | 29 | should "not update setting with invalid attributes" do 30 | post :update, params: { id: @ai_helper_setting, ai_helper_setting: { some_attribute: nil } } 31 | assert_response :redirect 32 | assert_not_nil assigns(:setting) 33 | assert_not_nil assigns(:model_profiles) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/model_factory.rb: -------------------------------------------------------------------------------- 1 | require "factory_bot" 2 | 3 | FactoryBot::SyntaxRunner.class_eval do 4 | include ActionDispatch::TestProcess 5 | include ActiveSupport::Testing::FileFixtures 6 | end 7 | 8 | FactoryBot.define do 9 | factory :project do 10 | sequence(:name) { |n| "Project #{n}" } 11 | sequence(:identifier) { |n| "project-#{n}" } 12 | description { "Project description" } 13 | homepage { "http://example.com" } 14 | is_public { true } 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/redmine_ai_helper_test_repo.git.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haru/redmine_ai_helper/9c80d67ab87508815c25762eeb9e8153e29fcb73/test/redmine_ai_helper_test_repo.git.tgz -------------------------------------------------------------------------------- /test/test_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "slack": { 4 | "command": "npx", 5 | "args": [ 6 | "-y", 7 | "@modelcontextprotocol/server-slack" 8 | ], 9 | "env": { 10 | "SLACK_BOT_TOKEN": "xoxb-your-bot-token", 11 | "SLACK_TEAM_ID": "T01234567", 12 | "SLACK_CHANNEL_IDS": "C01234567, C76543210" 13 | } 14 | }, 15 | "filesystem": { 16 | "command": "npx", 17 | "args": [ 18 | "-y", 19 | "@modelcontextprotocol/server-filesystem", 20 | "/tmp" 21 | ] 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Load the Redmine helper 2 | 3 | require "simplecov" 4 | require "simplecov-cobertura" 5 | 6 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[ 7 | SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter, 8 | SimpleCov::Formatter::HTMLFormatter 9 | # Coveralls::SimpleCov::Formatter 10 | ] 11 | 12 | SimpleCov.start do 13 | root File.expand_path(File.dirname(__FILE__) + "/..") 14 | add_filter "/test/" 15 | add_filter "lib/tasks" 16 | 17 | add_group "Controllers", "app/controllers" 18 | add_group "Models", "app/models" 19 | add_group "Helpers", "app/helpers" 20 | 21 | add_group "Plugin Features", "lib/redmine_ai_helper" 22 | end 23 | 24 | require File.expand_path(File.dirname(__FILE__) + "/../../../test/test_helper") 25 | 26 | require File.expand_path(File.dirname(__FILE__) + "/model_factory") 27 | 28 | # このファイルと同じフォルダにあるmodel_factory.rbを読み込む 29 | require_relative "./model_factory" 30 | 31 | AiHelperModelProfile.delete_all 32 | profile = AiHelperModelProfile.create!( 33 | name: "Test Profile", 34 | llm_type: "OpenAI", 35 | llm_model: "gpt-3.5-turbo", 36 | access_key: "test_key", 37 | organization_id: "test_org_id", 38 | base_uri: "https://api.openai.com/v1", 39 | ) 40 | 41 | setting = AiHelperSetting.find_or_create 42 | setting.model_profile_id = profile.id 43 | setting.additional_instructions = "This is a test system prompt." 44 | setting.save! 45 | -------------------------------------------------------------------------------- /test/unit/ai_helper_helper_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../test_helper", __FILE__) 2 | 3 | class AiHelperHelperTest < ActiveSupport::TestCase 4 | include AiHelperHelper 5 | 6 | def test_md_to_html 7 | markdown_text = "# Hello World\nThis is a test." 8 | expected_html = "

Hello World

\n

This is a test.

" 9 | 10 | assert_equal expected_html, md_to_html(markdown_text) 11 | end 12 | 13 | def test_md_to_html_with_invalid_characters 14 | markdown_text = "# Hello World\xC2\nThis is a test." 15 | expected_html = "

Hello World

\n

This is a test.

" 16 | 17 | assert_equal expected_html, md_to_html(markdown_text) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/unit/ai_helper_summary_cache_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class AiHelperSummaryCacheTest < ActiveSupport::TestCase 4 | 5 | # Replace this with your real tests. 6 | def test_truth 7 | assert true 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/unit/base_tools_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../test_helper", __FILE__) 2 | 3 | class BaseToolsTest < ActiveSupport::TestCase 4 | 5 | def setup 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/unit/chat_room_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../test_helper", __FILE__) 2 | require "redmine_ai_helper/chat_room" 3 | 4 | class RedmineAiHelper::ChatRoomTest < ActiveSupport::TestCase 5 | context "ChatRoom" do 6 | setup do 7 | @goal = "Complete the project successfully" 8 | @chat_room = RedmineAiHelper::ChatRoom.new(@goal) 9 | @mock_agent = mock("Agent") 10 | @mock_agent.stubs(:role).returns("mock_agent") 11 | @mock_agent.stubs(:perform_task).returns("Task completed") 12 | @mock_agent.stubs(:add_message).returns(nil) 13 | end 14 | 15 | should "initialize with goal" do 16 | assert_equal @goal, @chat_room.goal 17 | assert_equal 0, @chat_room.messages.size 18 | end 19 | 20 | should "add agent" do 21 | @chat_room.add_agent(@mock_agent) 22 | assert_includes @chat_room.agents, @mock_agent 23 | end 24 | 25 | should "add message" do 26 | @chat_room.add_message("user", "leader", "Test message", "all") 27 | assert_equal 1, @chat_room.messages.size 28 | assert_match "Test message", @chat_room.messages.last[:content] 29 | end 30 | 31 | should "get agent by role" do 32 | @chat_room.add_agent(@mock_agent) 33 | agent = @chat_room.get_agent("mock_agent") 34 | assert_equal @mock_agent, agent 35 | end 36 | 37 | should "send task to agent and receive response" do 38 | @chat_room.add_agent(@mock_agent) 39 | response = @chat_room.send_task("leader", "mock_agent", "Perform this task") 40 | assert_equal "Task completed", response 41 | assert_equal 2, @chat_room.messages.size 42 | assert_match "Perform this task", @chat_room.messages[-2][:content] 43 | assert_match "Task completed", @chat_room.messages.last[:content] 44 | end 45 | 46 | should "raise error if agent not found" do 47 | error = assert_raises(RuntimeError) do 48 | @chat_room.send_task("leader", "non_existent_agent", "Perform this task") 49 | end 50 | assert_match "Agent not found: non_existent_agent", error.message 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/unit/langfuse_util/anthropic_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | class RedmineAiHelper::LangfuseUtil::AnthropicTest < ActiveSupport::TestCase 4 | setup do 5 | # Save original :chat method if defined 6 | 7 | Langchain::LLM::Anthropic.class_eval do 8 | alias_method :__original_chat, :chat 9 | end 10 | 11 | Langchain::LLM::Anthropic.class_eval do 12 | define_method(:chat) do |*args, **kwargs| 13 | # Stubbed parent result for testing 14 | DummyResponse.new 15 | end 16 | end 17 | @client = RedmineAiHelper::LangfuseUtil::Anthropic.new( 18 | api_key: "test_key", 19 | ) 20 | langfuse = RedmineAiHelper::LangfuseUtil::LangfuseWrapper.new(input: "test") 21 | langfuse.stubs(:enabled?).returns(true) 22 | @client.langfuse = langfuse 23 | @client.langfuse.create_span(name: "test_span", input: "test_input") 24 | @client.langfuse.create_span(name: "test_span", input: "test_input") 25 | end 26 | 27 | teardown do 28 | Langchain::LLM::Anthropic.class_eval do 29 | remove_method :chat 30 | alias_method :chat, :__original_chat 31 | remove_method :__original_chat 32 | end 33 | end 34 | 35 | context "chat" do 36 | should "create an observation with correct parameters" do 37 | messages = [{ role: "user", content: "Test input" }] 38 | 39 | answer = @client.chat(messages: messages, model: "gemini-1.0", temperature: 0.5) 40 | assert answer 41 | end 42 | end 43 | 44 | class DummyResponse 45 | def chat_completion(*args) 46 | { "choices" => [{ "message" => { "content" => "answer" } }] } 47 | end 48 | 49 | def prompt_tokens 50 | 10 51 | end 52 | 53 | def completion_tokens 54 | 5 55 | end 56 | 57 | def total_tokens 58 | prompt_tokens + completion_tokens 59 | end 60 | end 61 | 62 | class DummyHttp 63 | attr_accessor :use_ssl, :read_timeout, :open_timeout 64 | 65 | def request(*args) 66 | return Response.new 67 | end 68 | 69 | class Response 70 | def body 71 | json = { "choices" => [{ "message" => { "content" => "answer" } }] } 72 | JSON.pretty_generate(json) 73 | end 74 | 75 | def code 76 | "200" 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/unit/langfuse_util/azure_open_ai_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | class RedmineAiHelper::LangfuseUtil::AzureOpenAiTest < ActiveSupport::TestCase 4 | setup do 5 | # Save original :chat method if defined 6 | 7 | Langchain::LLM::Azure.class_eval do 8 | alias_method :__original_chat, :chat 9 | end 10 | 11 | Langchain::LLM::Azure.class_eval do 12 | define_method(:chat) do |*args, **kwargs| 13 | # Stubbed parent result for testing 14 | DummyResponse.new 15 | end 16 | end 17 | @client = RedmineAiHelper::LangfuseUtil::AzureOpenAi.new( 18 | api_key: "test_key", 19 | ) 20 | langfuse = RedmineAiHelper::LangfuseUtil::LangfuseWrapper.new(input: "test") 21 | langfuse.stubs(:enabled?).returns(true) 22 | @client.langfuse = langfuse 23 | @client.langfuse.create_span(name: "test_span", input: "test_input") 24 | end 25 | 26 | teardown do 27 | Langchain::LLM::Azure.class_eval do 28 | remove_method :chat 29 | alias_method :chat, :__original_chat 30 | remove_method :__original_chat 31 | end 32 | end 33 | 34 | context "chat" do 35 | should "create an observation with correct parameters" do 36 | messages = [{ role: "user", content: "Test input" }] 37 | 38 | answer = @client.chat(messages: messages, model: "gemini-1.0", temperature: 0.5) 39 | assert answer 40 | end 41 | end 42 | 43 | class DummyResponse 44 | def chat_completion(*args) 45 | { "choices" => [{ "message" => { "content" => "answer" } }] } 46 | end 47 | 48 | def prompt_tokens 49 | 10 50 | end 51 | 52 | def completion_tokens 53 | 5 54 | end 55 | 56 | def total_tokens 57 | prompt_tokens + completion_tokens 58 | end 59 | end 60 | 61 | class DummyHttp 62 | attr_accessor :use_ssl, :read_timeout, :open_timeout 63 | 64 | def request(*args) 65 | return Response.new 66 | end 67 | 68 | class Response 69 | def body 70 | json = { "choices" => [{ "message" => { "content" => "answer" } }] } 71 | JSON.pretty_generate(json) 72 | end 73 | 74 | def code 75 | "200" 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/unit/langfuse_util/gemini_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | class RedmineAiHelper::LangfuseUtil::GeminiTest < ActiveSupport::TestCase 4 | context "Gemini" do 5 | setup do 6 | Langchain::LLM::GoogleGeminiResponse.stubs(:new).returns(DummyGeminiResponse.new) 7 | Net::HTTP.stubs(:new).returns(DummyHttp.new) 8 | Net::HTTP::Post.stubs(:new).returns(DummyHttpPost.new) 9 | @gemini = RedmineAiHelper::LangfuseUtil::Gemini.new(api_key: "test") 10 | langfuse = RedmineAiHelper::LangfuseUtil::LangfuseWrapper.new(input: "test") 11 | langfuse.stubs(:enabled?).returns(true) 12 | @gemini.langfuse = langfuse 13 | @gemini.langfuse.create_span(name: "test_span", input: "test_input") 14 | end 15 | 16 | context "chat" do 17 | should "create an observation with correct parameters" do 18 | messages = [{ role: "user", content: "Test input" }] 19 | 20 | answer = @gemini.chat(messages: messages, model: "gemini-1.0", temperature: 0.5) 21 | assert answer 22 | end 23 | end 24 | end 25 | 26 | class DummyGeminiResponse 27 | def chat_completion(*args) 28 | { "choices" => [{ "message" => { "content" => "answer" } }] } 29 | end 30 | 31 | def prompt_tokens 32 | 10 33 | end 34 | 35 | def completion_tokens 36 | 5 37 | end 38 | 39 | def total_tokens 40 | endprompt_tokens + completion_tokens 41 | end 42 | 43 | def endprompt_tokens 44 | 10 45 | end 46 | end 47 | 48 | class DummyHttp 49 | attr_accessor :use_ssl, :read_timeout, :open_timeout 50 | 51 | def request(*args) 52 | return Response.new 53 | end 54 | 55 | # Dummy HTTP response class for testing 56 | class Response 57 | def body 58 | json = { "choices" => [{ "message" => { "content" => "answer" } }] } 59 | JSON.pretty_generate(json) 60 | end 61 | 62 | def code 63 | "200" 64 | end 65 | end 66 | end 67 | 68 | class DummyHttpPost 69 | attr_accessor :content_type, :body 70 | end 71 | 72 | class DummyLangfuse 73 | def observation(name:, input:, output:, model: nil, metadata: {}) 74 | DummyObservation.new 75 | end 76 | 77 | def span(name:, input:) 78 | @current_span = DummyObservation.new 79 | end 80 | 81 | def flush 82 | # No-op for testing 83 | end 84 | 85 | def current_span 86 | nil 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/unit/langfuse_util/langfuse_wrapper_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | require "mocha/minitest" 3 | 4 | class RedmineAiHelper::LangfuseUtil::LangfuseWrapperTest < ActiveSupport::TestCase 5 | setup do 6 | RedmineAiHelper::Util::ConfigFile.stubs(:load_config).returns({ 7 | langfuse: { 8 | public_key: "test_public_key", 9 | endpoint: nil, 10 | }, 11 | }) 12 | Langfuse::Client.stubs(:instance).returns(DummyClient.new) 13 | 14 | @langfuse = RedmineAiHelper::LangfuseUtil::LangfuseWrapper.new(input: "test input") 15 | @langfuse.stubs(:enabled?).returns(true) 16 | end 17 | 18 | teardown do 19 | Langfuse.shutdown() 20 | end 21 | 22 | should "create a span with correct parameters" do 23 | @langfuse.create_span(name: "test_span", input: "test_input") 24 | assert @langfuse.current_span 25 | end 26 | 27 | should "find the current span" do 28 | span = @langfuse.create_span(name: "test_span", input: "test_input") 29 | assert_equal span, @langfuse.current_span 30 | assert @langfuse.finish_current_span(output: "test output") 31 | end 32 | 33 | context "SpanWrapper" do 34 | should "finish the span and return the span object" do 35 | span = @langfuse.create_span(name: "test_span", input: "test_input") 36 | assert span.finish(output: "end output") 37 | end 38 | 39 | should "create generation" do 40 | span = @langfuse.create_span(name: "test_span", input: "test_input") 41 | generation = span.create_generation(name: "aaa", messages: ["messages"], model: "test_model") 42 | assert generation 43 | assert generation 44 | end 45 | end 46 | 47 | context "GenerationWrapper" do 48 | should "finish the generation and return the generation object" do 49 | span = @langfuse.create_span(name: "test_span", input: "test_input") 50 | generation = span.create_generation(name: "test_gen", messages: ["message1", "message2"], model: "test_model") 51 | assert generation.finish(output: "test output", usage: { prompt_tokens: 10, completion_tokens: 5 }) 52 | end 53 | end 54 | 55 | class DummyClient 56 | def trace(attr = {}) 57 | Langfuse::Models::Trace.new( 58 | id: "test_trace_id", 59 | name: attr[:name], 60 | user_id: attr[:user_id], 61 | input: attr[:input], 62 | metadata: attr[:metadata], 63 | ) 64 | end 65 | 66 | def shutdown(**args) 67 | true 68 | end 69 | 70 | def span(attr = {}) 71 | Langfuse::Models::Span.new( 72 | id: "test_span_id", 73 | name: attr[:name], 74 | trace_id: 1, 75 | input: attr[:input], 76 | parent_span_id: attr[:parent_span_id], 77 | ) 78 | end 79 | 80 | def generation(attr = {}) 81 | Langfuse::Models::Generation.new( 82 | id: "test_generation_id", 83 | name: attr[:name], 84 | messages: attr[:messages], 85 | model: attr[:model], 86 | output: "test output", 87 | span_id: "test_span_id", 88 | ) 89 | end 90 | 91 | def update_span(span) 92 | span 93 | end 94 | 95 | def update_generation(generation) 96 | generation 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/unit/langfuse_util/open_ai_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | class RedmineAiHelper::LangfuseUtil::OpenAiTest < ActiveSupport::TestCase 4 | setup do 5 | # Save original :chat method if defined 6 | 7 | Langchain::LLM::OpenAI.class_eval do 8 | alias_method :__original_chat, :chat 9 | end 10 | 11 | Langchain::LLM::OpenAI.class_eval do 12 | define_method(:chat) do |*args, **kwargs| 13 | # Stubbed parent result for testing 14 | DummyResponse.new 15 | end 16 | end 17 | @client = RedmineAiHelper::LangfuseUtil::OpenAi.new( 18 | api_key: "test_key", 19 | ) 20 | langfuse = RedmineAiHelper::LangfuseUtil::LangfuseWrapper.new(input: "test") 21 | langfuse.stubs(:enabled?).returns(true) 22 | @client.langfuse = langfuse 23 | @client.langfuse.create_span(name: "test_span", input: "test_input") 24 | @client.langfuse.create_span(name: "test_span", input: "test_input") 25 | end 26 | 27 | teardown do 28 | Langchain::LLM::OpenAI.class_eval do 29 | remove_method :chat 30 | alias_method :chat, :__original_chat 31 | remove_method :__original_chat 32 | end 33 | end 34 | 35 | context "chat" do 36 | should "create an observation with correct parameters" do 37 | messages = [{ role: "user", content: "Test input" }] 38 | 39 | answer = @client.chat(messages: messages, model: "gemini-1.0", temperature: 0.5) 40 | assert answer 41 | end 42 | end 43 | 44 | class DummyResponse 45 | def chat_completion(*args) 46 | { "choices" => [{ "message" => { "content" => "answer" } }] } 47 | end 48 | 49 | def prompt_tokens 50 | 10 51 | end 52 | 53 | def completion_tokens 54 | 5 55 | end 56 | 57 | def total_tokens 58 | prompt_tokens + completion_tokens 59 | end 60 | end 61 | 62 | class DummyHttp 63 | attr_accessor :use_ssl, :read_timeout, :open_timeout 64 | 65 | def request(*args) 66 | return Response.new 67 | end 68 | 69 | class Response 70 | def body 71 | json = { "choices" => [{ "message" => { "content" => "answer" } }] } 72 | JSON.pretty_generate(json) 73 | end 74 | 75 | def code 76 | "200" 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/unit/llm_client/anthropic_provider_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | require "redmine_ai_helper/llm_client/anthropic_provider" 3 | 4 | class RedmineAiHelper::LlmClient::AnthropicProviderTest < ActiveSupport::TestCase 5 | context "AnthropicProvider" do 6 | setup do 7 | @provider = RedmineAiHelper::LlmClient::AnthropicProvider.new 8 | end 9 | 10 | should "generate a valid client" do 11 | Langchain::LLM::Anthropic.stubs(:new).returns(mock("AnthropicClient")) 12 | client = @provider.generate_client 13 | assert_not_nil client 14 | end 15 | 16 | should "raise an error if client generation fails" do 17 | Langchain::LLM::Anthropic.stubs(:new).returns(nil) 18 | assert_raises(RuntimeError, "Anthropic LLM Create Error") do 19 | @provider.generate_client 20 | end 21 | end 22 | 23 | should "create valid chat parameters" do 24 | system_prompt = { role: "system", content: "This is a system prompt" } 25 | messages = [{ role: "user", content: "Hello" }] 26 | chat_params = @provider.create_chat_param(system_prompt, messages) 27 | 28 | assert_equal messages, chat_params[:messages] 29 | assert_equal "This is a system prompt", chat_params[:system] 30 | end 31 | 32 | should "convert chunk correctly" do 33 | chunk = { "delta" => { "text" => "Test content" } } 34 | result = @provider.chunk_converter(chunk) 35 | assert_equal "Test content", result 36 | end 37 | 38 | should "return nil if chunk content is missing" do 39 | chunk = { "delta" => {} } 40 | result = @provider.chunk_converter(chunk) 41 | assert_nil result 42 | end 43 | 44 | should "reset assistant messages correctly" do 45 | assistant = mock("Assistant") 46 | assistant.expects(:clear_messages!).once 47 | assistant.expects(:instructions=).with("System instructions").once 48 | assistant.expects(:add_message).with(role: "user", content: "Hello").once 49 | 50 | system_prompt = "System instructions" 51 | messages = [{ role: "user", content: "Hello" }] 52 | 53 | @provider.reset_assistant_messages( 54 | assistant: assistant, 55 | system_prompt: system_prompt, 56 | messages: messages, 57 | ) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/unit/llm_client/azure_open_ai_provider_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | require "redmine_ai_helper/llm_client/open_ai_compatible_provider" 3 | 4 | class RedmineAiHelper::LlmClient::AzureOpenAiProviderTest < ActiveSupport::TestCase 5 | context "AzureOpenAiProvider" do 6 | setup do 7 | @setting = AiHelperSetting.find_or_create 8 | @openai_mock = AzureOpenAiProviderTest::DummyOpenAIClient.new 9 | Langchain::LLM::OpenAI.stubs(:new).returns(@openai_mock) 10 | @provider = RedmineAiHelper::LlmClient::AzureOpenAiProvider.new 11 | 12 | @original_model_profile = @setting.model_profile 13 | end 14 | 15 | teardown do 16 | @setting.model_profile = @original_model_profile 17 | @setting.save! 18 | end 19 | 20 | should "generate a valid client" do 21 | client = @provider.generate_client 22 | assert_not_nil client 23 | end 24 | 25 | should "raise an error if model profile is missing" do 26 | @setting.model_profile = nil 27 | @setting.save! 28 | assert_raises(RuntimeError, "Model Profile not found") do 29 | @provider.generate_client 30 | end 31 | end 32 | end 33 | 34 | context "OpenAiCompatible" do 35 | setup do 36 | @openai_mock = OpenAiCompatibleTest::DummyOpenAIClient.new 37 | ::OpenAI::Client.stubs(:new).returns(@openai_mock) 38 | # Langchain::LLM::OpenAI.stubs(:new).returns(@openai_mock) 39 | @client = RedmineAiHelper::LlmClient::OpenAiCompatibleProvider::OpenAiCompatible.new( 40 | api_key: "test_key", 41 | ) 42 | end 43 | 44 | should "return a valid response for chat" do 45 | params = { 46 | messages: [{ role: "user", content: "Hello" }], 47 | } 48 | response = @client.chat(params).chat_completion 49 | assert_not_nil response 50 | end 51 | end 52 | end 53 | 54 | module AzureOpenAiProviderTest 55 | class DummyOpenAIClient 56 | def chat(params = {}, &block) 57 | messages = params[:parameters][:messages] || [] 58 | # puts "Messages: #{messages.inspect}" 59 | message = messages.first[:content] 60 | # puts "Message content: #{message}" 61 | 62 | json_content = { 63 | tool_calls: [ 64 | { 65 | id: "call_rCE6Kk94VZZyUIrIlrZSH0Cr", 66 | type: "function", 67 | function: { 68 | name: "weather_tool__weather", 69 | arguments: "{\"location\": \"Tokyo\", \"date\": \"2023-10-01\"}", 70 | }, 71 | }, 72 | ], 73 | } 74 | content = JSON.pretty_generate(json_content) 75 | 76 | if message == "Hello" 77 | content = "This is a dummy response for testing." 78 | end 79 | 80 | response = { 81 | "choices" => [ 82 | { 83 | "message" => { 84 | "role" => "assistant", 85 | "content" => content, 86 | }, 87 | }, 88 | ], 89 | } 90 | block.call(response) if block_given? 91 | response 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/unit/llm_client/base_provider_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | require "redmine_ai_helper/llm_client/base_provider" 3 | 4 | class RedmineAiHelper::LlmClient::BaseProviderTest < ActiveSupport::TestCase 5 | context "BaseProvider" do 6 | setup do 7 | @provider = RedmineAiHelper::LlmClient::BaseProvider.new 8 | end 9 | 10 | should "raise NotImplementedError when generate_client is called" do 11 | assert_raises(NotImplementedError, "LLM provider not found") do 12 | @provider.generate_client 13 | end 14 | end 15 | 16 | should "create chat parameters correctly" do 17 | system_prompt = { content: "This is a system prompt" } 18 | messages = [ 19 | { role: "user", content: "Hello" }, 20 | { role: "assistant", content: "Hi there!" }, 21 | ] 22 | chat_params = @provider.create_chat_param(system_prompt, messages) 23 | 24 | assert_equal 3, chat_params[:messages].size 25 | assert_equal "This is a system prompt", chat_params[:messages][0][:content] 26 | assert_equal "Hello", chat_params[:messages][1][:content] 27 | assert_equal "Hi there!", chat_params[:messages][2][:content] 28 | end 29 | 30 | should "convert chunk correctly" do 31 | chunk = { "delta" => { "content" => "Test content" } } 32 | result = @provider.chunk_converter(chunk) 33 | assert_equal "Test content", result 34 | end 35 | 36 | should "return nil if chunk content is missing" do 37 | chunk = { "delta" => {} } 38 | result = @provider.chunk_converter(chunk) 39 | assert_nil result 40 | end 41 | 42 | should "reset assistant messages correctly" do 43 | mock_assistant = mock("Assistant") 44 | mock_assistant.expects(:clear_messages!).once 45 | mock_assistant.expects(:add_message).with(role: "system", content: "System instructions").once 46 | mock_assistant.expects(:add_message).with(role: "user", content: "Hello").once 47 | mock_assistant.expects(:add_message).with(role: "assistant", content: "Hi there!").once 48 | 49 | system_prompt = { content: "System instructions" } 50 | messages = [ 51 | { role: "user", content: "Hello" }, 52 | { role: "assistant", content: "Hi there!" }, 53 | ] 54 | 55 | @provider.reset_assistant_messages( 56 | assistant: mock_assistant, 57 | system_prompt: system_prompt, 58 | messages: messages, 59 | ) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/unit/llm_client/gemini_provider_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | require "redmine_ai_helper/llm_client/gemini_provider" 3 | 4 | class RedmineAiHelper::LlmClient::GeminiProviderTest < ActiveSupport::TestCase 5 | context "GeminiProvider" do 6 | setup do 7 | @provider = RedmineAiHelper::LlmClient::GeminiProvider.new 8 | end 9 | 10 | should "generate a valid client" do 11 | Langchain::LLM::GoogleGemini.stubs(:new).returns(mock("GeminiClient")) 12 | client = @provider.generate_client 13 | assert_not_nil client 14 | end 15 | 16 | should "raise an error if client generation fails" do 17 | Langchain::LLM::GoogleGemini.stubs(:new).returns(nil) 18 | assert_raises(RuntimeError, "Gemini LLM Create Error") do 19 | @provider.generate_client 20 | end 21 | end 22 | 23 | should "create valid chat parameters" do 24 | system_prompt = { content: "This is a system prompt" } 25 | messages = [ 26 | { role: "user", content: "Hello" }, 27 | { role: "assistant", content: "Hi there!" }, 28 | ] 29 | chat_params = @provider.create_chat_param(system_prompt, messages) 30 | 31 | assert_equal 2, chat_params[:messages].size 32 | assert_equal "user", chat_params[:messages][0][:role] 33 | assert_equal "Hello", chat_params[:messages][0][:parts][0][:text] 34 | assert_equal "assistant", chat_params[:messages][1][:role] 35 | assert_equal "Hi there!", chat_params[:messages][1][:parts][0][:text] 36 | assert_equal "This is a system prompt", chat_params[:system] 37 | end 38 | 39 | should "reset assistant messages correctly" do 40 | assistant = mock("Assistant") 41 | assistant.expects(:clear_messages!).once 42 | assistant.expects(:instructions=).with("System instructions").once 43 | assistant.expects(:add_message).with(role: "user", content: "Hello").once 44 | assistant.expects(:add_message).with(role: "model", content: "Hi there!").once 45 | 46 | system_prompt = "System instructions" 47 | messages = [ 48 | { role: "user", content: "Hello" }, 49 | { role: "assistant", content: "Hi there!" }, 50 | ] 51 | 52 | @provider.reset_assistant_messages( 53 | assistant: assistant, 54 | system_prompt: system_prompt, 55 | messages: messages, 56 | ) 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/unit/llm_provider_test.rb: -------------------------------------------------------------------------------- 1 | # filepath: lib/redmine_ai_helper/llm_provider_test.rb 2 | require File.expand_path("../../test_helper", __FILE__) 3 | 4 | class LlmProviderTest < ActiveSupport::TestCase 5 | context "LlmProvider" do 6 | setup do 7 | @llm_provider = RedmineAiHelper::LlmProvider 8 | end 9 | 10 | should "return correct options for select" do 11 | expected_options = [ 12 | ["OpenAI", "OpenAI"], 13 | ["OpenAI Compatible(Experimental)", "OpenAICompatible"], 14 | ["Gemini(Experimental)", "Gemini"], 15 | ["Anthropic(Experimental)", "Anthropic"], 16 | ["Azure OpenAI(Experimental)", "AzureOpenAi"], 17 | ] 18 | assert_equal expected_options, @llm_provider.option_for_select 19 | end 20 | 21 | context "get_llm_provider" do 22 | setup do 23 | @setting = AiHelperSetting.find_or_create 24 | end 25 | teardown do 26 | @setting.model_profile.llm_type = "OpenAI" 27 | @setting.model_profile.save! 28 | end 29 | 30 | should "return OpenAiProvider when OpenAI is selected" do 31 | @setting.model_profile.llm_type = "OpenAI" 32 | @setting.model_profile.save! 33 | 34 | provider = @llm_provider.get_llm_provider 35 | assert_instance_of RedmineAiHelper::LlmClient::OpenAiProvider, provider 36 | end 37 | 38 | should "return GeminiProvider when Gemini is selected" do 39 | @setting.model_profile.llm_type = "Gemini" 40 | @setting.model_profile.save! 41 | provider = @llm_provider.get_llm_provider 42 | assert_instance_of RedmineAiHelper::LlmClient::GeminiProvider, provider 43 | end 44 | 45 | should "raise NotImplementedError when Anthropic is selected" do 46 | @setting.model_profile.llm_type = "Anthropic" 47 | @setting.model_profile.save! 48 | provider = @llm_provider.get_llm_provider 49 | assert_instance_of RedmineAiHelper::LlmClient::AnthropicProvider, provider 50 | end 51 | 52 | should "raise NotImplementedError when an unknown LLM is selected" do 53 | @setting.model_profile.llm_type = "Unknown" 54 | @setting.model_profile.save! 55 | assert_raises(NotImplementedError) do 56 | @llm_provider.get_llm_provider 57 | end 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/unit/logger_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../test_helper", __FILE__) 2 | require "redmine_ai_helper/logger" 3 | 4 | class LoggerTest < ActiveSupport::TestCase 5 | include RedmineAiHelper::Logger 6 | 7 | # def self.setup 8 | # remove_log 9 | # end 10 | 11 | # def setup 12 | # @logger = RedmineAiHelper::CustomLogger.instance 13 | # end 14 | 15 | # def test_debug_logging 16 | # message = "This is a debug message" 17 | # @logger.debug(message) 18 | # # assert_includes read_log, "DEBUG -- #{message}" 19 | # end 20 | 21 | # def test_info_logging 22 | # message = "This is an info message" 23 | # @logger.info(message) 24 | # assert_includes read_log, "INFO -- #{message}" 25 | # end 26 | 27 | # def test_warn_logging 28 | # message = "This is a warn message" 29 | # @logger.warn(message) 30 | # assert_includes read_log, "WARN -- #{message}" 31 | # end 32 | 33 | # def test_error_logging 34 | # message = "This is an error message" 35 | # @logger.error(message) 36 | # assert_includes read_log, "ERROR -- #{message}" 37 | # end 38 | 39 | # def test_log_level_setting 40 | # @logger.set_log_level("debug") 41 | # assert_equal ::Logger::DEBUG, @logger.instance_variable_get(:@logger).level 42 | 43 | # @logger.set_log_level("info") 44 | # assert_equal ::Logger::INFO, @logger.instance_variable_get(:@logger).level 45 | 46 | # @logger.set_log_level("warn") 47 | # assert_equal ::Logger::WARN, @logger.instance_variable_get(:@logger).level 48 | 49 | # @logger.set_log_level("error") 50 | # assert_equal ::Logger::ERROR, @logger.instance_variable_get(:@logger).level 51 | # end 52 | 53 | # private 54 | 55 | # def read_log 56 | # log_file_path = Rails.root.join("log", "ai_helper.log") 57 | # File.read(log_file_path) 58 | # end 59 | 60 | # def remove_log 61 | # log_file_path = Rails.root.join("log", "ai_helper.log") 62 | # File.delete(log_file_path) if File.exist?(log_file_path) 63 | # end 64 | end 65 | -------------------------------------------------------------------------------- /test/unit/models/ai_helper_conversation_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | class AiHelperConversationTest < ActiveSupport::TestCase 4 | def setup 5 | @ai_helper = AiHelperConversation.new 6 | end 7 | 8 | 9 | def test_ai_helper_initialization 10 | assert_not_nil @ai_helper 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/unit/models/ai_helper_message_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | class AiHelperMessageTest < ActiveSupport::TestCase 4 | def setup 5 | @message = AiHelperMessage.new 6 | end 7 | 8 | def test_message_initialization 9 | assert_not_nil @message 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/unit/models/ai_helper_model_profile_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../test_helper" 2 | 3 | class AiHelperModelProfileTest < ActiveSupport::TestCase 4 | 5 | # Replace this with your real tests. 6 | def test_truth 7 | assert true 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/unit/models/ai_helper_setting_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../test_helper" 2 | 3 | class AiHelperSettingTest < ActiveSupport::TestCase 4 | # Setup method to create a default setting before each test 5 | setup do 6 | AiHelperSetting.destroy_all 7 | @setting = AiHelperSetting.setting 8 | model_profile = AiHelperModelProfile.create!( 9 | name: "Default Model Profile", 10 | llm_model: "gpt-3.5-turbo", 11 | access_key: "test_access_key", 12 | temperature: 0.7, 13 | base_uri: "https://api.openai.com/v1", 14 | max_tokens: 2048, 15 | llm_type: RedmineAiHelper::LlmProvider::LLM_OPENAI_COMPATIBLE, 16 | ) 17 | @setting.model_profile = model_profile 18 | end 19 | 20 | teardown do 21 | AiHelperSetting.destroy_all 22 | end 23 | 24 | context "max_tokens" do 25 | should "return nil if not set" do 26 | @setting.model_profile.max_tokens = nil 27 | @setting.model_profile.save! 28 | assert !@setting.max_tokens 29 | end 30 | 31 | should "return nil if max_tokens is 0" do 32 | @setting.model_profile.max_tokens = 0 33 | @setting.model_profile.save! 34 | assert !@setting.max_tokens 35 | end 36 | 37 | should "return value if max_token is setted" do 38 | @setting.model_profile.max_tokens = 1000 39 | @setting.model_profile.save! 40 | assert_equal 1000, @setting.max_tokens 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/unit/models/ai_helper_vector_data_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../test_helper" 2 | 3 | class AiHelperVectorDataTest < ActiveSupport::TestCase 4 | 5 | # Replace this with your real tests. 6 | def test_truth 7 | assert true 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/unit/tool_response_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../test_helper", __FILE__) 2 | 3 | 4 | class ToolResponseTest < ActiveSupport::TestCase 5 | def test_initialize_with_success_status 6 | response = RedmineAiHelper::ToolResponse.new(status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value") 7 | assert_equal RedmineAiHelper::ToolResponse::STATUS_SUCCESS, response.status 8 | assert_equal "Test value", response.value 9 | assert_nil response.error 10 | end 11 | 12 | def test_initialize_with_error_status 13 | response = RedmineAiHelper::ToolResponse.new(status: RedmineAiHelper::ToolResponse::STATUS_ERROR, error: "Test error") 14 | assert_equal RedmineAiHelper::ToolResponse::STATUS_ERROR, response.status 15 | assert_nil response.value 16 | assert_equal "Test error", response.error 17 | end 18 | 19 | def test_to_json 20 | response = RedmineAiHelper::ToolResponse.new(status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value") 21 | expected_json = { status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value", error: nil }.to_json 22 | assert_equal expected_json, response.to_json 23 | end 24 | 25 | def test_to_hash 26 | response = RedmineAiHelper::ToolResponse.new(status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value") 27 | expected_hash = { status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value", error: nil } 28 | assert_equal expected_hash, response.to_hash 29 | end 30 | 31 | def test_to_h 32 | response = RedmineAiHelper::ToolResponse.new(status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value") 33 | expected_hash = { status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value", error: nil } 34 | assert_equal expected_hash, response.to_h 35 | end 36 | 37 | def test_to_s 38 | response = RedmineAiHelper::ToolResponse.new(status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value") 39 | expected_string = { status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value", error: nil }.to_s 40 | assert_equal expected_string, response.to_s 41 | end 42 | 43 | def test_is_success? 44 | response = RedmineAiHelper::ToolResponse.new(status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS) 45 | assert response.is_success? 46 | end 47 | 48 | def test_is_error? 49 | response = RedmineAiHelper::ToolResponse.new(status: RedmineAiHelper::ToolResponse::STATUS_ERROR) 50 | assert response.is_error? 51 | end 52 | 53 | def test_create_error 54 | response = RedmineAiHelper::ToolResponse.create_error("Test error") 55 | assert_equal RedmineAiHelper::ToolResponse::STATUS_ERROR, response.status 56 | assert_equal "Test error", response.error 57 | assert_nil response.value 58 | end 59 | 60 | def test_create_success 61 | response = RedmineAiHelper::ToolResponse.create_success("Test value") 62 | assert_equal RedmineAiHelper::ToolResponse::STATUS_SUCCESS, response.status 63 | assert_equal "Test value", response.value 64 | assert_nil response.error 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/unit/tools/board_tools_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | class BoardToolsTest < ActiveSupport::TestCase 4 | fixtures :projects, :issues, :issue_statuses, :trackers, :enumerations, :users, :issue_categories, :versions, :custom_fields, :boards, :messages 5 | 6 | def setup 7 | @provider = RedmineAiHelper::Tools::BoardTools.new 8 | @project = Project.find(1) 9 | @board = @project.boards.first 10 | @message = @board.messages.first 11 | end 12 | 13 | def test_list_boards_success 14 | response = @provider.list_boards(project_id: @project.id) 15 | assert_equal @project.boards.count, response.size 16 | end 17 | 18 | def test_list_boards_project_not_found 19 | assert_raises(RuntimeError, "Project not found") do 20 | @provider.list_boards(project_id: 999) 21 | end 22 | end 23 | 24 | def test_board_info_success 25 | response = @provider.board_info(board_id: @board.id) 26 | assert_equal @board.id, response[:id] 27 | assert_equal @board.name, response[:name] 28 | end 29 | 30 | def test_board_info_not_found 31 | assert_raises(RuntimeError, "Board not found") do 32 | @provider.board_info(board_id: 999) 33 | end 34 | end 35 | 36 | def test_read_message_success 37 | response = @provider.read_message(message_id: @message.id) 38 | assert_equal @message.id, response[:id] 39 | assert_equal @message.content, response[:content] 40 | end 41 | 42 | def test_read_message_not_found 43 | assert_raises(RuntimeError, "Message not found") do 44 | @provider.read_message(message_id: 999) 45 | end 46 | end 47 | 48 | def test_generate_board_url 49 | response = @provider.generate_board_url(board_id: @board.id) 50 | assert_match(%r{boards/\d+}, response[:url]) 51 | end 52 | 53 | def test_generate_board_url_no_board_id 54 | assert_raises(ArgumentError) do 55 | @provider.generate_board_url(project_id: @project.id) 56 | end 57 | end 58 | 59 | def test_generate_message_url_no_message_id 60 | assert_raises(ArgumentError) do 61 | @provider.generate_message_url(board_id: @board.id) 62 | end 63 | end 64 | 65 | def test_generate_message_url 66 | response = @provider.generate_message_url(message_id: @message.id) 67 | assert_match(%r{/boards/\d+/topics/\d+}, response[:url]) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/unit/tools/mcp_tools_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | require "redmine_ai_helper/tools/mcp_tools" 3 | 4 | class RedmineAiHelper::Tools::McpToolsTest < ActiveSupport::TestCase 5 | context "McpTools" do 6 | setup do 7 | @tools = RedmineAiHelper::Tools::McpTools.new 8 | end 9 | 10 | should "return empty command array" do 11 | assert_equal [], RedmineAiHelper::Tools::McpTools.command_array 12 | end 13 | 14 | should "return empty env hash" do 15 | assert_equal({}, RedmineAiHelper::Tools::McpTools.env_hash) 16 | end 17 | 18 | context "method_missing" do 19 | setup do 20 | @command_json = { 21 | "command" => "npx", 22 | "args" => [ 23 | "-y", 24 | "@modelcontextprotocol/server-filesystem", 25 | "/tmp", 26 | ], 27 | } 28 | tool_class = RedmineAiHelper::Tools::McpTools.generate_tool_class( 29 | name: "filesystem2", 30 | json: @command_json, 31 | ) 32 | @filesystem_tool = tool_class.new 33 | end 34 | 35 | should "raise ArgumentError if function not exist" do 36 | assert_raises ArgumentError do 37 | @filesystem_tool.non_existent_function 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/unit/tools/project_tools_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | class ProjectToolsTest < ActiveSupport::TestCase 4 | fixtures :projects, :projects_trackers, :trackers, :users, :repositories, :changesets, :changes, :issues, :issue_statuses, :enumerations, :issue_categories, :trackers 5 | 6 | def setup 7 | @provider = RedmineAiHelper::Tools::ProjectTools.new 8 | enabled_module = EnabledModule.new 9 | enabled_module.project_id = 1 10 | enabled_module.name = "ai_helper" 11 | enabled_module.save! 12 | User.current = User.find(1) 13 | end 14 | 15 | def test_list_projects 16 | enabled_module = EnabledModule.new 17 | enabled_module.project_id = 2 18 | enabled_module.name = "ai_helper" 19 | enabled_module.save! 20 | 21 | response = @provider.list_projects() 22 | assert_equal 2, response.size 23 | project1 = Project.find(1) 24 | project2 = Project.find(2) 25 | [project1, project2].each_with_index do |project, index| 26 | value = response[index] 27 | assert_equal project.id, value[:id] 28 | assert_equal project.name, value[:name] 29 | end 30 | end 31 | 32 | def test_read_project_by_id 33 | project = Project.find(1) 34 | 35 | response = @provider.read_project(project_id: project.id) 36 | assert_equal project.id, response[:id] 37 | assert_equal project.name, response[:name] 38 | end 39 | 40 | def test_read_project_by_name 41 | project = Project.find(1) 42 | 43 | response = @provider.read_project(project_name: project.name) 44 | assert_equal project.id, response[:id] 45 | assert_equal project.name, response[:name] 46 | end 47 | 48 | def test_read_project_by_identifier 49 | project = Project.find(1) 50 | 51 | response = @provider.read_project(project_identifier: project.identifier) 52 | assert_equal project.id, response[:id] 53 | assert_equal project.name, response[:name] 54 | end 55 | 56 | def test_read_project_not_found 57 | assert_raises(RuntimeError, "Project not found") do 58 | @provider.read_project(project_id: 999) 59 | end 60 | end 61 | 62 | def test_read_project_no_args 63 | assert_raises(RuntimeError, "No id or name or Identifier specified.") do 64 | @provider.read_project 65 | end 66 | end 67 | 68 | def test_project_members 69 | project = Project.find(1) 70 | members = project.members 71 | 72 | response = @provider.project_members(project_ids: [project.id]) 73 | assert_equal members.size, response[:projects][0][:members].size 74 | assert_equal members.first.user_id, response[:projects][0][:members].first[:user_id] 75 | end 76 | 77 | def test_project_enabled_modules 78 | project = Project.find(1) 79 | enabled_modules = project.enabled_modules 80 | 81 | response = @provider.project_enabled_modules(project_id: project.id) 82 | assert_equal enabled_modules.size, response[:enabled_modules].size 83 | assert_equal enabled_modules.first.name, response[:enabled_modules].first[:name] 84 | end 85 | 86 | def test_list_project_activities 87 | assert_nothing_raised do 88 | project = Project.find(1) 89 | @provider.list_project_activities(project_id: project.id) 90 | 91 | author = User.find(1) 92 | @provider.list_project_activities(project_id: project.id, author_id: author.id) 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /test/unit/tools/system_tools_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | class SystemToolsTest < ActiveSupport::TestCase 4 | def setup 5 | @provider = RedmineAiHelper::Tools::SystemTools.new 6 | end 7 | 8 | def test_list_plugins 9 | response = @provider.list_plugins 10 | 11 | assert response[:plugins].any? 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/unit/tools/user_tools_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | class UserToolsTest < ActiveSupport::TestCase 4 | fixtures :projects, :issues, :issue_statuses, :trackers, :enumerations, :users, :issue_categories, :versions, :custom_fields, :custom_values, :groups_users, :members, :member_roles, :roles, :user_preferences 5 | include RedmineAiHelper::Tools 6 | 7 | context "UserTools" do 8 | setup do 9 | @provider = UserTools.new 10 | @users = User.where(status: User::STATUS_ACTIVE) 11 | end 12 | 13 | context "list users" do 14 | should "success by default" do 15 | result = @provider.list_users 16 | assert_equal @users.count, result[:total] 17 | assert_equal @users.map(&:id).sort, result[:users].map { |u| u[:id] }.sort 18 | end 19 | 20 | should "success with limit" do 21 | result = @provider.list_users(query: { limit: 5 }) 22 | assert_equal 5, result[:users].size 23 | end 24 | 25 | should "success with status" do 26 | locked = User.where(status: User::STATUS_LOCKED) 27 | result = @provider.list_users(query: { status: "locked" }) 28 | assert_equal locked.count, result[:users].size 29 | end 30 | 31 | should "success with date fields" do 32 | 2.times { |i| @users[i].last_login_on = (i + 1).days.ago; @users[i].save! } 33 | result = @provider.list_users(query: { date_fields: [{ field_name: "last_login_on", operator: ">=", value: 1.year.ago.to_s }] }) 34 | assert_equal 2, result[:users].size 35 | 36 | @users.update_all(last_login_on: 1.days.ago) 37 | 3.times { |i| @users[i].update_attribute(:last_login_on, (i + 2).years.ago) } 38 | @users[4].last_login_on = nil 39 | @users.each { |u| u.save! } 40 | result = @provider.list_users(query: { date_fields: [{ field_name: "last_login_on", operator: "<=", value: 1.year.ago.to_s }] }) 41 | assert_equal 4, result[:users].size 42 | end 43 | 44 | should "success with sort" do 45 | result = @provider.list_users(query: { sort: { field_name: "created_on", order: "asc" } }) 46 | assert_equal @users.order(:created_on).map(&:id), result[:users].map { |u| u[:id] } 47 | end 48 | end 49 | 50 | context "find user" do 51 | should "find user by name" do 52 | result = @provider.find_user(name: "admin") 53 | assert_equal 1, result[:users].size 54 | assert_equal "admin", result[:users].first[:login] 55 | end 56 | 57 | should "find user by login" do 58 | result = @provider.find_user(name: "admin") 59 | assert_equal 1, result[:users].size 60 | assert_equal "admin", result[:users].first[:login] 61 | end 62 | 63 | should "return error if name is not provided" do 64 | assert_raises(ArgumentError) do 65 | @provider.find_user() 66 | end 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/unit/tools/version_tool_provider_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | class VersionToolsTest < ActiveSupport::TestCase 4 | fixtures :projects, :issues, :issue_statuses, :trackers, :enumerations, :users, :issue_categories, :versions, :custom_fields, :boards, :messages 5 | 6 | def setup 7 | @provider = RedmineAiHelper::Tools::VersionTools.new 8 | @project = Project.find(1) 9 | @version = @project.versions.first 10 | end 11 | 12 | def test_list_versions_success 13 | response = @provider.list_versions(project_id: @project.id) 14 | assert_equal @project.versions.count, response.size 15 | end 16 | 17 | def test_list_versions_project_not_found 18 | assert_raises(RuntimeError, "Project not found") do 19 | @provider.list_versions(project_id: 999) 20 | end 21 | end 22 | 23 | def test_version_info_success 24 | response = @provider.version_info(version_ids: [@version.id]) 25 | assert_equal @version.id, response.first[:id] 26 | assert_equal @version.name, response.first[:name] 27 | end 28 | 29 | def test_version_info_not_found 30 | assert_raises(RuntimeError, "Version not found") do 31 | @provider.version_info(version_ids: [999]) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/unit/tools/wiki_tool_provider_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | class WikiToolsTest < ActiveSupport::TestCase 4 | fixtures :projects, :wikis, :wiki_pages, :users 5 | 6 | def setup 7 | @provider = RedmineAiHelper::Tools::WikiTools.new 8 | @project = Project.find(1) 9 | @wiki = @project.wiki 10 | @page = @wiki.pages.first 11 | end 12 | 13 | def test_read_wiki_page_success 14 | response = @provider.read_wiki_page(project_id: @project.id, title: @page.title) 15 | assert_equal @page.title, response[:title] 16 | end 17 | 18 | def test_read_wiki_page_not_found 19 | assert_raises(RuntimeError, "Page not found: title = Nonexistent Page") do 20 | @provider.read_wiki_page(project_id: @project.id, title: "Nonexistent Page") 21 | end 22 | end 23 | 24 | def test_list_wiki_pages 25 | response = @provider.list_wiki_pages(project_id: @project.id) 26 | assert_equal @wiki.pages.count, response.size 27 | end 28 | 29 | def test_generate_url_for_wiki_page 30 | response = @provider.generate_url_for_wiki_page(project_id: @project.id, title: @page.title) 31 | expected_url = "/projects/#{@project.identifier}/wiki/#{@page.title}" 32 | assert_equal expected_url, response[:url] 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/unit/util/config_file_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | require "redmine_ai_helper/util/config_file" 3 | 4 | class RedmineAiHelper::Util::ConfigFileTest < ActiveSupport::TestCase 5 | context "ConfigFile" do 6 | setup do 7 | @config_path = Rails.root.join("config", "ai_helper", "config.yml") 8 | end 9 | 10 | should "return an empty hash if the config file does not exist" do 11 | File.stubs(:exist?).with(@config_path).returns(false) 12 | config = RedmineAiHelper::Util::ConfigFile.load_config 13 | assert_equal({}, config) 14 | end 15 | 16 | should "load and symbolize keys from the config file" do 17 | mock_yaml = { 18 | "logger" => { "level" => "debug" }, 19 | "langfuse" => { "public_key" => "test_key" }, 20 | } 21 | File.stubs(:exist?).with(@config_path).returns(true) 22 | YAML.stubs(:load_file).with(@config_path).returns(mock_yaml) 23 | 24 | config = RedmineAiHelper::Util::ConfigFile.load_config 25 | expected_config = { 26 | logger: { level: "debug" }, 27 | langfuse: { public_key: "test_key" }, 28 | } 29 | assert_equal(expected_config, config) 30 | end 31 | 32 | should "return the correct config file path" do 33 | assert_equal @config_path, RedmineAiHelper::Util::ConfigFile.config_file_path 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/unit/util/issue_json_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | require "redmine_ai_helper/util/issue_json" 3 | 4 | class RedmineAiHelper::Util::IssueJsonTest < ActiveSupport::TestCase 5 | fixtures :projects, :issues, :issue_statuses, :trackers, :enumerations, :users, :issue_categories, :versions, :custom_fields, :attachments, :changesets, :journals, :journal_details, :changes 6 | 7 | context "generate_issue_data" do 8 | setup do 9 | @issue = Issue.first 10 | @issue.assigned_to = User.find(2) 11 | 12 | @issue.status = IssueStatus.find(2) 13 | @issue.save! 14 | issue2 = Issue.find(2) 15 | issue2.parent = @issue 16 | issue2.save! 17 | @issue.reload 18 | changeset = Changeset.first 19 | changeset.issues << @issue 20 | changeset.save! 21 | attachment = Attachment.find(1) 22 | attachment.container = @issue 23 | attachment.save! 24 | @issue.reload 25 | @test_class = TestClass.new 26 | @issue.due_date = Date.today + 7 27 | end 28 | 29 | should "generate correct issue data" do 30 | issue_data = @test_class.generate_issue_data(@issue) 31 | # puts JSON.pretty_generate(issue_data) 32 | 33 | assert_equal @issue.id, issue_data[:id] 34 | assert_equal @issue.subject, issue_data[:subject] 35 | assert_equal @issue.project.id, issue_data[:project][:id] 36 | assert_equal @issue.project.name, issue_data[:project][:name] 37 | assert_equal @issue.tracker.id, issue_data[:tracker][:id] 38 | assert_equal @issue.tracker.name, issue_data[:tracker][:name] 39 | assert_equal @issue.status.id, issue_data[:status][:id] 40 | assert_equal @issue.status.name, issue_data[:status][:name] 41 | assert_equal @issue.priority.id, issue_data[:priority][:id] 42 | assert_equal @issue.priority.name, issue_data[:priority][:name] 43 | assert_equal @issue.author.id, issue_data[:author][:id] 44 | assert_equal @issue.assigned_to.id, issue_data[:assigned_to][:id] 45 | assert_equal @issue.description, issue_data[:description] 46 | assert_equal @issue.start_date, issue_data[:start_date] 47 | assert_equal @issue.due_date, issue_data[:due_date] 48 | assert_equal @issue.done_ratio, issue_data[:done_ratio] 49 | assert_equal @issue.is_private, issue_data[:is_private] 50 | assert_equal @issue.estimated_hours, issue_data[:estimated_hours] 51 | assert_equal @issue.created_on.to_s, issue_data[:created_on].to_s 52 | assert_equal @issue.updated_on.to_s, issue_data[:updated_on].to_s 53 | end 54 | end 55 | 56 | class TestClass < RedmineAiHelper::BaseTools 57 | # This class is used to test the IssueJson module 58 | # It includes the IssueJson module to access its methods 59 | include RedmineAiHelper::Util::IssueJson 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/unit/util/mcp_tools_loader_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | require "redmine_ai_helper/util/mcp_tools_loader" 3 | 4 | class RedmineAiHelper::Util::McpToolsLoaderTest < ActiveSupport::TestCase 5 | teardown do 6 | end 7 | context "McpToolsLoader" do 8 | teardown do 9 | # Clean up stubs 10 | RedmineAiHelper::Util::McpToolsLoader.instance_variable_set(:@list, nil) 11 | RedmineAiHelper::Util::McpToolsLoader.instance_variable_set(:@config_file, nil) 12 | end 13 | should "return config file path" do 14 | tools_loader = RedmineAiHelper::Util::McpToolsLoader.instance 15 | config_file = tools_loader.config_file 16 | assert_equal File.join(Rails.root, "config", "ai_helper", "config.json"), config_file 17 | end 18 | end 19 | 20 | context "McpToolsLoader with test_config" do 21 | setup do 22 | loader = RedmineAiHelper::Util::McpToolsLoader.instance 23 | loader.instance_variable_set(:@config_file, nil) 24 | loader.instance_variable_set(:@list, nil) 25 | end 26 | 27 | teardown do 28 | loader = RedmineAiHelper::Util::McpToolsLoader.instance 29 | loader.instance_variable_set(:@config_file, nil) 30 | loader.instance_variable_set(:@list, nil) 31 | end 32 | 33 | should "load tools from config file" do 34 | test_config_file = File.expand_path("../../../test_config.json", __FILE__) 35 | 36 | loader = RedmineAiHelper::Util::McpToolsLoader.instance 37 | loader.instance_variable_set(:@config_file, test_config_file) 38 | tools = RedmineAiHelper::Util::McpToolsLoader.load 39 | 40 | assert_not_nil tools, "tools should not be nil" 41 | assert tools.is_a?(Array), "tools should be an Array" 42 | assert_equal 2, tools.length, "tools count should be 1" 43 | assert_equal "McpSlack", tools[0].name, "First tool should be McpSlack" 44 | end 45 | 46 | should "return empty array if config file does not exist" do 47 | loader = RedmineAiHelper::Util::McpToolsLoader.instance 48 | loader.instance_variable_set(:@config_file, "non_existent_config.json") 49 | 50 | tools = RedmineAiHelper::Util::McpToolsLoader.load 51 | 52 | assert_equal [], tools, "tools should be an empty array when config file does not exist" 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/unit/vector/issue_vector_db_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | require "redmine_ai_helper/vector/issue_vector_db" 3 | 4 | class RedmineAiHelper::Vector::IssueVectorDbTest < ActiveSupport::TestCase 5 | fixtures :projects, :issues, :issue_statuses, :trackers, :enumerations, :users, :journals 6 | 7 | context "IssueVectorDb" do 8 | setup do 9 | @issue = Issue.find(1) 10 | @issue.assigned_to = User.find(2) 11 | @vector_db = RedmineAiHelper::Vector::IssueVectorDb.new 12 | end 13 | 14 | should "return correct index name" do 15 | assert_equal "RedmineIssue", @vector_db.index_name 16 | end 17 | 18 | should "convert issue data to JSON text" do 19 | json_data = @vector_db.data_to_json(@issue) 20 | 21 | payload = json_data[:payload] 22 | assert_equal @issue.id, payload[:issue_id] 23 | assert_equal @issue.project.name, payload[:project_name] 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/unit/vector/qdrant_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | class RedmineAiHelper::Vector::QdrantTest < ActiveSupport::TestCase 4 | context "Qdrant" do 5 | setup do 6 | # Create a mock client and LLM 7 | @mock_client = mock("client") 8 | @mock_points = mock("points") 9 | @mock_llm = mock("llm") 10 | @mock_embedding = mock("embedding") 11 | @mock_llm.stubs(:embed).returns(@mock_embedding) 12 | @mock_embedding.stubs(:embedding).returns([0.1, 0.2, 0.3]) 13 | 14 | # Stub Langchain::Vectorsearch::Qdrant initializer 15 | @qdrant = RedmineAiHelper::Vector::Qdrant.allocate 16 | @qdrant.instance_variable_set(:@client, @mock_client) 17 | @qdrant.instance_variable_set(:@llm, @mock_llm) 18 | @qdrant.instance_variable_set(:@index_name, "test_collection") 19 | end 20 | 21 | should "return empty array if client is nil" do 22 | @qdrant.instance_variable_set(:@client, nil) 23 | results = @qdrant.ask_with_filter(query: "test", k: 5, filter: nil) 24 | assert_equal [], results 25 | end 26 | 27 | should "call client.points.search with correct parameters and return payloads" do 28 | # Prepare mock response 29 | mock_response = { 30 | "result" => [ 31 | { "payload" => { "id" => 1, "title" => "Issue 1" } }, 32 | { "payload" => { "id" => 2, "title" => "Issue 2" } }, 33 | ], 34 | } 35 | @mock_client.stubs(:points).returns(@mock_points) 36 | @mock_points.expects(:search).with( 37 | collection_name: "test_collection", 38 | limit: 2, 39 | vector: [0.1, 0.2, 0.3], 40 | with_payload: true, 41 | with_vector: true, 42 | filter: { foo: "bar" }, 43 | ).returns(mock_response) 44 | 45 | results = @qdrant.ask_with_filter(query: "test", k: 2, filter: { foo: "bar" }) 46 | assert_equal [{ "id" => 1, "title" => "Issue 1" }, { "id" => 2, "title" => "Issue 2" }], results 47 | end 48 | 49 | should "return empty array if result is nil" do 50 | @mock_client.stubs(:points).returns(@mock_points) 51 | @mock_points.stubs(:search).returns({ "result" => nil }) 52 | results = @qdrant.ask_with_filter(query: "test", k: 1, filter: nil) 53 | assert_equal [], results 54 | end 55 | 56 | should "return empty array if result is empty" do 57 | @mock_client.stubs(:points).returns(@mock_points) 58 | @mock_points.stubs(:search).returns({ "result" => [] }) 59 | results = @qdrant.ask_with_filter(query: "test", k: 1, filter: nil) 60 | assert_equal [], results 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/unit/vector/wiki_vector_db_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | require "redmine_ai_helper/vector/issue_vector_db" 3 | 4 | class RedmineAiHelper::Vector::WikiVectorDbTest < ActiveSupport::TestCase 5 | fixtures :projects, :issues, :issue_statuses, :trackers, :enumerations, :users, :journals, :wikis, :wiki_pages, :wiki_contents 6 | 7 | context "WikiVectorDb" do 8 | setup do 9 | @page = WikiPage.find(1) 10 | @vector_db = RedmineAiHelper::Vector::WikiVectorDb.new 11 | end 12 | 13 | should "return correct index name" do 14 | assert_equal "RedmineWiki", @vector_db.index_name 15 | end 16 | 17 | should "convert wiki data to JSON text" do 18 | json_data = @vector_db.data_to_json(@page) 19 | 20 | payload = json_data[:payload] 21 | assert_equal @page.id, payload[:wiki_id] 22 | assert_equal @page.project.name, payload[:project_name] 23 | end 24 | end 25 | end 26 | --------------------------------------------------------------------------------