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/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/feedback.py b/src/AI/agents/api/routes/feedback.py new file mode 100644 index 00000000000..bdae62cd3fa --- /dev/null +++ b/src/AI/agents/api/routes/feedback.py @@ -0,0 +1,60 @@ +"""User feedback API routes""" + +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request, Response +from pydantic import BaseModel, field_validator + +from shared.utils.langfuse_utils import get_trace_developer, 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 +DEVELOPER_HEADER = "X-Developer" + + +class FeedbackReq(BaseModel): + """User feedback (thumbs up/down) on an assistant message, recorded as a Langfuse score.""" + + thumbs_up: bool + comment: Optional[str] = None + + @field_validator("comment") + @classmethod + def _validate_comment(cls, v: Optional[str]) -> Optional[str]: + 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" + ) + return v + + +@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(trace_id) + if trace_owner != caller: + raise HTTPException(status_code=403) + + score_validation( + name=FEEDBACK_SCORE_NAME, + passed=req.thumbs_up, + trace_id=trace_id, + comment=req.comment, + 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 ae802c2c544..a74213f3c51 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, @@ -148,8 +154,12 @@ 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. + """ client = get_langfuse_client() if not config.LANGFUSE_ENABLED: return @@ -168,11 +178,16 @@ 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) + 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 +195,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() @@ -225,13 +243,46 @@ def __exit__(self, *args): pass -def _has_active_trace() -> bool: - """Return True when a Langfuse trace context is currently active.""" +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_id = get_client().get_current_trace_id() - return trace_id is not None + 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: + """Return the current Langfuse trace id, or None if unavailable or disabled.""" + if not is_langfuse_enabled(): + return None + try: + return get_client().get_current_trace_id() except Exception: - return False + log.exception("Failed to get current Langfuse trace ID") + 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 @@ -245,7 +296,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 @@ -259,5 +312,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 new file mode 100644 index 00000000000..b47813983d5 --- /dev/null +++ b/src/AI/agents/tests/api/test_feedback.py @@ -0,0 +1,106 @@ +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from api.main import app + +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 _put_feedback(payload, headers=None, path=FEEDBACK_PATH): + return TestClient(app).put(path, json=payload, headers=headers) + + +class TestFeedbackEndpoint: + def test_thumbs_up_writes_score_and_returns_204(self): + with ( + patch("api.routes.feedback.get_trace_developer", return_value=DEVELOPER), + patch("api.routes.feedback.score_validation") as mock_score, + ): + response = _put_feedback( + {"thumbs_up": True}, + headers=DEVELOPER_HEADER, + ) + + assert response.status_code == 204 + mock_score.assert_called_once_with( + name="user_feedback", + 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): + with ( + patch("api.routes.feedback.get_trace_developer", return_value=DEVELOPER), + patch("api.routes.feedback.score_validation") as mock_score, + ): + response = _put_feedback( + { + "thumbs_up": False, + "comment": "Svaret var ikke nyttig.", + }, + headers=DEVELOPER_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.", + score_id=f"{VALID_TRACE_ID}:user_feedback", + ) + + def test_missing_developer_header_returns_400(self): + with patch("api.routes.feedback.score_validation") as mock_score: + response = _put_feedback({"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 = _put_feedback( + {"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 = _put_feedback( + {"thumbs_up": True}, + headers=DEVELOPER_HEADER, + ) + + assert response.status_code == 403 + 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 = _put_feedback( + { + "thumbs_up": True, + "comment": "x" * 10001, + }, + headers=DEVELOPER_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..4e252ec67e1 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,25 @@ CancellationToken cancellationToken return NoContent(); } + [HttpPut("feedback/{traceId}")] + [RequestSizeLimit(20_000)] + 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, + 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..81939b1024f --- /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(bool ThumbsUp, [MaxLength(10000)] 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..020dd78a0d1 --- /dev/null +++ b/src/Designer/backend/src/Designer/Services/Implementation/Altinity/AltinityAgentClient.cs @@ -0,0 +1,48 @@ +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; +using Microsoft.Extensions.Options; + +namespace Altinn.Studio.Designer.Services.Implementation.Altinity; + +public class AltinityAgentClient : IAltinityAgentClient +{ + private const string FeedbackPathPrefix = "/api/feedback/"; + private const string DeveloperHeader = "X-Developer"; + + private readonly HttpClient _httpClient; + private readonly AltinitySettings _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 + ) + { + var requestUri = new Uri($"{_altinitySettings.AgentUrl}{FeedbackPathPrefix}{traceId}"); + using var httpRequest = new HttpRequestMessage(HttpMethod.Put, requestUri) + { + Content = JsonContent.Create(new { thumbs_up = thumbsUp, comment }), + }; + httpRequest.Headers.Add(DeveloperHeader, developer); + + using var response = await _httpClient.SendAsync(httpRequest, cancellationToken); + if (!response.IsSuccessStatusCode) + { + 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 new file mode 100644 index 00000000000..88cee3d6cf7 --- /dev/null +++ b/src/Designer/backend/src/Designer/Services/Interfaces/Altinity/IAltinityAgentClient.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Altinn.Studio.Designer.Services.Interfaces.Altinity; + +/// +/// 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. + /// + 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 new file mode 100644 index 00000000000..2cb7cec51c4 --- /dev/null +++ b/src/Designer/backend/tests/Designer.Tests/Controllers/ChatController/SubmitFeedbackTests.cs @@ -0,0 +1,73 @@ +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 const string TraceId = "trace-abc-123"; + private static string FeedbackUrl => $"designer/api/{Org}/{App}/chat/feedback/{TraceId}"; + + 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(true, null); + using var httpRequest = new HttpRequestMessage(HttpMethod.Put, FeedbackUrl) + { + Content = CreateJsonContent(request), + }; + + using var response = await HttpClient.SendAsync(httpRequest); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + _altinityAgentClientMock.Verify( + client => client.SendFeedbackAsync(Developer, TraceId, true, null, It.IsAny()), + Times.Once + ); + } + + [Fact] + public async Task SubmitFeedback_WithThumbsDownAndComment_ForwardsCommentToAgent() + { + var request = new ChatFeedbackRequest(false, "Svaret traff ikke helt."); + using var httpRequest = new HttpRequestMessage(HttpMethod.Put, FeedbackUrl) + { + Content = CreateJsonContent(request), + }; + + using var response = await HttpClient.SendAsync(httpRequest); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + _altinityAgentClientMock.Verify( + client => + client.SendFeedbackAsync( + Developer, + TraceId, + false, + "Svaret traff ikke helt.", + It.IsAny() + ), + Times.Once + ); + } +} diff --git a/src/Designer/frontend/AGENTS.md b/src/Designer/frontend/AGENTS.md index 661cfcee653..771e46fc548 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 `\packages\shared\src\contexts\ServicesContext.tsx` surfaces all query and mutation errors as toasts. + ## 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/AiAssistant.tsx b/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx index c7582f8dbca..e2a3838fd83 100644 --- a/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx +++ b/src/Designer/frontend/app-development/features/aiAssistant/AiAssistant.tsx @@ -7,12 +7,16 @@ 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 { 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(org, app); const { connectionStatus, @@ -65,6 +69,15 @@ 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'), + heading: t('ai_assistant.feedback_heading'), + detailsLabel: t('ai_assistant.feedback_details_label'), + detailsOptionalTag: t('general.optional'), + submit: t('ai_assistant.feedback_submit'), + cancel: t('general.cancel'), + }, }; if (!userHasAccessToAssistant) { @@ -93,6 +106,7 @@ function AiAssistant(): ReactElement { onSelectThread={selectThread} onCreateThread={clearCurrentSession} onDeleteThread={deleteThread} + onMessageFeedback={sendChatFeedback} connectionStatus={connectionStatus} workflowStatus={workflowStatus} previewContent={} 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(); 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..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 @@ -23,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 { @@ -33,13 +37,14 @@ export const useAltinityAssistant = (): UseAltinityAssistantResult => { cancelCurrentWorkflow, cancelledMessageContent, clearCancelledMessageContent, + messages, } = useAltinityWorkflow(threads); return { connectionStatus, workflowStatus, chatThreads: threads.chatThreads, - messages: threads.chatMessages, + messages, currentSessionId: threads.currentSessionId, onSubmitMessage, cancelCurrentWorkflow, 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/useAltinityWorkflow/useAltinityWorkflow.ts b/src/Designer/frontend/app-development/features/aiAssistant/hooks/useAltinityWorkflow/useAltinityWorkflow.ts index 9e3e5b8a9c4..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,11 +38,13 @@ export interface UseAltinityWorkflowResult { cancelCurrentWorkflow: () => Promise; cancelledMessageContent: string | null; clearCancelledMessageContent: () => void; + messages: Message[]; } 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 +110,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 +126,14 @@ 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); @@ -299,6 +310,11 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo setCancelledMessageContent(null); }, []); + const messages = useMemo( + () => decorateMessagesWithTraceIds(chatMessages, traceIdsByMessageId), + [chatMessages, traceIdsByMessageId], + ); + return { connectionStatus, workflowStatus, @@ -307,6 +323,7 @@ export const useAltinityWorkflow = (threads: AltinityThreadState): UseAltinityWo cancelCurrentWorkflow, cancelledMessageContent, clearCancelledMessageContent, + 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 diff --git a/src/Designer/frontend/language/src/nb.json b/src/Designer/frontend/language/src/nb.json index a059dd58eb9..4b260e5c4f4 100644 --- a/src/Designer/frontend/language/src/nb.json +++ b/src/Designer/frontend/language/src/nb.json @@ -87,6 +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": "Fortell oss mer", + "ai_assistant.feedback_heading": "Tilbakemelding", + "ai_assistant.feedback_submit": "Send", + "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/Assistant/Assistant.tsx b/src/Designer/frontend/libs/studio-assistant/src/Assistant/Assistant.tsx index 6ed0fd982ab..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,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 { UserFeedback } from '../types/UserFeedback'; import type { AssistantTexts } from '../types/AssistantTexts'; import type { ConnectionStatus } from '../types/ConnectionStatus'; import type { WorkflowStatus } from '../types/WorkflowStatus'; @@ -22,6 +23,7 @@ export type AssistantProps = { onSelectThread?: (threadId: string) => void; onDeleteThread?: (threadId: string) => void; onCreateThread?: () => void; + onMessageFeedback?: (feedback: UserFeedback) => void; workflowStatus: WorkflowStatus; previewContent: ReactElement; fileBrowserContent?: ReactElement; @@ -43,6 +45,7 @@ export function Assistant({ onSelectThread, onDeleteThread, onCreateThread, + onMessageFeedback, previewContent, fileBrowserContent, currentUser, @@ -64,6 +67,7 @@ export function Assistant({ onSelectThread={onSelectThread} onDeleteThread={onDeleteThread} onCreateThread={onCreateThread} + onMessageFeedback={onMessageFeedback} previewContent={previewContent} fileBrowserContent={fileBrowserContent} currentUser={currentUser} 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..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,6 +2,7 @@ import type { ReactElement } from 'react'; import { useRef, useEffect } from 'react'; import cn from 'classnames'; import { Messages } 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'; @@ -17,6 +18,7 @@ export type ChatColumnProps = { onCancelWorkflow?: () => void; cancelledMessageContent?: string | null; onCancelledMessageConsumed?: () => void; + onMessageFeedback?: (feedback: UserFeedback) => void; workflowStatus?: WorkflowStatus; enableCompactInterface: boolean; currentUser?: User; @@ -29,6 +31,7 @@ export function ChatColumn({ onCancelWorkflow, cancelledMessageContent, onCancelledMessageConsumed, + onMessageFeedback, workflowStatus, enableCompactInterface, currentUser, @@ -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..87c9506284a --- /dev/null +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.module.css @@ -0,0 +1,17 @@ +.feedbackBar { + display: flex; + gap: var(--ds-size-1); + margin-top: var(--ds-size-2); +} + +.dialogContent { + display: flex; + flex-direction: column; + gap: var(--ds-size-2); +} + +.dialogActions { + display: flex; + 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 new file mode 100644 index 00000000000..4979161762c --- /dev/null +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.test.tsx @@ -0,0 +1,96 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MessageFeedback } from './MessageFeedback'; +import type { MessageFeedbackProps } from './MessageFeedback'; +import { messageFeedbackTexts as feedbackTexts } from '../../../../mocks/mockTexts'; + +describe('MessageFeedback', () => { + it('renders thumbs up and thumbs down buttons', () => { + renderMessageFeedback(); + + expect(getThumbsUpButton()).toBeInTheDocument(); + expect(getThumbsDownButton()).toBeInTheDocument(); + }); + + it('opens feedback dialog when pressing either thumb button', async () => { + const user = userEvent.setup(); + renderMessageFeedback(); + + await user.click(getThumbsUpButton()); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + 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()); + await user.click(getSendButton()); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ + thumbsUp: true, + comment: undefined, + }); + }); + + 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(getSendButton()); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ + thumbsUp: false, + 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 = { + texts: feedbackTexts, + onSubmit: jest.fn(), +}; + +const renderMessageFeedback = (props: Partial = {}): void => { + render(); +}; + +const getThumbsUpButton = (): HTMLElement => + screen.getByRole('button', { name: feedbackTexts.thumbsUp }); + +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 new file mode 100644 index 00000000000..a5383df7863 --- /dev/null +++ b/src/Designer/frontend/libs/studio-assistant/src/components/ChatColumn/Messages/MessageFeedback/MessageFeedback.tsx @@ -0,0 +1,97 @@ +import type { ReactElement } from 'react'; +import { useState } from 'react'; +import { + StudioButton, + StudioDialog, + StudioFormGroup, + StudioHeading, + StudioTextarea, +} from '@studio/components'; +import { ThumbDownIcon, ThumbUpIcon, PaperplaneFillIcon, XMarkIcon } from '@studio/icons'; +import type { MessageFeedbackTexts } from '../../../../types/AssistantTexts'; +import type { FeedbackPayload } from '../../../../types/UserFeedback'; +import classes from './MessageFeedback.module.css'; + +export type MessageFeedbackProps = { + texts: MessageFeedbackTexts; + onSubmit: (payload: FeedbackPayload) => void; +}; + +export function MessageFeedback({ texts, onSubmit }: MessageFeedbackProps): ReactElement { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [selectedVote, setSelectedVote] = useState(null); + const [commentText, setCommentText] = useState(''); + const commentPlaceholder = selectedVote === true ? texts.thumbsUp : texts.thumbsDown; + + const handleVoteClick = (vote: boolean): void => { + setSelectedVote(vote); + setIsDialogOpen(true); + }; + + const handleSendFeedback = (): void => { + if (selectedVote === null) return; + + const trimmedComment = commentText.trim(); + onSubmit({ + thumbsUp: selectedVote, + comment: trimmedComment || undefined, + }); + handleDialogClose(); + }; + + const handleDialogClose = (): void => { + setIsDialogOpen(false); + setSelectedVote(null); + setCommentText(''); + }; + + return ( + <> +
+ handleVoteClick(true)} + icon={} + /> + handleVoteClick(false)} + icon={} + /> +
+ + + + {texts.heading} + + + + setCommentText(event.target.value)} + placeholder={commentPlaceholder} + /> + +
+ } + > + {texts.submit} + + }> + {texts.cancel} + +
+
+
+ + ); +} 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..dc0315c378b --- /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 { MessageFeedbackProps } from './MessageFeedback'; 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/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(); }; 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..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 @@ -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,6 +13,8 @@ import { isUrlSafe, } from '../../../utils/messageUtils'; import { ChatAvatar } from '../ChatAvatar'; +import { MessageFeedback } from './MessageFeedback'; +import type { UserFeedback } from '../../../types/UserFeedback'; const ASSISTANT_LABEL = 'Altinity'; const DEFAULT_USER_LABEL = 'Deg'; @@ -21,6 +24,8 @@ export type MessagesProps = { workflowStatus?: WorkflowStatus; currentUser?: User; assistantAvatarUrl?: string; + feedbackTexts?: MessageFeedbackTexts; + onMessageFeedback?: (feedback: UserFeedback) => void; }; export function Messages({ @@ -28,6 +33,8 @@ export function Messages({ workflowStatus, currentUser, assistantAvatarUrl, + feedbackTexts, + onMessageFeedback, }: MessagesProps): ReactElement { const showLoadingBubble = workflowStatus?.isActive === true; const loadingBubbleText = workflowStatus?.message ?? ''; @@ -40,6 +47,8 @@ export function Messages({ message={message} currentUser={currentUser} assistantAvatarUrl={assistantAvatarUrl} + feedbackTexts={feedbackTexts} + onMessageFeedback={onMessageFeedback} /> ))} {showLoadingBubble && ( @@ -79,9 +88,17 @@ type MessageItemProps = { message: Message; currentUser?: User; assistantAvatarUrl?: string; + feedbackTexts?: MessageFeedbackTexts; + onMessageFeedback?: (feedback: UserFeedback) => void; }; -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 +273,9 @@ function MessageItem({ message, currentUser, assistantAvatarUrl }: MessageItemPr ); }; + const traceId = message.role === MessageAuthor.Assistant ? message.traceId : undefined; + const showFeedback = traceId && feedbackTexts && onMessageFeedback; + return (
@@ -269,6 +289,12 @@ function MessageItem({ message, currentUser, assistantAvatarUrl }: MessageItemPr
{renderSources()} {renderFilesChanged()} + {showFeedback && ( + onMessageFeedback({ traceId, payload })} + /> + )}
); 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..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,6 +29,7 @@ export function CompleteInterface({ onSelectThread, onDeleteThread, onCreateThread, + onMessageFeedback, previewContent, fileBrowserContent, currentUser, @@ -91,6 +92,7 @@ export function CompleteInterface({ onCancelWorkflow={onCancelWorkflow} cancelledMessageContent={cancelledMessageContent} onCancelledMessageConsumed={onCancelledMessageConsumed} + onMessageFeedback={onMessageFeedback} workflowStatus={currentThreadWorkflowStatus} enableCompactInterface={false} currentUser={currentUser} diff --git a/src/Designer/frontend/libs/studio-assistant/src/index.ts b/src/Designer/frontend/libs/studio-assistant/src/index.ts index 1e767ce2d2a..b88fa034384 100644 --- a/src/Designer/frontend/libs/studio-assistant/src/index.ts +++ b/src/Designer/frontend/libs/studio-assistant/src/index.ts @@ -3,7 +3,12 @@ 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 { 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/mocks/mockTexts.ts b/src/Designer/frontend/libs/studio-assistant/src/mocks/mockTexts.ts index 189adff76d3..efa5e595a43 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,16 @@ const textAreaTexts: TextAreaTexts = { waitingForConnection: 'waitingForConnection', }; +export const messageFeedbackTexts: MessageFeedbackTexts = { + thumbsUp: 'feedbackThumbsUp', + thumbsDown: 'feedbackThumbsDown', + heading: 'feedbackHeading', + detailsLabel: 'feedbackDetailsLabel', + detailsOptionalTag: 'feedbackDetailsOptionalTag', + submit: 'feedbackSubmit', + cancel: 'feedbackCancel', +}; + export const mockTexts: AssistantTexts = { heading: 'heading', preview: 'preview', @@ -34,4 +45,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..2f2574550c9 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,17 @@ export type AssistantTexts = { send: string; cancel: string; assistantFirstMessage: string; + feedback: MessageFeedbackTexts; +}; + +export type MessageFeedbackTexts = { + thumbsUp: string; + thumbsDown: string; + heading: string; + detailsLabel: string; + detailsOptionalTag: string; + submit: string; + cancel: 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/libs/studio-assistant/src/types/UserFeedback.ts b/src/Designer/frontend/libs/studio-assistant/src/types/UserFeedback.ts new file mode 100644 index 00000000000..fa971c1dbba --- /dev/null +++ b/src/Designer/frontend/libs/studio-assistant/src/types/UserFeedback.ts @@ -0,0 +1,9 @@ +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 2db24aa615f..2cc9892078e 100644 --- a/src/Designer/frontend/packages/shared/src/api/mutations.ts +++ b/src/Designer/frontend/packages/shared/src/api/mutations.ts @@ -85,9 +85,13 @@ 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 } 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'; @@ -277,5 +281,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, 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 252e76f7a5d..13c511a3cc3 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, 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 new file mode 100644 index 00000000000..0afc2d904fe --- /dev/null +++ b/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.test.ts @@ -0,0 +1,21 @@ +import { queriesMock } from 'app-shared/mocks/queriesMock'; +import { renderHookWithProviders } from '../../mocks/renderHookWithProviders'; +import { app, org } from '@studio/testing/testids'; +import { useChatFeedbackMutation } from './useChatFeedbackMutation'; +import { waitFor } from '@testing-library/react'; + +describe('useChatFeedbackMutation', () => { + afterEach(jest.clearAllMocks); + + 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({ traceId, payload }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(queriesMock.sendChatFeedback).toHaveBeenCalledTimes(1); + 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 new file mode 100644 index 00000000000..c3cecf1adf1 --- /dev/null +++ b/src/Designer/frontend/packages/shared/src/hooks/mutations/useChatFeedbackMutation.ts @@ -0,0 +1,13 @@ +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: ({ traceId, payload }: ChatFeedbackMutationArgs) => + sendChatFeedback(org, app, traceId, 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..3c508a26a1b --- /dev/null +++ b/src/Designer/frontend/packages/shared/src/types/api/ChatFeedbackPayload.ts @@ -0,0 +1,4 @@ +export type ChatFeedbackPayload = { + 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';