Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/on_push_main_publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@ jobs:

steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
with:
version: v0.12.0

# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
- name: Log in to the Container registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
Expand All @@ -47,7 +47,7 @@ jobs:
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
Expand All @@ -64,7 +64,7 @@ jobs:
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
push: true
provenance: false
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,5 @@ docker-compose*.override.yml
ca

# devcontainer files:
.devcontainer
.devcontainer
.mcp.json
44 changes: 43 additions & 1 deletion assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,50 @@ Hooks.RemoveMissingBrainstorming = {
}
};

Hooks.LanesScrollIndicator = {
mounted() {
this.scrollContainer = this.el.querySelector('.lanes-container');
this.leftArrow = this.el.querySelector('#lanes-scroll-left');
this.rightArrow = this.el.querySelector('#lanes-scroll-right');

this.getColumnWidth = () => {
const col = this.scrollContainer.querySelector('[class*="col-"]');
return col ? col.offsetWidth : 300;
};

this.updateIndicators = () => {
const { scrollLeft, scrollWidth, clientWidth } = this.scrollContainer;
const canScrollLeft = scrollLeft > 0;
const canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;

this.leftArrow.classList.toggle('visible', canScrollLeft);
this.rightArrow.classList.toggle('visible', canScrollRight);
};

this.leftArrow.addEventListener('click', () => {
this.scrollContainer.scrollBy({ left: -this.getColumnWidth(), behavior: 'smooth' });
});

this.rightArrow.addEventListener('click', () => {
this.scrollContainer.scrollBy({ left: this.getColumnWidth(), behavior: 'smooth' });
});

this.scrollContainer.addEventListener('scroll', this.updateIndicators);
this.resizeObserver = new ResizeObserver(this.updateIndicators);
this.resizeObserver.observe(this.scrollContainer);

this.updateIndicators();
},
updated() {
this.updateIndicators();
},
destroyed() {
this.resizeObserver.disconnect();
}
};

