+ <.button
+ id="get-started-with-ai-btn"
+ phx-click="mark_disclaimer_read"
+ phx-target={@myself}
+ disabled={!@can_edit_workflow}
+ >
+ Get started with the AI Assistant
+
+
+ <.render_disclaimer />
+
+ """
+ end
+
+ attr :id, :string, default: "ai-assistant-disclaimer"
+
+ defp render_disclaimer(assigns) do
+ ~H"""
+
+ OpenFn AI Assistant helps users to build workflows faster and better. Built on Claude Sonnet 3.5 from Anthropic, here are a few ways you can use the assistant to improve your job writing experience:
+
+
Prompt the AI to write a job for you
+
Proofread and debug your job code
+
Understand why you are seeing an error
+
+
+
+
+ When you send a question to the AI, both the question and corresponding answer is stored with reference to the step. All collaborators within a project can see questions asked by other users.
+
+
+
+ Warning:
+
+
+ The assistant can sometimes provide incorrect or misleading responses based on the model. We recommend that you check the responses before applying them on real life data.
+
+
+
+ Please do not include real life data with personally identifiable information or sensitive business data in your queries. OpenFn sends all queries to Anthropic for response and will not be liable for any exposure due to your prompts
+
+ AI Assistant has not been configured for your instance.
+ Contact your admin to configure AI Assistant or try it on
+ OpenFn cloud for free.
+ Try the AI Assistant on
+
+ https://app.openfn.org
+
+
+
+
+ <% end %>
@@ -965,6 +987,9 @@ defmodule LightningWeb.WorkflowLive.Edit do
view_only_users_ids: view_only_users_ids,
active_menu_item: :overview,
expanded_job: nil,
+ ai_assistant_enabled: AiAssistant.enabled?(),
+ project_has_chat_sessions: nil,
+ chat_session_id: nil,
follow_run: nil,
step: nil,
manual_run_form: nil,
@@ -974,7 +999,13 @@ defmodule LightningWeb.WorkflowLive.Edit do
selected_run: nil,
selected_trigger: nil,
selection_mode: nil,
- query_params: %{"s" => nil, "m" => nil, "a" => nil},
+ query_params: %{
+ "s" => nil,
+ "m" => nil,
+ "a" => nil,
+ "v" => nil,
+ "chat" => nil
+ },
workflow: nil,
snapshot: nil,
changeset: nil,
@@ -1050,6 +1081,10 @@ defmodule LightningWeb.WorkflowLive.Edit do
|> push_redirect(to: ~p"/projects/#{socket.assigns.project}/w")
end
end
+ |> assign(
+ project_has_chat_sessions:
+ AiAssistant.project_has_any_session?(socket.assigns.project.id)
+ )
end
defp track_user_presence(socket) do
@@ -1662,7 +1697,9 @@ defmodule LightningWeb.WorkflowLive.Edit do
|> maybe_disable_canvas()}
end
- def handle_info(%{}, socket), do: {:noreply, socket}
+ def handle_info(%{}, socket) do
+ {:noreply, socket}
+ end
defp maybe_disable_canvas(socket) do
%{
@@ -1954,8 +1991,14 @@ defmodule LightningWeb.WorkflowLive.Edit do
|> assign(
query_params:
params
- |> Map.take(["s", "m", "a"])
- |> Enum.into(%{"s" => nil, "m" => nil, "a" => nil})
+ |> Map.take(["s", "m", "a", "v", "chat"])
+ |> Enum.into(%{
+ "s" => nil,
+ "m" => nil,
+ "a" => nil,
+ "v" => nil,
+ "chat" => nil
+ })
)
|> apply_query_params()
end
@@ -1983,6 +2026,18 @@ defmodule LightningWeb.WorkflowLive.Edit do
end
end
|> assign_follow_run(socket.assigns.query_params)
+ |> assign_chat_session_id(socket.assigns.query_params)
+ end
+
+ defp assign_chat_session_id(socket, %{"chat" => session_id})
+ when is_binary(session_id) do
+ socket
+ |> assign(chat_session_id: session_id)
+ end
+
+ defp assign_chat_session_id(socket, _params) do
+ socket
+ |> assign(chat_session_id: nil)
end
defp switch_changeset(socket) do
@@ -2059,7 +2114,8 @@ defmodule LightningWeb.WorkflowLive.Edit do
selected_edge: nil,
selected_job: nil,
selected_trigger: nil,
- selection_mode: nil
+ selection_mode: nil,
+ chat_session_id: nil
)
end
@@ -2086,11 +2142,21 @@ defmodule LightningWeb.WorkflowLive.Edit do
:triggers ->
socket
- |> assign(selected_job: nil, selected_trigger: value, selected_edge: nil)
+ |> assign(
+ selected_job: nil,
+ selected_trigger: value,
+ selected_edge: nil,
+ chat_session_id: nil
+ )
:edges ->
socket
- |> assign(selected_job: nil, selected_trigger: nil, selected_edge: value)
+ |> assign(
+ selected_job: nil,
+ selected_trigger: nil,
+ selected_edge: value,
+ chat_session_id: nil
+ )
end
end
@@ -2105,6 +2171,7 @@ defmodule LightningWeb.WorkflowLive.Edit do
query_params
|> Map.put("a", run.id)
|> Map.put("v", version)
+ |> Enum.reject(fn {_k, v} -> is_nil(v) end)
socket
|> push_patch(to: ~p"/projects/#{project}/w/#{workflow}?#{params}")
diff --git a/priv/repo/migrations/20240823072414_create_chat_sessions_tables.exs b/priv/repo/migrations/20240823072414_create_chat_sessions_tables.exs
new file mode 100644
index 0000000000..4e3cb2d237
--- /dev/null
+++ b/priv/repo/migrations/20240823072414_create_chat_sessions_tables.exs
@@ -0,0 +1,31 @@
+defmodule Lightning.Repo.Migrations.CreateChatSessionsTables do
+ use Ecto.Migration
+
+ def change do
+ create table(:ai_chat_sessions, primary_key: false) do
+ add :id, :uuid, primary_key: true
+ add :title, :string
+ add :is_public, :boolean
+ add :is_deleted, :boolean
+ add :job_id, references(:jobs, type: :binary_id, on_delete: :nilify_all)
+ add :user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
+
+ timestamps()
+ end
+
+ create table(:ai_chat_messages, primary_key: false) do
+ add :id, :uuid, primary_key: true
+
+ add :chat_session_id,
+ references(:ai_chat_sessions, type: :binary_id, on_delete: :delete_all)
+
+ add :user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
+ add :content, :text
+ add :role, :string
+ add :is_deleted, :boolean
+ add :is_public, :boolean
+
+ timestamps()
+ end
+ end
+end
diff --git a/test/lightning/ai_assistant/ai_assistant_test.exs b/test/lightning/ai_assistant/ai_assistant_test.exs
index 26554cef39..d6d392e459 100644
--- a/test/lightning/ai_assistant/ai_assistant_test.exs
+++ b/test/lightning/ai_assistant/ai_assistant_test.exs
@@ -1,43 +1,16 @@
defmodule Lightning.AiAssistantTest do
- use ExUnit.Case, async: true
+ use Lightning.DataCase, async: true
import Mox
alias Lightning.AiAssistant
- alias Lightning.AiAssistant.Session
-
- import Lightning.Factories
setup :verify_on_exit!
- describe "Session" do
- test "creation" do
- job = build(:job, body: "fn()", adaptor: "@openfn/language-common@latest")
- session = Session.new(job)
-
- assert session.adaptor == "@openfn/language-common@1.6.2"
- assert session.expression == "fn()"
- assert session.history == []
- end
-
- test "put_history" do
- job = build(:job, body: "fn()", adaptor: "@openfn/language-common@latest")
-
- session =
- Session.new(job)
- |> Session.put_history([%{role: :user, content: "fn()"}])
-
- assert session.history == [%{role: :user, content: "fn()"}]
- end
-
- test "put_expression" do
- job = build(:job, body: "fn()", adaptor: "@openfn/language-common@latest")
-
- session =
- Session.new(job)
- |> Session.put_expression("fn(state => state)")
-
- assert session.expression == "fn(state => state)"
- end
+ setup do
+ user = insert(:user)
+ project = insert(:project, project_users: [%{user: user, role: :owner}])
+ workflow = insert(:simple_workflow, project: project)
+ [user: user, project: project, workflow: workflow]
end
describe "endpoint_available?" do
@@ -75,63 +48,150 @@ defmodule Lightning.AiAssistantTest do
end
end
- test "query" do
- session =
- AiAssistant.new_session(%Lightning.Workflows.Job{
- body: "fn()",
- adaptor: "@openfn/language-common@latest"
- })
+ describe "query/2" do
+ test "queries and saves the response", %{
+ user: user,
+ workflow: %{jobs: [job_1 | _]} = _workflow
+ } do
+ session =
+ insert(:chat_session,
+ user: user,
+ job: job_1,
+ messages: [%{role: :user, content: "what?", user: user}]
+ )
+
+ Mox.stub(Lightning.MockConfig, :apollo, fn key ->
+ case key do
+ :endpoint -> "http://localhost:3000"
+ :openai_api_key -> "api_key"
+ end
+ end)
- Mox.stub(Lightning.MockConfig, :apollo, fn key ->
- case key do
- :endpoint -> "http://localhost:3000"
- :openai_api_key -> "api_key"
- end
- end)
+ reply =
+ """
+ {
+ "response": "Based on the provided guide and the API documentation for the OpenFn @openfn/language-common@1.14.0 adaptor, you can create jobs using the functions provided by the API to interact with different data sources and perform various operations.\\n\\nTo create a job using the HTTP adaptor, you can use functions like `get`, `post`, `put`, `patch`, `head`, and `options` to make HTTP requests. Here's an example job code using the HTTP adaptor:\\n\\n```javascript\\nconst { get, post, each, dataValue } = require('@openfn/language-common');\\n\\nexecute(\\n get('/patients'),\\n each('$.data.patients[*]', (item, index) => {\\n item.id = `item-${index}`;\\n }),\\n post('/patients', dataValue('patients'))\\n);\\n```\\n\\nIn this example, the job first fetches patient data using a GET request, then iterates over each patient to modify their ID, and finally posts the modified patient data back.\\n\\nYou can similarly create jobs using the Salesforce adaptor or the ODK adaptor by utilizing functions like `upsert`, `create`, `fields`, `field`, etc., as shown in the provided examples.\\n\\nFeel free to ask if you have any specific questions or need help with",
+ "history": [
+ { "role": "user", "content": "what?" },
+ {
+ "role": "assistant",
+ "content": "Based on the provided guide and the API documentation for the OpenFn @openfn/language-common@1.14.0 adaptor, you can create jobs using the functions provided by the API to interact with different data sources and perform various operations.\\n\\nTo create a job using the HTTP adaptor, you can use functions like `get`, `post`, `put`, `patch`, `head`, and `options` to make HTTP requests. Here's an example job code using the HTTP adaptor:\\n\\n```javascript\\nconst { get, post, each, dataValue } = require('@openfn/language-common');\\n\\nexecute(\\n get('/patients'),\\n each('$.data.patients[*]', (item, index) => {\\n item.id = `item-${index}`;\\n }),\\n post('/patients', dataValue('patients'))\\n);\\n```\\n\\nIn this example, the job first fetches patient data using a GET request, then iterates over each patient to modify their ID, and finally posts the modified patient data back.\\n\\nYou can similarly create jobs using the Salesforce adaptor or the ODK adaptor by utilizing functions like `upsert`, `create`, `fields`, `field`, etc., as shown in the provided examples.\\n\\nFeel free to ask if you have any specific questions or need help with"
+ }
+ ]
+ }
+ """
+ |> Jason.decode!()
+
+ expect(Lightning.Tesla.Mock, :call, fn %{method: :post, url: url}, _opts ->
+ assert url =~ "/services/job_chat"
+
+ {:ok, %Tesla.Env{status: 200, body: reply}}
+ end)
- reply =
- """
- {
- "response": "Based on the provided guide and the API documentation for the OpenFn @openfn/language-common@1.14.0 adaptor, you can create jobs using the functions provided by the API to interact with different data sources and perform various operations.\\n\\nTo create a job using the HTTP adaptor, you can use functions like `get`, `post`, `put`, `patch`, `head`, and `options` to make HTTP requests. Here's an example job code using the HTTP adaptor:\\n\\n```javascript\\nconst { get, post, each, dataValue } = require('@openfn/language-common');\\n\\nexecute(\\n get('/patients'),\\n each('$.data.patients[*]', (item, index) => {\\n item.id = `item-${index}`;\\n }),\\n post('/patients', dataValue('patients'))\\n);\\n```\\n\\nIn this example, the job first fetches patient data using a GET request, then iterates over each patient to modify their ID, and finally posts the modified patient data back.\\n\\nYou can similarly create jobs using the Salesforce adaptor or the ODK adaptor by utilizing functions like `upsert`, `create`, `fields`, `field`, etc., as shown in the provided examples.\\n\\nFeel free to ask if you have any specific questions or need help with",
- "history": [
- { "role": "user", "content": "what?" },
- {
- "role": "assistant",
- "content": "Based on the provided guide and the API documentation for the OpenFn @openfn/language-common@1.14.0 adaptor, you can create jobs using the functions provided by the API to interact with different data sources and perform various operations.\\n\\nTo create a job using the HTTP adaptor, you can use functions like `get`, `post`, `put`, `patch`, `head`, and `options` to make HTTP requests. Here's an example job code using the HTTP adaptor:\\n\\n```javascript\\nconst { get, post, each, dataValue } = require('@openfn/language-common');\\n\\nexecute(\\n get('/patients'),\\n each('$.data.patients[*]', (item, index) => {\\n item.id = `item-${index}`;\\n }),\\n post('/patients', dataValue('patients'))\\n);\\n```\\n\\nIn this example, the job first fetches patient data using a GET request, then iterates over each patient to modify their ID, and finally posts the modified patient data back.\\n\\nYou can similarly create jobs using the Salesforce adaptor or the ODK adaptor by utilizing functions like `upsert`, `create`, `fields`, `field`, etc., as shown in the provided examples.\\n\\nFeel free to ask if you have any specific questions or need help with"
- }
- ]
- }
- """
- |> Jason.decode!()
+ {:ok, updated_session} = AiAssistant.query(session, "foo")
- expect(Lightning.Tesla.Mock, :call, fn %{method: :post, url: url}, _opts ->
- assert url =~ "/services/job_chat"
+ assert Enum.count(updated_session.messages) == Enum.count(reply["history"])
- {:ok, %Tesla.Env{status: 200, body: reply}}
- end)
+ reply_message = List.last(reply["history"])
+ saved_message = List.last(updated_session.messages)
- {:ok, session} = AiAssistant.query(session, "foo")
+ assert reply_message["content"] == saved_message.content
+ assert reply_message["role"] == to_string(saved_message.role)
- assert session.history ==
- reply["history"]
- |> Enum.map(fn h -> %{role: h["role"], content: h["content"]} end)
+ assert Lightning.Repo.reload!(saved_message)
+ end
+ end
+
+ describe "list_sessions_for_job/1" do
+ test "lists the sessions in descending order of time updated", %{
+ user: user,
+ workflow: %{jobs: [job_1 | _]} = _workflow
+ } do
+ session_1 =
+ insert(:chat_session,
+ user: user,
+ job: job_1,
+ updated_at: DateTime.utc_now() |> DateTime.add(-5)
+ )
+
+ session_2 = insert(:chat_session, user: user, job: job_1)
+
+ assert [list_session_1, list_session_2] =
+ AiAssistant.list_sessions_for_job(job_1)
+
+ assert list_session_1.id == session_2.id
+ assert list_session_2.id == session_1.id
+
+ assert is_struct(list_session_1.user),
+ "user who created the session is preloaded"
+ end
end
- test "authorized?" do
- assert AiAssistant.authorized?(%Lightning.Accounts.User{role: :superuser})
- refute AiAssistant.authorized?(%Lightning.Accounts.User{role: :user})
+ describe "create_session/3" do
+ test "creates a new session", %{
+ user: user,
+ workflow: %{jobs: [job_1 | _]} = _workflow
+ } do
+ assert {:ok, session} = AiAssistant.create_session(job_1, user, "foo")
+
+ assert session.job_id == job_1.id
+ assert session.user_id == user.id
+ assert session.expression == job_1.body
+
+ assert session.adaptor ==
+ Lightning.AdaptorRegistry.resolve_adaptor(job_1.adaptor)
+
+ assert match?(
+ [%{role: :user, content: "foo", user: ^user}],
+ session.messages
+ )
+ end
end
- test "new_session" do
- job = %Lightning.Workflows.Job{
- body: "fn()",
- adaptor: "@openfn/language-common@latest"
- }
+ describe "save_message/2" do
+ test "calls limiter to increment ai queries when role is assistant" do
+ user = insert(:user)
- session = AiAssistant.new_session(job)
+ %{id: job_id} = job = insert(:job, workflow: build(:workflow))
- assert session.adaptor == "@openfn/language-common@1.6.2"
- assert session.expression == "fn()"
- assert session.history == []
+ session = insert(:chat_session, job: job, user: user)
+
+ Mox.expect(
+ Lightning.Extensions.MockUsageLimiter,
+ :increment_ai_queries,
+ 1,
+ fn %{job_id: ^job_id} -> Ecto.Multi.new() end
+ )
+
+ content1 = """
+ I am an assistant and I am here to help you with your questions.
+ """
+
+ AiAssistant.save_message(session, %{
+ role: :assistant,
+ content: content1,
+ user: user
+ })
+ end
+
+ test "does not call limiter to increment ai queries when role is user" do
+ user = insert(:user)
+
+ %{id: job_id} = job = insert(:job, workflow: build(:workflow))
+ session = insert(:chat_session, job: job, user: user)
+
+ Mox.expect(
+ Lightning.Extensions.MockUsageLimiter,
+ :increment_ai_queries,
+ 0,
+ fn %{job_id: ^job_id} -> Ecto.Multi.new() end
+ )
+
+ AiAssistant.save_message(session, %{
+ role: :user,
+ content: "What if I want to deduplicate the headers?",
+ user: user
+ })
+ end
end
end
diff --git a/test/lightning/ai_assistant/limiter_test.exs b/test/lightning/ai_assistant/limiter_test.exs
new file mode 100644
index 0000000000..f7fb2415c5
--- /dev/null
+++ b/test/lightning/ai_assistant/limiter_test.exs
@@ -0,0 +1,49 @@
+defmodule Lightning.AiAssistant.LimiterTest do
+ use ExUnit.Case, async: true
+
+ import Mox
+
+ alias Lightning.AiAssistant.Limiter
+
+ setup :verify_on_exit!
+
+ describe "validate_quota" do
+ test "return ok when limit is not reached" do
+ project_id = Ecto.UUID.generate()
+
+ stub(Lightning.Extensions.MockUsageLimiter, :limit_action, fn %{
+ type:
+ :ai_query
+ },
+ %{
+ project_id:
+ ^project_id
+ } ->
+ :ok
+ end)
+
+ assert :ok == Limiter.validate_quota(project_id)
+ end
+
+ test "return limiter error when limit is reached" do
+ limiter_error =
+ {:error, :too_many_queries,
+ %Lightning.Extensions.Message{text: "Too many queries"}}
+
+ project_id = Ecto.UUID.generate()
+
+ stub(Lightning.Extensions.MockUsageLimiter, :limit_action, fn %{
+ type:
+ :ai_query
+ },
+ %{
+ project_id:
+ ^project_id
+ } ->
+ limiter_error
+ end)
+
+ assert limiter_error == Limiter.validate_quota(project_id)
+ end
+ end
+end
diff --git a/test/lightning/extensions/usage_limiter_test.exs b/test/lightning/extensions/usage_limiter_test.exs
index b47d3426e8..7ebf7a891f 100644
--- a/test/lightning/extensions/usage_limiter_test.exs
+++ b/test/lightning/extensions/usage_limiter_test.exs
@@ -1,6 +1,8 @@
defmodule Lightning.Extensions.UsageLimiterTest do
use ExUnit.Case, async: true
+ import Lightning.Factories
+
alias Lightning.Extensions.UsageLimiting.Action
alias Lightning.Extensions.UsageLimiting.Context
alias Lightning.Extensions.UsageLimiter
@@ -30,4 +32,11 @@ defmodule Lightning.Extensions.UsageLimiterTest do
end)
end
end
+
+ describe "increment_ai_queries/2" do
+ test "returns no change" do
+ assert Ecto.Multi.new() ==
+ UsageLimiter.increment_ai_queries(build(:chat_session))
+ end
+ end
end
diff --git a/test/lightning_web/live/workflow_live/edit_test.exs b/test/lightning_web/live/workflow_live/edit_test.exs
index b8159461fa..89256dfe8d 100644
--- a/test/lightning_web/live/workflow_live/edit_test.exs
+++ b/test/lightning_web/live/workflow_live/edit_test.exs
@@ -1371,4 +1371,502 @@ defmodule LightningWeb.WorkflowLive.EditTest do
assert html =~ error_msg
end
end
+
+ describe "AI Assistant:" do
+ setup :create_workflow
+
+ test "correct information is displayed when the assistant is not configured",
+ %{
+ conn: conn,
+ project: project,
+ workflow: %{jobs: [job_1 | _]} = workflow
+ } do
+ # when not configured properly
+ Mox.stub(Lightning.MockConfig, :apollo, fn
+ :endpoint -> nil
+ :openai_api_key -> "openai_api_key"
+ end)
+
+ {:ok, view, _html} =
+ live(
+ conn,
+ ~p"/projects/#{project.id}/w/#{workflow.id}?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}"
+ )
+
+ render_async(view)
+ refute has_element?(view, "#aichat-#{job_1.id}")
+
+ assert render(view) =~
+ "AI Assistant has not been configured for your instance"
+
+ # when configured properly
+ Mox.stub(Lightning.MockConfig, :apollo, fn
+ :endpoint -> "http://localhost:4001"
+ :openai_api_key -> "openai_api_key"
+ end)
+
+ {:ok, view, _html} =
+ live(
+ conn,
+ ~p"/projects/#{project.id}/w/#{workflow.id}?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}"
+ )
+
+ render_async(view)
+ assert has_element?(view, "#aichat-#{job_1.id}")
+
+ refute render(view) =~
+ "AI Assistant has not been configured for your instance"
+ end
+
+ test "onboarding ui is displayed when no session exists for the project", %{
+ conn: conn,
+ project: project,
+ user: user,
+ workflow: %{jobs: [job_1, job_2 | _]} = workflow
+ } do
+ Mox.stub(Lightning.MockConfig, :apollo, fn
+ :endpoint -> "http://localhost:4001"
+ :openai_api_key -> "openai_api_key"
+ end)
+
+ # when no session exists
+ {:ok, view, _html} =
+ live(
+ conn,
+ ~p"/projects/#{project.id}/w/#{workflow.id}?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}"
+ )
+
+ render_async(view)
+
+ html = view |> element("#aichat-#{job_1.id}") |> render()
+ assert html =~ "Get started with the AI Assistant"
+ assert html =~ "Learn more about AI Assistant"
+ refute has_element?(view, "#ai-assistant-form")
+
+ # let's try clicking the get started button
+ view |> element("#get-started-with-ai-btn") |> render_click()
+ html = view |> element("#aichat-#{job_1.id}") |> render()
+ refute html =~ "Get started with the AI Assistant"
+ refute html =~ "Learn more about AI Assistant"
+ assert has_element?(view, "#ai-assistant-form")
+
+ # when a session exists
+ # notice I'm using another job for the session.
+ # This is because the onboarding is shown once per project and not per job
+ insert(:chat_session, user: user, job: job_2)
+
+ {:ok, view, _html} =
+ live(
+ conn,
+ ~p"/projects/#{project.id}/w/#{workflow.id}?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}"
+ )
+
+ render_async(view)
+
+ html = view |> element("#aichat-#{job_1.id}") |> render()
+ refute html =~ "Get started with the AI Assistant"
+ refute html =~ "Learn more about AI Assistant"
+
+ assert has_element?(view, "#ai-assistant-form")
+ end
+
+ test "authorized users can send a message", %{
+ conn: conn,
+ project: project,
+ user: user,
+ workflow: %{jobs: [job_1 | _]} = workflow
+ } do
+ apollo_endpoint = "http://localhost:4001"
+
+ Mox.stub(Lightning.MockConfig, :apollo, fn
+ :endpoint -> apollo_endpoint
+ :openai_api_key -> "openai_api_key"
+ end)
+
+ Mox.stub(
+ Lightning.Tesla.Mock,
+ :call,
+ fn
+ %{method: :get, url: ^apollo_endpoint <> "/"}, _opts ->
+ {:ok, %Tesla.Env{status: 200}}
+
+ %{method: :post}, _opts ->
+ {:ok,
+ %Tesla.Env{
+ status: 200,
+ body: %{
+ "history" => [%{"role" => "assistant", "content" => "Hello!"}]
+ }
+ }}
+ end
+ )
+
+ # insert session so that the onboarding flow is not displayed
+ insert(:chat_session, user: user, job: job_1)
+
+ for {conn, _user} <-
+ setup_project_users(conn, project, [:owner, :admin, :editor]) do
+ {:ok, view, _html} =
+ live(
+ conn,
+ ~p"/projects/#{project.id}/w/#{workflow.id}?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}"
+ )
+
+ render_async(view)
+
+ assert view
+ |> form("#ai-assistant-form")
+ |> has_element?()
+
+ input_element = element(view, "#ai-assistant-form textarea")
+ submit_btn = element(view, "#ai-assistant-form-submit-btn")
+
+ assert has_element?(input_element)
+ refute render(input_element) =~ "disabled=\"disabled\""
+ assert has_element?(submit_btn)
+ refute render(submit_btn) =~ "disabled=\"disabled\""
+
+ # try submitting a message
+ html =
+ view
+ |> form("#ai-assistant-form")
+ |> render_submit(%{content: "Hello"})
+
+ refute html =~ "You are not authorized to use the Ai Assistant"
+
+ assert_patch(view)
+ end
+
+ for {conn, _user} <- setup_project_users(conn, project, [:viewer]) do
+ {:ok, view, _html} =
+ live(
+ conn,
+ ~p"/projects/#{project.id}/w/#{workflow.id}?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}"
+ )
+
+ render_async(view)
+
+ assert view
+ |> form("#ai-assistant-form")
+ |> has_element?()
+
+ input_element = element(view, "#ai-assistant-form textarea")
+ submit_btn = element(view, "#ai-assistant-form-submit-btn")
+
+ assert has_element?(input_element)
+ assert render(input_element) =~ "disabled=\"disabled\""
+ assert has_element?(submit_btn)
+ assert render(submit_btn) =~ "disabled=\"disabled\""
+
+ # try submitting a message
+ html =
+ view
+ |> form("#ai-assistant-form")
+ |> render_submit(%{content: "Hello"})
+
+ assert html =~ "You are not authorized to use the Ai Assistant"
+ end
+ end
+
+ test "users can start a new session", %{
+ conn: conn,
+ project: project,
+ workflow: %{jobs: [job_1 | _]} = workflow,
+ test: test
+ } do
+ apollo_endpoint = "http://localhost:4001"
+
+ Mox.stub(Lightning.MockConfig, :apollo, fn
+ :endpoint -> apollo_endpoint
+ :openai_api_key -> "openai_api_key"
+ end)
+
+ Mox.stub(
+ Lightning.Tesla.Mock,
+ :call,
+ fn
+ %{method: :get, url: ^apollo_endpoint <> "/"}, _opts ->
+ {:ok, %Tesla.Env{status: 200}}
+
+ %{method: :post}, _opts ->
+ # delay the response to simulate a long running request
+ # I'm doing this to test the pending assistant resp message
+ test |> to_string() |> Lightning.subscribe()
+
+ receive do
+ :return_resp ->
+ {:ok,
+ %Tesla.Env{
+ status: 200,
+ body: %{
+ "history" => [
+ %{"role" => "user", "content" => "Ping"},
+ %{"role" => "assistant", "content" => "Pong"}
+ ]
+ }
+ }}
+ end
+ end
+ )
+
+ {:ok, view, _html} =
+ live(
+ conn,
+ ~p"/projects/#{project.id}/w/#{workflow.id}?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}"
+ )
+
+ render_async(view)
+
+ # click the get started button
+ view |> element("#get-started-with-ai-btn") |> render_click()
+
+ # submit a message
+ view
+ |> form("#ai-assistant-form")
+ |> render_submit(%{content: "Ping"})
+
+ assert_patch(view)
+
+ # pending message is shown
+ assert has_element?(view, "#assistant-pending-message")
+ refute render(view) =~ "Pong"
+
+ # return the response
+ test |> to_string() |> Lightning.broadcast(:return_resp)
+ html = render_async(view)
+
+ # pending message is not shown
+ refute has_element?(view, "#assistant-pending-message")
+ assert html =~ "Pong"
+ end
+
+ test "users can resume a session", %{
+ conn: conn,
+ project: project,
+ user: user,
+ workflow: %{jobs: [job_1 | _]} = workflow
+ } do
+ apollo_endpoint = "http://localhost:4001"
+
+ Mox.stub(Lightning.MockConfig, :apollo, fn
+ :endpoint -> apollo_endpoint
+ :openai_api_key -> "openai_api_key"
+ end)
+
+ expected_question = "Can you help me with this?"
+ expected_answer = "No, I am a robot"
+
+ Mox.stub(
+ Lightning.Tesla.Mock,
+ :call,
+ fn
+ %{method: :get, url: ^apollo_endpoint <> "/"}, _opts ->
+ {:ok, %Tesla.Env{status: 200}}
+
+ %{method: :post}, _opts ->
+ {:ok,
+ %Tesla.Env{
+ status: 200,
+ body: %{
+ "history" => [
+ %{"role" => "user", "content" => "Ping"},
+ %{"role" => "assistant", "content" => "Pong"},
+ %{"role" => "user", "content" => expected_question},
+ %{"role" => "assistant", "content" => expected_answer}
+ ]
+ }
+ }}
+ end
+ )
+
+ session =
+ insert(:chat_session,
+ user: user,
+ job: job_1,
+ messages: [
+ %{role: :user, content: "Ping", user: user},
+ %{role: :assistant, content: "Pong"}
+ ]
+ )
+
+ {:ok, view, _html} =
+ live(
+ conn,
+ ~p"/projects/#{project.id}/w/#{workflow.id}?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}"
+ )
+
+ html = render_async(view)
+
+ assert html =~ session.title
+
+ # click the link to open the session
+ view |> element("#session-#{session.id}") |> render_click()
+
+ assert_patch(view)
+
+ # submit a message
+ html =
+ view
+ |> form("#ai-assistant-form")
+ |> render_submit(%{content: expected_question})
+
+ # answer is not yet shown
+ refute html =~ expected_answer
+
+ html = render_async(view)
+
+ # answer is now displayed
+ assert html =~ expected_answer
+ end
+
+ test "an error is displayed incase the assistant does not return 200", %{
+ conn: conn,
+ project: project,
+ workflow: %{jobs: [job_1 | _]} = workflow
+ } do
+ apollo_endpoint = "http://localhost:4001"
+
+ Mox.stub(Lightning.MockConfig, :apollo, fn
+ :endpoint -> apollo_endpoint
+ :openai_api_key -> "openai_api_key"
+ end)
+
+ Mox.stub(
+ Lightning.Tesla.Mock,
+ :call,
+ fn
+ %{method: :get, url: ^apollo_endpoint <> "/"}, _opts ->
+ {:ok, %Tesla.Env{status: 200}}
+
+ %{method: :post}, _opts ->
+ {:ok, %Tesla.Env{status: 400}}
+ end
+ )
+
+ {:ok, view, _html} =
+ live(
+ conn,
+ ~p"/projects/#{project.id}/w/#{workflow.id}?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}"
+ )
+
+ render_async(view)
+
+ # click the get started button
+ view |> element("#get-started-with-ai-btn") |> render_click()
+
+ # submit a message
+ view
+ |> form("#ai-assistant-form")
+ |> render_submit(%{content: "Ping"})
+
+ assert_patch(view)
+
+ render_async(view)
+
+ # pending message is not shown
+ assert has_element?(view, "#assistant-failed-message")
+
+ assert view |> element("#assistant-failed-message") |> render() =~
+ "Oops! Could not reach the Ai Server. Please try again later."
+ end
+
+ test "an error is displayed incase the assistant query process crashes", %{
+ conn: conn,
+ project: project,
+ workflow: %{jobs: [job_1 | _]} = workflow
+ } do
+ apollo_endpoint = "http://localhost:4001"
+
+ Mox.stub(Lightning.MockConfig, :apollo, fn
+ :endpoint -> apollo_endpoint
+ :openai_api_key -> "openai_api_key"
+ end)
+
+ Mox.stub(
+ Lightning.Tesla.Mock,
+ :call,
+ fn
+ %{method: :get, url: ^apollo_endpoint <> "/"}, _opts ->
+ {:ok, %Tesla.Env{status: 200}}
+
+ %{method: :post}, _opts ->
+ raise "oops"
+ end
+ )
+
+ {:ok, view, _html} =
+ live(
+ conn,
+ ~p"/projects/#{project.id}/w/#{workflow.id}?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}"
+ )
+
+ render_async(view)
+
+ # click the get started button
+ view |> element("#get-started-with-ai-btn") |> render_click()
+
+ # submit a message
+ view
+ |> form("#ai-assistant-form")
+ |> render_submit(%{content: "Ping"})
+
+ assert_patch(view)
+
+ render_async(view)
+
+ # pending message is not shown
+ assert has_element?(view, "#assistant-failed-message")
+
+ assert view |> element("#assistant-failed-message") |> render() =~
+ "Oops! Something went wrong. Please try again."
+ end
+
+ test "shows a flash error when limit has reached", %{
+ conn: conn,
+ project: %{id: project_id} = project,
+ workflow: %{jobs: [job_1 | _]} = workflow
+ } do
+ [{conn, _user}] = setup_project_users(conn, project, [:owner])
+
+ Mox.stub(Lightning.MockConfig, :apollo, fn
+ :endpoint -> "http://localhost:4001/health_check"
+ :openai_api_key -> "openai_api_key"
+ end)
+
+ error_message = "You have reached your quota of AI queries"
+
+ Mox.stub(Lightning.Extensions.MockUsageLimiter, :limit_action, fn %{
+ type:
+ :ai_query
+ },
+ %{
+ project_id:
+ ^project_id
+ } ->
+ {:error, :too_many_queries,
+ %Lightning.Extensions.Message{text: error_message}}
+ end)
+
+ {:ok, view, _html} =
+ live(
+ conn,
+ ~p"/projects/#{project.id}/w/#{workflow.id}?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}"
+ )
+
+ render_async(view)
+
+ # click the get started button
+ view |> element("#get-started-with-ai-btn") |> render_click()
+
+ assert has_element?(view, "#ai-assistant-error", error_message)
+ assert render(view) =~ "aria-label=\"#{error_message}\""
+
+ # submiting a message shows the flash
+ view
+ |> form("#ai-assistant-form")
+ |> render_submit(%{content: "Ping"})
+
+ assert has_element?(view, "#ai-assistant-error", error_message)
+ end
+ end
end
diff --git a/test/support/factories.ex b/test/support/factories.ex
index 7bedd6594d..2fa524a33f 100644
--- a/test/support/factories.ex
+++ b/test/support/factories.ex
@@ -301,6 +301,23 @@ defmodule Lightning.Factories do
}
end
+ def chat_session_factory do
+ %Lightning.AiAssistant.ChatSession{
+ id: fn -> Ecto.UUID.generate() end,
+ expression: "fn()",
+ adaptor: "@openfn/language-common@latest",
+ title: "Some session title",
+ messages: []
+ }
+ end
+
+ def chat_message_factory do
+ %Lightning.AiAssistant.ChatMessage{
+ content: "Hello, world!",
+ role: :user
+ }
+ end
+
# ----------------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------------
diff --git a/test/support/stub_usage_limiter.ex b/test/support/stub_usage_limiter.ex
index 484b465db2..9d62d36fb9 100644
--- a/test/support/stub_usage_limiter.ex
+++ b/test/support/stub_usage_limiter.ex
@@ -32,6 +32,9 @@ defmodule Lightning.Extensions.StubUsageLimiter do
{:error, :too_many_runs, %Message{text: "Runs limit exceeded"}}
end
+ @impl true
+ def increment_ai_queries(multi), do: multi
+
@impl true
def get_run_options(_context),
do: [run_timeout_ms: Config.default_max_run_duration()]