From b11f16c39b85e49199152b14cda9dde219cf3e76 Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Thu, 7 May 2026 22:32:45 +0200 Subject: [PATCH 01/26] first iteration --- .../agents/graph/nodes/assistant_node.py | 2 +- .../agents/graph/nodes/reviewer_node.py | 4 + src/AI/agents/api/routes/agent.py | 48 +++++++- src/AI/agents/shared/utils/langfuse_utils.py | 16 ++- src/AI/agents/tests/api/test_feedback.py | 81 +++++++++++++ .../Designer/Controllers/ChatController.cs | 28 ++++- .../Models/Dto/ChatFeedbackRequest.cs | 5 + src/Designer/backend/src/Designer/Program.cs | 1 + .../Altinity/AltinityAgentClient.cs | 93 ++++++++++++++ .../Altinity/IAltinityAgentClient.cs | 24 ++++ .../ChatController/SubmitFeedbackTests.cs | 97 +++++++++++++++ .../features/aiAssistant/AiAssistant.tsx | 21 +++- .../useAltinityAssistant.test.ts | 3 +- .../useAltinityAssistant.ts | 21 +++- .../useAltinityThreads.test.ts | 32 +++-- .../useAltinityThreads/useAltinityThreads.ts | 12 +- .../useAltinityWebSocket.ts | 1 + .../useAltinityWorkflow.ts | 27 +++-- src/Designer/frontend/language/src/nb.json | 6 + .../src/Assistant/Assistant.tsx | 4 + .../src/components/ChatColumn/ChatColumn.tsx | 5 + .../MessageFeedback.module.css | 25 ++++ .../MessageFeedback/MessageFeedback.test.tsx | 108 +++++++++++++++++ .../MessageFeedback/MessageFeedback.tsx | 113 ++++++++++++++++++ .../Messages/MessageFeedback/index.ts | 2 + .../ChatColumn/Messages/Messages.tsx | 34 +++++- .../CompleteInterface/CompleteInterface.tsx | 2 + .../libs/studio-assistant/src/index.ts | 11 +- .../studio-assistant/src/mocks/mockTexts.ts | 11 ++ .../src/types/AssistantConfig.ts | 1 + .../src/types/AssistantTexts.ts | 10 ++ .../studio-assistant/src/types/ChatThread.ts | 1 + .../packages/shared/src/api/mutations.ts | 6 +- .../frontend/packages/shared/src/api/paths.js | 1 + .../mutations/useChatFeedbackMutation.test.ts | 26 ++++ .../mutations/useChatFeedbackMutation.ts | 12 ++ .../packages/shared/src/mocks/queriesMock.ts | 1 + .../src/types/api/ChatFeedbackPayload.ts | 5 + .../packages/shared/src/types/api/index.ts | 1 + 39 files changed, 861 insertions(+), 40 deletions(-) create mode 100644 src/AI/agents/tests/api/test_feedback.py create mode 100644 src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs create mode 100644 src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs create mode 100644 src/Designer/backend/src/Designer/Services/Interfaces/Altinity/IAltinityAgentClient.cs create mode 100644 src/Designer/backend/tests/Designer.Tests/Controllers/ChatController/SubmitFeedbackTests.cs create mode 100644 src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.module.css create mode 100644 src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx create mode 100644 src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx create mode 100644 src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/index.ts create mode 100644 src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.test.ts create mode 100644 src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.ts create mode 100644 src/Designer/frontend/packages/shared/src/types/api/ChatFeedbackPayload.ts diff --git a/src/AI/agents/agents/graph/nodes/assistant_node.py b/src/AI/agents/agents/graph/nodes/assistant_node.py index 4ee67ec5ffd..1a431cd3569 100644 --- a/src/AI/agents/agents/graph/nodes/assistant_node.py +++ b/src/AI/agents/agents/graph/nodes/assistant_node.py @@ -151,7 +151,7 @@ def _check_cancelled(): "tools_used": list(tool_results.keys()), "sources": cited_sources, # Only sources that were actually cited "mode": "chat", - "trace_id": main_span.trace_id, + "traceId": main_span.trace_id, } # Add this Q&A to conversation history for future context diff --git a/src/AI/agents/agents/graph/nodes/reviewer_node.py b/src/AI/agents/agents/graph/nodes/reviewer_node.py index 22f6ee66f5a..834b75ad6e9 100644 --- a/src/AI/agents/agents/graph/nodes/reviewer_node.py +++ b/src/AI/agents/agents/graph/nodes/reviewer_node.py @@ -7,6 +7,7 @@ from agents.workflows.reviewer.pipeline import ( execute_reviewer_workflow, ) +from shared.utils.langfuse_utils import get_current_trace_id from shared.utils.logging_utils import get_logger log = get_logger(__name__) @@ -193,6 +194,7 @@ async def handle(state: AgentState) -> AgentState: "author": "assistant", "content": summary, "filesChanged": changed_files, + "traceId": get_current_trace_id(), }, ) ) @@ -265,6 +267,7 @@ async def handle(state: AgentState) -> AgentState: "author": "assistant", "content": summary, "filesChanged": changed_files, + "traceId": get_current_trace_id(), }, ) ) @@ -300,6 +303,7 @@ async def handle(state: AgentState) -> AgentState: "author": "assistant", "content": f"## Error\n\nAn error occurred during processing: {str(e)}", "filesChanged": [], + "traceId": get_current_trace_id(), }, ) ) diff --git a/src/AI/agents/api/routes/agent.py b/src/AI/agents/api/routes/agent.py index 5e4cb9d325f..1ad236a32c5 100644 --- a/src/AI/agents/api/routes/agent.py +++ b/src/AI/agents/api/routes/agent.py @@ -1,6 +1,6 @@ """Agent workflow API routes""" import re -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel, Field, field_validator from agents.graph.state import AgentState from agents.graph.runner import run_in_background @@ -10,6 +10,7 @@ from agents.services.git.repo_manager import get_repo_manager from api.dependencies import get_designer_api_key from shared.config import get_config +from shared.utils.langfuse_utils import score_validation from shared.utils.logging_utils import get_logger from pathlib import Path from typing import Optional, List @@ -338,4 +339,47 @@ async def get_session_status(session_id: str): status = sink.get_session_status(session_id) if status is None: return {"session_id": session_id, "status": "unknown"} - return {"session_id": session_id, **status} \ No newline at end of file + return {"session_id": session_id, **status} + + +_FEEDBACK_SCORE_NAME = "user_feedback" +_FEEDBACK_COMMENT_MAX_LENGTH = 4000 + + +class FeedbackReq(BaseModel): + """User feedback (thumbs up/down) on an assistant message, recorded as a Langfuse score.""" + + trace_id: str + thumbs_up: bool + comment: Optional[str] = None + + @field_validator("trace_id") + @classmethod + def _validate_trace_id(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("trace_id must be non-empty") + return v + + @field_validator("comment") + @classmethod + def _validate_comment(cls, v: Optional[str]) -> Optional[str]: + if v is not None and len(v) > _FEEDBACK_COMMENT_MAX_LENGTH: + raise ValueError( + f"comment must not exceed {_FEEDBACK_COMMENT_MAX_LENGTH} characters" + ) + return v + + +@router.post("/api/agent/feedback", status_code=204) +async def submit_feedback( + req: FeedbackReq, + designer_api_key: str = Depends(get_designer_api_key), +): + """Record a thumbs-up/thumbs-down user feedback as a Langfuse score on the given trace.""" + score_validation( + name=_FEEDBACK_SCORE_NAME, + passed=req.thumbs_up, + trace_id=req.trace_id, + comment=req.comment, + ) + return Response(status_code=204) \ No newline at end of file diff --git a/src/AI/agents/shared/utils/langfuse_utils.py b/src/AI/agents/shared/utils/langfuse_utils.py index ae802c2c544..2102ac157a8 100644 --- a/src/AI/agents/shared/utils/langfuse_utils.py +++ b/src/AI/agents/shared/utils/langfuse_utils.py @@ -225,13 +225,19 @@ def __exit__(self, *args): pass -def _has_active_trace() -> bool: - """Return True when a Langfuse trace context is currently active.""" +def get_current_trace_id() -> str | None: + """Return the active Langfuse trace ID, or None if no trace context is active.""" + if not is_langfuse_enabled(): + return None try: - trace_id = get_client().get_current_trace_id() - return trace_id is not None + return get_client().get_current_trace_id() except Exception: - return False + return None + + +def _has_active_trace() -> bool: + """Return True when a Langfuse trace context is currently active.""" + return get_current_trace_id() is not None @contextmanager diff --git a/src/AI/agents/tests/api/test_feedback.py b/src/AI/agents/tests/api/test_feedback.py new file mode 100644 index 00000000000..2a7f4e79523 --- /dev/null +++ b/src/AI/agents/tests/api/test_feedback.py @@ -0,0 +1,81 @@ +from unittest.mock import patch +from fastapi.testclient import TestClient +from api.main import app + +FEEDBACK_PATH = "/api/agent/feedback" +VALID_API_KEY_HEADER = {"X-Api-Key": "test-key"} +VALID_TRACE_ID = "trace-abc-123" + + +class TestFeedbackEndpoint: + def test_thumbs_up_writes_score_and_returns_204(self): + with patch("api.routes.agent.score_validation") as mock_score: + response = TestClient(app).post( + FEEDBACK_PATH, + json={"trace_id": VALID_TRACE_ID, "thumbs_up": True}, + headers=VALID_API_KEY_HEADER, + ) + + assert response.status_code == 204 + mock_score.assert_called_once_with( + name="user_feedback", + passed=True, + trace_id=VALID_TRACE_ID, + comment=None, + ) + + def test_thumbs_down_with_comment_is_forwarded(self): + with patch("api.routes.agent.score_validation") as mock_score: + response = TestClient(app).post( + FEEDBACK_PATH, + json={ + "trace_id": VALID_TRACE_ID, + "thumbs_up": False, + "comment": "Svaret var ikke nyttig.", + }, + headers=VALID_API_KEY_HEADER, + ) + + assert response.status_code == 204 + mock_score.assert_called_once_with( + name="user_feedback", + passed=False, + trace_id=VALID_TRACE_ID, + comment="Svaret var ikke nyttig.", + ) + + def test_missing_api_key_is_rejected(self): + with patch("api.routes.agent.score_validation") as mock_score: + response = TestClient(app).post( + FEEDBACK_PATH, + json={"trace_id": VALID_TRACE_ID, "thumbs_up": True}, + ) + + assert response.status_code == 403 + mock_score.assert_not_called() + + def test_empty_trace_id_returns_422(self): + with patch("api.routes.agent.score_validation") as mock_score: + response = TestClient(app).post( + FEEDBACK_PATH, + json={"trace_id": "", "thumbs_up": True}, + headers=VALID_API_KEY_HEADER, + ) + + assert response.status_code == 422 + mock_score.assert_not_called() + + def test_comment_over_4000_chars_returns_422(self): + with patch("api.routes.agent.score_validation") as mock_score: + response = TestClient(app).post( + FEEDBACK_PATH, + json={ + "trace_id": VALID_TRACE_ID, + "thumbs_up": True, + "comment": "x" * 4001, + }, + headers=VALID_API_KEY_HEADER, + ) + + assert response.status_code == 422 + mock_score.assert_not_called() diff --git a/src/Designer/backend/src/Designer/Controllers/ChatController.cs b/src/Designer/backend/src/Designer/Controllers/ChatController.cs index 4b7d4480ba1..79d53435660 100644 --- a/src/Designer/backend/src/Designer/Controllers/ChatController.cs +++ b/src/Designer/backend/src/Designer/Controllers/ChatController.cs @@ -7,6 +7,7 @@ using Altinn.Studio.Designer.Models.Dto; using Altinn.Studio.Designer.Repository.Models; using Altinn.Studio.Designer.Services.Interfaces; +using Altinn.Studio.Designer.Services.Interfaces.Altinity; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -16,7 +17,7 @@ namespace Altinn.Studio.Designer.Controllers; [Authorize] [AutoValidateAntiforgeryToken] [Route("designer/api/{org}/{app:regex(^(?!datamodels$)[[a-z]][[a-z0-9-]]{{1,28}}[[a-z0-9]]$)}/chat")] -public class ChatController(IChatService chatService) : ControllerBase +public class ChatController(IChatService chatService, IAltinityAgentClient altinityAgentClient) : ControllerBase { [HttpGet("threads")] public async Task>> GetThreads( @@ -134,6 +135,31 @@ CancellationToken cancellationToken return NoContent(); } + [HttpPost("feedback")] + [RequestSizeLimit(20_000)] + public async Task SubmitFeedback( + string org, + string app, + [FromBody] ChatFeedbackRequest request, + CancellationToken cancellationToken + ) + { + if (string.IsNullOrWhiteSpace(request.TraceId)) + { + return BadRequest("traceId is required"); + } + + string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); + await altinityAgentClient.SendFeedbackAsync( + developer, + request.TraceId, + request.ThumbsUp, + request.Comment, + cancellationToken + ); + return NoContent(); + } + private AltinnRepoEditingContext GetEditingContext(string org, string app) { string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); diff --git a/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs b/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs new file mode 100644 index 00000000000..71da4b1cfe0 --- /dev/null +++ b/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs @@ -0,0 +1,5 @@ +using System.ComponentModel.DataAnnotations; + +namespace Altinn.Studio.Designer.Models.Dto; + +public record ChatFeedbackRequest([MaxLength(200)] string TraceId, bool ThumbsUp, [MaxLength(4000)] string? Comment); diff --git a/src/Designer/backend/src/Designer/Program.cs b/src/Designer/backend/src/Designer/Program.cs index 8de761294bf..bd0aef38514 100644 --- a/src/Designer/backend/src/Designer/Program.cs +++ b/src/Designer/backend/src/Designer/Program.cs @@ -162,6 +162,7 @@ void ConfigureServices(IServiceCollection services, IConfiguration configuration services.Configure(configuration.GetSection("MaskinportenClientSettings")); services.Configure(configuration.GetSection("AltinitySettings")); services.AddSingleton(); + services.AddHttpClient(); var maskinPortenClientName = "MaskinportenClient"; services.RegisterMaskinportenClientDefinition( maskinPortenClientName, diff --git a/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs b/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs new file mode 100644 index 00000000000..cdaa23f2bd1 --- /dev/null +++ b/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs @@ -0,0 +1,93 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Configuration; +using Altinn.Studio.Designer.Models.ApiKey; +using Altinn.Studio.Designer.Services.Interfaces; +using Altinn.Studio.Designer.Services.Interfaces.Altinity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Altinn.Studio.Designer.Services.Implementation.Altinity; + +public class AltinityAgentClient : IAltinityAgentClient +{ + private const string FeedbackPath = "/api/agent/feedback"; + private const string ApiKeyHeader = "X-Api-Key"; + private const string DeveloperHeader = "X-Developer"; + private const string ApiKeyNamePrefix = "altinity-feedback-"; + private static readonly TimeSpan ApiKeyLifetime = TimeSpan.FromMinutes(2); + + private readonly HttpClient _httpClient; + private readonly AltinitySettings _altinitySettings; + private readonly IApiKeyService _apiKeyService; + private readonly ILogger _logger; + + public AltinityAgentClient( + HttpClient httpClient, + IOptions altinitySettings, + IApiKeyService apiKeyService, + ILogger logger + ) + { + _httpClient = httpClient; + _altinitySettings = altinitySettings.Value; + _apiKeyService = apiKeyService; + _logger = logger; + } + + public async Task SendFeedbackAsync( + string developer, + string traceId, + bool thumbsUp, + string? comment, + CancellationToken cancellationToken = default + ) + { + string apiKey = await CreateShortLivedApiKeyAsync(developer, cancellationToken); + + var requestUri = new Uri($"{_altinitySettings.AgentUrl}{FeedbackPath}"); + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri) + { + Content = JsonContent.Create( + new + { + trace_id = traceId, + thumbs_up = thumbsUp, + comment, + } + ), + }; + httpRequest.Headers.Add(ApiKeyHeader, apiKey); + httpRequest.Headers.Add(DeveloperHeader, developer); + + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(_altinitySettings.TimeoutSeconds)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + + using var response = await _httpClient.SendAsync(httpRequest, linkedCts.Token); + if (!response.IsSuccessStatusCode) + { + string responseContent = await response.Content.ReadAsStringAsync(linkedCts.Token); + _logger.LogError("Altinity feedback returned {StatusCode}: {Body}", response.StatusCode, responseContent); + throw new HttpRequestException($"Altinity feedback returned {response.StatusCode}: {responseContent}"); + } + } + + private async Task CreateShortLivedApiKeyAsync(string developer, CancellationToken cancellationToken) + { + string apiKeyName = $"{ApiKeyNamePrefix}{Guid.NewGuid()}"; + DateTimeOffset expiresAt = DateTimeOffset.UtcNow.Add(ApiKeyLifetime); + + var (rawKey, _) = await _apiKeyService.CreateAsync( + developer, + apiKeyName, + ApiKeyType.System, + expiresAt, + cancellationToken: cancellationToken + ); + + return rawKey; + } +} diff --git a/src/Designer/backend/src/Designer/Services/Interfaces/Altinity/IAltinityAgentClient.cs b/src/Designer/backend/src/Designer/Services/Interfaces/Altinity/IAltinityAgentClient.cs new file mode 100644 index 00000000000..a19eef56446 --- /dev/null +++ b/src/Designer/backend/src/Designer/Services/Interfaces/Altinity/IAltinityAgentClient.cs @@ -0,0 +1,24 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Altinn.Studio.Designer.Services.Interfaces.Altinity; + +/// +/// HTTP client for forwarding request/response actions from the Designer backend to the +/// Altinity agents service. Streaming events use the persistent WebSocket +/// (); one-shot requests like user feedback go here. +/// +public interface IAltinityAgentClient +{ + /// + /// Records a user thumbs-up/thumbs-down on an assistant message as a Langfuse score + /// against the given trace, with an optional free-text comment. + /// + Task SendFeedbackAsync( + string developer, + string traceId, + bool thumbsUp, + string? comment, + CancellationToken cancellationToken = default + ); +} diff --git a/src/Designer/backend/tests/Designer.Tests/Controllers/ChatController/SubmitFeedbackTests.cs b/src/Designer/backend/tests/Designer.Tests/Controllers/ChatController/SubmitFeedbackTests.cs new file mode 100644 index 00000000000..619ef6c3fa8 --- /dev/null +++ b/src/Designer/backend/tests/Designer.Tests/Controllers/ChatController/SubmitFeedbackTests.cs @@ -0,0 +1,97 @@ +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Models.Dto; +using Altinn.Studio.Designer.Services.Interfaces.Altinity; +using Designer.Tests.Fixtures; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +namespace Designer.Tests.Controllers.ChatController; + +public class SubmitFeedbackTests : ChatControllerTestsBase +{ + private static string FeedbackUrl => $"designer/api/{Org}/{App}/chat/feedback"; + + private readonly Mock _altinityAgentClientMock = new(); + + public SubmitFeedbackTests(WebApplicationFactory factory, DesignerDbFixture designerDbFixture) + : base(factory, designerDbFixture) { } + + protected override void ConfigureTestServices(IServiceCollection services) + { + base.ConfigureTestServices(services); + services.AddSingleton(_altinityAgentClientMock.Object); + } + + [Fact] + public async Task SubmitFeedback_WithValidThumbsUp_ForwardsToAgentAndReturnsNoContent() + { + var request = new ChatFeedbackRequest("trace-abc-123", true, null); + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, FeedbackUrl) + { + Content = CreateJsonContent(request), + }; + + using var response = await HttpClient.SendAsync(httpRequest); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + _altinityAgentClientMock.Verify( + client => client.SendFeedbackAsync(Developer, "trace-abc-123", true, null, It.IsAny()), + Times.Once + ); + } + + [Fact] + public async Task SubmitFeedback_WithThumbsDownAndComment_ForwardsCommentToAgent() + { + var request = new ChatFeedbackRequest("trace-abc-123", false, "Svaret traff ikke helt."); + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, FeedbackUrl) + { + Content = CreateJsonContent(request), + }; + + using var response = await HttpClient.SendAsync(httpRequest); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + _altinityAgentClientMock.Verify( + client => + client.SendFeedbackAsync( + Developer, + "trace-abc-123", + false, + "Svaret traff ikke helt.", + It.IsAny() + ), + Times.Once + ); + } + + [Fact] + public async Task SubmitFeedback_WithEmptyTraceId_ReturnsBadRequest() + { + var request = new ChatFeedbackRequest(string.Empty, true, null); + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, FeedbackUrl) + { + Content = CreateJsonContent(request), + }; + + using var response = await HttpClient.SendAsync(httpRequest); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + _altinityAgentClientMock.Verify( + client => + client.SendFeedbackAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + } +} diff --git a/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx b/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx index c7582f8dbca..b000182ed99 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx +++ b/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; -import type { AssistantTexts } from '@studio/assistant'; +import { useCallback } from 'react'; +import type { AssistantTexts, FeedbackVote } from '@studio/assistant'; import { Assistant } from '@studio/assistant'; import { Trans, useTranslation } from 'react-i18next'; import { useAltinityAssistant, useAltinityPermissions } from './hooks'; @@ -7,12 +8,21 @@ import { Preview } from './components/Preview'; import { FileBrowser } from './components/FileBrowser'; import classes from './AiAssistant.module.css'; import { useUserQuery } from 'app-shared/hooks/queries'; +import { useChatFeedbackMutation } from 'app-shared/hooks/mutations/useChatFeedbackMutation'; import { StudioCenter, StudioAlert, StudioParagraph } from '@studio/components'; function AiAssistant(): ReactElement { const { t } = useTranslation(); const { data: currentUser } = useUserQuery(); const userHasAccessToAssistant = useAltinityPermissions(); + const { mutate: sendChatFeedback } = useChatFeedbackMutation(); + + const handleMessageFeedback = useCallback( + (traceId: string, vote: FeedbackVote, comment?: string) => { + sendChatFeedback({ traceId, thumbsUp: vote === 'up', comment }); + }, + [sendChatFeedback], + ); const { connectionStatus, @@ -65,6 +75,14 @@ function AiAssistant(): ReactElement { send: t('ai_assistant.send'), cancel: 'Avbryt', assistantFirstMessage: t('ai_assistant.assistant_first_message'), + feedback: { + thumbsUp: t('ai_assistant.feedback_thumbs_up'), + thumbsDown: t('ai_assistant.feedback_thumbs_down'), + thanksHeading: t('ai_assistant.feedback_thanks_heading'), + elaboratePrompt: t('ai_assistant.feedback_elaborate_prompt'), + commentPlaceholder: t('ai_assistant.feedback_comment_placeholder'), + submit: t('ai_assistant.feedback_submit'), + }, }; if (!userHasAccessToAssistant) { @@ -98,6 +116,7 @@ function AiAssistant(): ReactElement { previewContent={} fileBrowserContent={} currentUser={currentUser} + onMessageFeedback={handleMessageFeedback} /> ); diff --git a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.test.ts b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.test.ts index 1fdda752916..35647acfedc 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.test.ts +++ b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.test.ts @@ -30,6 +30,7 @@ describe('useAltinityAssistant', () => { cancelCurrentWorkflow: jest.fn(), cancelledMessageContent: null, clearCancelledMessageContent: jest.fn(), + traceIdsByMessageId: {}, }); const { result } = renderUseAltinityAssistant(); @@ -54,7 +55,7 @@ const createThreadState = (): AltinityThreadState => ({ createThread: jest.fn().mockResolvedValue('new-thread-id'), deleteThread: jest.fn(), deleteMessage: jest.fn(), - createMessage: jest.fn(), + createMessage: jest.fn().mockResolvedValue({ id: 'persisted-id' }), }); const renderUseAltinityAssistant = () => renderHook(() => useAltinityAssistant()); diff --git a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.ts b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.ts index caef99a4a50..9c45d9275c5 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.ts +++ b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.ts @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import type { ChatThread, Message, @@ -5,6 +6,7 @@ import type { WorkflowStatus, ConnectionStatus, } from '@studio/assistant'; +import { MessageAuthor } from '@studio/assistant'; import { useAltinityThreads } from '../useAltinityThreads/useAltinityThreads'; import { useAltinityWorkflow } from '../useAltinityWorkflow/useAltinityWorkflow'; @@ -33,13 +35,19 @@ export const useAltinityAssistant = (): UseAltinityAssistantResult => { cancelCurrentWorkflow, cancelledMessageContent, clearCancelledMessageContent, + traceIdsByMessageId, } = useAltinityWorkflow(threads); + const messages = useMemo( + () => decorateMessagesWithTraceIds(threads.chatMessages, traceIdsByMessageId), + [threads.chatMessages, traceIdsByMessageId], + ); + return { connectionStatus, workflowStatus, chatThreads: threads.chatThreads, - messages: threads.chatMessages, + messages, currentSessionId: threads.currentSessionId, onSubmitMessage, cancelCurrentWorkflow, @@ -50,3 +58,14 @@ export const useAltinityAssistant = (): UseAltinityAssistantResult => { deleteThread: threads.deleteThread, }; }; + +function decorateMessagesWithTraceIds( + messages: Message[], + traceIdsByMessageId: Record, +): Message[] { + return messages.map((message) => { + if (message.role !== MessageAuthor.Assistant || !message.id) return message; + const traceId = traceIdsByMessageId[message.id]; + return traceId ? { ...message, traceId } : message; + }); +} diff --git a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityThreads/useAltinityThreads.test.ts b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityThreads/useAltinityThreads.test.ts index 7282162cfda..4ec084180da 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityThreads/useAltinityThreads.test.ts +++ b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityThreads/useAltinityThreads.test.ts @@ -45,7 +45,9 @@ describe('useAltinityThreads', () => { } as any); mockUseDeleteChatThreadMutation.mockReturnValue({ mutate: jest.fn() } as any); mockUseChatMessagesQuery.mockReturnValue({ data: [], isLoading: false } as any); - mockUseCreateChatMessageMutation.mockReturnValue({ mutate: jest.fn() } as any); + mockUseCreateChatMessageMutation.mockReturnValue({ + mutateAsync: jest.fn().mockResolvedValue({ id: 'persisted-id' }), + } as any); mockUseDeleteChatMessageMutation.mockReturnValue({ mutate: jest.fn() } as any); }); @@ -92,9 +94,11 @@ describe('useAltinityThreads', () => { expect(deleteMessageMutate).toHaveBeenCalledWith({ threadId, messageId: 'message-1' }); }); - it('createMessage forwards user fields and omits assistant fields', () => { - const createMessageMutate = jest.fn(); - mockUseCreateChatMessageMutation.mockReturnValue({ mutate: createMessageMutate } as any); + it('createMessage forwards user fields and omits assistant fields', async () => { + const createMessageMutateAsync = jest.fn().mockResolvedValue({ id: 'persisted-id' }); + mockUseCreateChatMessageMutation.mockReturnValue({ + mutateAsync: createMessageMutateAsync, + } as any); const userMessage: UserMessage = { role: MessageAuthor.User, @@ -106,11 +110,11 @@ describe('useAltinityThreads', () => { const { result } = renderUseAltinityThreads(); - act(() => { - result.current.createMessage(threadId, userMessage); + await act(async () => { + await result.current.createMessage(threadId, userMessage); }); - expect(createMessageMutate).toHaveBeenCalledWith({ + expect(createMessageMutateAsync).toHaveBeenCalledWith({ threadId, payload: { role: MessageAuthor.User, @@ -123,9 +127,11 @@ describe('useAltinityThreads', () => { }); }); - it('createMessage forwards assistant fields and omits user fields', () => { - const createMessageMutate = jest.fn(); - mockUseCreateChatMessageMutation.mockReturnValue({ mutate: createMessageMutate } as any); + it('createMessage forwards assistant fields and omits user fields', async () => { + const createMessageMutateAsync = jest.fn().mockResolvedValue({ id: 'persisted-id' }); + mockUseCreateChatMessageMutation.mockReturnValue({ + mutateAsync: createMessageMutateAsync, + } as any); const assistantMessage: AssistantMessage = { role: MessageAuthor.Assistant, @@ -137,11 +143,11 @@ describe('useAltinityThreads', () => { const { result } = renderUseAltinityThreads(); - act(() => { - result.current.createMessage(threadId, assistantMessage); + await act(async () => { + await result.current.createMessage(threadId, assistantMessage); }); - expect(createMessageMutate).toHaveBeenCalledWith({ + expect(createMessageMutateAsync).toHaveBeenCalledWith({ threadId, payload: { role: MessageAuthor.Assistant, diff --git a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityThreads/useAltinityThreads.ts b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityThreads/useAltinityThreads.ts index 656777b6535..a8ec2f6eca6 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityThreads/useAltinityThreads.ts +++ b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityThreads/useAltinityThreads.ts @@ -2,6 +2,7 @@ import { useState, useRef, useCallback } from 'react'; import type { MutableRefObject } from 'react'; import type { ChatThread, UserMessage, AssistantMessage, Message } from '@studio/assistant'; import { MessageAuthor } from '@studio/assistant'; +import type { ChatMessage } from 'app-shared/types/api'; import { useChatThreadsQuery } from 'app-shared/hooks/queries/useChatThreadsQuery'; import { useCreateChatThreadMutation } from 'app-shared/hooks/mutations/useCreateChatThreadMutation'; import { useDeleteChatThreadMutation } from 'app-shared/hooks/mutations/useDeleteChatThreadMutation'; @@ -19,7 +20,10 @@ export interface AltinityThreadState { createThread: (title: string) => Promise; deleteThread: (threadId: string) => void; deleteMessage: (threadId: string, messageId: string) => void; - createMessage: (threadId: string, message: UserMessage | AssistantMessage) => void; + createMessage: ( + threadId: string, + message: UserMessage | AssistantMessage, + ) => Promise; } export const useAltinityThreads = (): AltinityThreadState => { @@ -31,7 +35,7 @@ export const useAltinityThreads = (): AltinityThreadState => { const { mutate: deleteChatThread } = useDeleteChatThreadMutation(); const { data: chatMessages } = useChatMessagesQuery(currentSessionId); - const { mutate: createChatMessage } = useCreateChatMessageMutation(); + const { mutateAsync: createChatMessage } = useCreateChatMessageMutation(); const { mutate: deleteChatMessage } = useDeleteChatMessageMutation(); const setCurrentSession = useCallback((sessionId: string | null) => { @@ -68,9 +72,9 @@ export const useAltinityThreads = (): AltinityThreadState => { ); const createMessage = useCallback( - (threadId: string, message: UserMessage | AssistantMessage) => { + (threadId: string, message: UserMessage | AssistantMessage): Promise => { const isUser = message.role === MessageAuthor.User; - createChatMessage({ + return createChatMessage({ threadId, payload: { role: message.role, diff --git a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWebSocket/useAltinityWebSocket.ts b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWebSocket/useAltinityWebSocket.ts index 90de8a150e1..43f7eea900c 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWebSocket/useAltinityWebSocket.ts +++ b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWebSocket/useAltinityWebSocket.ts @@ -118,6 +118,7 @@ function registerAgentMessageHandler( messageCallbackRef: React.MutableRefObject<((message: WorkflowEvent) => void) | null>, ): void { connection.on(AltinityClientsName.ReceiveAgentMessage, (message: WorkflowEvent) => { + console.log('[WS frame]', message.type, JSON.stringify(message.data)); if ( message.type === 'workflow_status' && message.data?.message?.toLowerCase() === 'session created' diff --git a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWorkflow/useAltinityWorkflow.ts b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWorkflow/useAltinityWorkflow.ts index 9e3e5b8a9c4..70c606d3278 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWorkflow/useAltinityWorkflow.ts +++ b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWorkflow/useAltinityWorkflow.ts @@ -36,11 +36,13 @@ export interface UseAltinityWorkflowResult { cancelCurrentWorkflow: () => Promise; cancelledMessageContent: string | null; clearCancelledMessageContent: () => void; + traceIdsByMessageId: Record; } export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWorkflowResult => { const [workflowStatus, setWorkflowStatus] = useState({ isActive: false }); const [cancelledMessageContent, setCancelledMessageContent] = useState(null); + const [traceIdsByMessageId, setTraceIdsByMessageId] = useState>({}); const { connectionStatus, sessionId: backendSessionId, @@ -106,7 +108,7 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo ); const handleAssistantMessage = useCallback( - (event: WorkflowEvent & { type: 'assistant_message' }) => { + async (event: WorkflowEvent & { type: 'assistant_message' }) => { const assistantMessage = event.data; const messageContent = getAssistantMessageContent(assistantMessage); const messageTimestamp = getAssistantMessageTimestamp(assistantMessage); @@ -122,7 +124,11 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo filesChanged: assistantMessage.filesChanged || [], sources: assistantMessage.sources || [], }; - createMessage(threadId, finalAssistantMessage); + const persisted = await createMessage(threadId, finalAssistantMessage); + + if (assistantMessage.traceId && persisted?.id) { + setTraceIdsByMessageId((prev) => ({ ...prev, [persisted.id]: assistantMessage.traceId! })); + } if (event.session_id && !shouldSkipBranchOps(assistantMessage)) { resetRepoForSession(event.session_id); @@ -134,7 +140,9 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo const handleWorkflowEvent = useCallback( (event: WorkflowEvent) => { if (event.type === 'assistant_message') { - handleAssistantMessage(event); + handleAssistantMessage(event).catch((error) => + console.error('Failed to handle assistant message:', error), + ); } else if (event.type === 'status') { const isTerminal = event.data?.status === 'completed' || @@ -157,7 +165,7 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo content: WORKFLOW_ERROR_MESSAGE, createdAt: new Date().toISOString(), filesChanged: [], - }); + }).catch((error) => console.error('Failed to persist error message:', error)); } }, [applyStatusMessage, handleAssistantMessage, currentSessionIdRef, createMessage], @@ -215,7 +223,9 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo const runWorkflowForSession = useCallback( async (threadId: string, userMessage: UserMessage): Promise => { activeWorkflowThreadId.current = threadId; - createMessage(threadId, userMessage); + createMessage(threadId, userMessage).catch((error) => + console.error('Failed to persist user message:', error), + ); try { const result = await startAgentWorkflow( threadId, @@ -229,7 +239,9 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo content: formatRejectionMessage(result), createdAt: new Date().toISOString(), filesChanged: [], - }); + }).catch((persistError) => + console.error('Failed to persist rejection message:', persistError), + ); } } catch (error) { console.error('Workflow request failed:', error); @@ -238,7 +250,7 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo content: WORKFLOW_ERROR_MESSAGE, createdAt: new Date().toISOString(), filesChanged: [], - }); + }).catch((persistError) => console.error('Failed to persist error message:', persistError)); } }, [createMessage, startAgentWorkflow], @@ -307,6 +319,7 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo cancelCurrentWorkflow, cancelledMessageContent, clearCancelledMessageContent, + traceIdsByMessageId, }; }; diff --git a/src/Designer/frontend/language/src/nb.json b/src/Designer/frontend/language/src/nb.json index a059dd58eb9..3bec4cb1dda 100644 --- a/src/Designer/frontend/language/src/nb.json +++ b/src/Designer/frontend/language/src/nb.json @@ -87,6 +87,12 @@ "ai_assistant.add_attachment": "Last opp vedlegg", "ai_assistant.allow_app_changes": "Tillat endringer i appen", "ai_assistant.assistant_first_message": "Hva kan jeg hjelpe med?", + "ai_assistant.feedback_comment_placeholder": "Skriv tilbakemeldingen din her ...", + "ai_assistant.feedback_elaborate_prompt": "Ønsker du å utdype?", + "ai_assistant.feedback_submit": "Send", + "ai_assistant.feedback_thanks_heading": "Takk for tilbakemeldingen, den er mottatt!", + "ai_assistant.feedback_thumbs_down": "Ikke nyttig svar", + "ai_assistant.feedback_thumbs_up": "Nyttig svar", "ai_assistant.file_browser": "Kode", "ai_assistant.heading": "KI-assistent", "ai_assistant.hide_threads": "Skjul tråder", diff --git a/src/Designer/frontend/libs/studio-assistant/src/Assistant/Assistant.tsx b/src/Designer/frontend/libs/studio-assistant/src/Assistant/Assistant.tsx index 6ed0fd982ab..688da98c5f2 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/Assistant/Assistant.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/Assistant/Assistant.tsx @@ -3,6 +3,7 @@ import type { ReactElement } from 'react'; import type { ChatThread, UserMessage, Message } from '../types/ChatThread'; import { CompactInterface } from '../components/CompactInterface/CompactInterface'; import { CompleteInterface } from '../components/CompleteInterface/CompleteInterface'; +import type { MessageFeedbackHandler } from '../components/ChatColumn/Messages/Messages'; import type { AssistantTexts } from '../types/AssistantTexts'; import type { ConnectionStatus } from '../types/ConnectionStatus'; import type { WorkflowStatus } from '../types/WorkflowStatus'; @@ -26,6 +27,7 @@ export type AssistantProps = { previewContent: ReactElement; fileBrowserContent?: ReactElement; currentUser?: User; + onMessageFeedback?: MessageFeedbackHandler; }; export function Assistant({ @@ -46,6 +48,7 @@ export function Assistant({ previewContent, fileBrowserContent, currentUser, + onMessageFeedback, }: AssistantProps): React.ReactElement { return enableCompactInterface ? ( @@ -67,6 +70,7 @@ export function Assistant({ previewContent={previewContent} fileBrowserContent={fileBrowserContent} currentUser={currentUser} + onMessageFeedback={onMessageFeedback} /> ); } diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/ChatColumn.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/ChatColumn.tsx index f9f0d253068..01afe673bae 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/ChatColumn.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/ChatColumn.tsx @@ -2,6 +2,7 @@ import type { ReactElement } from 'react'; import { useRef, useEffect } from 'react'; import cn from 'classnames'; import { Messages } from './Messages/Messages'; +import type { MessageFeedbackHandler } from './Messages/Messages'; import { UserInput } from './UserInput/UserInput'; import classes from './ChatColumn.module.css'; import { StudioParagraph } from '@studio/components'; @@ -20,6 +21,7 @@ export type ChatColumnProps = { workflowStatus?: WorkflowStatus; enableCompactInterface: boolean; currentUser?: User; + onMessageFeedback?: MessageFeedbackHandler; }; export function ChatColumn({ @@ -32,6 +34,7 @@ export function ChatColumn({ workflowStatus, enableCompactInterface, currentUser, + onMessageFeedback, }: ChatColumnProps): ReactElement { const workflowIsActive = workflowStatus?.isActive === true; const messagesEndRef = useRef(null); @@ -70,6 +73,8 @@ export function ChatColumn({ workflowStatus={workflowStatus} currentUser={currentUser} assistantAvatarUrl={undefined} + feedbackTexts={texts.feedback} + onMessageFeedback={onMessageFeedback} />
diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.module.css b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.module.css new file mode 100644 index 00000000000..ba21bebf685 --- /dev/null +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.module.css @@ -0,0 +1,25 @@ +.feedbackBar { + display: flex; + gap: var(--ds-size-1, 0.25rem); + margin-top: var(--ds-size-2, 0.5rem); +} + +.feedbackButton { + padding: var(--ds-size-1, 0.25rem); + min-height: auto; +} + +.feedbackButtonSelected { + color: var(--ds-color-accent-base-default, currentColor); +} + +.dialog { + max-width: 28rem; +} + +.dialogActions { + display: flex; + justify-content: flex-end; + gap: var(--ds-size-2, 0.5rem); + margin-top: var(--ds-size-3, 0.75rem); +} diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx new file mode 100644 index 00000000000..6243f900921 --- /dev/null +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx @@ -0,0 +1,108 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MessageFeedback } from './MessageFeedback'; +import type { MessageFeedbackProps } from './MessageFeedback'; +import type { MessageFeedbackTexts } from '../../../../types/AssistantTexts'; + +const mockTexts: MessageFeedbackTexts = { + thumbsUp: 'Nyttig svar', + thumbsDown: 'Ikke nyttig svar', + thanksHeading: 'Takk for tilbakemeldingen, den er mottatt!', + elaboratePrompt: 'Ønsker du å utdype?', + commentPlaceholder: 'Skriv tilbakemeldingen din her ...', + submit: 'Send', +}; + +describe('MessageFeedback', () => { + beforeAll(() => { + if (!HTMLDialogElement.prototype.showModal) { + HTMLDialogElement.prototype.showModal = function showModal() { + this.setAttribute('open', ''); + }; + } + if (!HTMLDialogElement.prototype.close) { + HTMLDialogElement.prototype.close = function close() { + this.removeAttribute('open'); + this.dispatchEvent(new Event('close')); + }; + } + }); + + it('renders both thumb buttons enabled by default', () => { + renderMessageFeedback(); + + expect(getThumbsUpButton()).toBeEnabled(); + expect(getThumbsDownButton()).toBeEnabled(); + }); + + it('marks the chosen button as pressed and opens the dialog after the first vote, without firing onSubmit yet', async () => { + const user = userEvent.setup(); + const onSubmit = jest.fn(); + renderMessageFeedback({ onSubmit }); + + await user.click(getThumbsUpButton()); + + expect(getThumbsUpButton()).toHaveAttribute('aria-pressed', 'true'); + expect(getThumbsDownButton()).toBeEnabled(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('fires onSubmit once with no comment when the dialog is dismissed without typing', async () => { + const user = userEvent.setup(); + const onSubmit = jest.fn(); + renderMessageFeedback({ onSubmit }); + + await user.click(getThumbsUpButton()); + closeDialogViaEscape(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith('up', undefined); + }); + + it('fires onSubmit once with the comment when the user submits the textarea', async () => { + const user = userEvent.setup(); + const onSubmit = jest.fn(); + renderMessageFeedback({ onSubmit }); + + await user.click(getThumbsDownButton()); + await user.type(screen.getByRole('textbox'), 'Svaret traff ikke helt.'); + await user.click(screen.getByRole('button', { name: mockTexts.submit })); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith('down', 'Svaret traff ikke helt.'); + }); + + it('does not fire onSubmit a second time if the dialog close fires twice', async () => { + const user = userEvent.setup(); + const onSubmit = jest.fn(); + renderMessageFeedback({ onSubmit }); + + await user.click(getThumbsUpButton()); + const dialogElement = screen.getByRole('dialog'); + dialogElement.dispatchEvent(new Event('close')); + dialogElement.dispatchEvent(new Event('close')); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); + +const defaultProps: MessageFeedbackProps = { + texts: mockTexts, + onSubmit: jest.fn(), +}; + +const renderMessageFeedback = (props: Partial = {}): void => { + render(); +}; + +const getThumbsUpButton = (): HTMLElement => + screen.getByRole('button', { name: mockTexts.thumbsUp }); + +const getThumbsDownButton = (): HTMLElement => + screen.getByRole('button', { name: mockTexts.thumbsDown }); + +const closeDialogViaEscape = (): void => { + const dialogElement = screen.getByRole('dialog') as HTMLDialogElement; + dialogElement.close(); +}; diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx new file mode 100644 index 00000000000..76bff237240 --- /dev/null +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx @@ -0,0 +1,113 @@ +import type { ReactElement } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { + StudioButton, + StudioDialog, + StudioHeading, + StudioParagraph, + StudioTextarea, +} from '@studio/components'; +import { ThumbDownIcon, ThumbUpIcon } from '@studio/icons'; +import type { MessageFeedbackTexts } from '../../../../types/AssistantTexts'; +import classes from './MessageFeedback.module.css'; + +export type FeedbackVote = 'up' | 'down'; + +export type MessageFeedbackProps = { + texts: MessageFeedbackTexts; + onSubmit: (vote: FeedbackVote, comment?: string) => void; +}; + +export function MessageFeedback({ texts, onSubmit }: MessageFeedbackProps): ReactElement { + const [selectedVote, setSelectedVote] = useState(null); + const [commentText, setCommentText] = useState(''); + const dialogRef = useRef(null); + const hasSubmittedRef = useRef(false); + const commentTextRef = useRef(''); + const selectedVoteRef = useRef(null); + + useEffect(() => { + commentTextRef.current = commentText; + }, [commentText]); + + useEffect(() => { + selectedVoteRef.current = selectedVote; + }, [selectedVote]); + + useEffect(() => { + const dialogElement = dialogRef.current; + if (!dialogElement) return undefined; + + const handleClose = (): void => { + if (hasSubmittedRef.current) return; + const currentVote = selectedVoteRef.current; + if (!currentVote) return; + hasSubmittedRef.current = true; + const trimmedComment = commentTextRef.current.trim(); + onSubmit(currentVote, trimmedComment.length > 0 ? trimmedComment : undefined); + }; + + dialogElement.addEventListener('close', handleClose); + return () => dialogElement.removeEventListener('close', handleClose); + }, [onSubmit]); + + const handleVoteClick = (vote: FeedbackVote): void => { + if (selectedVote) return; + setSelectedVote(vote); + dialogRef.current?.showModal(); + }; + + const handleSendComment = (): void => { + dialogRef.current?.close(); + }; + + return ( + <> +
+ handleVoteClick('up')} + className={`${classes.feedbackButton} ${ + selectedVote === 'up' ? classes.feedbackButtonSelected : '' + }`} + icon={} + /> + handleVoteClick('down')} + className={`${classes.feedbackButton} ${ + selectedVote === 'down' ? classes.feedbackButtonSelected : '' + }`} + icon={} + /> +
+ + + + {texts.thanksHeading} + + + + {texts.elaboratePrompt} + setCommentText(event.target.value)} + placeholder={texts.commentPlaceholder} + rows={4} + /> +
+ + {texts.submit} + +
+
+
+ + ); +} diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/index.ts b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/index.ts new file mode 100644 index 00000000000..1e9b841227d --- /dev/null +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/index.ts @@ -0,0 +1,2 @@ +export { MessageFeedback } from './MessageFeedback'; +export type { FeedbackVote, MessageFeedbackProps } from './MessageFeedback'; diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.tsx index 73acd68edf5..aabecc2bf37 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.tsx @@ -5,6 +5,7 @@ import type { User } from '../../../types/User'; import { MessageAuthor } from '../../../types/MessageAuthor'; import classes from './Messages.module.css'; import type { Message, UserAttachment, UserMessage, Source } from '../../../types/ChatThread'; +import type { MessageFeedbackTexts } from '../../../types/AssistantTexts'; import type { WorkflowStatus } from '../../../types/WorkflowStatus'; import { formatAssistantMessageContent, @@ -12,15 +13,25 @@ import { isUrlSafe, } from '../../../utils/messageUtils'; import { ChatAvatar } from '../ChatAvatar'; +import { MessageFeedback } from './MessageFeedback'; +import type { FeedbackVote } from './MessageFeedback'; const ASSISTANT_LABEL = 'Altinity'; const DEFAULT_USER_LABEL = 'Deg'; +export type MessageFeedbackHandler = ( + traceId: string, + vote: FeedbackVote, + comment?: string, +) => void; + export type MessagesProps = { messages: Message[]; workflowStatus?: WorkflowStatus; currentUser?: User; assistantAvatarUrl?: string; + feedbackTexts?: MessageFeedbackTexts; + onMessageFeedback?: MessageFeedbackHandler; }; export function Messages({ @@ -28,6 +39,8 @@ export function Messages({ workflowStatus, currentUser, assistantAvatarUrl, + feedbackTexts, + onMessageFeedback, }: MessagesProps): ReactElement { const showLoadingBubble = workflowStatus?.isActive === true; const loadingBubbleText = workflowStatus?.message ?? ''; @@ -40,6 +53,8 @@ export function Messages({ message={message} currentUser={currentUser} assistantAvatarUrl={assistantAvatarUrl} + feedbackTexts={feedbackTexts} + onMessageFeedback={onMessageFeedback} /> ))} {showLoadingBubble && ( @@ -79,9 +94,17 @@ type MessageItemProps = { message: Message; currentUser?: User; assistantAvatarUrl?: string; + feedbackTexts?: MessageFeedbackTexts; + onMessageFeedback?: MessageFeedbackHandler; }; -function MessageItem({ message, currentUser, assistantAvatarUrl }: MessageItemProps): ReactElement { +function MessageItem({ + message, + currentUser, + assistantAvatarUrl, + feedbackTexts, + onMessageFeedback, +}: MessageItemProps): ReactElement { const isUser = message.role === MessageAuthor.User; const userLabel = currentUser?.full_name ?? DEFAULT_USER_LABEL; @@ -256,6 +279,9 @@ function MessageItem({ message, currentUser, assistantAvatarUrl }: MessageItemPr ); }; + const traceId = message.role === MessageAuthor.Assistant ? message.traceId : undefined; + const showFeedback = Boolean(traceId && feedbackTexts && onMessageFeedback); + return (
@@ -269,6 +295,12 @@ function MessageItem({ message, currentUser, assistantAvatarUrl }: MessageItemPr
{renderSources()} {renderFilesChanged()} + {showFeedback && ( + onMessageFeedback!(traceId!, vote, comment)} + /> + )}
); diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/CompleteInterface/CompleteInterface.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/CompleteInterface/CompleteInterface.tsx index c653b627e8a..8ad58ed2fde 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/CompleteInterface/CompleteInterface.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/CompleteInterface/CompleteInterface.tsx @@ -32,6 +32,7 @@ export function CompleteInterface({ previewContent, fileBrowserContent, currentUser, + onMessageFeedback, }: CompleteInterfaceProps): ReactElement { const [isThreadColumnCollapsed, setIsThreadColumnCollapsed] = useState(false); const [toolColumnMode, setToolColumnMode] = useState(ToolColumnMode.Preview); @@ -94,6 +95,7 @@ export function CompleteInterface({ workflowStatus={currentThreadWorkflowStatus} enableCompactInterface={false} currentUser={currentUser} + onMessageFeedback={onMessageFeedback} /> diff --git a/src/Designer/frontend/libs/studio-assistant/src/index.ts b/src/Designer/frontend/libs/studio-assistant/src/index.ts index 1e767ce2d2a..eaa8c6f1d5f 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/index.ts +++ b/src/Designer/frontend/libs/studio-assistant/src/index.ts @@ -3,7 +3,16 @@ export { ErrorMessages } from './types/AssistantConfig'; export { MessageAuthor } from './types/MessageAuthor'; export type { ChatThread, UserMessage, UserAttachment, Source, Message } from './types/ChatThread'; export { Assistant } from './Assistant/Assistant'; -export type { AssistantTexts, AboutAssistantDialogTexts } from './types/AssistantTexts'; +export type { + AssistantTexts, + AboutAssistantDialogTexts, + MessageFeedbackTexts, +} from './types/AssistantTexts'; +export type { + MessageFeedbackHandler, + MessagesProps, +} from './components/ChatColumn/Messages/Messages'; +export type { FeedbackVote } from './components/ChatColumn/Messages/MessageFeedback'; export type { AssistantMessage } from './types/ChatThread'; export type { User } from './types/User'; export type { WorkflowStatus } from './types/WorkflowStatus'; diff --git a/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts b/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts index 189adff76d3..1642b257ff9 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts +++ b/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts @@ -1,6 +1,7 @@ import type { AboutAssistantDialogTexts, AssistantTexts, + MessageFeedbackTexts, TextAreaTexts, } from '../types/AssistantTexts'; @@ -18,6 +19,15 @@ const textAreaTexts: TextAreaTexts = { waitingForConnection: 'waitingForConnection', }; +const messageFeedbackTexts: MessageFeedbackTexts = { + thumbsUp: 'feedbackThumbsUp', + thumbsDown: 'feedbackThumbsDown', + thanksHeading: 'feedbackThanksHeading', + elaboratePrompt: 'feedbackElaboratePrompt', + commentPlaceholder: 'feedbackCommentPlaceholder', + submit: 'feedbackSubmit', +}; + export const mockTexts: AssistantTexts = { heading: 'heading', preview: 'preview', @@ -34,4 +44,5 @@ export const mockTexts: AssistantTexts = { send: 'send', cancel: 'cancel', assistantFirstMessage: 'Hva kan jeg hjelpe med?', + feedback: messageFeedbackTexts, }; diff --git a/src/Designer/frontend/libs/studio-assistant/src/types/AssistantConfig.ts b/src/Designer/frontend/libs/studio-assistant/src/types/AssistantConfig.ts index bef9abece2f..e118f58d2e6 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/types/AssistantConfig.ts +++ b/src/Designer/frontend/libs/studio-assistant/src/types/AssistantConfig.ts @@ -17,6 +17,7 @@ export interface AssistantMessageData { sources?: Source[]; mode?: 'chat' | 'edit'; no_branch_operations?: boolean; + traceId?: string; } export interface WorkflowStatusData { diff --git a/src/Designer/frontend/libs/studio-assistant/src/types/AssistantTexts.ts b/src/Designer/frontend/libs/studio-assistant/src/types/AssistantTexts.ts index b560bfab76d..056c1058334 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/types/AssistantTexts.ts +++ b/src/Designer/frontend/libs/studio-assistant/src/types/AssistantTexts.ts @@ -16,6 +16,16 @@ export type AssistantTexts = { send: string; cancel: string; assistantFirstMessage: string; + feedback: MessageFeedbackTexts; +}; + +export type MessageFeedbackTexts = { + thumbsUp: string; + thumbsDown: string; + thanksHeading: string; + elaboratePrompt: string; + commentPlaceholder: string; + submit: string; }; export type AboutAssistantDialogTexts = { diff --git a/src/Designer/frontend/libs/studio-assistant/src/types/ChatThread.ts b/src/Designer/frontend/libs/studio-assistant/src/types/ChatThread.ts index 3dcf368045d..ab7ffcf75cd 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/types/ChatThread.ts +++ b/src/Designer/frontend/libs/studio-assistant/src/types/ChatThread.ts @@ -41,6 +41,7 @@ export type AssistantMessage = { createdAt: string; filesChanged?: string[]; sources?: Source[]; + traceId?: string; }; export type Message = UserMessage | AssistantMessage; diff --git a/src/Designer/frontend/packages/shared/src/api/mutations.ts b/src/Designer/frontend/packages/shared/src/api/mutations.ts index 2db24aa615f..e17cb4ef51d 100644 --- a/src/Designer/frontend/packages/shared/src/api/mutations.ts +++ b/src/Designer/frontend/packages/shared/src/api/mutations.ts @@ -85,9 +85,10 @@ import { chatThreadPath, chatMessagesPath, chatMessagePath, + chatFeedbackPath, } from 'app-shared/api/paths'; import type { AddLanguagePayload } from 'app-shared/types/api/AddLanguagePayload'; -import type { AddRepoParams, ChatThread, CreateChatMessagePayload, CreateChatThreadPayload } from 'app-shared/types/api'; +import type { AddRepoParams, ChatFeedbackPayload, ChatMessage, ChatThread, CreateChatMessagePayload, CreateChatThreadPayload } from 'app-shared/types/api'; import type { ApplicationAttachmentMetadata } from 'app-shared/types/ApplicationAttachmentMetadata'; import type { CreateDeploymentPayload } from 'app-shared/types/api/CreateDeploymentPayload'; import type { CreateReleasePayload } from 'app-shared/types/api/CreateReleasePayload'; @@ -277,5 +278,6 @@ export const updateBotAccount = (org: string, botAccountId: string, deployEnviro export const createChatThread = (org: string, app: string, payload: CreateChatThreadPayload) => post(chatThreadsPath(org, app), payload); export const updateChatThread = (org: string, app: string, threadId: string, payload: { title: string }) => put(chatThreadPath(org, app, threadId), payload); export const deleteChatThread = (org: string, app: string, threadId: string) => del(chatThreadPath(org, app, threadId)); -export const createChatMessage = (org: string, app: string, threadId: string, payload: CreateChatMessagePayload) => post(chatMessagesPath(org, app, threadId), payload); +export const createChatMessage = (org: string, app: string, threadId: string, payload: CreateChatMessagePayload) => post(chatMessagesPath(org, app, threadId), payload); export const deleteChatMessage = (org: string, app: string, threadId: string, messageId: string) => del(chatMessagePath(org, app, threadId, messageId)); +export const sendChatFeedback = (org: string, app: string, payload: ChatFeedbackPayload) => post(chatFeedbackPath(org, app), payload); diff --git a/src/Designer/frontend/packages/shared/src/api/paths.js b/src/Designer/frontend/packages/shared/src/api/paths.js index 252e76f7a5d..b0fd6f8cc42 100644 --- a/src/Designer/frontend/packages/shared/src/api/paths.js +++ b/src/Designer/frontend/packages/shared/src/api/paths.js @@ -221,6 +221,7 @@ export const chatThreadsPath = (org, app) => `${apiBasePath}/${org}/${app}/chat/ export const chatThreadPath = (org, app, threadId) => `${apiBasePath}/${org}/${app}/chat/threads/${threadId}`; // Put, Delete export const chatMessagesPath = (org, app, threadId) => `${apiBasePath}/${org}/${app}/chat/threads/${threadId}/messages`; // Get, Post export const chatMessagePath = (org, app, threadId, messageId) => `${apiBasePath}/${org}/${app}/chat/threads/${threadId}/messages/${messageId}`; // Delete +export const chatFeedbackPath = (org, app) => `${apiBasePath}/${org}/${app}/chat/feedback`; // Post // Contact export const belongsToOrg = () => `${apiBasePath}/contact/belongs-to-org`; diff --git a/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.test.ts b/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.test.ts new file mode 100644 index 00000000000..f12493dad96 --- /dev/null +++ b/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.test.ts @@ -0,0 +1,26 @@ +import { queriesMock } from 'app-shared/mocks/queriesMock'; +import { renderHookWithProviders } from 'app-development/test/mocks'; +import { app, org } from '@studio/testing/testids'; +import { useChatFeedbackMutation } from './useChatFeedbackMutation'; +import { waitFor } from '@testing-library/react'; +import type { ChatFeedbackPayload } from 'app-shared/types/api'; + +describe('useChatFeedbackMutation', () => { + afterEach(jest.clearAllMocks); + + it('Calls sendChatFeedback with correct arguments and payload', async () => { + const payload: ChatFeedbackPayload = { + traceId: 'trace-abc-123', + thumbsUp: true, + comment: 'Veldig nyttig!', + }; + const result = renderHookWithProviders()(() => useChatFeedbackMutation()).renderHookResult + .result; + + result.current.mutate(payload); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(queriesMock.sendChatFeedback).toHaveBeenCalledTimes(1); + expect(queriesMock.sendChatFeedback).toHaveBeenCalledWith(org, app, payload); + }); +}); diff --git a/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.ts b/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.ts new file mode 100644 index 00000000000..47dba95a561 --- /dev/null +++ b/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.ts @@ -0,0 +1,12 @@ +import { useMutation } from '@tanstack/react-query'; +import { useServicesContext } from 'app-shared/contexts/ServicesContext'; +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import type { ChatFeedbackPayload } from 'app-shared/types/api'; + +export const useChatFeedbackMutation = () => { + const { sendChatFeedback } = useServicesContext(); + const { org, app } = useStudioEnvironmentParams(); + return useMutation({ + mutationFn: (payload: ChatFeedbackPayload) => sendChatFeedback(org, app, payload), + }); +}; diff --git a/src/Designer/frontend/packages/shared/src/mocks/queriesMock.ts b/src/Designer/frontend/packages/shared/src/mocks/queriesMock.ts index 972d39a00ef..a4d46a2b203 100644 --- a/src/Designer/frontend/packages/shared/src/mocks/queriesMock.ts +++ b/src/Designer/frontend/packages/shared/src/mocks/queriesMock.ts @@ -413,6 +413,7 @@ export const queriesMock: ServicesContextProps = { deleteChatThread: jest.fn().mockImplementation(() => Promise.resolve()), createChatMessage: jest.fn().mockImplementation(() => Promise.resolve()), deleteChatMessage: jest.fn().mockImplementation(() => Promise.resolve()), + sendChatFeedback: jest.fn().mockImplementation(() => Promise.resolve()), // Mutations - Org settings - Contact points addContactPoint: jest.fn().mockImplementation(() => Promise.resolve()), diff --git a/src/Designer/frontend/packages/shared/src/types/api/ChatFeedbackPayload.ts b/src/Designer/frontend/packages/shared/src/types/api/ChatFeedbackPayload.ts new file mode 100644 index 00000000000..5653f576240 --- /dev/null +++ b/src/Designer/frontend/packages/shared/src/types/api/ChatFeedbackPayload.ts @@ -0,0 +1,5 @@ +export type ChatFeedbackPayload = { + traceId: string; + thumbsUp: boolean; + comment?: string; +}; diff --git a/src/Designer/frontend/packages/shared/src/types/api/index.ts b/src/Designer/frontend/packages/shared/src/types/api/index.ts index 3f3b24557d0..e1b61ddf31d 100644 --- a/src/Designer/frontend/packages/shared/src/types/api/index.ts +++ b/src/Designer/frontend/packages/shared/src/types/api/index.ts @@ -1,4 +1,5 @@ export * from './AddLanguagePayload'; +export * from './ChatFeedbackPayload'; export * from './ChatMessage'; export * from './ChatThread'; export * from './AddRepoParams'; From 2c3bf8f95b8a8d7ef8791371f84da648ce121eb5 Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Tue, 12 May 2026 09:47:35 +0200 Subject: [PATCH 02/26] move feedback endpoint out of agent route --- src/AI/agents/api/main.py | 3 +- src/AI/agents/api/routes/__init__.py | 3 +- src/AI/agents/api/routes/agent.py | 44 +-------------- src/AI/agents/api/routes/feedback.py | 55 +++++++++++++++++++ src/AI/agents/tests/api/test_feedback.py | 12 ++-- .../Models/Dto/ChatFeedbackRequest.cs | 2 +- .../Altinity/AltinityAgentClient.cs | 2 +- 7 files changed, 68 insertions(+), 53 deletions(-) create mode 100644 src/AI/agents/api/routes/feedback.py diff --git a/src/AI/agents/api/main.py b/src/AI/agents/api/main.py index 9fd05262254..ac2a36b3806 100644 --- a/src/AI/agents/api/main.py +++ b/src/AI/agents/api/main.py @@ -19,7 +19,7 @@ # Get configuration config = get_config() -from api.routes import register_websocket_routes, agent_router +from api.routes import register_websocket_routes, agent_router, feedback_router # Configure logging logging.basicConfig( @@ -107,6 +107,7 @@ async def lifespan(app: FastAPI): # Register routes register_websocket_routes(app) app.include_router(agent_router) +app.include_router(feedback_router) @app.get("/favicon.ico") diff --git a/src/AI/agents/api/routes/__init__.py b/src/AI/agents/api/routes/__init__.py index 2578411cced..4277f1eab21 100644 --- a/src/AI/agents/api/routes/__init__.py +++ b/src/AI/agents/api/routes/__init__.py @@ -1,5 +1,6 @@ """API routes module""" from .websocket import register_websocket_routes from .agent import router as agent_router +from .feedback import router as feedback_router -__all__ = ['register_websocket_routes', 'agent_router'] \ No newline at end of file +__all__ = ['register_websocket_routes', 'agent_router', 'feedback_router'] \ No newline at end of file diff --git a/src/AI/agents/api/routes/agent.py b/src/AI/agents/api/routes/agent.py index 1ad236a32c5..77eb56b2a14 100644 --- a/src/AI/agents/api/routes/agent.py +++ b/src/AI/agents/api/routes/agent.py @@ -1,6 +1,6 @@ """Agent workflow API routes""" import re -from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field, field_validator from agents.graph.state import AgentState from agents.graph.runner import run_in_background @@ -10,7 +10,6 @@ from agents.services.git.repo_manager import get_repo_manager from api.dependencies import get_designer_api_key from shared.config import get_config -from shared.utils.langfuse_utils import score_validation from shared.utils.logging_utils import get_logger from pathlib import Path from typing import Optional, List @@ -342,44 +341,3 @@ async def get_session_status(session_id: str): return {"session_id": session_id, **status} -_FEEDBACK_SCORE_NAME = "user_feedback" -_FEEDBACK_COMMENT_MAX_LENGTH = 4000 - - -class FeedbackReq(BaseModel): - """User feedback (thumbs up/down) on an assistant message, recorded as a Langfuse score.""" - - trace_id: str - thumbs_up: bool - comment: Optional[str] = None - - @field_validator("trace_id") - @classmethod - def _validate_trace_id(cls, v: str) -> str: - if not v or not v.strip(): - raise ValueError("trace_id must be non-empty") - return v - - @field_validator("comment") - @classmethod - def _validate_comment(cls, v: Optional[str]) -> Optional[str]: - if v is not None and len(v) > _FEEDBACK_COMMENT_MAX_LENGTH: - raise ValueError( - f"comment must not exceed {_FEEDBACK_COMMENT_MAX_LENGTH} characters" - ) - return v - - -@router.post("/api/agent/feedback", status_code=204) -async def submit_feedback( - req: FeedbackReq, - designer_api_key: str = Depends(get_designer_api_key), -): - """Record a thumbs-up/thumbs-down user feedback as a Langfuse score on the given trace.""" - score_validation( - name=_FEEDBACK_SCORE_NAME, - passed=req.thumbs_up, - trace_id=req.trace_id, - comment=req.comment, - ) - return Response(status_code=204) \ No newline at end of file diff --git a/src/AI/agents/api/routes/feedback.py b/src/AI/agents/api/routes/feedback.py new file mode 100644 index 00000000000..ba03967615e --- /dev/null +++ b/src/AI/agents/api/routes/feedback.py @@ -0,0 +1,55 @@ +"""User feedback API routes""" + +from typing import Optional + +from fastapi import APIRouter, Depends, Response +from pydantic import BaseModel, field_validator + +from api.dependencies import get_designer_api_key +from shared.utils.langfuse_utils import score_validation +from shared.utils.logging_utils import get_logger + +router = APIRouter() +log = get_logger(__name__) + +FEEDBACK_SCORE_NAME = "user_feedback" +FEEDBACK_COMMENT_MAX_LENGTH = 10000 + + +class FeedbackReq(BaseModel): + """User feedback (thumbs up/down) on an assistant message, recorded as a Langfuse score.""" + + trace_id: str + thumbs_up: bool + comment: Optional[str] = None + + @field_validator("trace_id") + @classmethod + def _validate_trace_id(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("trace_id must be non-empty") + return v + + @field_validator("comment") + @classmethod + def _validate_comment(cls, v: Optional[str]) -> Optional[str]: + if v is not None and len(v) > FEEDBACK_COMMENT_MAX_LENGTH: + raise ValueError( + f"comment must not exceed {FEEDBACK_COMMENT_MAX_LENGTH} characters" + ) + return v + + +@router.post("/api/feedback", status_code=204) +async def submit_feedback( + req: FeedbackReq, + designer_api_key: str = Depends(get_designer_api_key), +): + """Records user feedback as a Langfuse score on the given trace.""" + score_validation( + name=FEEDBACK_SCORE_NAME, + passed=req.thumbs_up, + trace_id=req.trace_id, + comment=req.comment, + ) + return Response(status_code=204) diff --git a/src/AI/agents/tests/api/test_feedback.py b/src/AI/agents/tests/api/test_feedback.py index 2a7f4e79523..a922f5e92df 100644 --- a/src/AI/agents/tests/api/test_feedback.py +++ b/src/AI/agents/tests/api/test_feedback.py @@ -2,14 +2,14 @@ from fastapi.testclient import TestClient from api.main import app -FEEDBACK_PATH = "/api/agent/feedback" +FEEDBACK_PATH = "/api/feedback" VALID_API_KEY_HEADER = {"X-Api-Key": "test-key"} VALID_TRACE_ID = "trace-abc-123" class TestFeedbackEndpoint: def test_thumbs_up_writes_score_and_returns_204(self): - with patch("api.routes.agent.score_validation") as mock_score: + with patch("api.routes.feedback.score_validation") as mock_score: response = TestClient(app).post( FEEDBACK_PATH, json={"trace_id": VALID_TRACE_ID, "thumbs_up": True}, @@ -25,7 +25,7 @@ def test_thumbs_up_writes_score_and_returns_204(self): ) def test_thumbs_down_with_comment_is_forwarded(self): - with patch("api.routes.agent.score_validation") as mock_score: + with patch("api.routes.feedback.score_validation") as mock_score: response = TestClient(app).post( FEEDBACK_PATH, json={ @@ -45,7 +45,7 @@ def test_thumbs_down_with_comment_is_forwarded(self): ) def test_missing_api_key_is_rejected(self): - with patch("api.routes.agent.score_validation") as mock_score: + with patch("api.routes.feedback.score_validation") as mock_score: response = TestClient(app).post( FEEDBACK_PATH, json={"trace_id": VALID_TRACE_ID, "thumbs_up": True}, @@ -55,7 +55,7 @@ def test_missing_api_key_is_rejected(self): mock_score.assert_not_called() def test_empty_trace_id_returns_422(self): - with patch("api.routes.agent.score_validation") as mock_score: + with patch("api.routes.feedback.score_validation") as mock_score: response = TestClient(app).post( FEEDBACK_PATH, json={"trace_id": "", "thumbs_up": True}, @@ -66,7 +66,7 @@ def test_empty_trace_id_returns_422(self): mock_score.assert_not_called() def test_comment_over_4000_chars_returns_422(self): - with patch("api.routes.agent.score_validation") as mock_score: + with patch("api.routes.feedback.score_validation") as mock_score: response = TestClient(app).post( FEEDBACK_PATH, json={ diff --git a/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs b/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs index 71da4b1cfe0..28555b8cf01 100644 --- a/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs +++ b/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs @@ -2,4 +2,4 @@ namespace Altinn.Studio.Designer.Models.Dto; -public record ChatFeedbackRequest([MaxLength(200)] string TraceId, bool ThumbsUp, [MaxLength(4000)] string? Comment); +public record ChatFeedbackRequest([MaxLength(200)] string TraceId, bool ThumbsUp, [MaxLength(10000)] string? Comment); diff --git a/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs b/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs index cdaa23f2bd1..a9d212f8b9d 100644 --- a/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs +++ b/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs @@ -14,7 +14,7 @@ namespace Altinn.Studio.Designer.Services.Implementation.Altinity; public class AltinityAgentClient : IAltinityAgentClient { - private const string FeedbackPath = "/api/agent/feedback"; + private const string FeedbackPath = "/api/feedback"; private const string ApiKeyHeader = "X-Api-Key"; private const string DeveloperHeader = "X-Developer"; private const string ApiKeyNamePrefix = "altinity-feedback-"; From 88c85b6d5ad0268f0e63352af80f185595b98c03 Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Tue, 12 May 2026 10:20:41 +0200 Subject: [PATCH 03/26] remove unused X-API-KEY logic from AltinityAgentClient --- src/AI/agents/api/routes/feedback.py | 14 ++++---- src/AI/agents/shared/utils/langfuse_utils.py | 28 ++++++++++++---- src/AI/agents/tests/api/test_feedback.py | 19 ++--------- .../Models/Dto/ChatFeedbackRequest.cs | 2 +- .../Altinity/AltinityAgentClient.cs | 32 ------------------- 5 files changed, 31 insertions(+), 64 deletions(-) diff --git a/src/AI/agents/api/routes/feedback.py b/src/AI/agents/api/routes/feedback.py index ba03967615e..b4d89d9c2ac 100644 --- a/src/AI/agents/api/routes/feedback.py +++ b/src/AI/agents/api/routes/feedback.py @@ -2,10 +2,9 @@ from typing import Optional -from fastapi import APIRouter, Depends, Response +from fastapi import APIRouter, Response from pydantic import BaseModel, field_validator -from api.dependencies import get_designer_api_key from shared.utils.langfuse_utils import score_validation from shared.utils.logging_utils import get_logger @@ -26,14 +25,16 @@ class FeedbackReq(BaseModel): @field_validator("trace_id") @classmethod def _validate_trace_id(cls, v: str) -> str: - if not v or not v.strip(): + if not v.strip(): raise ValueError("trace_id must be non-empty") return v @field_validator("comment") @classmethod def _validate_comment(cls, v: Optional[str]) -> Optional[str]: - if v is not None and len(v) > FEEDBACK_COMMENT_MAX_LENGTH: + if v is None: + return v + if len(v) > FEEDBACK_COMMENT_MAX_LENGTH: raise ValueError( f"comment must not exceed {FEEDBACK_COMMENT_MAX_LENGTH} characters" ) @@ -41,10 +42,7 @@ def _validate_comment(cls, v: Optional[str]) -> Optional[str]: @router.post("/api/feedback", status_code=204) -async def submit_feedback( - req: FeedbackReq, - designer_api_key: str = Depends(get_designer_api_key), -): +async def submit_feedback(req: FeedbackReq): """Records user feedback as a Langfuse score on the given trace.""" score_validation( name=FEEDBACK_SCORE_NAME, diff --git a/src/AI/agents/shared/utils/langfuse_utils.py b/src/AI/agents/shared/utils/langfuse_utils.py index 2102ac157a8..8a872ccdd08 100644 --- a/src/AI/agents/shared/utils/langfuse_utils.py +++ b/src/AI/agents/shared/utils/langfuse_utils.py @@ -1,6 +1,9 @@ """Langfuse tracking utilities""" + from contextlib import contextmanager + from langfuse import Langfuse, get_client + from shared.config.base_config import get_config from shared.utils.logging_utils import get_logger @@ -24,8 +27,8 @@ def init_langfuse(): return None try: - from opentelemetry.sdk.trace import TracerProvider as _OtelTracerProvider from opentelemetry import trace as _otel_trace + from opentelemetry.sdk.trace import TracerProvider as _OtelTracerProvider # Give Langfuse its own dedicated TracerProvider so it is isolated from # the global OTel provider that third-party libraries (fastmcp) share. @@ -47,7 +50,9 @@ def init_langfuse(): _otel_trace.set_tracer_provider(_OtelTracerProvider()) _initialized = True - log.info(f"Langfuse initialized successfully (host: {config.LANGFUSE_HOST}, release: {config.LANGFUSE_RELEASE}, env: {config.LANGFUSE_ENVIRONMENT})") + log.info( + f"Langfuse initialized successfully (host: {config.LANGFUSE_HOST}, release: {config.LANGFUSE_RELEASE}, env: {config.LANGFUSE_ENVIRONMENT})" + ) return _client @@ -141,6 +146,7 @@ def flush_langfuse(): except Exception as e: log.debug(f"Failed to flush Langfuse: {e}") + def score_validation( name: str, passed: bool, @@ -169,10 +175,13 @@ def score_validation( if comment: kwargs["comment"] = comment client.create_score(**kwargs) - log.debug("Langfuse score '%s' = %s written to trace %s", name, passed, trace_id) + log.debug( + "Langfuse score '%s' = %s written to trace %s", name, passed, trace_id + ) except Exception as e: log.debug("Failed to create Langfuse score '%s': %s", name, e) + # For backward compatibility with code that expects these functions # These are no-ops now since Langfuse handles things differently def start_run_safe(run_name: str = None, **kwargs): @@ -180,11 +189,14 @@ def start_run_safe(run_name: str = None, **kwargs): Legacy compatibility function. Langfuse uses traces instead of runs. Returns a dummy context manager. """ + class DummyContext: def __enter__(self): return self + def __exit__(self, *args): pass + return DummyContext() @@ -226,12 +238,12 @@ def __exit__(self, *args): def get_current_trace_id() -> str | None: - """Return the active Langfuse trace ID, or None if no trace context is active.""" if not is_langfuse_enabled(): return None try: return get_client().get_current_trace_id() except Exception: + log.exception("Failed to get current Langfuse trace ID") return None @@ -251,7 +263,9 @@ def trace_span(name: str, **kwargs): yield _NoopSpan() return - with get_client().start_as_current_observation(as_type="span", name=name, **kwargs) as span: + with get_client().start_as_current_observation( + as_type="span", name=name, **kwargs + ) as span: yield span @@ -265,5 +279,7 @@ def trace_generation(name: str, **kwargs): yield _NoopSpan() return - with get_client().start_as_current_observation(name=name, as_type="generation", **kwargs) as span: + with get_client().start_as_current_observation( + name=name, as_type="generation", **kwargs + ) as span: yield span diff --git a/src/AI/agents/tests/api/test_feedback.py b/src/AI/agents/tests/api/test_feedback.py index a922f5e92df..ab69e9549de 100644 --- a/src/AI/agents/tests/api/test_feedback.py +++ b/src/AI/agents/tests/api/test_feedback.py @@ -3,7 +3,6 @@ from api.main import app FEEDBACK_PATH = "/api/feedback" -VALID_API_KEY_HEADER = {"X-Api-Key": "test-key"} VALID_TRACE_ID = "trace-abc-123" @@ -13,7 +12,6 @@ def test_thumbs_up_writes_score_and_returns_204(self): response = TestClient(app).post( FEEDBACK_PATH, json={"trace_id": VALID_TRACE_ID, "thumbs_up": True}, - headers=VALID_API_KEY_HEADER, ) assert response.status_code == 204 @@ -33,7 +31,6 @@ def test_thumbs_down_with_comment_is_forwarded(self): "thumbs_up": False, "comment": "Svaret var ikke nyttig.", }, - headers=VALID_API_KEY_HEADER, ) assert response.status_code == 204 @@ -44,37 +41,25 @@ def test_thumbs_down_with_comment_is_forwarded(self): comment="Svaret var ikke nyttig.", ) - def test_missing_api_key_is_rejected(self): - with patch("api.routes.feedback.score_validation") as mock_score: - response = TestClient(app).post( - FEEDBACK_PATH, - json={"trace_id": VALID_TRACE_ID, "thumbs_up": True}, - ) - - assert response.status_code == 403 - mock_score.assert_not_called() - def test_empty_trace_id_returns_422(self): with patch("api.routes.feedback.score_validation") as mock_score: response = TestClient(app).post( FEEDBACK_PATH, json={"trace_id": "", "thumbs_up": True}, - headers=VALID_API_KEY_HEADER, ) assert response.status_code == 422 mock_score.assert_not_called() - def test_comment_over_4000_chars_returns_422(self): + def test_comment_over_max_length_returns_422(self): with patch("api.routes.feedback.score_validation") as mock_score: response = TestClient(app).post( FEEDBACK_PATH, json={ "trace_id": VALID_TRACE_ID, "thumbs_up": True, - "comment": "x" * 4001, + "comment": "x" * 10001, }, - headers=VALID_API_KEY_HEADER, ) assert response.status_code == 422 diff --git a/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs b/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs index 28555b8cf01..7148dd2b0f5 100644 --- a/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs +++ b/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs @@ -2,4 +2,4 @@ namespace Altinn.Studio.Designer.Models.Dto; -public record ChatFeedbackRequest([MaxLength(200)] string TraceId, bool ThumbsUp, [MaxLength(10000)] string? Comment); +public record ChatFeedbackRequest([MaxLength(64)] string TraceId, bool ThumbsUp, [MaxLength(10000)] string? Comment); diff --git a/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs b/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs index a9d212f8b9d..427a01c319e 100644 --- a/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs +++ b/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs @@ -4,10 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Altinn.Studio.Designer.Configuration; -using Altinn.Studio.Designer.Models.ApiKey; -using Altinn.Studio.Designer.Services.Interfaces; using Altinn.Studio.Designer.Services.Interfaces.Altinity; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Altinn.Studio.Designer.Services.Implementation.Altinity; @@ -15,27 +12,18 @@ namespace Altinn.Studio.Designer.Services.Implementation.Altinity; public class AltinityAgentClient : IAltinityAgentClient { private const string FeedbackPath = "/api/feedback"; - private const string ApiKeyHeader = "X-Api-Key"; private const string DeveloperHeader = "X-Developer"; - private const string ApiKeyNamePrefix = "altinity-feedback-"; - private static readonly TimeSpan ApiKeyLifetime = TimeSpan.FromMinutes(2); private readonly HttpClient _httpClient; private readonly AltinitySettings _altinitySettings; - private readonly IApiKeyService _apiKeyService; - private readonly ILogger _logger; public AltinityAgentClient( HttpClient httpClient, IOptions altinitySettings, - IApiKeyService apiKeyService, - ILogger logger ) { _httpClient = httpClient; _altinitySettings = altinitySettings.Value; - _apiKeyService = apiKeyService; - _logger = logger; } public async Task SendFeedbackAsync( @@ -46,8 +34,6 @@ public async Task SendFeedbackAsync( CancellationToken cancellationToken = default ) { - string apiKey = await CreateShortLivedApiKeyAsync(developer, cancellationToken); - var requestUri = new Uri($"{_altinitySettings.AgentUrl}{FeedbackPath}"); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri) { @@ -60,7 +46,6 @@ public async Task SendFeedbackAsync( } ), }; - httpRequest.Headers.Add(ApiKeyHeader, apiKey); httpRequest.Headers.Add(DeveloperHeader, developer); using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(_altinitySettings.TimeoutSeconds)); @@ -70,24 +55,7 @@ public async Task SendFeedbackAsync( if (!response.IsSuccessStatusCode) { string responseContent = await response.Content.ReadAsStringAsync(linkedCts.Token); - _logger.LogError("Altinity feedback returned {StatusCode}: {Body}", response.StatusCode, responseContent); throw new HttpRequestException($"Altinity feedback returned {response.StatusCode}: {responseContent}"); } } - - private async Task CreateShortLivedApiKeyAsync(string developer, CancellationToken cancellationToken) - { - string apiKeyName = $"{ApiKeyNamePrefix}{Guid.NewGuid()}"; - DateTimeOffset expiresAt = DateTimeOffset.UtcNow.Add(ApiKeyLifetime); - - var (rawKey, _) = await _apiKeyService.CreateAsync( - developer, - apiKeyName, - ApiKeyType.System, - expiresAt, - cancellationToken: cancellationToken - ); - - return rawKey; - } } From 5b8bc3faefa94bbe9c1ba83020615cb704c20639 Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Wed, 13 May 2026 09:21:43 +0200 Subject: [PATCH 04/26] fix dialog text and styling --- .../Altinity/AltinityAgentClient.cs | 2 +- .../features/aiAssistant/AiAssistant.tsx | 5 ++--- src/Designer/frontend/language/src/nb.json | 5 ++--- .../MessageFeedback/MessageFeedback.module.css | 11 ++++++----- .../MessageFeedback/MessageFeedback.test.tsx | 5 ++--- .../MessageFeedback/MessageFeedback.tsx | 18 +++++++++--------- .../ChatColumn/Messages/Messages.module.css | 12 ++++++------ .../studio-assistant/src/mocks/mockTexts.ts | 5 ++--- .../src/types/AssistantTexts.ts | 5 ++--- 9 files changed, 32 insertions(+), 36 deletions(-) diff --git a/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs b/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs index 427a01c319e..4fbf9d9c312 100644 --- a/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs +++ b/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs @@ -19,7 +19,7 @@ public class AltinityAgentClient : IAltinityAgentClient public AltinityAgentClient( HttpClient httpClient, - IOptions altinitySettings, + IOptions altinitySettings ) { _httpClient = httpClient; diff --git a/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx b/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx index b000182ed99..75343ef00a6 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx +++ b/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx @@ -78,9 +78,8 @@ function AiAssistant(): ReactElement { feedback: { thumbsUp: t('ai_assistant.feedback_thumbs_up'), thumbsDown: t('ai_assistant.feedback_thumbs_down'), - thanksHeading: t('ai_assistant.feedback_thanks_heading'), - elaboratePrompt: t('ai_assistant.feedback_elaborate_prompt'), - commentPlaceholder: t('ai_assistant.feedback_comment_placeholder'), + heading: t('ai_assistant.feedback_heading'), + body: t('ai_assistant.feedback_body'), submit: t('ai_assistant.feedback_submit'), }, }; diff --git a/src/Designer/frontend/language/src/nb.json b/src/Designer/frontend/language/src/nb.json index 3bec4cb1dda..fc6f5f69ab1 100644 --- a/src/Designer/frontend/language/src/nb.json +++ b/src/Designer/frontend/language/src/nb.json @@ -87,10 +87,9 @@ "ai_assistant.add_attachment": "Last opp vedlegg", "ai_assistant.allow_app_changes": "Tillat endringer i appen", "ai_assistant.assistant_first_message": "Hva kan jeg hjelpe med?", - "ai_assistant.feedback_comment_placeholder": "Skriv tilbakemeldingen din her ...", - "ai_assistant.feedback_elaborate_prompt": "Ønsker du å utdype?", + "ai_assistant.feedback_body": "Takk for tilbakemeldingen. Vil du utdype?", + "ai_assistant.feedback_heading": "Tilbakemelding", "ai_assistant.feedback_submit": "Send", - "ai_assistant.feedback_thanks_heading": "Takk for tilbakemeldingen, den er mottatt!", "ai_assistant.feedback_thumbs_down": "Ikke nyttig svar", "ai_assistant.feedback_thumbs_up": "Nyttig svar", "ai_assistant.file_browser": "Kode", diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.module.css b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.module.css index ba21bebf685..f16a1f8736a 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.module.css +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.module.css @@ -13,13 +13,14 @@ color: var(--ds-color-accent-base-default, currentColor); } -.dialog { - max-width: 28rem; +.dialogContent { + display: flex; + flex-direction: column; + gap: var(--ds-size-2); } .dialogActions { display: flex; - justify-content: flex-end; - gap: var(--ds-size-2, 0.5rem); - margin-top: var(--ds-size-3, 0.75rem); + gap: var(--ds-size-2); + margin-top: var(--ds-size-2); } diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx index 6243f900921..2bbfcdae1a5 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx @@ -7,9 +7,8 @@ import type { MessageFeedbackTexts } from '../../../../types/AssistantTexts'; const mockTexts: MessageFeedbackTexts = { thumbsUp: 'Nyttig svar', thumbsDown: 'Ikke nyttig svar', - thanksHeading: 'Takk for tilbakemeldingen, den er mottatt!', - elaboratePrompt: 'Ønsker du å utdype?', - commentPlaceholder: 'Skriv tilbakemeldingen din her ...', + heading: 'Tilbakemelding', + body: 'Tilbakemeldingen er mottatt! Ønsker du å utdype?', submit: 'Send', }; diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx index 76bff237240..ba259d5c4bc 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx @@ -7,7 +7,7 @@ import { StudioParagraph, StudioTextarea, } from '@studio/components'; -import { ThumbDownIcon, ThumbUpIcon } from '@studio/icons'; +import { ThumbDownIcon, ThumbUpIcon, PaperplaneFillIcon } from '@studio/icons'; import type { MessageFeedbackTexts } from '../../../../types/AssistantTexts'; import classes from './MessageFeedback.module.css'; @@ -89,20 +89,20 @@ export function MessageFeedback({ texts, onSubmit }: MessageFeedbackProps): Reac - - {texts.thanksHeading} - + {texts.heading} - - {texts.elaboratePrompt} + + {texts.body} setCommentText(event.target.value)} - placeholder={texts.commentPlaceholder} - rows={4} />
- + } + > {texts.submit}
diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.module.css b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.module.css index adcd5fdff14..4c9186a705e 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.module.css +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.module.css @@ -196,7 +196,7 @@ color: inherit; } -.assistantMessage h1 { +.assistantContent h1 { font-size: 1.25rem; font-weight: 600; margin: 1rem 0 0.5rem 0; @@ -204,11 +204,11 @@ line-height: 1.3; } -.assistantMessage h1:first-child { +.assistantContent h1:first-child { margin-top: 0; } -.assistantMessage h2 { +.assistantContent h2 { font-size: 1.125rem; font-weight: 600; margin: 1rem 0 0.5rem 0; @@ -216,11 +216,11 @@ line-height: 1.3; } -.assistantMessage h2:first-child { +.assistantContent h2:first-child { margin-top: 0; } -.assistantMessage h3 { +.assistantContent h3 { font-size: 1rem; font-weight: 600; margin: 0.75rem 0 0.375rem 0; @@ -228,7 +228,7 @@ line-height: 1.4; } -.assistantMessage h3:first-child { +.assistantContent h3:first-child { margin-top: 0; } diff --git a/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts b/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts index 1642b257ff9..f98851e7833 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts +++ b/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts @@ -22,9 +22,8 @@ const textAreaTexts: TextAreaTexts = { const messageFeedbackTexts: MessageFeedbackTexts = { thumbsUp: 'feedbackThumbsUp', thumbsDown: 'feedbackThumbsDown', - thanksHeading: 'feedbackThanksHeading', - elaboratePrompt: 'feedbackElaboratePrompt', - commentPlaceholder: 'feedbackCommentPlaceholder', + heading: 'feedbackHeading', + body: 'feedbackBody', submit: 'feedbackSubmit', }; diff --git a/src/Designer/frontend/libs/studio-assistant/src/types/AssistantTexts.ts b/src/Designer/frontend/libs/studio-assistant/src/types/AssistantTexts.ts index 056c1058334..03463691e1b 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/types/AssistantTexts.ts +++ b/src/Designer/frontend/libs/studio-assistant/src/types/AssistantTexts.ts @@ -22,9 +22,8 @@ export type AssistantTexts = { export type MessageFeedbackTexts = { thumbsUp: string; thumbsDown: string; - thanksHeading: string; - elaboratePrompt: string; - commentPlaceholder: string; + heading: string; + body: string; submit: string; }; From ac24cc028860a28db7a14227af34e9a6fcda7dfe Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Wed, 13 May 2026 10:22:05 +0200 Subject: [PATCH 05/26] refactor MessageFeedback and tests --- .../MessageFeedback.module.css | 13 +--- .../MessageFeedback/MessageFeedback.test.tsx | 55 ++++------------- .../MessageFeedback/MessageFeedback.tsx | 59 +++++-------------- 3 files changed, 28 insertions(+), 99 deletions(-) diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.module.css b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.module.css index f16a1f8736a..23046b11f99 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.module.css +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.module.css @@ -1,16 +1,7 @@ .feedbackBar { display: flex; - gap: var(--ds-size-1, 0.25rem); - margin-top: var(--ds-size-2, 0.5rem); -} - -.feedbackButton { - padding: var(--ds-size-1, 0.25rem); - min-height: auto; -} - -.feedbackButtonSelected { - color: var(--ds-color-accent-base-default, currentColor); + gap: var(--ds-size-1); + margin-top: var(--ds-size-2); } .dialogContent { diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx index 2bbfcdae1a5..b336186a253 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx @@ -8,82 +8,52 @@ const mockTexts: MessageFeedbackTexts = { thumbsUp: 'Nyttig svar', thumbsDown: 'Ikke nyttig svar', heading: 'Tilbakemelding', - body: 'Tilbakemeldingen er mottatt! Ønsker du å utdype?', + body: 'Takk for tilbakemeldingen. Vil du utdype?', submit: 'Send', }; describe('MessageFeedback', () => { - beforeAll(() => { - if (!HTMLDialogElement.prototype.showModal) { - HTMLDialogElement.prototype.showModal = function showModal() { - this.setAttribute('open', ''); - }; - } - if (!HTMLDialogElement.prototype.close) { - HTMLDialogElement.prototype.close = function close() { - this.removeAttribute('open'); - this.dispatchEvent(new Event('close')); - }; - } - }); - - it('renders both thumb buttons enabled by default', () => { + it('renders thumbs up and thumbs down buttons', () => { renderMessageFeedback(); - expect(getThumbsUpButton()).toBeEnabled(); - expect(getThumbsDownButton()).toBeEnabled(); + expect(getThumbsUpButton()).toBeInTheDocument(); + expect(getThumbsDownButton()).toBeInTheDocument(); }); - it('marks the chosen button as pressed and opens the dialog after the first vote, without firing onSubmit yet', async () => { + it('opens feedback dialog when pressing either thumb button', async () => { const user = userEvent.setup(); const onSubmit = jest.fn(); renderMessageFeedback({ onSubmit }); await user.click(getThumbsUpButton()); - expect(getThumbsUpButton()).toHaveAttribute('aria-pressed', 'true'); - expect(getThumbsDownButton()).toBeEnabled(); expect(screen.getByRole('dialog')).toBeInTheDocument(); - expect(onSubmit).not.toHaveBeenCalled(); }); - it('fires onSubmit once with no comment when the dialog is dismissed without typing', async () => { + it('calls onSubmit without comment when there is no comment', async () => { const user = userEvent.setup(); const onSubmit = jest.fn(); renderMessageFeedback({ onSubmit }); await user.click(getThumbsUpButton()); - closeDialogViaEscape(); + await user.click(getSendButton()); expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit).toHaveBeenCalledWith('up', undefined); + expect(onSubmit).toHaveBeenCalledWith('up'); }); - it('fires onSubmit once with the comment when the user submits the textarea', async () => { + it('calls onSubmit with comment when there is a comment', async () => { const user = userEvent.setup(); const onSubmit = jest.fn(); renderMessageFeedback({ onSubmit }); await user.click(getThumbsDownButton()); await user.type(screen.getByRole('textbox'), 'Svaret traff ikke helt.'); - await user.click(screen.getByRole('button', { name: mockTexts.submit })); + await user.click(getSendButton()); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit).toHaveBeenCalledWith('down', 'Svaret traff ikke helt.'); }); - - it('does not fire onSubmit a second time if the dialog close fires twice', async () => { - const user = userEvent.setup(); - const onSubmit = jest.fn(); - renderMessageFeedback({ onSubmit }); - - await user.click(getThumbsUpButton()); - const dialogElement = screen.getByRole('dialog'); - dialogElement.dispatchEvent(new Event('close')); - dialogElement.dispatchEvent(new Event('close')); - - expect(onSubmit).toHaveBeenCalledTimes(1); - }); }); const defaultProps: MessageFeedbackProps = { @@ -101,7 +71,4 @@ const getThumbsUpButton = (): HTMLElement => const getThumbsDownButton = (): HTMLElement => screen.getByRole('button', { name: mockTexts.thumbsDown }); -const closeDialogViaEscape = (): void => { - const dialogElement = screen.getByRole('dialog') as HTMLDialogElement; - dialogElement.close(); -}; +const getSendButton = (): HTMLElement => screen.getByRole('button', { name: mockTexts.submit }); diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx index ba259d5c4bc..44b41419a89 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { StudioButton, StudioDialog, @@ -22,43 +22,19 @@ export function MessageFeedback({ texts, onSubmit }: MessageFeedbackProps): Reac const [selectedVote, setSelectedVote] = useState(null); const [commentText, setCommentText] = useState(''); const dialogRef = useRef(null); - const hasSubmittedRef = useRef(false); - const commentTextRef = useRef(''); - const selectedVoteRef = useRef(null); - - useEffect(() => { - commentTextRef.current = commentText; - }, [commentText]); - - useEffect(() => { - selectedVoteRef.current = selectedVote; - }, [selectedVote]); - - useEffect(() => { - const dialogElement = dialogRef.current; - if (!dialogElement) return undefined; - - const handleClose = (): void => { - if (hasSubmittedRef.current) return; - const currentVote = selectedVoteRef.current; - if (!currentVote) return; - hasSubmittedRef.current = true; - const trimmedComment = commentTextRef.current.trim(); - onSubmit(currentVote, trimmedComment.length > 0 ? trimmedComment : undefined); - }; - - dialogElement.addEventListener('close', handleClose); - return () => dialogElement.removeEventListener('close', handleClose); - }, [onSubmit]); const handleVoteClick = (vote: FeedbackVote): void => { - if (selectedVote) return; setSelectedVote(vote); dialogRef.current?.showModal(); }; - const handleSendComment = (): void => { - dialogRef.current?.close(); + const handleSendFeedback = (): void => { + const trimmedComment = commentText.trim(); + if (trimmedComment) { + onSubmit(selectedVote, trimmedComment); + } else { + onSubmit(selectedVote); + } }; return ( @@ -68,26 +44,21 @@ export function MessageFeedback({ texts, onSubmit }: MessageFeedbackProps): Reac variant='tertiary' data-size='sm' aria-label={texts.thumbsUp} - aria-pressed={selectedVote === 'up'} + title={texts.thumbsUp} onClick={() => handleVoteClick('up')} - className={`${classes.feedbackButton} ${ - selectedVote === 'up' ? classes.feedbackButtonSelected : '' - }`} - icon={} + icon={} /> handleVoteClick('down')} - className={`${classes.feedbackButton} ${ - selectedVote === 'down' ? classes.feedbackButtonSelected : '' - }`} - icon={} + icon={} /> - + + {texts.heading} @@ -100,7 +71,7 @@ export function MessageFeedback({ texts, onSubmit }: MessageFeedbackProps): Reac
} > {texts.submit} From f2fab70f4066bb91f593d0ec1cc334ff9d88b4dc Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Wed, 13 May 2026 10:30:47 +0200 Subject: [PATCH 06/26] split bundled chat-types imports into per-file imports --- src/Designer/frontend/packages/shared/src/api/mutations.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Designer/frontend/packages/shared/src/api/mutations.ts b/src/Designer/frontend/packages/shared/src/api/mutations.ts index e17cb4ef51d..754b3e94ddd 100644 --- a/src/Designer/frontend/packages/shared/src/api/mutations.ts +++ b/src/Designer/frontend/packages/shared/src/api/mutations.ts @@ -88,7 +88,10 @@ import { chatFeedbackPath, } from 'app-shared/api/paths'; import type { AddLanguagePayload } from 'app-shared/types/api/AddLanguagePayload'; -import type { AddRepoParams, ChatFeedbackPayload, ChatMessage, ChatThread, CreateChatMessagePayload, CreateChatThreadPayload } from 'app-shared/types/api'; +import type { AddRepoParams } from 'app-shared/types/api'; +import type { ChatFeedbackPayload } from 'app-shared/types/api/ChatFeedbackPayload'; +import type { ChatMessage, CreateChatMessagePayload } from 'app-shared/types/api/ChatMessage'; +import type { ChatThread, CreateChatThreadPayload } from 'app-shared/types/api/ChatThread'; import type { ApplicationAttachmentMetadata } from 'app-shared/types/ApplicationAttachmentMetadata'; import type { CreateDeploymentPayload } from 'app-shared/types/api/CreateDeploymentPayload'; import type { CreateReleasePayload } from 'app-shared/types/api/CreateReleasePayload'; From 24cc893cd4925f91d0063ede8fe9a2adc24559ed Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Wed, 13 May 2026 10:38:17 +0200 Subject: [PATCH 07/26] use centralized mock texts in MessageFeedback.test.tsx --- .../MessageFeedback/MessageFeedback.test.tsx | 18 +++++------------- .../studio-assistant/src/mocks/mockTexts.ts | 2 +- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx index b336186a253..4ab16f3f381 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx @@ -2,15 +2,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MessageFeedback } from './MessageFeedback'; import type { MessageFeedbackProps } from './MessageFeedback'; -import type { MessageFeedbackTexts } from '../../../../types/AssistantTexts'; - -const mockTexts: MessageFeedbackTexts = { - thumbsUp: 'Nyttig svar', - thumbsDown: 'Ikke nyttig svar', - heading: 'Tilbakemelding', - body: 'Takk for tilbakemeldingen. Vil du utdype?', - submit: 'Send', -}; +import { messageFeedbackTexts as feedbackTexts } from '../../../../mocks/mockTexts'; describe('MessageFeedback', () => { it('renders thumbs up and thumbs down buttons', () => { @@ -57,7 +49,7 @@ describe('MessageFeedback', () => { }); const defaultProps: MessageFeedbackProps = { - texts: mockTexts, + texts: feedbackTexts, onSubmit: jest.fn(), }; @@ -66,9 +58,9 @@ const renderMessageFeedback = (props: Partial = {}): void }; const getThumbsUpButton = (): HTMLElement => - screen.getByRole('button', { name: mockTexts.thumbsUp }); + screen.getByRole('button', { name: feedbackTexts.thumbsUp }); const getThumbsDownButton = (): HTMLElement => - screen.getByRole('button', { name: mockTexts.thumbsDown }); + screen.getByRole('button', { name: feedbackTexts.thumbsDown }); -const getSendButton = (): HTMLElement => screen.getByRole('button', { name: mockTexts.submit }); +const getSendButton = (): HTMLElement => screen.getByRole('button', { name: feedbackTexts.submit }); diff --git a/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts b/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts index f98851e7833..53ed0b38ae7 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts +++ b/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts @@ -19,7 +19,7 @@ const textAreaTexts: TextAreaTexts = { waitingForConnection: 'waitingForConnection', }; -const messageFeedbackTexts: MessageFeedbackTexts = { +export const messageFeedbackTexts: MessageFeedbackTexts = { thumbsUp: 'feedbackThumbsUp', thumbsDown: 'feedbackThumbsDown', heading: 'feedbackHeading', From 930e405c919d82eb28b03db2178afd9b47cae00e Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Wed, 13 May 2026 12:36:43 +0200 Subject: [PATCH 08/26] extract UserFeedback type; simplify onMessageFeedback handlers --- .../features/aiAssistant/AiAssistant.tsx | 12 ++------ .../src/Assistant/Assistant.tsx | 8 +++--- .../src/components/ChatColumn/ChatColumn.tsx | 6 ++-- .../MessageFeedback/MessageFeedback.test.tsx | 18 +++++++++--- .../MessageFeedback/MessageFeedback.tsx | 28 +++++++++---------- .../Messages/MessageFeedback/index.ts | 2 +- .../ChatColumn/Messages/Messages.tsx | 17 +++-------- .../CompleteInterface/CompleteInterface.tsx | 4 +-- .../libs/studio-assistant/src/index.ts | 6 +--- .../src/types/UserFeedback.ts | 5 ++++ 10 files changed, 50 insertions(+), 56 deletions(-) create mode 100644 src/Designer/frontend/libs/studio-assistant/src/types/UserFeedback.ts diff --git a/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx b/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx index 75343ef00a6..56400c13323 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx +++ b/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx @@ -1,6 +1,5 @@ import type { ReactElement } from 'react'; -import { useCallback } from 'react'; -import type { AssistantTexts, FeedbackVote } from '@studio/assistant'; +import type { AssistantTexts } from '@studio/assistant'; import { Assistant } from '@studio/assistant'; import { Trans, useTranslation } from 'react-i18next'; import { useAltinityAssistant, useAltinityPermissions } from './hooks'; @@ -17,13 +16,6 @@ function AiAssistant(): ReactElement { const userHasAccessToAssistant = useAltinityPermissions(); const { mutate: sendChatFeedback } = useChatFeedbackMutation(); - const handleMessageFeedback = useCallback( - (traceId: string, vote: FeedbackVote, comment?: string) => { - sendChatFeedback({ traceId, thumbsUp: vote === 'up', comment }); - }, - [sendChatFeedback], - ); - const { connectionStatus, workflowStatus, @@ -110,12 +102,12 @@ function AiAssistant(): ReactElement { onSelectThread={selectThread} onCreateThread={clearCurrentSession} onDeleteThread={deleteThread} + onMessageFeedback={sendChatFeedback} connectionStatus={connectionStatus} workflowStatus={workflowStatus} previewContent={} fileBrowserContent={} currentUser={currentUser} - onMessageFeedback={handleMessageFeedback} />
); diff --git a/src/Designer/frontend/libs/studio-assistant/src/Assistant/Assistant.tsx b/src/Designer/frontend/libs/studio-assistant/src/Assistant/Assistant.tsx index 688da98c5f2..e541a376dea 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/Assistant/Assistant.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/Assistant/Assistant.tsx @@ -3,7 +3,7 @@ import type { ReactElement } from 'react'; import type { ChatThread, UserMessage, Message } from '../types/ChatThread'; import { CompactInterface } from '../components/CompactInterface/CompactInterface'; import { CompleteInterface } from '../components/CompleteInterface/CompleteInterface'; -import type { MessageFeedbackHandler } from '../components/ChatColumn/Messages/Messages'; +import type { UserFeedback } from '../types/UserFeedback'; import type { AssistantTexts } from '../types/AssistantTexts'; import type { ConnectionStatus } from '../types/ConnectionStatus'; import type { WorkflowStatus } from '../types/WorkflowStatus'; @@ -23,11 +23,11 @@ export type AssistantProps = { onSelectThread?: (threadId: string) => void; onDeleteThread?: (threadId: string) => void; onCreateThread?: () => void; + onMessageFeedback?: (feedback: UserFeedback) => void; workflowStatus: WorkflowStatus; previewContent: ReactElement; fileBrowserContent?: ReactElement; currentUser?: User; - onMessageFeedback?: MessageFeedbackHandler; }; export function Assistant({ @@ -45,10 +45,10 @@ export function Assistant({ onSelectThread, onDeleteThread, onCreateThread, + onMessageFeedback, previewContent, fileBrowserContent, currentUser, - onMessageFeedback, }: AssistantProps): React.ReactElement { return enableCompactInterface ? ( @@ -67,10 +67,10 @@ export function Assistant({ onSelectThread={onSelectThread} onDeleteThread={onDeleteThread} onCreateThread={onCreateThread} + onMessageFeedback={onMessageFeedback} previewContent={previewContent} fileBrowserContent={fileBrowserContent} currentUser={currentUser} - onMessageFeedback={onMessageFeedback} /> ); } diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/ChatColumn.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/ChatColumn.tsx index 01afe673bae..47e8d8acc4a 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/ChatColumn.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/ChatColumn.tsx @@ -2,7 +2,7 @@ import type { ReactElement } from 'react'; import { useRef, useEffect } from 'react'; import cn from 'classnames'; import { Messages } from './Messages/Messages'; -import type { MessageFeedbackHandler } from './Messages/Messages'; +import type { UserFeedback } from '../../types/UserFeedback'; import { UserInput } from './UserInput/UserInput'; import classes from './ChatColumn.module.css'; import { StudioParagraph } from '@studio/components'; @@ -18,10 +18,10 @@ export type ChatColumnProps = { onCancelWorkflow?: () => void; cancelledMessageContent?: string | null; onCancelledMessageConsumed?: () => void; + onMessageFeedback?: (feedback: UserFeedback) => void; workflowStatus?: WorkflowStatus; enableCompactInterface: boolean; currentUser?: User; - onMessageFeedback?: MessageFeedbackHandler; }; export function ChatColumn({ @@ -31,10 +31,10 @@ export function ChatColumn({ onCancelWorkflow, cancelledMessageContent, onCancelledMessageConsumed, + onMessageFeedback, workflowStatus, enableCompactInterface, currentUser, - onMessageFeedback, }: ChatColumnProps): ReactElement { const workflowIsActive = workflowStatus?.isActive === true; const messagesEndRef = useRef(null); diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx index 4ab16f3f381..838b8b66252 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx @@ -4,6 +4,8 @@ import { MessageFeedback } from './MessageFeedback'; import type { MessageFeedbackProps } from './MessageFeedback'; import { messageFeedbackTexts as feedbackTexts } from '../../../../mocks/mockTexts'; +const traceId = 'trace-123'; + describe('MessageFeedback', () => { it('renders thumbs up and thumbs down buttons', () => { renderMessageFeedback(); @@ -14,8 +16,7 @@ describe('MessageFeedback', () => { it('opens feedback dialog when pressing either thumb button', async () => { const user = userEvent.setup(); - const onSubmit = jest.fn(); - renderMessageFeedback({ onSubmit }); + renderMessageFeedback(); await user.click(getThumbsUpButton()); @@ -31,7 +32,11 @@ describe('MessageFeedback', () => { await user.click(getSendButton()); expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit).toHaveBeenCalledWith('up'); + expect(onSubmit).toHaveBeenCalledWith({ + traceId, + thumbsUp: true, + comment: undefined, + }); }); it('calls onSubmit with comment when there is a comment', async () => { @@ -44,12 +49,17 @@ describe('MessageFeedback', () => { await user.click(getSendButton()); expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit).toHaveBeenCalledWith('down', 'Svaret traff ikke helt.'); + expect(onSubmit).toHaveBeenCalledWith({ + traceId, + thumbsUp: false, + comment: 'Svaret traff ikke helt.', + }); }); }); const defaultProps: MessageFeedbackProps = { texts: feedbackTexts, + traceId, onSubmit: jest.fn(), }; diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx index 44b41419a89..334b18dad55 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx @@ -9,32 +9,32 @@ import { } from '@studio/components'; import { ThumbDownIcon, ThumbUpIcon, PaperplaneFillIcon } from '@studio/icons'; import type { MessageFeedbackTexts } from '../../../../types/AssistantTexts'; +import type { UserFeedback } from '../../../../types/UserFeedback'; import classes from './MessageFeedback.module.css'; -export type FeedbackVote = 'up' | 'down'; - export type MessageFeedbackProps = { texts: MessageFeedbackTexts; - onSubmit: (vote: FeedbackVote, comment?: string) => void; + traceId: string; + onSubmit: (feedback: UserFeedback) => void; }; -export function MessageFeedback({ texts, onSubmit }: MessageFeedbackProps): ReactElement { - const [selectedVote, setSelectedVote] = useState(null); +export function MessageFeedback({ texts, traceId, onSubmit }: MessageFeedbackProps): ReactElement { + const [selectedThumbsUp, setSelectedThumbsUp] = useState(null); const [commentText, setCommentText] = useState(''); const dialogRef = useRef(null); - const handleVoteClick = (vote: FeedbackVote): void => { - setSelectedVote(vote); + const handleVoteClick = (thumbsUp: boolean): void => { + setSelectedThumbsUp(thumbsUp); dialogRef.current?.showModal(); }; const handleSendFeedback = (): void => { const trimmedComment = commentText.trim(); - if (trimmedComment) { - onSubmit(selectedVote, trimmedComment); - } else { - onSubmit(selectedVote); - } + onSubmit({ + traceId, + thumbsUp: selectedThumbsUp, + comment: trimmedComment || undefined, + }); }; return ( @@ -45,7 +45,7 @@ export function MessageFeedback({ texts, onSubmit }: MessageFeedbackProps): Reac data-size='sm' aria-label={texts.thumbsUp} title={texts.thumbsUp} - onClick={() => handleVoteClick('up')} + onClick={() => handleVoteClick(true)} icon={} /> handleVoteClick('down')} + onClick={() => handleVoteClick(false)} icon={} /> diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/index.ts b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/index.ts index 1e9b841227d..dc0315c378b 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/index.ts +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/index.ts @@ -1,2 +1,2 @@ export { MessageFeedback } from './MessageFeedback'; -export type { FeedbackVote, MessageFeedbackProps } from './MessageFeedback'; +export type { MessageFeedbackProps } from './MessageFeedback'; diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.tsx index aabecc2bf37..939d87c22f5 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.tsx @@ -14,24 +14,18 @@ import { } from '../../../utils/messageUtils'; import { ChatAvatar } from '../ChatAvatar'; import { MessageFeedback } from './MessageFeedback'; -import type { FeedbackVote } from './MessageFeedback'; +import type { UserFeedback } from '../../../types/UserFeedback'; const ASSISTANT_LABEL = 'Altinity'; const DEFAULT_USER_LABEL = 'Deg'; -export type MessageFeedbackHandler = ( - traceId: string, - vote: FeedbackVote, - comment?: string, -) => void; - export type MessagesProps = { messages: Message[]; workflowStatus?: WorkflowStatus; currentUser?: User; assistantAvatarUrl?: string; feedbackTexts?: MessageFeedbackTexts; - onMessageFeedback?: MessageFeedbackHandler; + onMessageFeedback?: (feedback: UserFeedback) => void; }; export function Messages({ @@ -95,7 +89,7 @@ type MessageItemProps = { currentUser?: User; assistantAvatarUrl?: string; feedbackTexts?: MessageFeedbackTexts; - onMessageFeedback?: MessageFeedbackHandler; + onMessageFeedback?: (feedback: UserFeedback) => void; }; function MessageItem({ @@ -296,10 +290,7 @@ function MessageItem({ {renderSources()} {renderFilesChanged()} {showFeedback && ( - onMessageFeedback!(traceId!, vote, comment)} - /> + )} diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/CompleteInterface/CompleteInterface.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/CompleteInterface/CompleteInterface.tsx index 8ad58ed2fde..f9e5b2a7e94 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/CompleteInterface/CompleteInterface.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/CompleteInterface/CompleteInterface.tsx @@ -29,10 +29,10 @@ export function CompleteInterface({ onSelectThread, onDeleteThread, onCreateThread, + onMessageFeedback, previewContent, fileBrowserContent, currentUser, - onMessageFeedback, }: CompleteInterfaceProps): ReactElement { const [isThreadColumnCollapsed, setIsThreadColumnCollapsed] = useState(false); const [toolColumnMode, setToolColumnMode] = useState(ToolColumnMode.Preview); @@ -92,10 +92,10 @@ export function CompleteInterface({ onCancelWorkflow={onCancelWorkflow} cancelledMessageContent={cancelledMessageContent} onCancelledMessageConsumed={onCancelledMessageConsumed} + onMessageFeedback={onMessageFeedback} workflowStatus={currentThreadWorkflowStatus} enableCompactInterface={false} currentUser={currentUser} - onMessageFeedback={onMessageFeedback} />
diff --git a/src/Designer/frontend/libs/studio-assistant/src/index.ts b/src/Designer/frontend/libs/studio-assistant/src/index.ts index eaa8c6f1d5f..b88fa034384 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/index.ts +++ b/src/Designer/frontend/libs/studio-assistant/src/index.ts @@ -8,11 +8,7 @@ export type { AboutAssistantDialogTexts, MessageFeedbackTexts, } from './types/AssistantTexts'; -export type { - MessageFeedbackHandler, - MessagesProps, -} from './components/ChatColumn/Messages/Messages'; -export type { FeedbackVote } from './components/ChatColumn/Messages/MessageFeedback'; +export type { MessagesProps } from './components/ChatColumn/Messages/Messages'; export type { AssistantMessage } from './types/ChatThread'; export type { User } from './types/User'; export type { WorkflowStatus } from './types/WorkflowStatus'; diff --git a/src/Designer/frontend/libs/studio-assistant/src/types/UserFeedback.ts b/src/Designer/frontend/libs/studio-assistant/src/types/UserFeedback.ts new file mode 100644 index 00000000000..b129dd18a67 --- /dev/null +++ b/src/Designer/frontend/libs/studio-assistant/src/types/UserFeedback.ts @@ -0,0 +1,5 @@ +export type UserFeedback = { + traceId: string; + thumbsUp: boolean; + comment?: string; +}; From 43467a2876b8fbd102948c96265339302605b81f Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Wed, 13 May 2026 13:21:54 +0200 Subject: [PATCH 09/26] remove unnecessary error logging --- src/AI/agents/api/routes/agent.py | 4 +--- src/Designer/frontend/AGENTS.md | 2 ++ .../useAltinityWorkflow/useAltinityWorkflow.ts | 16 +++++----------- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/AI/agents/api/routes/agent.py b/src/AI/agents/api/routes/agent.py index 77eb56b2a14..5e4cb9d325f 100644 --- a/src/AI/agents/api/routes/agent.py +++ b/src/AI/agents/api/routes/agent.py @@ -338,6 +338,4 @@ async def get_session_status(session_id: str): status = sink.get_session_status(session_id) if status is None: return {"session_id": session_id, "status": "unknown"} - return {"session_id": session_id, **status} - - + return {"session_id": session_id, **status} \ No newline at end of file diff --git a/src/Designer/frontend/AGENTS.md b/src/Designer/frontend/AGENTS.md index 661cfcee653..35596398f54 100644 --- a/src/Designer/frontend/AGENTS.md +++ b/src/Designer/frontend/AGENTS.md @@ -94,6 +94,8 @@ The frontend consists of several React packages in the following directories: Components MUST use custom hooks, never call `useQuery`/`useMutation` directly. +**Error handling:** A global `MutationCache`/`QueryCache` `onError` in `ServicesContext.tsx` surfaces all query and mutation errors as toasts. Do not add per-call `onError`/`.catch` handlers just to log or toast — that's already covered. Only add a local handler when you need behavior beyond the default toast. To suppress the default toast for a specific call, set `meta.hideDefaultError` on the query/mutation options. + ## Unit tests - Unit tests should be placed in the same folder as the component it is testing. diff --git a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWorkflow/useAltinityWorkflow.ts b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWorkflow/useAltinityWorkflow.ts index 70c606d3278..4860b528153 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWorkflow/useAltinityWorkflow.ts +++ b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWorkflow/useAltinityWorkflow.ts @@ -140,9 +140,7 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo const handleWorkflowEvent = useCallback( (event: WorkflowEvent) => { if (event.type === 'assistant_message') { - handleAssistantMessage(event).catch((error) => - console.error('Failed to handle assistant message:', error), - ); + handleAssistantMessage(event); } else if (event.type === 'status') { const isTerminal = event.data?.status === 'completed' || @@ -165,7 +163,7 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo content: WORKFLOW_ERROR_MESSAGE, createdAt: new Date().toISOString(), filesChanged: [], - }).catch((error) => console.error('Failed to persist error message:', error)); + }); } }, [applyStatusMessage, handleAssistantMessage, currentSessionIdRef, createMessage], @@ -223,9 +221,7 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo const runWorkflowForSession = useCallback( async (threadId: string, userMessage: UserMessage): Promise => { activeWorkflowThreadId.current = threadId; - createMessage(threadId, userMessage).catch((error) => - console.error('Failed to persist user message:', error), - ); + createMessage(threadId, userMessage); try { const result = await startAgentWorkflow( threadId, @@ -239,9 +235,7 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo content: formatRejectionMessage(result), createdAt: new Date().toISOString(), filesChanged: [], - }).catch((persistError) => - console.error('Failed to persist rejection message:', persistError), - ); + }); } } catch (error) { console.error('Workflow request failed:', error); @@ -250,7 +244,7 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo content: WORKFLOW_ERROR_MESSAGE, createdAt: new Date().toISOString(), filesChanged: [], - }).catch((persistError) => console.error('Failed to persist error message:', persistError)); + }); } }, [createMessage, startAgentWorkflow], From 9cfa6046303d6f7fd1df7bd85bd8479a541a85c8 Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Wed, 13 May 2026 14:09:12 +0200 Subject: [PATCH 10/26] extract decorateMessagesWithTraceIds to utils file --- src/Designer/frontend/AGENTS.md | 2 +- .../useAltinityAssistant.test.ts | 2 +- .../useAltinityAssistant.ts | 24 +++--------- .../useAltinityWorkflow.ts | 18 +++++++-- .../aiAssistant/utils/messageUtils.test.ts | 38 ++++++++++++++++++- .../aiAssistant/utils/messageUtils.ts | 15 +++++++- 6 files changed, 70 insertions(+), 29 deletions(-) diff --git a/src/Designer/frontend/AGENTS.md b/src/Designer/frontend/AGENTS.md index 35596398f54..7ff164acf70 100644 --- a/src/Designer/frontend/AGENTS.md +++ b/src/Designer/frontend/AGENTS.md @@ -94,7 +94,7 @@ The frontend consists of several React packages in the following directories: Components MUST use custom hooks, never call `useQuery`/`useMutation` directly. -**Error handling:** A global `MutationCache`/`QueryCache` `onError` in `ServicesContext.tsx` surfaces all query and mutation errors as toasts. Do not add per-call `onError`/`.catch` handlers just to log or toast — that's already covered. Only add a local handler when you need behavior beyond the default toast. To suppress the default toast for a specific call, set `meta.hideDefaultError` on the query/mutation options. +**Error handling:** A global `MutationCache`/`QueryCache` `onError` in `ServicesContext.tsx` surfaces all query and mutation errors as toasts. Do not add per-call error handlers just to log or toast — that's already covered. Only add a local handler when you need behavior beyond the default toast. ## Unit tests diff --git a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.test.ts b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.test.ts index 35647acfedc..c9225a48295 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.test.ts +++ b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.test.ts @@ -30,7 +30,7 @@ describe('useAltinityAssistant', () => { cancelCurrentWorkflow: jest.fn(), cancelledMessageContent: null, clearCancelledMessageContent: jest.fn(), - traceIdsByMessageId: {}, + messages: [], }); const { result } = renderUseAltinityAssistant(); diff --git a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.ts b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.ts index 9c45d9275c5..e47975db442 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.ts +++ b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.ts @@ -1,4 +1,3 @@ -import { useMemo } from 'react'; import type { ChatThread, Message, @@ -6,7 +5,6 @@ import type { WorkflowStatus, ConnectionStatus, } from '@studio/assistant'; -import { MessageAuthor } from '@studio/assistant'; import { useAltinityThreads } from '../useAltinityThreads/useAltinityThreads'; import { useAltinityWorkflow } from '../useAltinityWorkflow/useAltinityWorkflow'; @@ -25,6 +23,10 @@ export interface UseAltinityAssistantResult { deleteThread: (threadId: string) => void; } +/** + * Cohabitates all the callers that the main AiAssistant component needs. Do not add logic to this hook beyond this. + * TODO: consider exposing useAltinityWorkflow to the caller directly, and deleting this hook. + */ export const useAltinityAssistant = (): UseAltinityAssistantResult => { const threads = useAltinityThreads(); const { @@ -35,14 +37,9 @@ export const useAltinityAssistant = (): UseAltinityAssistantResult => { cancelCurrentWorkflow, cancelledMessageContent, clearCancelledMessageContent, - traceIdsByMessageId, + messages, } = useAltinityWorkflow(threads); - const messages = useMemo( - () => decorateMessagesWithTraceIds(threads.chatMessages, traceIdsByMessageId), - [threads.chatMessages, traceIdsByMessageId], - ); - return { connectionStatus, workflowStatus, @@ -58,14 +55,3 @@ export const useAltinityAssistant = (): UseAltinityAssistantResult => { deleteThread: threads.deleteThread, }; }; - -function decorateMessagesWithTraceIds( - messages: Message[], - traceIdsByMessageId: Record, -): Message[] { - return messages.map((message) => { - if (message.role !== MessageAuthor.Assistant || !message.id) return message; - const traceId = traceIdsByMessageId[message.id]; - return traceId ? { ...message, traceId } : message; - }); -} diff --git a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWorkflow/useAltinityWorkflow.ts b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWorkflow/useAltinityWorkflow.ts index 4860b528153..19d5491b988 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWorkflow/useAltinityWorkflow.ts +++ b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWorkflow/useAltinityWorkflow.ts @@ -1,7 +1,8 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import type { UserMessage, AssistantMessage, + Message, WorkflowEvent, WorkflowStatus, ConnectionStatus, @@ -17,6 +18,7 @@ import { useCheckoutBranchMutation } from 'app-shared/hooks/mutations/useCheckou import { useAltinityWebSocket } from '../useAltinityWebSocket/useAltinityWebSocket'; import type { AltinityThreadState } from '../useAltinityThreads/useAltinityThreads'; import { + decorateMessagesWithTraceIds, formatRejectionMessage, getAssistantMessageContent, getAssistantMessageTimestamp, @@ -36,7 +38,7 @@ export interface UseAltinityWorkflowResult { cancelCurrentWorkflow: () => Promise; cancelledMessageContent: string | null; clearCancelledMessageContent: () => void; - traceIdsByMessageId: Record; + messages: Message[]; } export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWorkflowResult => { @@ -127,7 +129,10 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo const persisted = await createMessage(threadId, finalAssistantMessage); if (assistantMessage.traceId && persisted?.id) { - setTraceIdsByMessageId((prev) => ({ ...prev, [persisted.id]: assistantMessage.traceId! })); + setTraceIdsByMessageId((prev) => ({ + ...prev, + [persisted.id]: assistantMessage.traceId, + })); } if (event.session_id && !shouldSkipBranchOps(assistantMessage)) { @@ -305,6 +310,11 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo setCancelledMessageContent(null); }, []); + const messages = useMemo( + () => decorateMessagesWithTraceIds(chatMessages, traceIdsByMessageId), + [chatMessages, traceIdsByMessageId], + ); + return { connectionStatus, workflowStatus, @@ -313,7 +323,7 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo cancelCurrentWorkflow, cancelledMessageContent, clearCancelledMessageContent, - traceIdsByMessageId, + messages, }; }; diff --git a/src/Designer/frontend/app-development/features/aiAssistant/utils/messageUtils.test.ts b/src/Designer/frontend/app-development/features/aiAssistant/utils/messageUtils.test.ts index d8e69e2d18a..a2c46f9e981 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/utils/messageUtils.test.ts +++ b/src/Designer/frontend/app-development/features/aiAssistant/utils/messageUtils.test.ts @@ -1,6 +1,7 @@ -import type { AgentResponse, AssistantMessageData } from '@studio/assistant'; -import { ErrorMessages } from '@studio/assistant'; +import type { AgentResponse, AssistantMessageData, Message } from '@studio/assistant'; +import { ErrorMessages, MessageAuthor } from '@studio/assistant'; import { + decorateMessagesWithTraceIds, formatErrorMessage, formatRejectionMessage, getAssistantMessageContent, @@ -66,4 +67,37 @@ describe('messageUtils', () => { expect(shouldSkipBranchOps({ no_branch_operations: true })).toBe(true); }); }); + + describe('decorateMessagesWithTraceIds', () => { + const assistantMessage: Message = { + id: 'assistant-1', + role: MessageAuthor.Assistant, + content: 'Hi', + createdAt: 'now', + }; + const userMessage: Message = { + id: 'user-1', + role: MessageAuthor.User, + content: 'Hello', + createdAt: 'now', + allowAppChanges: false, + }; + + it('attaches traceId to assistant messages with a matching id', () => { + const [decorated] = decorateMessagesWithTraceIds([assistantMessage], { + 'assistant-1': 'trace-1', + }); + expect(decorated).toEqual({ ...assistantMessage, traceId: 'trace-1' }); + }); + + it('leaves messages without a matching traceId unchanged', () => { + const [decorated] = decorateMessagesWithTraceIds([assistantMessage], {}); + expect(decorated).toBe(assistantMessage); + }); + + it('does not decorate user messages', () => { + const [decorated] = decorateMessagesWithTraceIds([userMessage], { 'user-1': 'trace-1' }); + expect(decorated).toBe(userMessage); + }); + }); }); diff --git a/src/Designer/frontend/app-development/features/aiAssistant/utils/messageUtils.ts b/src/Designer/frontend/app-development/features/aiAssistant/utils/messageUtils.ts index f5a4189bfea..ada3519168d 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/utils/messageUtils.ts +++ b/src/Designer/frontend/app-development/features/aiAssistant/utils/messageUtils.ts @@ -1,5 +1,16 @@ -import type { AgentResponse, AssistantMessageData } from '@studio/assistant'; -import { ErrorMessages } from '@studio/assistant'; +import type { AgentResponse, AssistantMessageData, Message } from '@studio/assistant'; +import { ErrorMessages, MessageAuthor } from '@studio/assistant'; + +export function decorateMessagesWithTraceIds( + messages: Message[], + traceIdsByMessageId: Record, +): Message[] { + return messages.map((message) => { + if (message.role !== MessageAuthor.Assistant || !message.id) return message; + const traceId = traceIdsByMessageId[message.id]; + return traceId ? { ...message, traceId } : message; + }); +} export function formatRejectionMessage(result: AgentResponse): string { const suggestions = result.parsed_intent?.suggestions From ad008e69cabe1697b3e1b415cedc0eb81945bbc3 Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Wed, 13 May 2026 15:16:17 +0200 Subject: [PATCH 11/26] validate developer owns trace before writing feedback to it --- src/AI/agents/api/routes/feedback.py | 19 ++++- src/AI/agents/shared/utils/langfuse_utils.py | 26 +++++++ src/AI/agents/tests/api/test_feedback.py | 78 +++++++++++++++---- .../MessageFeedback/MessageFeedback.test.tsx | 10 +++ .../MessageFeedback/MessageFeedback.tsx | 2 + 5 files changed, 118 insertions(+), 17 deletions(-) diff --git a/src/AI/agents/api/routes/feedback.py b/src/AI/agents/api/routes/feedback.py index b4d89d9c2ac..f38522c394c 100644 --- a/src/AI/agents/api/routes/feedback.py +++ b/src/AI/agents/api/routes/feedback.py @@ -2,10 +2,10 @@ from typing import Optional -from fastapi import APIRouter, Response +from fastapi import APIRouter, HTTPException, Request, Response from pydantic import BaseModel, field_validator -from shared.utils.langfuse_utils import score_validation +from shared.utils.langfuse_utils import get_trace_developer, score_validation from shared.utils.logging_utils import get_logger router = APIRouter() @@ -13,6 +13,7 @@ FEEDBACK_SCORE_NAME = "user_feedback" FEEDBACK_COMMENT_MAX_LENGTH = 10000 +DEVELOPER_HEADER = "X-Developer" class FeedbackReq(BaseModel): @@ -42,8 +43,20 @@ def _validate_comment(cls, v: Optional[str]) -> Optional[str]: @router.post("/api/feedback", status_code=204) -async def submit_feedback(req: FeedbackReq): +async def submit_feedback(req: FeedbackReq, request: Request): """Records user feedback as a Langfuse score on the given trace.""" + caller = request.headers.get(DEVELOPER_HEADER) + if not caller: + raise HTTPException( + status_code=400, detail=f"Missing {DEVELOPER_HEADER} header" + ) + + trace_owner = get_trace_developer(req.trace_id) + if trace_owner is None: + raise HTTPException(status_code=403, detail="Trace owner unknown") + if trace_owner != caller: + raise HTTPException(status_code=403, detail="Not the trace owner") + score_validation( name=FEEDBACK_SCORE_NAME, passed=req.thumbs_up, diff --git a/src/AI/agents/shared/utils/langfuse_utils.py b/src/AI/agents/shared/utils/langfuse_utils.py index 8a872ccdd08..1d450df0fd6 100644 --- a/src/AI/agents/shared/utils/langfuse_utils.py +++ b/src/AI/agents/shared/utils/langfuse_utils.py @@ -237,6 +237,32 @@ def __exit__(self, *args): pass +def get_trace_developer(trace_id: str) -> str | None: + """Return the developer stored on a Langfuse trace's root-span metadata.""" + client = get_langfuse_client() + if not client or not trace_id: + return None + try: + trace = client.api.trace.get(trace_id) + except Exception as exc: + log.debug("Langfuse trace lookup failed for %s: %s", trace_id, exc) + return None + + observations = getattr(trace, "observations", None) or [] + root_observation = next( + ( + obs + for obs in observations + if not getattr(obs, "parent_observation_id", None) + ), + None, + ) + if root_observation is None: + return None + metadata = getattr(root_observation, "metadata", None) or {} + return metadata.get("developer") + + def get_current_trace_id() -> str | None: if not is_langfuse_enabled(): return None diff --git a/src/AI/agents/tests/api/test_feedback.py b/src/AI/agents/tests/api/test_feedback.py index ab69e9549de..e6a0801c3da 100644 --- a/src/AI/agents/tests/api/test_feedback.py +++ b/src/AI/agents/tests/api/test_feedback.py @@ -1,17 +1,29 @@ from unittest.mock import patch + from fastapi.testclient import TestClient + from api.main import app FEEDBACK_PATH = "/api/feedback" VALID_TRACE_ID = "trace-abc-123" +DEVELOPER = "ola" +OTHER_DEVELOPER = "kari" +DEVELOPER_HEADER = {"X-Developer": DEVELOPER} + + +def _post_feedback(payload, headers=None): + return TestClient(app).post(FEEDBACK_PATH, json=payload, headers=headers) class TestFeedbackEndpoint: def test_thumbs_up_writes_score_and_returns_204(self): - with patch("api.routes.feedback.score_validation") as mock_score: - response = TestClient(app).post( - FEEDBACK_PATH, - json={"trace_id": VALID_TRACE_ID, "thumbs_up": True}, + with ( + patch("api.routes.feedback.get_trace_developer", return_value=DEVELOPER), + patch("api.routes.feedback.score_validation") as mock_score, + ): + response = _post_feedback( + {"trace_id": VALID_TRACE_ID, "thumbs_up": True}, + headers=DEVELOPER_HEADER, ) assert response.status_code == 204 @@ -23,14 +35,17 @@ def test_thumbs_up_writes_score_and_returns_204(self): ) def test_thumbs_down_with_comment_is_forwarded(self): - with patch("api.routes.feedback.score_validation") as mock_score: - response = TestClient(app).post( - FEEDBACK_PATH, - json={ + with ( + patch("api.routes.feedback.get_trace_developer", return_value=DEVELOPER), + patch("api.routes.feedback.score_validation") as mock_score, + ): + response = _post_feedback( + { "trace_id": VALID_TRACE_ID, "thumbs_up": False, "comment": "Svaret var ikke nyttig.", }, + headers=DEVELOPER_HEADER, ) assert response.status_code == 204 @@ -41,11 +56,46 @@ def test_thumbs_down_with_comment_is_forwarded(self): comment="Svaret var ikke nyttig.", ) + def test_missing_developer_header_returns_400(self): + with patch("api.routes.feedback.score_validation") as mock_score: + response = _post_feedback({"trace_id": VALID_TRACE_ID, "thumbs_up": True}) + + assert response.status_code == 400 + mock_score.assert_not_called() + + def test_unknown_trace_owner_returns_403(self): + with ( + patch("api.routes.feedback.get_trace_developer", return_value=None), + patch("api.routes.feedback.score_validation") as mock_score, + ): + response = _post_feedback( + {"trace_id": VALID_TRACE_ID, "thumbs_up": True}, + headers=DEVELOPER_HEADER, + ) + + assert response.status_code == 403 + mock_score.assert_not_called() + + def test_owner_mismatch_returns_403(self): + with ( + patch( + "api.routes.feedback.get_trace_developer", return_value=OTHER_DEVELOPER + ), + patch("api.routes.feedback.score_validation") as mock_score, + ): + response = _post_feedback( + {"trace_id": VALID_TRACE_ID, "thumbs_up": True}, + headers=DEVELOPER_HEADER, + ) + + assert response.status_code == 403 + mock_score.assert_not_called() + def test_empty_trace_id_returns_422(self): with patch("api.routes.feedback.score_validation") as mock_score: - response = TestClient(app).post( - FEEDBACK_PATH, - json={"trace_id": "", "thumbs_up": True}, + response = _post_feedback( + {"trace_id": "", "thumbs_up": True}, + headers=DEVELOPER_HEADER, ) assert response.status_code == 422 @@ -53,13 +103,13 @@ def test_empty_trace_id_returns_422(self): def test_comment_over_max_length_returns_422(self): with patch("api.routes.feedback.score_validation") as mock_score: - response = TestClient(app).post( - FEEDBACK_PATH, - json={ + response = _post_feedback( + { "trace_id": VALID_TRACE_ID, "thumbs_up": True, "comment": "x" * 10001, }, + headers=DEVELOPER_HEADER, ) assert response.status_code == 422 diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx index 838b8b66252..64e6583ff06 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx @@ -39,6 +39,16 @@ describe('MessageFeedback', () => { }); }); + it('closes the dialog after submitting feedback', async () => { + const user = userEvent.setup(); + renderMessageFeedback(); + + await user.click(getThumbsUpButton()); + await user.click(getSendButton()); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + it('calls onSubmit with comment when there is a comment', async () => { const user = userEvent.setup(); const onSubmit = jest.fn(); diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx index 334b18dad55..77f7b0eccef 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx @@ -35,6 +35,7 @@ export function MessageFeedback({ texts, traceId, onSubmit }: MessageFeedbackPro thumbsUp: selectedThumbsUp, comment: trimmedComment || undefined, }); + dialogRef.current?.close(); }; return ( @@ -64,6 +65,7 @@ export function MessageFeedback({ texts, traceId, onSubmit }: MessageFeedbackPro {texts.body} + {/* TODO: Add label for the text area, with an "optional" tag */} setCommentText(event.target.value)} From f31a524523631e38b75eb4082ae66f65f8e83e94 Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Thu, 14 May 2026 09:08:28 +0200 Subject: [PATCH 12/26] reset feedback states when closing dialog --- .../hooks/useAltinityWebSocket/useAltinityWebSocket.ts | 1 - .../Messages/MessageFeedback/MessageFeedback.tsx | 8 +++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWebSocket/useAltinityWebSocket.ts b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWebSocket/useAltinityWebSocket.ts index 43f7eea900c..90de8a150e1 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWebSocket/useAltinityWebSocket.ts +++ b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWebSocket/useAltinityWebSocket.ts @@ -118,7 +118,6 @@ function registerAgentMessageHandler( messageCallbackRef: React.MutableRefObject<((message: WorkflowEvent) => void) | null>, ): void { connection.on(AltinityClientsName.ReceiveAgentMessage, (message: WorkflowEvent) => { - console.log('[WS frame]', message.type, JSON.stringify(message.data)); if ( message.type === 'workflow_status' && message.data?.message?.toLowerCase() === 'session created' diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx index 77f7b0eccef..ab64b856d41 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx @@ -35,7 +35,13 @@ export function MessageFeedback({ texts, traceId, onSubmit }: MessageFeedbackPro thumbsUp: selectedThumbsUp, comment: trimmedComment || undefined, }); + handleDialogClose(); + }; + + const handleDialogClose = (): void => { dialogRef.current?.close(); + setSelectedThumbsUp(null); + setCommentText(''); }; return ( @@ -59,7 +65,7 @@ export function MessageFeedback({ texts, traceId, onSubmit }: MessageFeedbackPro /> - + {texts.heading} From f97a73c9c455ff74fc8fe3e94d732f00b3c6e9d4 Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Fri, 15 May 2026 08:50:21 +0200 Subject: [PATCH 13/26] fix typing to pass strict null checks --- .../Messages/MessageFeedback/MessageFeedback.tsx | 12 +++++++----- .../src/components/ChatColumn/Messages/Messages.tsx | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx index ab64b856d41..51dea89da1a 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx @@ -19,20 +19,22 @@ export type MessageFeedbackProps = { }; export function MessageFeedback({ texts, traceId, onSubmit }: MessageFeedbackProps): ReactElement { - const [selectedThumbsUp, setSelectedThumbsUp] = useState(null); + const [selectedVote, setSelectedVote] = useState(null); const [commentText, setCommentText] = useState(''); const dialogRef = useRef(null); - const handleVoteClick = (thumbsUp: boolean): void => { - setSelectedThumbsUp(thumbsUp); + const handleVoteClick = (vote: boolean): void => { + setSelectedVote(vote); dialogRef.current?.showModal(); }; const handleSendFeedback = (): void => { + if (selectedVote === null) return; + const trimmedComment = commentText.trim(); onSubmit({ traceId, - thumbsUp: selectedThumbsUp, + thumbsUp: selectedVote, comment: trimmedComment || undefined, }); handleDialogClose(); @@ -40,7 +42,7 @@ export function MessageFeedback({ texts, traceId, onSubmit }: MessageFeedbackPro const handleDialogClose = (): void => { dialogRef.current?.close(); - setSelectedThumbsUp(null); + setSelectedVote(null); setCommentText(''); }; diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.tsx index 939d87c22f5..87ddfa0621b 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.tsx @@ -274,7 +274,7 @@ function MessageItem({ }; const traceId = message.role === MessageAuthor.Assistant ? message.traceId : undefined; - const showFeedback = Boolean(traceId && feedbackTexts && onMessageFeedback); + const showFeedback = traceId && feedbackTexts && onMessageFeedback; return (
From b02142b1c80d7f785d37dc265a77f3366076299f Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Fri, 15 May 2026 09:07:00 +0200 Subject: [PATCH 14/26] use state for dialog --- .../Messages/MessageFeedback/MessageFeedback.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx index 51dea89da1a..424573151d6 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import { useRef, useState } from 'react'; +import { useState } from 'react'; import { StudioButton, StudioDialog, @@ -19,13 +19,13 @@ export type MessageFeedbackProps = { }; export function MessageFeedback({ texts, traceId, onSubmit }: MessageFeedbackProps): ReactElement { + const [isDialogOpen, setIsDialogOpen] = useState(false); const [selectedVote, setSelectedVote] = useState(null); const [commentText, setCommentText] = useState(''); - const dialogRef = useRef(null); const handleVoteClick = (vote: boolean): void => { setSelectedVote(vote); - dialogRef.current?.showModal(); + setIsDialogOpen(true); }; const handleSendFeedback = (): void => { @@ -41,7 +41,7 @@ export function MessageFeedback({ texts, traceId, onSubmit }: MessageFeedbackPro }; const handleDialogClose = (): void => { - dialogRef.current?.close(); + setIsDialogOpen(false); setSelectedVote(null); setCommentText(''); }; @@ -67,7 +67,7 @@ export function MessageFeedback({ texts, traceId, onSubmit }: MessageFeedbackPro />
- + {texts.heading} From 162148082956ca9f5827b30dd7c9465891982f7f Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Fri, 15 May 2026 11:58:18 +0200 Subject: [PATCH 15/26] use StudioFormGroup in MessageFeedback --- .../features/aiAssistant/AiAssistant.tsx | 6 ++++-- src/Designer/frontend/language/src/nb.json | 5 +++-- .../MessageFeedback/MessageFeedback.tsx | 17 +++++++++-------- .../studio-assistant/src/mocks/mockTexts.ts | 6 ++++-- .../src/types/AssistantTexts.ts | 6 ++++-- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx b/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx index 56400c13323..203fcecc104 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx +++ b/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx @@ -70,8 +70,10 @@ function AiAssistant(): ReactElement { feedback: { thumbsUp: t('ai_assistant.feedback_thumbs_up'), thumbsDown: t('ai_assistant.feedback_thumbs_down'), - heading: t('ai_assistant.feedback_heading'), - body: t('ai_assistant.feedback_body'), + positiveHeading: t('ai_assistant.feedback_positive_heading'), + negativeHeading: t('ai_assistant.feedback_negative_heading'), + detailsLabel: t('ai_assistant.feedback_details_label'), + detailsOptionalTag: t('general.optional'), submit: t('ai_assistant.feedback_submit'), }, }; diff --git a/src/Designer/frontend/language/src/nb.json b/src/Designer/frontend/language/src/nb.json index fc6f5f69ab1..b158063b136 100644 --- a/src/Designer/frontend/language/src/nb.json +++ b/src/Designer/frontend/language/src/nb.json @@ -87,8 +87,9 @@ "ai_assistant.add_attachment": "Last opp vedlegg", "ai_assistant.allow_app_changes": "Tillat endringer i appen", "ai_assistant.assistant_first_message": "Hva kan jeg hjelpe med?", - "ai_assistant.feedback_body": "Takk for tilbakemeldingen. Vil du utdype?", - "ai_assistant.feedback_heading": "Tilbakemelding", + "ai_assistant.feedback_details_label": "Detaljer", + "ai_assistant.feedback_negative_heading": "Negativ tilbakemelding", + "ai_assistant.feedback_positive_heading": "Positiv tilbakemelding", "ai_assistant.feedback_submit": "Send", "ai_assistant.feedback_thumbs_down": "Ikke nyttig svar", "ai_assistant.feedback_thumbs_up": "Nyttig svar", diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx index 424573151d6..a6ba153e445 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx @@ -3,8 +3,8 @@ import { useState } from 'react'; import { StudioButton, StudioDialog, + StudioFormGroup, StudioHeading, - StudioParagraph, StudioTextarea, } from '@studio/components'; import { ThumbDownIcon, ThumbUpIcon, PaperplaneFillIcon } from '@studio/icons'; @@ -22,6 +22,7 @@ export function MessageFeedback({ texts, traceId, onSubmit }: MessageFeedbackPro const [isDialogOpen, setIsDialogOpen] = useState(false); const [selectedVote, setSelectedVote] = useState(null); const [commentText, setCommentText] = useState(''); + const dialogHeading = selectedVote === true ? texts.positiveHeading : texts.negativeHeading; const handleVoteClick = (vote: boolean): void => { setSelectedVote(vote); @@ -69,15 +70,15 @@ export function MessageFeedback({ texts, traceId, onSubmit }: MessageFeedbackPro - {texts.heading} + {dialogHeading} - {texts.body} - {/* TODO: Add label for the text area, with an "optional" tag */} - setCommentText(event.target.value)} - /> + + setCommentText(event.target.value)} + /> +
Date: Fri, 15 May 2026 12:56:53 +0200 Subject: [PATCH 16/26] feedback endpoint returns 403 without message --- src/AI/agents/api/routes/feedback.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/AI/agents/api/routes/feedback.py b/src/AI/agents/api/routes/feedback.py index f38522c394c..b37a37ca377 100644 --- a/src/AI/agents/api/routes/feedback.py +++ b/src/AI/agents/api/routes/feedback.py @@ -52,10 +52,8 @@ async def submit_feedback(req: FeedbackReq, request: Request): ) trace_owner = get_trace_developer(req.trace_id) - if trace_owner is None: - raise HTTPException(status_code=403, detail="Trace owner unknown") if trace_owner != caller: - raise HTTPException(status_code=403, detail="Not the trace owner") + raise HTTPException(status_code=403) score_validation( name=FEEDBACK_SCORE_NAME, From fbe5e424b1cdc7edbb4a89a63b3f27ce6e40ed9b Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Fri, 15 May 2026 13:57:51 +0200 Subject: [PATCH 17/26] simplify designer backend code --- .../Designer/Controllers/ChatController.cs | 20 +--------- .../Models/Dto/ChatFeedbackRequest.cs | 6 ++- .../Altinity/AltinityAgentClient.cs | 21 ++--------- .../Altinity/IAltinityAgentClient.cs | 16 ++------ .../ChatController/SubmitFeedbackTests.cs | 37 +------------------ src/Designer/frontend/AGENTS.md | 2 +- 6 files changed, 17 insertions(+), 85 deletions(-) diff --git a/src/Designer/backend/src/Designer/Controllers/ChatController.cs b/src/Designer/backend/src/Designer/Controllers/ChatController.cs index 79d53435660..21a39d310ae 100644 --- a/src/Designer/backend/src/Designer/Controllers/ChatController.cs +++ b/src/Designer/backend/src/Designer/Controllers/ChatController.cs @@ -137,26 +137,10 @@ CancellationToken cancellationToken [HttpPost("feedback")] [RequestSizeLimit(20_000)] - public async Task SubmitFeedback( - string org, - string app, - [FromBody] ChatFeedbackRequest request, - CancellationToken cancellationToken - ) + public async Task SubmitFeedback(string org, string app, [FromBody] ChatFeedbackRequest request) { - if (string.IsNullOrWhiteSpace(request.TraceId)) - { - return BadRequest("traceId is required"); - } - string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); - await altinityAgentClient.SendFeedbackAsync( - developer, - request.TraceId, - request.ThumbsUp, - request.Comment, - cancellationToken - ); + await altinityAgentClient.SendFeedbackAsync(developer, request.TraceId, request.ThumbsUp, request.Comment); return NoContent(); } diff --git a/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs b/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs index 7148dd2b0f5..22733db0a52 100644 --- a/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs +++ b/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs @@ -2,4 +2,8 @@ namespace Altinn.Studio.Designer.Models.Dto; -public record ChatFeedbackRequest([MaxLength(64)] string TraceId, bool ThumbsUp, [MaxLength(10000)] string? Comment); +public record ChatFeedbackRequest( + [MinLength(1), MaxLength(64)] string TraceId, + bool ThumbsUp, + [MaxLength(10000)] string? Comment +); diff --git a/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs b/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs index 4fbf9d9c312..cad316cf62c 100644 --- a/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs +++ b/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs @@ -1,7 +1,6 @@ using System; using System.Net.Http; using System.Net.Http.Json; -using System.Threading; using System.Threading.Tasks; using Altinn.Studio.Designer.Configuration; using Altinn.Studio.Designer.Services.Interfaces.Altinity; @@ -17,22 +16,13 @@ public class AltinityAgentClient : IAltinityAgentClient private readonly HttpClient _httpClient; private readonly AltinitySettings _altinitySettings; - public AltinityAgentClient( - HttpClient httpClient, - IOptions altinitySettings - ) + public AltinityAgentClient(HttpClient httpClient, IOptions altinitySettings) { _httpClient = httpClient; _altinitySettings = altinitySettings.Value; } - public async Task SendFeedbackAsync( - string developer, - string traceId, - bool thumbsUp, - string? comment, - CancellationToken cancellationToken = default - ) + public async Task SendFeedbackAsync(string developer, string traceId, bool thumbsUp, string? comment) { var requestUri = new Uri($"{_altinitySettings.AgentUrl}{FeedbackPath}"); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri) @@ -48,13 +38,10 @@ public async Task SendFeedbackAsync( }; httpRequest.Headers.Add(DeveloperHeader, developer); - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(_altinitySettings.TimeoutSeconds)); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - - using var response = await _httpClient.SendAsync(httpRequest, linkedCts.Token); + using var response = await _httpClient.SendAsync(httpRequest); if (!response.IsSuccessStatusCode) { - string responseContent = await response.Content.ReadAsStringAsync(linkedCts.Token); + string responseContent = await response.Content.ReadAsStringAsync(); throw new HttpRequestException($"Altinity feedback returned {response.StatusCode}: {responseContent}"); } } diff --git a/src/Designer/backend/src/Designer/Services/Interfaces/Altinity/IAltinityAgentClient.cs b/src/Designer/backend/src/Designer/Services/Interfaces/Altinity/IAltinityAgentClient.cs index a19eef56446..ed67effa4a0 100644 --- a/src/Designer/backend/src/Designer/Services/Interfaces/Altinity/IAltinityAgentClient.cs +++ b/src/Designer/backend/src/Designer/Services/Interfaces/Altinity/IAltinityAgentClient.cs @@ -1,24 +1,14 @@ -using System.Threading; using System.Threading.Tasks; namespace Altinn.Studio.Designer.Services.Interfaces.Altinity; /// -/// HTTP client for forwarding request/response actions from the Designer backend to the -/// Altinity agents service. Streaming events use the persistent WebSocket -/// (); one-shot requests like user feedback go here. +/// HTTP client for forwarding requests to the Altinity agents service. /// public interface IAltinityAgentClient { /// - /// Records a user thumbs-up/thumbs-down on an assistant message as a Langfuse score - /// against the given trace, with an optional free-text comment. + /// Records a user thumbs-up/thumbs-down on an assistant message as a Langfuse score against the given trace. /// - Task SendFeedbackAsync( - string developer, - string traceId, - bool thumbsUp, - string? comment, - CancellationToken cancellationToken = default - ); + Task SendFeedbackAsync(string developer, string traceId, bool thumbsUp, string? comment); } diff --git a/src/Designer/backend/tests/Designer.Tests/Controllers/ChatController/SubmitFeedbackTests.cs b/src/Designer/backend/tests/Designer.Tests/Controllers/ChatController/SubmitFeedbackTests.cs index 619ef6c3fa8..f36f4a15515 100644 --- a/src/Designer/backend/tests/Designer.Tests/Controllers/ChatController/SubmitFeedbackTests.cs +++ b/src/Designer/backend/tests/Designer.Tests/Controllers/ChatController/SubmitFeedbackTests.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Http; -using System.Threading; using System.Threading.Tasks; using Altinn.Studio.Designer.Models.Dto; using Altinn.Studio.Designer.Services.Interfaces.Altinity; @@ -40,7 +39,7 @@ public async Task SubmitFeedback_WithValidThumbsUp_ForwardsToAgentAndReturnsNoCo Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); _altinityAgentClientMock.Verify( - client => client.SendFeedbackAsync(Developer, "trace-abc-123", true, null, It.IsAny()), + client => client.SendFeedbackAsync(Developer, "trace-abc-123", true, null), Times.Once ); } @@ -58,40 +57,8 @@ public async Task SubmitFeedback_WithThumbsDownAndComment_ForwardsCommentToAgent Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); _altinityAgentClientMock.Verify( - client => - client.SendFeedbackAsync( - Developer, - "trace-abc-123", - false, - "Svaret traff ikke helt.", - It.IsAny() - ), + client => client.SendFeedbackAsync(Developer, "trace-abc-123", false, "Svaret traff ikke helt."), Times.Once ); } - - [Fact] - public async Task SubmitFeedback_WithEmptyTraceId_ReturnsBadRequest() - { - var request = new ChatFeedbackRequest(string.Empty, true, null); - using var httpRequest = new HttpRequestMessage(HttpMethod.Post, FeedbackUrl) - { - Content = CreateJsonContent(request), - }; - - using var response = await HttpClient.SendAsync(httpRequest); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - _altinityAgentClientMock.Verify( - client => - client.SendFeedbackAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() - ), - Times.Never - ); - } } diff --git a/src/Designer/frontend/AGENTS.md b/src/Designer/frontend/AGENTS.md index 7ff164acf70..3ef5ccc3c0e 100644 --- a/src/Designer/frontend/AGENTS.md +++ b/src/Designer/frontend/AGENTS.md @@ -94,7 +94,7 @@ The frontend consists of several React packages in the following directories: Components MUST use custom hooks, never call `useQuery`/`useMutation` directly. -**Error handling:** A global `MutationCache`/`QueryCache` `onError` in `ServicesContext.tsx` surfaces all query and mutation errors as toasts. Do not add per-call error handlers just to log or toast — that's already covered. Only add a local handler when you need behavior beyond the default toast. +**Error handling:** A global `MutationCache`/`QueryCache` `onError` in `ServicesContext.tsx` surfaces all query and mutation errors as toasts. ## Unit tests From 4e467b9f383653f6e240cf22044d167af2f963eb Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Fri, 15 May 2026 14:41:17 +0200 Subject: [PATCH 18/26] add cancel button to dialog --- .../features/aiAssistant/AiAssistant.tsx | 1 + src/Designer/frontend/language/src/nb.json | 2 +- .../MessageFeedback.module.css | 2 +- .../MessageFeedback/MessageFeedback.test.tsx | 35 +++++++++++++------ .../MessageFeedback/MessageFeedback.tsx | 5 ++- .../studio-assistant/src/mocks/mockTexts.ts | 1 + .../src/types/AssistantTexts.ts | 1 + 7 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx b/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx index 203fcecc104..de3e2aa3dfe 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx +++ b/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx @@ -75,6 +75,7 @@ function AiAssistant(): ReactElement { detailsLabel: t('ai_assistant.feedback_details_label'), detailsOptionalTag: t('general.optional'), submit: t('ai_assistant.feedback_submit'), + cancel: t('general.cancel'), }, }; diff --git a/src/Designer/frontend/language/src/nb.json b/src/Designer/frontend/language/src/nb.json index b158063b136..9ac52e68e01 100644 --- a/src/Designer/frontend/language/src/nb.json +++ b/src/Designer/frontend/language/src/nb.json @@ -87,7 +87,7 @@ "ai_assistant.add_attachment": "Last opp vedlegg", "ai_assistant.allow_app_changes": "Tillat endringer i appen", "ai_assistant.assistant_first_message": "Hva kan jeg hjelpe med?", - "ai_assistant.feedback_details_label": "Detaljer", + "ai_assistant.feedback_details_label": "Legg inn detaljer", "ai_assistant.feedback_negative_heading": "Negativ tilbakemelding", "ai_assistant.feedback_positive_heading": "Positiv tilbakemelding", "ai_assistant.feedback_submit": "Send", diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.module.css b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.module.css index 23046b11f99..87c9506284a 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.module.css +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.module.css @@ -12,6 +12,6 @@ .dialogActions { display: flex; - gap: var(--ds-size-2); + gap: var(--ds-size-3); margin-top: var(--ds-size-2); } diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx index 64e6583ff06..bd109c6a21c 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx @@ -39,16 +39,6 @@ describe('MessageFeedback', () => { }); }); - it('closes the dialog after submitting feedback', async () => { - const user = userEvent.setup(); - renderMessageFeedback(); - - await user.click(getThumbsUpButton()); - await user.click(getSendButton()); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - it('calls onSubmit with comment when there is a comment', async () => { const user = userEvent.setup(); const onSubmit = jest.fn(); @@ -65,6 +55,28 @@ describe('MessageFeedback', () => { comment: 'Svaret traff ikke helt.', }); }); + + it('closes the dialog without calling onSubmit when pressing cancel', async () => { + const user = userEvent.setup(); + const onSubmit = jest.fn(); + renderMessageFeedback({ onSubmit }); + + await user.click(getThumbsUpButton()); + await user.click(getCancelButton()); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('closes the dialog after submitting feedback', async () => { + const user = userEvent.setup(); + renderMessageFeedback(); + + await user.click(getThumbsUpButton()); + await user.click(getSendButton()); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); }); const defaultProps: MessageFeedbackProps = { @@ -84,3 +96,6 @@ const getThumbsDownButton = (): HTMLElement => screen.getByRole('button', { name: feedbackTexts.thumbsDown }); const getSendButton = (): HTMLElement => screen.getByRole('button', { name: feedbackTexts.submit }); + +const getCancelButton = (): HTMLElement => + screen.getByRole('button', { name: feedbackTexts.cancel }); diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx index a6ba153e445..652ebc58833 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx @@ -7,7 +7,7 @@ import { StudioHeading, StudioTextarea, } from '@studio/components'; -import { ThumbDownIcon, ThumbUpIcon, PaperplaneFillIcon } from '@studio/icons'; +import { ThumbDownIcon, ThumbUpIcon, PaperplaneFillIcon, XMarkIcon } from '@studio/icons'; import type { MessageFeedbackTexts } from '../../../../types/AssistantTexts'; import type { UserFeedback } from '../../../../types/UserFeedback'; import classes from './MessageFeedback.module.css'; @@ -87,6 +87,9 @@ export function MessageFeedback({ texts, traceId, onSubmit }: MessageFeedbackPro > {texts.submit} + }> + {texts.cancel} +
diff --git a/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts b/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts index d9105c0cf2b..39d5db2cd75 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts +++ b/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts @@ -27,6 +27,7 @@ export const messageFeedbackTexts: MessageFeedbackTexts = { detailsLabel: 'feedbackDetailsLabel', detailsOptionalTag: 'feedbackDetailsOptionalTag', submit: 'feedbackSubmit', + cancel: 'feedbackCancel', }; export const mockTexts: AssistantTexts = { diff --git a/src/Designer/frontend/libs/studio-assistant/src/types/AssistantTexts.ts b/src/Designer/frontend/libs/studio-assistant/src/types/AssistantTexts.ts index 301170a6f6c..8a2c8c1b0aa 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/types/AssistantTexts.ts +++ b/src/Designer/frontend/libs/studio-assistant/src/types/AssistantTexts.ts @@ -27,6 +27,7 @@ export type MessageFeedbackTexts = { detailsLabel: string; detailsOptionalTag: string; submit: string; + cancel: string; }; export type AboutAssistantDialogTexts = { From 4b409b87eb2fed477f97c7f273c7759a78ffcb6a Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Fri, 15 May 2026 20:13:59 +0200 Subject: [PATCH 19/26] make useChatFeedbackMutation take org, app as params --- src/AI/agents/shared/utils/langfuse_utils.py | 1 + .../app-development/features/aiAssistant/AiAssistant.tsx | 4 +++- .../src/hooks/mutations/useChatFeedbackMutation.test.ts | 5 ++--- .../shared/src/hooks/mutations/useChatFeedbackMutation.ts | 4 +--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/AI/agents/shared/utils/langfuse_utils.py b/src/AI/agents/shared/utils/langfuse_utils.py index 1d450df0fd6..4f525e86c6e 100644 --- a/src/AI/agents/shared/utils/langfuse_utils.py +++ b/src/AI/agents/shared/utils/langfuse_utils.py @@ -264,6 +264,7 @@ def get_trace_developer(trace_id: str) -> str | None: def get_current_trace_id() -> str | None: + """Return the current Langfuse trace id, or None if unavailable or disabled.""" if not is_langfuse_enabled(): return None try: diff --git a/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx b/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx index de3e2aa3dfe..a73d64af8cf 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx +++ b/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx @@ -8,13 +8,15 @@ import { FileBrowser } from './components/FileBrowser'; import classes from './AiAssistant.module.css'; import { useUserQuery } from 'app-shared/hooks/queries'; import { useChatFeedbackMutation } from 'app-shared/hooks/mutations/useChatFeedbackMutation'; +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; import { StudioCenter, StudioAlert, StudioParagraph } from '@studio/components'; function AiAssistant(): ReactElement { const { t } = useTranslation(); + const { org, app } = useStudioEnvironmentParams(); const { data: currentUser } = useUserQuery(); const userHasAccessToAssistant = useAltinityPermissions(); - const { mutate: sendChatFeedback } = useChatFeedbackMutation(); + const { mutate: sendChatFeedback } = useChatFeedbackMutation(org, app); const { connectionStatus, diff --git a/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.test.ts b/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.test.ts index f12493dad96..c18bd7c7a8b 100644 --- a/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.test.ts +++ b/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.test.ts @@ -1,5 +1,5 @@ import { queriesMock } from 'app-shared/mocks/queriesMock'; -import { renderHookWithProviders } from 'app-development/test/mocks'; +import { renderHookWithProviders } from '../../mocks/renderHookWithProviders'; import { app, org } from '@studio/testing/testids'; import { useChatFeedbackMutation } from './useChatFeedbackMutation'; import { waitFor } from '@testing-library/react'; @@ -14,8 +14,7 @@ describe('useChatFeedbackMutation', () => { thumbsUp: true, comment: 'Veldig nyttig!', }; - const result = renderHookWithProviders()(() => useChatFeedbackMutation()).renderHookResult - .result; + const { result } = renderHookWithProviders(() => useChatFeedbackMutation(org, app)); result.current.mutate(payload); await waitFor(() => expect(result.current.isSuccess).toBe(true)); diff --git a/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.ts b/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.ts index 47dba95a561..41b1324c9a8 100644 --- a/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.ts +++ b/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.ts @@ -1,11 +1,9 @@ import { useMutation } from '@tanstack/react-query'; import { useServicesContext } from 'app-shared/contexts/ServicesContext'; -import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; import type { ChatFeedbackPayload } from 'app-shared/types/api'; -export const useChatFeedbackMutation = () => { +export const useChatFeedbackMutation = (org: string, app: string) => { const { sendChatFeedback } = useServicesContext(); - const { org, app } = useStudioEnvironmentParams(); return useMutation({ mutationFn: (payload: ChatFeedbackPayload) => sendChatFeedback(org, app, payload), }); From 7dc71aae5c465a91db90213ae416de15b9729b49 Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Fri, 15 May 2026 20:25:34 +0200 Subject: [PATCH 20/26] add tests to Messages --- .../ChatColumn/Messages/Messages.test.tsx | 61 ++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.test.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.test.tsx index f64bd835c8a..0eab8071bdc 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.test.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.test.tsx @@ -2,6 +2,7 @@ import { Messages, type MessagesProps } from './Messages'; import { render, screen } from '@testing-library/react'; import type { Message } from '../../../types/ChatThread'; import { MessageAuthor } from '../../../types/MessageAuthor'; +import { messageFeedbackTexts } from '../../../mocks/mockTexts'; // Test data const userMessageContent = 'User message'; @@ -29,7 +30,7 @@ const fileAttachment = { name: 'notes.txt', mimeType: 'text/plain' }; describe('Messages', () => { it('should render all messages', () => { - renderMessages({ messages: mockMessages }); + renderMessages(); const userMessage = screen.getByText(userMessageContent); const assistantMessage = screen.getByText(assistantMessageContent); @@ -110,8 +111,62 @@ describe('Messages', () => { const link = screen.getByRole('link', { name: new RegExp(sourceTitle) }); expect(link).toHaveAttribute('href', safeUrl); }); + + it('renders feedback buttons for assistant messages', () => { + const assistantMessageWithTraceId: Message = { + role: MessageAuthor.Assistant, + content: assistantMessageContent, + createdAt: new Date().toISOString(), + traceId: 'trace-123', + }; + renderMessages({ messages: [assistantMessageWithTraceId] }); + + expect(screen.getByRole('button', { name: messageFeedbackTexts.thumbsUp })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: messageFeedbackTexts.thumbsDown }), + ).toBeInTheDocument(); + }); + + it('does not render feedback buttons for user messages', () => { + const userMessage: Message = { + role: MessageAuthor.User, + content: userMessageContent, + createdAt: new Date().toISOString(), + allowAppChanges: false, + }; + renderMessages({ messages: [userMessage] }); + + expect( + screen.queryByRole('button', { name: messageFeedbackTexts.thumbsUp }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: messageFeedbackTexts.thumbsDown }), + ).not.toBeInTheDocument(); + }); + + it('does not render feedback buttons when traceId is missing', () => { + const assistantMessageWithoutTraceId: Message = { + role: MessageAuthor.Assistant, + content: assistantMessageContent, + createdAt: new Date().toISOString(), + }; + renderMessages({ messages: [assistantMessageWithoutTraceId] }); + + expect( + screen.queryByRole('button', { name: messageFeedbackTexts.thumbsUp }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: messageFeedbackTexts.thumbsDown }), + ).not.toBeInTheDocument(); + }); }); -const renderMessages = (props: MessagesProps): void => { - render(); +const defaultProps: MessagesProps = { + messages: mockMessages, + feedbackTexts: messageFeedbackTexts, + onMessageFeedback: jest.fn(), +}; + +const renderMessages = (props: Partial = {}): void => { + render(); }; From a9d6817916562bbd9aeb7fcfde0c73eefc24329d Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Fri, 15 May 2026 21:34:43 +0200 Subject: [PATCH 21/26] make feedback endpoint idempotent --- src/AI/agents/api/routes/feedback.py | 1 + src/AI/agents/shared/utils/langfuse_utils.py | 9 ++++++++- src/AI/agents/tests/api/test_feedback.py | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/AI/agents/api/routes/feedback.py b/src/AI/agents/api/routes/feedback.py index b37a37ca377..0b3be1ae010 100644 --- a/src/AI/agents/api/routes/feedback.py +++ b/src/AI/agents/api/routes/feedback.py @@ -60,5 +60,6 @@ async def submit_feedback(req: FeedbackReq, request: Request): passed=req.thumbs_up, trace_id=req.trace_id, comment=req.comment, + score_id=f"{req.trace_id}:{FEEDBACK_SCORE_NAME}", ) return Response(status_code=204) diff --git a/src/AI/agents/shared/utils/langfuse_utils.py b/src/AI/agents/shared/utils/langfuse_utils.py index 4f525e86c6e..270962b88c2 100644 --- a/src/AI/agents/shared/utils/langfuse_utils.py +++ b/src/AI/agents/shared/utils/langfuse_utils.py @@ -154,8 +154,13 @@ def score_validation( observation_id: str | None = None, config_id: str | None = None, comment: str | None = None, + score_id: str | None = None, ) -> None: - """Write a boolean validation result as a Langfuse score (1 = pass, 0 = fail).""" + """Write a boolean validation result as a Langfuse score (1 = pass, 0 = fail). + + Pass `score_id` to upsert: re-using the same id on a later call overwrites + the previous score instead of creating a new one. + """ client = get_langfuse_client() if not config.LANGFUSE_ENABLED: return @@ -174,6 +179,8 @@ def score_validation( kwargs["observation_id"] = observation_id if comment: kwargs["comment"] = comment + if score_id: + kwargs["score_id"] = score_id client.create_score(**kwargs) log.debug( "Langfuse score '%s' = %s written to trace %s", name, passed, trace_id diff --git a/src/AI/agents/tests/api/test_feedback.py b/src/AI/agents/tests/api/test_feedback.py index e6a0801c3da..54b6512683b 100644 --- a/src/AI/agents/tests/api/test_feedback.py +++ b/src/AI/agents/tests/api/test_feedback.py @@ -32,6 +32,7 @@ def test_thumbs_up_writes_score_and_returns_204(self): passed=True, trace_id=VALID_TRACE_ID, comment=None, + score_id=f"{VALID_TRACE_ID}:user_feedback", ) def test_thumbs_down_with_comment_is_forwarded(self): @@ -54,6 +55,7 @@ def test_thumbs_down_with_comment_is_forwarded(self): passed=False, trace_id=VALID_TRACE_ID, comment="Svaret var ikke nyttig.", + score_id=f"{VALID_TRACE_ID}:user_feedback", ) def test_missing_developer_header_returns_400(self): From cdc4560410e1a72168c917976dad501f549f23fa Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Mon, 18 May 2026 10:37:22 +0200 Subject: [PATCH 22/26] use PUT/upsert so repeat requests overwrites previous feedback on that message --- src/AI/agents/api/routes/feedback.py | 23 +++++------- src/AI/agents/shared/utils/langfuse_utils.py | 3 +- src/AI/agents/tests/api/test_feedback.py | 36 +++++++------------ .../Designer/Controllers/ChatController.cs | 6 ++-- .../Models/Dto/ChatFeedbackRequest.cs | 6 +--- .../Altinity/AltinityAgentClient.cs | 15 +++----- .../ChatController/SubmitFeedbackTests.cs | 18 +++++----- .../MessageFeedback/MessageFeedback.test.tsx | 5 --- .../MessageFeedback/MessageFeedback.tsx | 8 ++--- .../ChatColumn/Messages/Messages.tsx | 5 ++- .../src/types/UserFeedback.ts | 8 +++-- .../packages/shared/src/api/mutations.ts | 2 +- .../frontend/packages/shared/src/api/paths.js | 2 +- .../mutations/useChatFeedbackMutation.test.ts | 14 +++----- .../mutations/useChatFeedbackMutation.ts | 5 ++- .../src/types/api/ChatFeedbackPayload.ts | 1 - 16 files changed, 62 insertions(+), 95 deletions(-) diff --git a/src/AI/agents/api/routes/feedback.py b/src/AI/agents/api/routes/feedback.py index 0b3be1ae010..bdae62cd3fa 100644 --- a/src/AI/agents/api/routes/feedback.py +++ b/src/AI/agents/api/routes/feedback.py @@ -19,17 +19,9 @@ class FeedbackReq(BaseModel): """User feedback (thumbs up/down) on an assistant message, recorded as a Langfuse score.""" - trace_id: str thumbs_up: bool comment: Optional[str] = None - @field_validator("trace_id") - @classmethod - def _validate_trace_id(cls, v: str) -> str: - if not v.strip(): - raise ValueError("trace_id must be non-empty") - return v - @field_validator("comment") @classmethod def _validate_comment(cls, v: Optional[str]) -> Optional[str]: @@ -42,24 +34,27 @@ def _validate_comment(cls, v: Optional[str]) -> Optional[str]: return v -@router.post("/api/feedback", status_code=204) -async def submit_feedback(req: FeedbackReq, request: Request): - """Records user feedback as a Langfuse score on the given trace.""" +@router.put("/api/feedback/{trace_id}", status_code=204) +async def submit_feedback(trace_id: str, req: FeedbackReq, request: Request): + """Records user feedback as a Langfuse score on the given trace. + + A second PUT for the same trace overwrites the previous score. + """ caller = request.headers.get(DEVELOPER_HEADER) if not caller: raise HTTPException( status_code=400, detail=f"Missing {DEVELOPER_HEADER} header" ) - trace_owner = get_trace_developer(req.trace_id) + trace_owner = get_trace_developer(trace_id) if trace_owner != caller: raise HTTPException(status_code=403) score_validation( name=FEEDBACK_SCORE_NAME, passed=req.thumbs_up, - trace_id=req.trace_id, + trace_id=trace_id, comment=req.comment, - score_id=f"{req.trace_id}:{FEEDBACK_SCORE_NAME}", + score_id=f"{trace_id}:{FEEDBACK_SCORE_NAME}", ) return Response(status_code=204) diff --git a/src/AI/agents/shared/utils/langfuse_utils.py b/src/AI/agents/shared/utils/langfuse_utils.py index 270962b88c2..a74213f3c51 100644 --- a/src/AI/agents/shared/utils/langfuse_utils.py +++ b/src/AI/agents/shared/utils/langfuse_utils.py @@ -158,8 +158,7 @@ def score_validation( ) -> None: """Write a boolean validation result as a Langfuse score (1 = pass, 0 = fail). - Pass `score_id` to upsert: re-using the same id on a later call overwrites - the previous score instead of creating a new one. + Pass `score_id` to upsert: re-using the same id on a later call overwrites the previous score. """ client = get_langfuse_client() if not config.LANGFUSE_ENABLED: diff --git a/src/AI/agents/tests/api/test_feedback.py b/src/AI/agents/tests/api/test_feedback.py index 54b6512683b..b47813983d5 100644 --- a/src/AI/agents/tests/api/test_feedback.py +++ b/src/AI/agents/tests/api/test_feedback.py @@ -4,15 +4,15 @@ from api.main import app -FEEDBACK_PATH = "/api/feedback" VALID_TRACE_ID = "trace-abc-123" +FEEDBACK_PATH = f"/api/feedback/{VALID_TRACE_ID}" DEVELOPER = "ola" OTHER_DEVELOPER = "kari" DEVELOPER_HEADER = {"X-Developer": DEVELOPER} -def _post_feedback(payload, headers=None): - return TestClient(app).post(FEEDBACK_PATH, json=payload, headers=headers) +def _put_feedback(payload, headers=None, path=FEEDBACK_PATH): + return TestClient(app).put(path, json=payload, headers=headers) class TestFeedbackEndpoint: @@ -21,8 +21,8 @@ def test_thumbs_up_writes_score_and_returns_204(self): patch("api.routes.feedback.get_trace_developer", return_value=DEVELOPER), patch("api.routes.feedback.score_validation") as mock_score, ): - response = _post_feedback( - {"trace_id": VALID_TRACE_ID, "thumbs_up": True}, + response = _put_feedback( + {"thumbs_up": True}, headers=DEVELOPER_HEADER, ) @@ -40,9 +40,8 @@ def test_thumbs_down_with_comment_is_forwarded(self): patch("api.routes.feedback.get_trace_developer", return_value=DEVELOPER), patch("api.routes.feedback.score_validation") as mock_score, ): - response = _post_feedback( + response = _put_feedback( { - "trace_id": VALID_TRACE_ID, "thumbs_up": False, "comment": "Svaret var ikke nyttig.", }, @@ -60,7 +59,7 @@ def test_thumbs_down_with_comment_is_forwarded(self): def test_missing_developer_header_returns_400(self): with patch("api.routes.feedback.score_validation") as mock_score: - response = _post_feedback({"trace_id": VALID_TRACE_ID, "thumbs_up": True}) + response = _put_feedback({"thumbs_up": True}) assert response.status_code == 400 mock_score.assert_not_called() @@ -70,8 +69,8 @@ def test_unknown_trace_owner_returns_403(self): patch("api.routes.feedback.get_trace_developer", return_value=None), patch("api.routes.feedback.score_validation") as mock_score, ): - response = _post_feedback( - {"trace_id": VALID_TRACE_ID, "thumbs_up": True}, + response = _put_feedback( + {"thumbs_up": True}, headers=DEVELOPER_HEADER, ) @@ -85,29 +84,18 @@ def test_owner_mismatch_returns_403(self): ), patch("api.routes.feedback.score_validation") as mock_score, ): - response = _post_feedback( - {"trace_id": VALID_TRACE_ID, "thumbs_up": True}, + response = _put_feedback( + {"thumbs_up": True}, headers=DEVELOPER_HEADER, ) assert response.status_code == 403 mock_score.assert_not_called() - def test_empty_trace_id_returns_422(self): - with patch("api.routes.feedback.score_validation") as mock_score: - response = _post_feedback( - {"trace_id": "", "thumbs_up": True}, - headers=DEVELOPER_HEADER, - ) - - assert response.status_code == 422 - mock_score.assert_not_called() - def test_comment_over_max_length_returns_422(self): with patch("api.routes.feedback.score_validation") as mock_score: - response = _post_feedback( + response = _put_feedback( { - "trace_id": VALID_TRACE_ID, "thumbs_up": True, "comment": "x" * 10001, }, diff --git a/src/Designer/backend/src/Designer/Controllers/ChatController.cs b/src/Designer/backend/src/Designer/Controllers/ChatController.cs index 21a39d310ae..26564cbcf17 100644 --- a/src/Designer/backend/src/Designer/Controllers/ChatController.cs +++ b/src/Designer/backend/src/Designer/Controllers/ChatController.cs @@ -135,12 +135,12 @@ CancellationToken cancellationToken return NoContent(); } - [HttpPost("feedback")] + [HttpPut("feedback/{traceId}")] [RequestSizeLimit(20_000)] - public async Task SubmitFeedback(string org, string app, [FromBody] ChatFeedbackRequest request) + public async Task SubmitFeedback(string traceId, [FromBody] ChatFeedbackRequest request) { string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); - await altinityAgentClient.SendFeedbackAsync(developer, request.TraceId, request.ThumbsUp, request.Comment); + await altinityAgentClient.SendFeedbackAsync(developer, traceId, request.ThumbsUp, request.Comment); return NoContent(); } diff --git a/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs b/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs index 22733db0a52..81939b1024f 100644 --- a/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs +++ b/src/Designer/backend/src/Designer/Models/Dto/ChatFeedbackRequest.cs @@ -2,8 +2,4 @@ namespace Altinn.Studio.Designer.Models.Dto; -public record ChatFeedbackRequest( - [MinLength(1), MaxLength(64)] string TraceId, - bool ThumbsUp, - [MaxLength(10000)] string? Comment -); +public record ChatFeedbackRequest(bool ThumbsUp, [MaxLength(10000)] string? Comment); diff --git a/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs b/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs index cad316cf62c..043cf5e6d70 100644 --- a/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs +++ b/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs @@ -10,7 +10,7 @@ namespace Altinn.Studio.Designer.Services.Implementation.Altinity; public class AltinityAgentClient : IAltinityAgentClient { - private const string FeedbackPath = "/api/feedback"; + private const string FeedbackPathPrefix = "/api/feedback/"; private const string DeveloperHeader = "X-Developer"; private readonly HttpClient _httpClient; @@ -24,17 +24,10 @@ public AltinityAgentClient(HttpClient httpClient, IOptions alt public async Task SendFeedbackAsync(string developer, string traceId, bool thumbsUp, string? comment) { - var requestUri = new Uri($"{_altinitySettings.AgentUrl}{FeedbackPath}"); - using var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri) + var requestUri = new Uri($"{_altinitySettings.AgentUrl}{FeedbackPathPrefix}{traceId}"); + using var httpRequest = new HttpRequestMessage(HttpMethod.Put, requestUri) { - Content = JsonContent.Create( - new - { - trace_id = traceId, - thumbs_up = thumbsUp, - comment, - } - ), + Content = JsonContent.Create(new { thumbs_up = thumbsUp, comment }), }; httpRequest.Headers.Add(DeveloperHeader, developer); diff --git a/src/Designer/backend/tests/Designer.Tests/Controllers/ChatController/SubmitFeedbackTests.cs b/src/Designer/backend/tests/Designer.Tests/Controllers/ChatController/SubmitFeedbackTests.cs index f36f4a15515..efeedea3d0e 100644 --- a/src/Designer/backend/tests/Designer.Tests/Controllers/ChatController/SubmitFeedbackTests.cs +++ b/src/Designer/backend/tests/Designer.Tests/Controllers/ChatController/SubmitFeedbackTests.cs @@ -13,7 +13,8 @@ namespace Designer.Tests.Controllers.ChatController; public class SubmitFeedbackTests : ChatControllerTestsBase { - private static string FeedbackUrl => $"designer/api/{Org}/{App}/chat/feedback"; + private const string TraceId = "trace-abc-123"; + private static string FeedbackUrl => $"designer/api/{Org}/{App}/chat/feedback/{TraceId}"; private readonly Mock _altinityAgentClientMock = new(); @@ -29,8 +30,8 @@ protected override void ConfigureTestServices(IServiceCollection services) [Fact] public async Task SubmitFeedback_WithValidThumbsUp_ForwardsToAgentAndReturnsNoContent() { - var request = new ChatFeedbackRequest("trace-abc-123", true, null); - using var httpRequest = new HttpRequestMessage(HttpMethod.Post, FeedbackUrl) + var request = new ChatFeedbackRequest(true, null); + using var httpRequest = new HttpRequestMessage(HttpMethod.Put, FeedbackUrl) { Content = CreateJsonContent(request), }; @@ -38,17 +39,14 @@ public async Task SubmitFeedback_WithValidThumbsUp_ForwardsToAgentAndReturnsNoCo using var response = await HttpClient.SendAsync(httpRequest); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - _altinityAgentClientMock.Verify( - client => client.SendFeedbackAsync(Developer, "trace-abc-123", true, null), - Times.Once - ); + _altinityAgentClientMock.Verify(client => client.SendFeedbackAsync(Developer, TraceId, true, null), Times.Once); } [Fact] public async Task SubmitFeedback_WithThumbsDownAndComment_ForwardsCommentToAgent() { - var request = new ChatFeedbackRequest("trace-abc-123", false, "Svaret traff ikke helt."); - using var httpRequest = new HttpRequestMessage(HttpMethod.Post, FeedbackUrl) + var request = new ChatFeedbackRequest(false, "Svaret traff ikke helt."); + using var httpRequest = new HttpRequestMessage(HttpMethod.Put, FeedbackUrl) { Content = CreateJsonContent(request), }; @@ -57,7 +55,7 @@ public async Task SubmitFeedback_WithThumbsDownAndComment_ForwardsCommentToAgent Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); _altinityAgentClientMock.Verify( - client => client.SendFeedbackAsync(Developer, "trace-abc-123", false, "Svaret traff ikke helt."), + client => client.SendFeedbackAsync(Developer, TraceId, false, "Svaret traff ikke helt."), Times.Once ); } diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx index bd109c6a21c..4979161762c 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx @@ -4,8 +4,6 @@ import { MessageFeedback } from './MessageFeedback'; import type { MessageFeedbackProps } from './MessageFeedback'; import { messageFeedbackTexts as feedbackTexts } from '../../../../mocks/mockTexts'; -const traceId = 'trace-123'; - describe('MessageFeedback', () => { it('renders thumbs up and thumbs down buttons', () => { renderMessageFeedback(); @@ -33,7 +31,6 @@ describe('MessageFeedback', () => { expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit).toHaveBeenCalledWith({ - traceId, thumbsUp: true, comment: undefined, }); @@ -50,7 +47,6 @@ describe('MessageFeedback', () => { expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit).toHaveBeenCalledWith({ - traceId, thumbsUp: false, comment: 'Svaret traff ikke helt.', }); @@ -81,7 +77,6 @@ describe('MessageFeedback', () => { const defaultProps: MessageFeedbackProps = { texts: feedbackTexts, - traceId, onSubmit: jest.fn(), }; diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx index 652ebc58833..a4d580a2231 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx @@ -9,16 +9,15 @@ import { } from '@studio/components'; import { ThumbDownIcon, ThumbUpIcon, PaperplaneFillIcon, XMarkIcon } from '@studio/icons'; import type { MessageFeedbackTexts } from '../../../../types/AssistantTexts'; -import type { UserFeedback } from '../../../../types/UserFeedback'; +import type { FeedbackPayload } from '../../../../types/UserFeedback'; import classes from './MessageFeedback.module.css'; export type MessageFeedbackProps = { texts: MessageFeedbackTexts; - traceId: string; - onSubmit: (feedback: UserFeedback) => void; + onSubmit: (payload: FeedbackPayload) => void; }; -export function MessageFeedback({ texts, traceId, onSubmit }: MessageFeedbackProps): ReactElement { +export function MessageFeedback({ texts, onSubmit }: MessageFeedbackProps): ReactElement { const [isDialogOpen, setIsDialogOpen] = useState(false); const [selectedVote, setSelectedVote] = useState(null); const [commentText, setCommentText] = useState(''); @@ -34,7 +33,6 @@ export function MessageFeedback({ texts, traceId, onSubmit }: MessageFeedbackPro const trimmedComment = commentText.trim(); onSubmit({ - traceId, thumbsUp: selectedVote, comment: trimmedComment || undefined, }); diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.tsx index 87ddfa0621b..94945ec7ec9 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/Messages.tsx @@ -290,7 +290,10 @@ function MessageItem({ {renderSources()} {renderFilesChanged()} {showFeedback && ( - + onMessageFeedback({ traceId, payload })} + /> )} diff --git a/src/Designer/frontend/libs/studio-assistant/src/types/UserFeedback.ts b/src/Designer/frontend/libs/studio-assistant/src/types/UserFeedback.ts index b129dd18a67..fa971c1dbba 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/types/UserFeedback.ts +++ b/src/Designer/frontend/libs/studio-assistant/src/types/UserFeedback.ts @@ -1,5 +1,9 @@ -export type UserFeedback = { - traceId: string; +export type FeedbackPayload = { thumbsUp: boolean; comment?: string; }; + +export type UserFeedback = { + traceId: string; + payload: FeedbackPayload; +}; diff --git a/src/Designer/frontend/packages/shared/src/api/mutations.ts b/src/Designer/frontend/packages/shared/src/api/mutations.ts index 754b3e94ddd..2cc9892078e 100644 --- a/src/Designer/frontend/packages/shared/src/api/mutations.ts +++ b/src/Designer/frontend/packages/shared/src/api/mutations.ts @@ -283,4 +283,4 @@ export const updateChatThread = (org: string, app: string, threadId: string, pay export const deleteChatThread = (org: string, app: string, threadId: string) => del(chatThreadPath(org, app, threadId)); export const createChatMessage = (org: string, app: string, threadId: string, payload: CreateChatMessagePayload) => post(chatMessagesPath(org, app, threadId), payload); export const deleteChatMessage = (org: string, app: string, threadId: string, messageId: string) => del(chatMessagePath(org, app, threadId, messageId)); -export const sendChatFeedback = (org: string, app: string, payload: ChatFeedbackPayload) => post(chatFeedbackPath(org, app), payload); +export const sendChatFeedback = (org: string, app: string, traceId: string, payload: ChatFeedbackPayload) => put(chatFeedbackPath(org, app, traceId), payload); diff --git a/src/Designer/frontend/packages/shared/src/api/paths.js b/src/Designer/frontend/packages/shared/src/api/paths.js index b0fd6f8cc42..13c511a3cc3 100644 --- a/src/Designer/frontend/packages/shared/src/api/paths.js +++ b/src/Designer/frontend/packages/shared/src/api/paths.js @@ -221,7 +221,7 @@ export const chatThreadsPath = (org, app) => `${apiBasePath}/${org}/${app}/chat/ export const chatThreadPath = (org, app, threadId) => `${apiBasePath}/${org}/${app}/chat/threads/${threadId}`; // Put, Delete export const chatMessagesPath = (org, app, threadId) => `${apiBasePath}/${org}/${app}/chat/threads/${threadId}/messages`; // Get, Post export const chatMessagePath = (org, app, threadId, messageId) => `${apiBasePath}/${org}/${app}/chat/threads/${threadId}/messages/${messageId}`; // Delete -export const chatFeedbackPath = (org, app) => `${apiBasePath}/${org}/${app}/chat/feedback`; // Post +export const chatFeedbackPath = (org, app, traceId) => `${apiBasePath}/${org}/${app}/chat/feedback/${traceId}`; // Put // Contact export const belongsToOrg = () => `${apiBasePath}/contact/belongs-to-org`; diff --git a/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.test.ts b/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.test.ts index c18bd7c7a8b..0afc2d904fe 100644 --- a/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.test.ts +++ b/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.test.ts @@ -3,23 +3,19 @@ import { renderHookWithProviders } from '../../mocks/renderHookWithProviders'; import { app, org } from '@studio/testing/testids'; import { useChatFeedbackMutation } from './useChatFeedbackMutation'; import { waitFor } from '@testing-library/react'; -import type { ChatFeedbackPayload } from 'app-shared/types/api'; describe('useChatFeedbackMutation', () => { afterEach(jest.clearAllMocks); - it('Calls sendChatFeedback with correct arguments and payload', async () => { - const payload: ChatFeedbackPayload = { - traceId: 'trace-abc-123', - thumbsUp: true, - comment: 'Veldig nyttig!', - }; + it('calls sendChatFeedback with correct arguments', async () => { + const traceId = 'trace-abc-123'; + const payload = { thumbsUp: true, comment: 'Veldig nyttig!' }; const { result } = renderHookWithProviders(() => useChatFeedbackMutation(org, app)); - result.current.mutate(payload); + result.current.mutate({ traceId, payload }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(queriesMock.sendChatFeedback).toHaveBeenCalledTimes(1); - expect(queriesMock.sendChatFeedback).toHaveBeenCalledWith(org, app, payload); + expect(queriesMock.sendChatFeedback).toHaveBeenCalledWith(org, app, traceId, payload); }); }); diff --git a/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.ts b/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.ts index 41b1324c9a8..c3cecf1adf1 100644 --- a/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.ts +++ b/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.ts @@ -2,9 +2,12 @@ import { useMutation } from '@tanstack/react-query'; import { useServicesContext } from 'app-shared/contexts/ServicesContext'; import type { ChatFeedbackPayload } from 'app-shared/types/api'; +type ChatFeedbackMutationArgs = { traceId: string; payload: ChatFeedbackPayload }; + export const useChatFeedbackMutation = (org: string, app: string) => { const { sendChatFeedback } = useServicesContext(); return useMutation({ - mutationFn: (payload: ChatFeedbackPayload) => sendChatFeedback(org, app, payload), + mutationFn: ({ traceId, payload }: ChatFeedbackMutationArgs) => + sendChatFeedback(org, app, traceId, payload), }); }; diff --git a/src/Designer/frontend/packages/shared/src/types/api/ChatFeedbackPayload.ts b/src/Designer/frontend/packages/shared/src/types/api/ChatFeedbackPayload.ts index 5653f576240..3c508a26a1b 100644 --- a/src/Designer/frontend/packages/shared/src/types/api/ChatFeedbackPayload.ts +++ b/src/Designer/frontend/packages/shared/src/types/api/ChatFeedbackPayload.ts @@ -1,5 +1,4 @@ export type ChatFeedbackPayload = { - traceId: string; thumbsUp: boolean; comment?: string; }; From b96728d3cb897687e1f1d4978b6dd5a5e8875b36 Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Mon, 18 May 2026 11:01:21 +0200 Subject: [PATCH 23/26] update texts based on feedback --- .../app-development/features/aiAssistant/AiAssistant.tsx | 3 +-- src/Designer/frontend/language/src/nb.json | 9 ++++----- .../Messages/MessageFeedback/MessageFeedback.tsx | 5 +++-- .../libs/studio-assistant/src/mocks/mockTexts.ts | 3 +-- .../libs/studio-assistant/src/types/AssistantTexts.ts | 3 +-- 5 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx b/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx index a73d64af8cf..e2a3838fd83 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx +++ b/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx @@ -72,8 +72,7 @@ function AiAssistant(): ReactElement { feedback: { thumbsUp: t('ai_assistant.feedback_thumbs_up'), thumbsDown: t('ai_assistant.feedback_thumbs_down'), - positiveHeading: t('ai_assistant.feedback_positive_heading'), - negativeHeading: t('ai_assistant.feedback_negative_heading'), + heading: t('ai_assistant.feedback_heading'), detailsLabel: t('ai_assistant.feedback_details_label'), detailsOptionalTag: t('general.optional'), submit: t('ai_assistant.feedback_submit'), diff --git a/src/Designer/frontend/language/src/nb.json b/src/Designer/frontend/language/src/nb.json index 9ac52e68e01..4b260e5c4f4 100644 --- a/src/Designer/frontend/language/src/nb.json +++ b/src/Designer/frontend/language/src/nb.json @@ -87,12 +87,11 @@ "ai_assistant.add_attachment": "Last opp vedlegg", "ai_assistant.allow_app_changes": "Tillat endringer i appen", "ai_assistant.assistant_first_message": "Hva kan jeg hjelpe med?", - "ai_assistant.feedback_details_label": "Legg inn detaljer", - "ai_assistant.feedback_negative_heading": "Negativ tilbakemelding", - "ai_assistant.feedback_positive_heading": "Positiv tilbakemelding", + "ai_assistant.feedback_details_label": "Fortell oss mer", + "ai_assistant.feedback_heading": "Tilbakemelding", "ai_assistant.feedback_submit": "Send", - "ai_assistant.feedback_thumbs_down": "Ikke nyttig svar", - "ai_assistant.feedback_thumbs_up": "Nyttig svar", + "ai_assistant.feedback_thumbs_down": "Svaret var ikke nyttig", + "ai_assistant.feedback_thumbs_up": "Svaret var nyttig", "ai_assistant.file_browser": "Kode", "ai_assistant.heading": "KI-assistent", "ai_assistant.hide_threads": "Skjul tråder", diff --git a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx index a4d580a2231..a5383df7863 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx @@ -21,7 +21,7 @@ export function MessageFeedback({ texts, onSubmit }: MessageFeedbackProps): Reac const [isDialogOpen, setIsDialogOpen] = useState(false); const [selectedVote, setSelectedVote] = useState(null); const [commentText, setCommentText] = useState(''); - const dialogHeading = selectedVote === true ? texts.positiveHeading : texts.negativeHeading; + const commentPlaceholder = selectedVote === true ? texts.thumbsUp : texts.thumbsDown; const handleVoteClick = (vote: boolean): void => { setSelectedVote(vote); @@ -68,13 +68,14 @@ export function MessageFeedback({ texts, onSubmit }: MessageFeedbackProps): Reac - {dialogHeading} + {texts.heading} setCommentText(event.target.value)} + placeholder={commentPlaceholder} />
diff --git a/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts b/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts index 39d5db2cd75..efa5e595a43 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts +++ b/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts @@ -22,8 +22,7 @@ const textAreaTexts: TextAreaTexts = { export const messageFeedbackTexts: MessageFeedbackTexts = { thumbsUp: 'feedbackThumbsUp', thumbsDown: 'feedbackThumbsDown', - positiveHeading: 'feedbackPositiveHeading', - negativeHeading: 'feedbackNegativeHeading', + heading: 'feedbackHeading', detailsLabel: 'feedbackDetailsLabel', detailsOptionalTag: 'feedbackDetailsOptionalTag', submit: 'feedbackSubmit', diff --git a/src/Designer/frontend/libs/studio-assistant/src/types/AssistantTexts.ts b/src/Designer/frontend/libs/studio-assistant/src/types/AssistantTexts.ts index 8a2c8c1b0aa..2f2574550c9 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/types/AssistantTexts.ts +++ b/src/Designer/frontend/libs/studio-assistant/src/types/AssistantTexts.ts @@ -22,8 +22,7 @@ export type AssistantTexts = { export type MessageFeedbackTexts = { thumbsUp: string; thumbsDown: string; - positiveHeading: string; - negativeHeading: string; + heading: string; detailsLabel: string; detailsOptionalTag: string; submit: string; From 91c620207ae6aa19b10120b9663789f7c901084a Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Mon, 18 May 2026 12:14:44 +0200 Subject: [PATCH 24/26] add cancellation token support --- .../src/Designer/Controllers/ChatController.cs | 14 ++++++++++++-- .../Altinity/AltinityAgentClient.cs | 13 ++++++++++--- .../Interfaces/Altinity/IAltinityAgentClient.cs | 9 ++++++++- .../ChatController/SubmitFeedbackTests.cs | 15 +++++++++++++-- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/Designer/backend/src/Designer/Controllers/ChatController.cs b/src/Designer/backend/src/Designer/Controllers/ChatController.cs index 26564cbcf17..4e252ec67e1 100644 --- a/src/Designer/backend/src/Designer/Controllers/ChatController.cs +++ b/src/Designer/backend/src/Designer/Controllers/ChatController.cs @@ -137,10 +137,20 @@ CancellationToken cancellationToken [HttpPut("feedback/{traceId}")] [RequestSizeLimit(20_000)] - public async Task SubmitFeedback(string traceId, [FromBody] ChatFeedbackRequest request) + public async Task SubmitFeedback( + string traceId, + [FromBody] ChatFeedbackRequest request, + CancellationToken cancellationToken + ) { string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); - await altinityAgentClient.SendFeedbackAsync(developer, traceId, request.ThumbsUp, request.Comment); + await altinityAgentClient.SendFeedbackAsync( + developer, + traceId, + request.ThumbsUp, + request.Comment, + cancellationToken + ); return NoContent(); } diff --git a/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs b/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs index 043cf5e6d70..020dd78a0d1 100644 --- a/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs +++ b/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs @@ -1,6 +1,7 @@ using System; using System.Net.Http; using System.Net.Http.Json; +using System.Threading; using System.Threading.Tasks; using Altinn.Studio.Designer.Configuration; using Altinn.Studio.Designer.Services.Interfaces.Altinity; @@ -22,7 +23,13 @@ public AltinityAgentClient(HttpClient httpClient, IOptions alt _altinitySettings = altinitySettings.Value; } - public async Task SendFeedbackAsync(string developer, string traceId, bool thumbsUp, string? comment) + public async Task SendFeedbackAsync( + string developer, + string traceId, + bool thumbsUp, + string? comment, + CancellationToken cancellationToken + ) { var requestUri = new Uri($"{_altinitySettings.AgentUrl}{FeedbackPathPrefix}{traceId}"); using var httpRequest = new HttpRequestMessage(HttpMethod.Put, requestUri) @@ -31,10 +38,10 @@ public async Task SendFeedbackAsync(string developer, string traceId, bool thumb }; httpRequest.Headers.Add(DeveloperHeader, developer); - using var response = await _httpClient.SendAsync(httpRequest); + using var response = await _httpClient.SendAsync(httpRequest, cancellationToken); if (!response.IsSuccessStatusCode) { - string responseContent = await response.Content.ReadAsStringAsync(); + string responseContent = await response.Content.ReadAsStringAsync(cancellationToken); throw new HttpRequestException($"Altinity feedback returned {response.StatusCode}: {responseContent}"); } } diff --git a/src/Designer/backend/src/Designer/Services/Interfaces/Altinity/IAltinityAgentClient.cs b/src/Designer/backend/src/Designer/Services/Interfaces/Altinity/IAltinityAgentClient.cs index ed67effa4a0..88cee3d6cf7 100644 --- a/src/Designer/backend/src/Designer/Services/Interfaces/Altinity/IAltinityAgentClient.cs +++ b/src/Designer/backend/src/Designer/Services/Interfaces/Altinity/IAltinityAgentClient.cs @@ -1,3 +1,4 @@ +using System.Threading; using System.Threading.Tasks; namespace Altinn.Studio.Designer.Services.Interfaces.Altinity; @@ -10,5 +11,11 @@ public interface IAltinityAgentClient /// /// Records a user thumbs-up/thumbs-down on an assistant message as a Langfuse score against the given trace. /// - Task SendFeedbackAsync(string developer, string traceId, bool thumbsUp, string? comment); + Task SendFeedbackAsync( + string developer, + string traceId, + bool thumbsUp, + string? comment, + CancellationToken cancellationToken + ); } diff --git a/src/Designer/backend/tests/Designer.Tests/Controllers/ChatController/SubmitFeedbackTests.cs b/src/Designer/backend/tests/Designer.Tests/Controllers/ChatController/SubmitFeedbackTests.cs index efeedea3d0e..2cb7cec51c4 100644 --- a/src/Designer/backend/tests/Designer.Tests/Controllers/ChatController/SubmitFeedbackTests.cs +++ b/src/Designer/backend/tests/Designer.Tests/Controllers/ChatController/SubmitFeedbackTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using Altinn.Studio.Designer.Models.Dto; using Altinn.Studio.Designer.Services.Interfaces.Altinity; @@ -39,7 +40,10 @@ public async Task SubmitFeedback_WithValidThumbsUp_ForwardsToAgentAndReturnsNoCo using var response = await HttpClient.SendAsync(httpRequest); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - _altinityAgentClientMock.Verify(client => client.SendFeedbackAsync(Developer, TraceId, true, null), Times.Once); + _altinityAgentClientMock.Verify( + client => client.SendFeedbackAsync(Developer, TraceId, true, null, It.IsAny()), + Times.Once + ); } [Fact] @@ -55,7 +59,14 @@ public async Task SubmitFeedback_WithThumbsDownAndComment_ForwardsCommentToAgent Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); _altinityAgentClientMock.Verify( - client => client.SendFeedbackAsync(Developer, TraceId, false, "Svaret traff ikke helt."), + client => + client.SendFeedbackAsync( + Developer, + TraceId, + false, + "Svaret traff ikke helt.", + It.IsAny() + ), Times.Once ); } From db81a9ff6078407eb137ae84b8d974c18bf1bd87 Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Mon, 18 May 2026 12:41:55 +0200 Subject: [PATCH 25/26] small fixes --- src/Designer/frontend/AGENTS.md | 2 +- .../hooks/useAltinityAssistant/useAltinityAssistant.test.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Designer/frontend/AGENTS.md b/src/Designer/frontend/AGENTS.md index 3ef5ccc3c0e..771e46fc548 100644 --- a/src/Designer/frontend/AGENTS.md +++ b/src/Designer/frontend/AGENTS.md @@ -94,7 +94,7 @@ The frontend consists of several React packages in the following directories: Components MUST use custom hooks, never call `useQuery`/`useMutation` directly. -**Error handling:** A global `MutationCache`/`QueryCache` `onError` in `ServicesContext.tsx` surfaces all query and mutation errors as toasts. +**Error handling:** A global `MutationCache`/`QueryCache` `onError` in `\packages\shared\src\contexts\ServicesContext.tsx` surfaces all query and mutation errors as toasts. ## Unit tests diff --git a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.test.ts b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.test.ts index c9225a48295..1fdda752916 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.test.ts +++ b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.test.ts @@ -30,7 +30,6 @@ describe('useAltinityAssistant', () => { cancelCurrentWorkflow: jest.fn(), cancelledMessageContent: null, clearCancelledMessageContent: jest.fn(), - messages: [], }); const { result } = renderUseAltinityAssistant(); @@ -55,7 +54,7 @@ const createThreadState = (): AltinityThreadState => ({ createThread: jest.fn().mockResolvedValue('new-thread-id'), deleteThread: jest.fn(), deleteMessage: jest.fn(), - createMessage: jest.fn().mockResolvedValue({ id: 'persisted-id' }), + createMessage: jest.fn(), }); const renderUseAltinityAssistant = () => renderHook(() => useAltinityAssistant()); From dece4a1f27e847a6ea3ea60b47d2596d6d0162df Mon Sep 17 00:00:00 2001 From: ErlingHauan Date: Mon, 18 May 2026 12:50:18 +0200 Subject: [PATCH 26/26] add required prop messages to test setup --- .../hooks/useAltinityAssistant/useAltinityAssistant.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.test.ts b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.test.ts index 1fdda752916..49b474ef135 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.test.ts +++ b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityAssistant/useAltinityAssistant.test.ts @@ -30,6 +30,7 @@ describe('useAltinityAssistant', () => { cancelCurrentWorkflow: jest.fn(), cancelledMessageContent: null, clearCancelledMessageContent: jest.fn(), + messages: [], }); const { result } = renderUseAltinityAssistant();