// The brainstorming secret from the url ("#123") is added as well to the socket. The secret is not available on the server side by default.
let liveSocket = new LiveSocket("/live", Socket, {
let liveSocket = new LiveSocket("/live", Socket, {
hooks: Hooks, params: { _csrf_token: csrfToken, adminSecret: window.location.hash.substring(1) }
})

Expand Down
14 changes: 14 additions & 0 deletions assets/scss/_bootstrap_custom.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@
@import "kits/list";
@import "kits/utilities";

.card-img-top-mindwendel {
overflow: hidden;
max-height: 150px;
border-bottom: 1px solid rgba(0, 0, 0, 0.125);

&__img {
width: 100%;
height: 150px;
object-fit: cover;
display: block;
border-radius: calc(0.25rem - 1px) calc(0.25rem - 1px) 0 0;
}
}

.card-body-mindwendel-idea {
@extend .card-body;
padding: 0.5rem 0.5rem;
Expand Down
74 changes: 68 additions & 6 deletions assets/scss/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,72 @@ div[data-phx-session] {
height: 100%;
}

.form-control-color {
max-width: 50px;
}

.heading-error {
font-size: 8rem;
.lanes-container-wrapper {
position: relative;

.lanes-container {
overflow-x: auto;
scrollbar-color: $gray-500 $gray-200;

&::-webkit-scrollbar {
height: 8px;
}

&::-webkit-scrollbar-track {
background: $gray-200;
border-radius: 4px;
}

&::-webkit-scrollbar-thumb {
background-color: $gray-500;
border-radius: 4px;

&:hover {
background-color: $gray-600;
}
}

> [class*="col-"] {
flex: 0 0 auto;
min-width: 300px;
}
}
}

.lanes-scroll-indicator {
position: absolute;
top: 0;
bottom: 0;
width: 40px;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 1;

&.visible {
opacity: 1;
pointer-events: auto;
cursor: pointer;
}

&--right {
right: 0;
}

&--left {
left: 0;
}

.scroll-arrow {
position: sticky;
top: 50vh;
display: block;
font-size: 1.5rem;
color: $gray-600;
text-align: center;
}
}

.text-pre-line {
white-space: pre-line;
}
11 changes: 0 additions & 11 deletions docker-compose.dev.yml

This file was deleted.

15 changes: 15 additions & 0 deletions lib/mindwendel/ai/config.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
defmodule Mindwendel.AI.Config do
@callback fetch_ai_config!() :: keyword()

def fetch_ai_config! do
impl().fetch_ai_config!()
end

defp impl do
Application.get_env(
:mindwendel,
:ai_config_service,
Mindwendel.AI.Config.DefaultImpl
)
end
end
8 changes: 8 additions & 0 deletions lib/mindwendel/ai/config/default_impl.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
defmodule Mindwendel.AI.Config.DefaultImpl do
@behaviour Mindwendel.AI.Config

@impl Mindwendel.AI.Config
def fetch_ai_config! do
Application.fetch_env!(:mindwendel, :ai)
end
end
3 changes: 2 additions & 1 deletion lib/mindwendel/ai/token_tracking_service.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ defmodule Mindwendel.AI.TokenTrackingService do
"""

import Ecto.Query
alias Mindwendel.AI.Config
alias Mindwendel.AI.TokenUsage
alias Mindwendel.Repo

Expand Down Expand Up @@ -196,6 +197,6 @@ defmodule Mindwendel.AI.TokenTrackingService do
end

defp fetch_ai_config! do
Application.fetch_env!(:mindwendel, :ai)
Config.fetch_ai_config!()
end
end
33 changes: 18 additions & 15 deletions lib/mindwendel/brainstormings/idea.ex
Original file line number Diff line number Diff line change
Expand Up @@ -71,28 +71,31 @@ defmodule Mindwendel.Brainstormings.Idea do
end

defp strip_html(text) when is_binary(text) do
# Strip all HTML tags by parsing with Floki and extracting text content
# This removes all tags, event handlers, and JavaScript
case Floki.parse_document(text) do
{:ok, parsed} ->
parsed
|> Floki.text(sep: " ")
|> normalize_whitespace()

{:error, _} ->
# If parsing fails, fall back to basic regex stripping
text
|> String.replace(~r/<[^>]*>/, "")
|> normalize_whitespace()
end
# Convert literal newlines to <br/> so Floki natively preserves them as \n
text
|> String.replace("\n", "<br/>")
|> then(fn html ->
case Floki.parse_document(html) do
{:ok, parsed} ->
Floki.text(parsed, sep: " ")

{:error, _} ->
String.replace(html, ~r/<[^>]*>/, "")
end
end)
|> normalize_whitespace()
end

defp strip_html(text), do: text

defp normalize_whitespace(text) do
text
# Trim horizontal whitespace around newlines, then collapse remaining
|> String.replace(~r/[^\S\n]*\n[^\S\n]*/, "\n")
|> String.replace(~r/[^\S\n]+/, " ")
# Cap consecutive newlines at 2 (one blank line between paragraphs)
|> String.replace(~r/\n{3,}/, "\n\n")
|> String.trim()
|> String.replace(~r/\s+/, " ")
end

defp maybe_put_idea_labels(changeset, attrs) do
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
defmodule Mindwendel.Services.ChatCompletions.ChatCompletionsServiceImpl do
require Logger

alias Mindwendel.AI.Config
alias Mindwendel.AI.Schemas.IdeaLabelAssignment
alias Mindwendel.AI.Schemas.IdeaResponse
alias Mindwendel.AI.TokenTrackingService
Expand Down Expand Up @@ -543,6 +544,6 @@ defmodule Mindwendel.Services.ChatCompletions.ChatCompletionsServiceImpl do
end

defp fetch_ai_config! do
Application.fetch_env!(:mindwendel, :ai)
Config.fetch_ai_config!()
end
end
70 changes: 37 additions & 33 deletions lib/mindwendel/services/idea_clustering_service.ex
Original file line number Diff line number Diff line change
Expand Up @@ -55,39 +55,43 @@ defmodule Mindwendel.Services.IdeaClusteringService do
{:ok, :skipped}

true ->
locale = Gettext.get_locale(MindwendelWeb.Gettext)
label_payload = build_label_payload(brainstorming.labels)
idea_payload = build_idea_payload(ideas)

with {:ok, raw_assignments} <-
ChatCompletionsService.classify_labels(
brainstorming.name,
label_payload,
idea_payload,
locale
),
{:ok, assignments} <- normalize_assignments(raw_assignments) do
Logger.debug(fn ->
truncated =
raw_assignments
|> inspect(limit: 15, printable_limit: 300, width: 80)

"AI clustering raw assignments: #{truncated}"
end)

handle_assignments(brainstorming, ideas, assignments)
else
{:error, %{} = validation_errors} ->
Logger.error(
"AI clustering returned invalid assignments for #{brainstorming.id}: #{inspect(validation_errors)}"
)

{:error, {:invalid_assignments, validation_errors}}

{:error, reason} ->
Logger.error("AI clustering failed for #{brainstorming.id}: #{inspect(reason)}")
{:error, reason}
end
classify_and_assign(brainstorming, ideas)
end
end

defp classify_and_assign(brainstorming, ideas) do
locale = Gettext.get_locale(MindwendelWeb.Gettext)
label_payload = build_label_payload(brainstorming.labels)
idea_payload = build_idea_payload(ideas)

with {:ok, raw_assignments} <-
ChatCompletionsService.classify_labels(
brainstorming.name,
label_payload,
idea_payload,
locale
),
{:ok, assignments} <- normalize_assignments(raw_assignments) do
Logger.debug(fn ->
truncated =
raw_assignments
|> inspect(limit: 15, printable_limit: 300, width: 80)

"AI clustering raw assignments: #{truncated}"
end)

handle_assignments(brainstorming, ideas, assignments)
else
{:error, %{} = validation_errors} ->
Logger.error(
"AI clustering returned invalid assignments for #{brainstorming.id}: #{inspect(validation_errors)}"
)

{:error, {:invalid_assignments, validation_errors}}

{:error, reason} ->
Logger.error("AI clustering failed for #{brainstorming.id}: #{inspect(reason)}")
{:error, reason}
end
end

Expand Down
Loading