diff --git a/public/locales/en/copilotChatBot.json b/public/locales/en/copilotChatBot.json index 57095631f..cd450554f 100644 --- a/public/locales/en/copilotChatBot.json +++ b/public/locales/en/copilotChatBot.json @@ -12,6 +12,9 @@ "rateQuestion": "How would you rate this conversation ?", "copilotWarning": "Assistant visible only in full page.", "copilotChatBotErrorMessage": "An unexpected error occured while creating the report. Refresh the page and try again.", + "copilotTitle": "Aletheia Copilot", "copilotClearHistoryTooltip": "Clear conversation history", - "copilotCloseSidebarTooltip": "Close sidebar" + "copilotCloseSidebarTooltip": "Close sidebar", + "sessionLoadingError": "Failed to load conversation. Please try again.", + "sessionCreateError": "Failed to start a new conversation. Please try again." } \ No newline at end of file diff --git a/public/locales/pt/copilotChatBot.json b/public/locales/pt/copilotChatBot.json index 64d843a91..9d57b9df2 100644 --- a/public/locales/pt/copilotChatBot.json +++ b/public/locales/pt/copilotChatBot.json @@ -12,6 +12,9 @@ "rateQuestion": "Como você avaliaria esta conversa ?", "copilotWarning": "Assistente apenas disponivel na página completa.", "copilotChatBotErrorMessage": "Ocorreu um erro inesperado ao criar o relatório. Atualize a página e tente novamente.", + "copilotTitle": "Aletheia Copilot", "copilotClearHistoryTooltip": "Limpar histórico de conversa", - "copilotCloseSidebarTooltip": "Fechar menu lateral" + "copilotCloseSidebarTooltip": "Fechar menu lateral", + "sessionLoadingError": "Falha ao carregar conversa. Por favor, tente novamente.", + "sessionCreateError": "Falha ao iniciar nova conversa. Por favor, tente novamente." } \ No newline at end of file diff --git a/server/automated-fact-checking/automated-fact-checking.service.ts b/server/automated-fact-checking/automated-fact-checking.service.ts index 05f437fed..050d0db5c 100644 --- a/server/automated-fact-checking/automated-fact-checking.service.ts +++ b/server/automated-fact-checking/automated-fact-checking.service.ts @@ -1,12 +1,19 @@ -import { Inject, Injectable, Scope } from "@nestjs/common"; +import { Inject, Injectable, Logger, Scope } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import type { BaseRequest } from "../types"; import { REQUEST } from "@nestjs/core"; import { JwtService } from "@nestjs/jwt"; +export interface AgenciaResponse { + stream: string; + json: any; + executionId?: string; +} + @Injectable({ scope: Scope.REQUEST }) export class AutomatedFactCheckingService { agenciaURL: string; + private readonly logger = new Logger("AutomatedFactCheckingService"); constructor( @Inject(REQUEST) private req: BaseRequest, @@ -18,15 +25,22 @@ export class AutomatedFactCheckingService { ); } - async getResponseFromAgents(data): Promise<{ stream: string; json: any }> { + private getAgenciaToken(): string { + const { access_token } = this.configService.get("agencia"); + return this.jwtService.sign( + { sub: this.req.user._id }, + { secret: access_token, expiresIn: "15m" } + ); + } + + async getResponseFromAgents( + data, + sessionId?: string + ): Promise { try { - const { access_token } = this.configService.get("agencia"); - const agenciaAccessToken = this.jwtService.sign( - { sub: this.req.user._id }, - { secret: access_token, expiresIn: "15m" } - ); + const agenciaAccessToken = this.getAgenciaToken(); - const params = { + const params: any = { input: { claim: data.claim, context: data.context, @@ -38,6 +52,11 @@ export class AutomatedFactCheckingService { }, }; + // Include session_id at top level if provided + if (sessionId) { + params.session_id = sessionId; + } + const response = await fetch(`${this.agenciaURL}/invoke`, { method: "POST", body: JSON.stringify(params), @@ -48,32 +67,170 @@ export class AutomatedFactCheckingService { keepalive: true, }); - let reader = response.body.getReader(); + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `Agencia returned HTTP ${response.status}: ${errorBody.substring(0, 200)}` + ); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); let streamResponse = ""; let done, value; while (!done) { ({ done, value } = await reader.read()); - streamResponse += new TextDecoder().decode(value, { - stream: true, - }); + streamResponse += decoder.decode(value, { stream: true }); + } + + // Parse NDJSON: each line is a JSON object + const lines = streamResponse.trim().split("\n").filter(Boolean); + + // Extract execution_id from the "started" line (first line) + let executionId: string | undefined; + const firstLine = JSON.parse(lines[0]); + if (firstLine.status === "started" && firstLine.execution_id) { + executionId = firstLine.execution_id; + this.logger.log( + `Agencia execution started: ${executionId}` + ); + } + + // Last line has the final result + const lastLine = JSON.parse(lines[lines.length - 1]); + + if (lastLine.status === "error") { + throw new Error(lastLine.detail || "Agencia processing error"); } - const jsonResponse = JSON.parse(streamResponse); + const result = lastLine.message; - if (jsonResponse?.detail) { - return { stream: jsonResponse.detail, json: {} }; + if (result?.detail) { + return { stream: result.detail, json: {}, executionId }; } - if (jsonResponse?.message) { - const report = JSON.parse(jsonResponse?.message?.messages); - return { stream: streamResponse, json: { messages: report } }; + if (result?.messages) { + const report = JSON.parse(result.messages); + return { + stream: streamResponse, + json: { messages: report }, + executionId, + }; } - return { stream: streamResponse, json: { messages: {} } }; + return { + stream: streamResponse, + json: { messages: {} }, + executionId, + }; } catch (error) { throw new Error(`"Agencia's server error": ${error}`); } } + + private validateSessionId(sessionId: string): void { + // Allow only a safe subset of characters for session identifiers. + // Adjust this regex to match the actual expected format if needed. + const SESSION_ID_REGEX = /^[A-Za-z0-9_-]+$/; + if ( + typeof sessionId !== "string" || + sessionId.length === 0 || + sessionId.length > 256 || + !SESSION_ID_REGEX.test(sessionId) + ) { + throw new Error("Invalid sessionId"); + } + } + + private validateExecutionId(executionId: string): void { + // Allow only a safe subset of characters for execution identifiers. + const EXECUTION_ID_REGEX = /^[A-Za-z0-9_-]+$/; + if ( + typeof executionId !== "string" || + executionId.length === 0 || + executionId.length > 256 || + !EXECUTION_ID_REGEX.test(executionId) + ) { + throw new Error("Invalid executionId"); + } + } + + /** + * List all executions for a given session. + * Calls GET /executions/{session_id} on Agencia. + */ + async getExecutions(sessionId: string): Promise { + try { + this.validateSessionId(sessionId); + const safeSessionId = encodeURIComponent(sessionId); + const agenciaAccessToken = this.getAgenciaToken(); + + const response = await fetch( + `${this.agenciaURL}/executions/${safeSessionId}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${agenciaAccessToken}`, + }, + } + ); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `Agencia returned HTTP ${response.status}: ${errorBody.substring(0, 200)}` + ); + } + + return await response.json(); + } catch (error) { + this.logger.error( + `Failed to fetch executions for session ${sessionId}: ${error}` + ); + throw new Error(`"Agencia's server error": ${error}`); + } + } + + /** + * Get a specific execution by session and execution ID. + * Calls GET /executions/{session_id}/{execution_id} on Agencia. + */ + async getExecution( + sessionId: string, + executionId: string + ): Promise { + try { + this.validateSessionId(sessionId); + this.validateExecutionId(executionId); + const safeSessionId = encodeURIComponent(sessionId); + const safeExecutionId = encodeURIComponent(executionId); + const agenciaAccessToken = this.getAgenciaToken(); + + const response = await fetch( + `${this.agenciaURL}/executions/${safeSessionId}/${safeExecutionId}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${agenciaAccessToken}`, + }, + } + ); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `Agencia returned HTTP ${response.status}: ${errorBody.substring(0, 200)}` + ); + } + + return await response.json(); + } catch (error) { + this.logger.error( + `Failed to fetch execution ${executionId}: ${error}` + ); + throw new Error(`"Agencia's server error": ${error}`); + } + } } diff --git a/server/copilot/copilot-chat.controller.ts b/server/copilot/copilot-chat.controller.ts index a311d2402..78c1a76c0 100644 --- a/server/copilot/copilot-chat.controller.ts +++ b/server/copilot/copilot-chat.controller.ts @@ -1,44 +1,99 @@ -/** - * Controller for Langchain Chat operations. - * - * Handles HTTP requests for context-aware chat interactions - * in the Langchain application. This controller is responsible for - * validating incoming request data and orchestrating chat interactions through the LangchainChatService. - * It supports endpoints for initiating context-aware chat - * and ensuring a versatile chat service experience. - * - * @class LangchainChatController - * - * @method contextAwareChat - Initiates a context-aware chat interaction. Accepts POST requests with a ContextAwareMessagesDto to manage chat context. - * Leverages LangchainChatService for processing. - * @param {ContextAwareMessagesDto} contextAwareMessagesDto - DTO for managing chat context. - * @returns Contextual chat response from the LangchainChatService. - * - * This controller uses decorators to define routes and their configurations, ensuring proper request handling and response formatting. It also integrates file upload handling for PDF documents, enabling document-context chat functionalities. - */ - -import { Body, Controller, Post, Req } from "@nestjs/common"; +import { + Body, + Controller, + Get, + Param, + Post, + Query, + Req, +} from "@nestjs/common"; import { CopilotChatService } from "./copilot-chat.service"; -import { ContextAwareMessagesDto } from "./dtos/context-aware-messages.dto"; +import { + CreateSessionDto, + SessionAgentChatDto, +} from "./dtos/context-aware-messages.dto"; import { FactCheckerOnly } from "../auth/decorators/auth.decorator"; +import { CopilotSessionService } from "./copilot-session.service"; +import { AutomatedFactCheckingService } from "../automated-fact-checking/automated-fact-checking.service"; @Controller() export class CopilotChatController { - constructor(private readonly copilotChatService: CopilotChatService) {} + constructor( + private readonly copilotChatService: CopilotChatService, + private readonly copilotSessionService: CopilotSessionService, + private readonly automatedFactCheckingService: AutomatedFactCheckingService + ) {} + + @FactCheckerOnly() + @Get("api/copilot-session") + async getSession( + @Query("claimReviewDataHash") claimReviewDataHash: string, + @Req() req + ) { + const session = await this.copilotSessionService.getActiveSession( + req.user._id, + claimReviewDataHash + ); + return { session }; + } + + @FactCheckerOnly() + @Post("api/copilot-session") + async createSession( + @Body() createSessionDto: CreateSessionDto, + @Req() req + ) { + const session = await this.copilotSessionService.createSession( + req.user._id, + createSessionDto.claimReviewDataHash, + createSessionDto.context + ); + return { session }; + } + + @FactCheckerOnly() + @Post("api/copilot-session/:id/clear") + async clearSession(@Param("id") id: string) { + await this.copilotSessionService.deactivateSession(id); + return { success: true }; + } @FactCheckerOnly() @Post("api/agent-chat") async agentChat( - @Body() contextAwareMessagesDto: ContextAwareMessagesDto, + @Body() sessionAgentChatDto: SessionAgentChatDto, @Req() req ) { try { return await this.copilotChatService.agentChat( - contextAwareMessagesDto, - req.language + sessionAgentChatDto, + req.language, + req.user._id ); } catch (e) { throw new Error(e); } } + + @FactCheckerOnly() + @Get("api/copilot-session/:sessionId/executions") + async getExecutions(@Param("sessionId") sessionId: string) { + const executions = + await this.automatedFactCheckingService.getExecutions(sessionId); + return { executions }; + } + + @FactCheckerOnly() + @Get("api/copilot-session/:sessionId/executions/:executionId") + async getExecution( + @Param("sessionId") sessionId: string, + @Param("executionId") executionId: string + ) { + const execution = + await this.automatedFactCheckingService.getExecution( + sessionId, + executionId + ); + return { execution }; + } } diff --git a/server/copilot/copilot-chat.module.ts b/server/copilot/copilot-chat.module.ts index 61e9246a2..0d459d4a0 100644 --- a/server/copilot/copilot-chat.module.ts +++ b/server/copilot/copilot-chat.module.ts @@ -1,19 +1,31 @@ import { Module } from "@nestjs/common"; +import { MongooseModule } from "@nestjs/mongoose"; import { CopilotChatService } from "./copilot-chat.service"; import { CopilotChatController } from "./copilot-chat.controller"; import { AutomatedFactCheckingModule } from "../automated-fact-checking/automated-fact-checking.module"; import { EditorParseModule } from "../editor-parse/editor-parse.module"; import { AbilityModule } from "../auth/ability/ability.module"; import { ConfigModule } from "@nestjs/config"; +import { CopilotSessionService } from "./copilot-session.service"; +import { CopilotSourceService } from "./copilot-source.service"; +import { + CopilotSession, + CopilotSessionSchema, +} from "./schemas/copilot-session.schema"; +import { SourceModule } from "../source/source.module"; @Module({ imports: [ + MongooseModule.forFeature([ + { name: CopilotSession.name, schema: CopilotSessionSchema }, + ]), AutomatedFactCheckingModule, EditorParseModule, AbilityModule, ConfigModule, + SourceModule, ], controllers: [CopilotChatController], - providers: [CopilotChatService], + providers: [CopilotChatService, CopilotSessionService, CopilotSourceService], }) export class CopilotChatModule {} diff --git a/server/copilot/copilot-chat.service.ts b/server/copilot/copilot-chat.service.ts index 66d78ff73..d5d92a6e6 100644 --- a/server/copilot/copilot-chat.service.ts +++ b/server/copilot/copilot-chat.service.ts @@ -1,28 +1,12 @@ -/** - * Service for handling Langchain Chat operations. - * - * This service facilitates various types of chat interactions using OpenAI's language models. - * It supports context-aware chat. - * Basic context-aware chat utilize pre-defined templates for processing user queries, - * - * @class CopilotChatService - * - * @method contextAwareChat - Processes messages with consideration for the context of previous interactions, using a context-aware template for coherent responses. Handles errors with HttpExceptions. - * @param {ContextAwareMessagesDto} contextAwareMessagesDto - Data Transfer Object containing the user’s current message and the chat history. - * @returns Contextually relevant response from the OpenAI model. - * - * The class utilizes several internal methods for operations such as loading chat chains, formatting messages, generating success responses, and handling exceptions. - * These methods interact with external libraries and services, including the OpenAI API, file system operations, and custom utilities for message formatting and response generation. - */ - import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common"; import { ChatOpenAI } from "@langchain/openai"; import customMessage from "./customMessage.response"; import { MESSAGES } from "./messages.constants"; import { openAI } from "./openAI.constants"; import { - ContextAwareMessagesDto, + SessionAgentChatDto, SenderEnum, + Context, } from "./dtos/context-aware-messages.dto"; import { z } from "zod"; @@ -39,6 +23,8 @@ import { HumanMessage, AIMessage } from "@langchain/core/messages"; import { AutomatedFactCheckingService } from "../automated-fact-checking/automated-fact-checking.service"; import { EditorParseService } from "../editor-parse/editor-parse.service"; import { ConfigService } from "@nestjs/config"; +import { CopilotSessionService } from "./copilot-session.service"; +import { CopilotSourceService } from "./copilot-source.service"; enum SearchType { online = "online", @@ -51,124 +37,231 @@ export class CopilotChatService { constructor( private automatedFactCheckingService: AutomatedFactCheckingService, private editorParseService: EditorParseService, - private configService: ConfigService + private configService: ConfigService, + private copilotSessionService: CopilotSessionService, + private copilotSourceService: CopilotSourceService ) {} - editorReport = null; - - getFactCheckingReportTool = { - name: "get-fact-checking-report", - description: - "Use this tool to create a fact-checking report providing the information to the automated fact checking agents", - schema: z.object({ - claim: z.string().describe("The claim provided by the user"), - context: z - .object({ - //Bad behavior: When the user do not pass a value, the agent assumes the value from the date context - published_since: z - .string() - .describe( - "the oldest date provided specifically and just by the user" - ), - published_until: z - .string() - .describe( - "the newest date provided or if it's not provided the date that the claim was stated" - ), - city: z - .string() - .describe( - "the city location provided specifically and just by the user" - ), - sources: z - .array(z.string()) - .describe( - "the suggested sources as an array provided specifically and just by the user" - ), - }) - .describe( - "Context provided by the user to construct the fact-checking report" - ), - searchType: z - .nativeEnum(SearchType) - .describe( - "The search type provided by the user, must be a valid enum value" - ) - .default(SearchType.online), - }), - func: async (data) => { - try { - const { stream, json } = - await this.automatedFactCheckingService.getResponseFromAgents( - data - ); - - if (json?.messages) { - this.editorReport = - await this.editorParseService.schema2editor({ + + private createFactCheckingReportTool( + editorReportRef: { value: any }, + executionIdRef: { value: string | null }, + userId: string, + sessionId: string + ) { + return { + name: "get-fact-checking-report", + description: + "Use this tool to create a fact-checking report providing the information to the automated fact checking agents", + schema: z.object({ + claim: z.string().describe("The claim provided by the user"), + context: z + .object({ + published_since: z + .string() + .describe( + "the oldest date provided specifically and just by the user" + ), + published_until: z + .string() + .describe( + "the newest date provided or if it's not provided the date that the claim was stated" + ), + city: z + .string() + .describe( + "the city location provided specifically and just by the user" + ), + sources: z + .array(z.string()) + .describe( + "the suggested sources as an array provided specifically and just by the user" + ), + }) + .describe( + "Context provided by the user to construct the fact-checking report" + ), + searchType: z + .nativeEnum(SearchType) + .describe( + "The search type provided by the user, must be a valid enum value" + ) + .default(SearchType.online), + }), + func: async (data) => { + try { + const { stream, json, executionId } = + await this.automatedFactCheckingService.getResponseFromAgents( + data, + sessionId + ); + + // Capture execution_id for storage in the session message + if (executionId) { + executionIdRef.value = executionId; + } + + if (json?.messages) { + const agenciaSources = + json.messages?.sources || []; + + // Persist Agencia sources and normalize url→href + const normalizedSources = + agenciaSources.length > 0 + ? await this.copilotSourceService.processAgenciaSources( + agenciaSources, + userId + ) + : []; + + // Build the schema for the editor. + // Agencia sources don't have field/textRange data needed + // for inline citations, so we append them as a reference + // list at the end of the report text instead. + const schemaForEditor = { ...json.messages, sources: [], - }); + }; + + if ( + normalizedSources.length > 0 && + schemaForEditor.report + ) { + schemaForEditor.report = + this.appendSourcesToContent( + schemaForEditor.report, + normalizedSources + ); + } + + editorReportRef.value = + await this.editorParseService.schema2editor( + schemaForEditor + ); + } + + return stream; + } catch (error) { + this.logger.error(error); + return String(error); } + }, + }; + } - return stream; - } catch (error) { - this.logger.error(error); - return error; + /** + * Appends a reference list of sources to the end of a report content string. + * + * Agencia sources don't include the field/textRange metadata required for + * inline citations in the editor, so we present them as a numbered list + * appended to the report text. The fact-checker can then reorganize or + * integrate them as needed. + */ + private appendSourcesToContent( + content: string, + sources: { href?: string; title?: string }[] + ): string { + if (!sources || sources.length === 0) { + return content; + } + + const sourceLines = sources.map((source, index) => { + const num = index + 1; + if (source.title && source.title !== source.href) { + return `${num}. ${source.title}: ${source.href}`; } - }, - }; + return `${num}. ${source.href}`; + }); - async agentChat( - contextAwareMessagesDto: ContextAwareMessagesDto, - language - ) { + return `${content}\n\nFontes:\n${sourceLines.join("\n")}`; + } + + async agentChat(sessionAgentChatDto: SessionAgentChatDto, language, userId: string) { try { - const date = new Date(contextAwareMessagesDto.context.claimDate); + const { sessionId, message } = sessionAgentChatDto; + + const session = + await this.copilotSessionService.getSessionById(sessionId); + if (!session) { + throw new Error("Session not found"); + } + + const context = session.context as Context; + const date = new Date(context.claimDate); const localizedDate = date.toLocaleDateString(); language = language === "pt" ? "Portuguese" : "English"; - const messagesHistory = contextAwareMessagesDto.messages.map( - (message) => this.transformMessage(message) + + // Build rich context strings + const personalities = + context.personalityNames?.length > 0 + ? context.personalityNames.join(", ") + : context.personalityName || "unknown"; + const contentModel = context.contentModel || ""; + const topics = + context.topics?.length > 0 + ? context.topics.join(", ") + : ""; + + // Persist the user message + await this.copilotSessionService.addMessage(sessionId, { + sender: SenderEnum.User, + content: message, + type: "info", + }); + + // Build chat history from stored session messages (excluding the one we just added) + const messagesHistory = session.messages.map((msg) => + this.transformMessage(msg) ); + + // Use local ref objects instead of instance variables (fixes concurrency bug) + const editorReportRef = { value: null }; + const executionIdRef: { value: string | null } = { value: null }; const tools = [ new DynamicStructuredTool( - this.getFactCheckingReportTool as any + this.createFactCheckingReportTool( + editorReportRef, + executionIdRef, + userId, + sessionId + ) as any ), ]; - const currentMessageContent = - contextAwareMessagesDto.messages[ - contextAwareMessagesDto.messages.length - 1 - ]; - const prompt = ChatPromptTemplate.fromMessages([ [ "system", - ` - You are the Fact-checker Aletheia's Assistant, working with a fact-checker who requires assistance. - Your primary goal is to gather all relevant information from the user about the claim: {claim} that needs to be fact-checked. - - Please follow these steps carefully - - 1. Confirm the claim for fact-checking: - - If the user requests assistance with fact-checking, ask the user to confirm the claim that he wants to review is the claim: {claim} stated by {personality}, assure to always compose this specific question using these values {claim} and {personality} if they exists. - - 2. Confirm the type of research: - - Ask the user how should we proceed the research by either searching on internet or searching in public gazettes - - 3. Based on the type of research, proceed gathering the necessary information: - **public gazettes**: ask the following questions sequentially: - - "In which Brazilian city or state was the claim made?" - - "Do you have a specific time period during which we should search in the public gazettes (e.g. January 2022 to December 2022), or should we search up to the date the claim was stated: {date}?" - - **online search**: ask the following question: - - "Do you have any specific sources you suggest we consult for verifying this claim?" - - - Always pose your questions one at a time and in the specified order. - - Persist in asking all necessary questions. Do not use the tool until you have thoroughly completed all preceding steps. - Maintain the use of formal language in your responses, ensuring that all communication is conducted in {language}. - Only after all questions have been addressed and all relevant information has been gathered from the user you should proceed to use the get-fact-checking-report tool.`, + `You are the Fact-checker Aletheia's Assistant, working with a fact-checker who requires assistance. +Your primary goal is to gather all relevant information from the user about the claim that needs to be fact-checked. + +## Claim Context +- **Claim**: {claim} +- **Stated by**: {personalities} +- **Claim title**: {claimTitle} +- **Date stated**: {date} +- **Content type**: {contentModel} +- **Related topics**: {topics} + +## Steps to follow + +1. **Confirm the claim for fact-checking**: + - Ask the user to confirm the claim they want to review is: "{claim}" stated by {personalities}. + - Always compose this question using the claim and personalities values above if they exist. + +2. **Confirm the type of research**: + - Ask the user how to proceed: searching on the internet or searching in public gazettes. + +3. **Gather information based on the type of research**: + - **Public gazettes**: ask the following questions sequentially: + - "In which Brazilian city or state was the claim made?" + - "Do you have a specific time period during which we should search in the public gazettes (e.g. January 2022 to December 2022), or should we search up to the date the claim was stated: {date}?" + - **Online search**: ask the following question: + - "Do you have any specific sources you suggest we consult for verifying this claim?" + +## Rules +- Always pose your questions one at a time and in the specified order. +- Persist in asking all necessary questions. Do not use the tool until you have thoroughly completed all preceding steps. +- Maintain the use of formal language in your responses, ensuring that all communication is conducted in {language}. +- Only after all questions have been addressed and all relevant information has been gathered from the user you should proceed to use the get-fact-checking-report tool.`, ], new MessagesPlaceholder({ variableName: "chat_history" }), ["user", "{input}"], @@ -177,7 +270,7 @@ export class CopilotChatService { const llm = new ChatOpenAI({ temperature: +openAI.BASIC_CHAT_OPENAI_TEMPERATURE, - modelName: openAI.GPT_3_5_TURBO_1106.toString(), + modelName: openAI.GPT_5_MINI.toString(), apiKey: this.configService.get("openai.api_key"), }); @@ -195,16 +288,37 @@ export class CopilotChatService { const response = await agentExecutor.invoke({ language: language, date: localizedDate, - claim: contextAwareMessagesDto.context.sentence, - personality: contextAwareMessagesDto.context.personalityName, - input: currentMessageContent, + claim: context.sentence, + personalities: personalities, + claimTitle: context.claimTitle || "", + contentModel: contentModel || "not specified", + topics: topics || "none", + input: message, chat_history: messagesHistory, }); + // Persist the assistant response (include editorReport and executionId if produced) + const assistantMessage: any = { + sender: SenderEnum.Assistant, + content: response.output, + type: "info", + }; + if (editorReportRef.value) { + assistantMessage.editorReport = editorReportRef.value; + } + if (executionIdRef.value) { + assistantMessage.executionId = executionIdRef.value; + } + await this.copilotSessionService.addMessage( + sessionId, + assistantMessage + ); + return customMessage(HttpStatus.OK, MESSAGES.SUCCESS, { sender: SenderEnum.Assistant, content: response.output, - editorReport: this.editorReport, + editorReport: editorReportRef.value, + executionId: executionIdRef.value, }); } catch (e: unknown) { this.exceptionHandling(e); diff --git a/server/copilot/copilot-session.service.ts b/server/copilot/copilot-session.service.ts new file mode 100644 index 000000000..4cf80612a --- /dev/null +++ b/server/copilot/copilot-session.service.ts @@ -0,0 +1,86 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { Model } from "mongoose"; +import { + CopilotSession, + CopilotSessionDocument, + CopilotSessionMessage, +} from "./schemas/copilot-session.schema"; + +@Injectable() +export class CopilotSessionService { + private readonly logger = new Logger("CopilotSessionService"); + + constructor( + @InjectModel(CopilotSession.name) + private copilotSessionModel: Model + ) {} + + async createSession( + userId: string, + claimReviewDataHash: string, + context: object + ): Promise { + const session = new this.copilotSessionModel({ + userId, + claimReviewDataHash, + context, + messages: [], + isActive: true, + }); + return session.save(); + } + + async getActiveSession( + userId: string, + claimReviewDataHash: string + ): Promise { + // Ensure claimReviewDataHash is treated as a literal value and not a query object + if (typeof claimReviewDataHash !== "string") { + this.logger.warn( + `Invalid claimReviewDataHash type in getActiveSession: ${typeof claimReviewDataHash}` + ); + return null; + } + + return this.copilotSessionModel + .findOne({ + userId, + claimReviewDataHash: { $eq: claimReviewDataHash }, + isActive: true, + }) + .sort({ createdAt: -1 }) + .exec(); + } + + async getSessionById( + sessionId: string + ): Promise { + return this.copilotSessionModel.findById(sessionId).exec(); + } + + async addMessage( + sessionId: string, + message: CopilotSessionMessage + ): Promise { + return this.copilotSessionModel + .findByIdAndUpdate( + sessionId, + { $push: { messages: message } }, + { new: true } + ) + .exec(); + } + + async deactivateSession( + sessionId: string + ): Promise { + return this.copilotSessionModel + .findByIdAndUpdate( + sessionId, + { isActive: false }, + { new: true } + ) + .exec(); + } +} diff --git a/server/copilot/copilot-source.service.ts b/server/copilot/copilot-source.service.ts new file mode 100644 index 000000000..dadad098e --- /dev/null +++ b/server/copilot/copilot-source.service.ts @@ -0,0 +1,147 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { SourceService } from "../source/source.service"; +import { NameSpaceEnum } from "../auth/name-space/schemas/name-space.schema"; + +/** + * Normalized source format used throughout the copilot module. + * + * Agencia returns sources in two formats depending on search type: + * - Web search: objects like { url: "https://...", title: "Page Title", type: "web_search" } + * - Gazette search: plain strings like "Porto Alegre (2024-06-11): https://..." + * + * Both are normalized to this interface for downstream processing. + */ +export interface AgenciaSource { + url?: string; + href?: string; + title?: string; + type?: string; + props?: Record; +} + +// Matches http:// or https:// URLs in a string +const URL_REGEX = /https?:\/\/[^\s,)}\]]+/i; + +@Injectable() +export class CopilotSourceService { + private readonly logger = new Logger("CopilotSourceService"); + + constructor(private readonly sourceService: SourceService) {} + + /** + * Parses a plain-string source into a normalized AgenciaSource object. + * + * Gazette search returns sources like: + * "Porto Alegre (2024-06-11): https://data.queridodiario.ok.org.br/..." + * "PDF da Lei nº 13.947/2024: http://dopaonlineupload.procempa.com.br/..." + * + * Extracts the URL and uses the remaining text as the title. + */ + private parseStringSource(source: string): AgenciaSource | null { + const match = source.match(URL_REGEX); + if (!match) { + return null; + } + + const href = match[0]; + // Everything before the URL becomes the title (trimmed of trailing separators) + const title = source + .substring(0, match.index) + .replace(/[:\s]+$/, "") + .trim(); + + return { + href, + title: title || href, + type: "gazette", + }; + } + + /** + * Normalizes a single source entry (string or object) into an AgenciaSource. + * Returns null if no valid URL can be extracted. + */ + private normalizeSource(source: string | AgenciaSource): AgenciaSource | null { + if (typeof source === "string") { + return this.parseStringSource(source); + } + + const sourceHref = source.href || source.url; + if (!sourceHref) { + return null; + } + + return { ...source, href: sourceHref }; + } + + /** + * Processes sources returned by Agencia and persists them as Source documents. + * + * Handles both formats returned by Agencia: + * - Plain strings (gazette search): parsed to extract URL and description + * - Objects with url/href (web search): normalized to use `href` + * + * For each source: + * - Normalizes to AgenciaSource with `href` field + * - Validates and deduplicates via SourceService.create() (md5 hash of href) + * - Stores Agencia metadata (title, type) in the Source `props` field + * - Gracefully skips invalid URLs or sources without extractable URL + * - Returns normalized sources array for downstream use + * + * @param sources - Array of sources from Agencia (strings or objects) + * @param userId - The ID of the user who initiated the copilot session + * @param nameSpace - Optional namespace (defaults to Main) + * @returns Normalized sources array with `href` field + */ + async processAgenciaSources( + sources: (string | AgenciaSource)[], + userId: string, + nameSpace: string = NameSpaceEnum.Main + ): Promise { + if (!sources || sources.length === 0) { + return []; + } + + const normalizedSources: AgenciaSource[] = []; + + for (const source of sources) { + const normalized = this.normalizeSource(source); + + if (!normalized?.href) { + this.logger.warn( + `Skipping Agencia source without extractable URL: ${ + typeof source === "string" + ? source.substring(0, 80) + : JSON.stringify(source) + }` + ); + continue; + } + + normalizedSources.push(normalized); + + try { + await this.sourceService.create({ + href: normalized.href, + user: userId, + nameSpace, + props: { + ...(normalized.props || {}), + title: normalized.title, + type: normalized.type, + }, + }); + + this.logger.log( + `Persisted Agencia source: ${normalized.href}` + ); + } catch (error) { + this.logger.warn( + `Failed to process Agencia source ${normalized.href}: ${error.message}` + ); + } + } + + return normalizedSources; + } +} diff --git a/server/copilot/dtos/context-aware-messages.dto.ts b/server/copilot/dtos/context-aware-messages.dto.ts index 68c6588f8..de765e4a9 100644 --- a/server/copilot/dtos/context-aware-messages.dto.ts +++ b/server/copilot/dtos/context-aware-messages.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsArray, IsObject } from "class-validator"; +import { IsArray, IsObject, IsString } from "class-validator"; /** * Data Transfer Object for an individual message. @@ -25,7 +25,10 @@ export type Context = { claimDate: Date; claimTitle: string; personalityName: string; + personalityNames: string[]; sentence: string; + contentModel: string; + topics: string[]; }; export type Message = { @@ -42,3 +45,23 @@ export class ContextAwareMessagesDto { @IsObject() context: Context; } + +export class SessionAgentChatDto { + @ApiProperty() + @IsString() + sessionId: string; + + @ApiProperty() + @IsString() + message: string; +} + +export class CreateSessionDto { + @ApiProperty() + @IsString() + claimReviewDataHash: string; + + @ApiProperty() + @IsObject() + context: Context; +} diff --git a/server/copilot/openAI.constants.ts b/server/copilot/openAI.constants.ts index 7436bba16..d7e609f21 100644 --- a/server/copilot/openAI.constants.ts +++ b/server/copilot/openAI.constants.ts @@ -12,5 +12,6 @@ */ export enum openAI { GPT_3_5_TURBO_1106 = "gpt-3.5-turbo-1106", - BASIC_CHAT_OPENAI_TEMPERATURE = "0", + BASIC_CHAT_OPENAI_TEMPERATURE = "1", + GPT_5_MINI = "gpt-5-mini-2025-08-07", } diff --git a/server/copilot/schemas/copilot-session.schema.ts b/server/copilot/schemas/copilot-session.schema.ts new file mode 100644 index 000000000..fe742767a --- /dev/null +++ b/server/copilot/schemas/copilot-session.schema.ts @@ -0,0 +1,54 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import * as mongoose from "mongoose"; +import { User } from "../../users/schemas/user.schema"; + +export type CopilotSessionDocument = CopilotSession & mongoose.Document; + +@Schema({ timestamps: true }) +export class CopilotSessionMessage { + @Prop({ required: true }) + sender: string; + + @Prop({ required: true }) + content: string; + + @Prop({ required: false }) + type: string; + + @Prop({ type: Object, required: false }) + editorReport?: object; + + @Prop({ required: false }) + executionId?: string; +} + +const CopilotSessionMessageSchema = + SchemaFactory.createForClass(CopilotSessionMessage); + +@Schema({ timestamps: true }) +export class CopilotSession { + @Prop({ + type: mongoose.Types.ObjectId, + required: true, + ref: "User", + }) + userId: User; + + @Prop({ required: true }) + claimReviewDataHash: string; + + @Prop({ + type: [CopilotSessionMessageSchema], + default: [], + }) + messages: CopilotSessionMessage[]; + + @Prop({ type: Object, required: false }) + context: object; + + @Prop({ default: true }) + isActive: boolean; +} + +export const CopilotSessionSchema = + SchemaFactory.createForClass(CopilotSession); diff --git a/server/summarization/summarization-crawler-chain.service.ts b/server/summarization/summarization-crawler-chain.service.ts index 582b4a155..a1f82f6de 100644 --- a/server/summarization/summarization-crawler-chain.service.ts +++ b/server/summarization/summarization-crawler-chain.service.ts @@ -9,7 +9,7 @@ import { ConfigService } from "@nestjs/config"; @Injectable() export class SummarizationCrawlerChainService { private readonly logger = new Logger("SummarizationChainLogger"); - constructor(private configService: ConfigService) {} + constructor(private configService: ConfigService) { } createBulletPointsChain(): StuffDocumentsChain { const systemMessage = `Write a summary of the following text delimited by triple dashes.`; @@ -27,7 +27,7 @@ export class SummarizationCrawlerChainService { }); const llm = new ChatOpenAI({ temperature: +openAI.BASIC_CHAT_OPENAI_TEMPERATURE, - modelName: openAI.GPT_3_5_TURBO_1106.toString(), + modelName: openAI.GPT_5_MINI.toString(), apiKey: this.configService.get("openai.api_key"), }); diff --git a/server/summarization/summarization-crawler.service.ts b/server/summarization/summarization-crawler.service.ts index 877647ef1..c52bcddf3 100644 --- a/server/summarization/summarization-crawler.service.ts +++ b/server/summarization/summarization-crawler.service.ts @@ -20,7 +20,7 @@ export class SummarizationCrawlerService { constructor( private chainService: SummarizationCrawlerChainService, private configService: ConfigService - ) {} + ) { } async getSummarizedReviews(dailyReviews: any[]): Promise { try { @@ -63,17 +63,16 @@ export class SummarizationCrawlerService { const reportContent = summarizedReviews.length > 0 ? summarizedReviews - .map( - (review) => ` + .map( + (review) => `
-

${ - classificationTranslations[review.classification] - } | ${review.summary}

+

${classificationTranslations[review.classification] + } | ${review.summary}

Link para Checagem

` - ) - .join("") + ) + .join("") : `

Nenhuma informação disponível na atualização de hoje. Caso tenha encontrado um problema, por favor, entre em contato conosco através do email contato@aletheiafact.org

`; @@ -92,31 +91,31 @@ export class SummarizationCrawlerService { color: ${colors.primary}; } .not-fact { - color: #006060; + color: #006060; } .trustworthy { - color: #008000; + color: #008000; } .trustworthy-but { - color: #5A781D; + color: #5A781D; } .arguable { - color: #9F6B3F; + color: #9F6B3F; } .misleading { - color: #D6395F; + color: #D6395F; } .false { - color: #D32B20; + color: #D32B20; } .unsustainable { - color: #A74165; + color: #A74165; } .exaggerated { - color: #B8860B; + color: #B8860B; } .unverifiable { - color: #C9502A; + color: #C9502A; } @@ -147,7 +146,7 @@ export class SummarizationCrawlerService { const llm = new ChatOpenAI({ temperature: +openAI.BASIC_CHAT_OPENAI_TEMPERATURE, - modelName: openAI.GPT_3_5_TURBO_1106.toString(), + modelName: openAI.GPT_5_MINI.toString(), apiKey: this.configService.get("openai.api_key"), }); diff --git a/src/api/copilotApi.ts b/src/api/copilotApi.ts index 8c6e47663..5dd69500a 100644 --- a/src/api/copilotApi.ts +++ b/src/api/copilotApi.ts @@ -1,27 +1,63 @@ import axios from "axios"; -const request = axios.create({ +const chatRequest = axios.create({ withCredentials: true, baseURL: `/api/agent-chat`, }); -const agentChat = (params) => { - return request - .post("/", params) - .then((response) => { - return response.data; - }) +const sessionRequest = axios.create({ + withCredentials: true, + baseURL: `/api/copilot-session`, +}); + +const getSession = (claimReviewDataHash: string) => { + return sessionRequest + .get("/", { params: { claimReviewDataHash } }) + .then((response) => response.data) + .catch((err) => { + console.error("Error fetching copilot session: ", err); + throw err; + }); +}; + +const createSession = (claimReviewDataHash: string, context: object) => { + return sessionRequest + .post("/", { claimReviewDataHash, context }) + .then((response) => response.data) + .catch((err) => { + console.error("Error creating copilot session: ", err); + throw err; + }); +}; + +const clearSession = (sessionId: string) => { + return sessionRequest + .post(`/${sessionId}/clear`) + .then((response) => response.data) + .catch((err) => { + console.error("Error clearing copilot session: ", err); + throw err; + }); +}; + +const sendMessage = (sessionId: string, message: string) => { + return chatRequest + .post("/", { sessionId, message }) + .then((response) => response.data) .catch((err) => { console.error( "Error while chatting with Aletheia's Assistant: ", err ); - return err; + throw err; }); }; const copilotApi = { - agentChat, + getSession, + createSession, + clearSession, + sendMessage, }; export default copilotApi; diff --git a/src/components/AffixButton/AffixCopilotButton.tsx b/src/components/AffixButton/AffixCopilotButton.tsx index bceb0ba99..e05ce0d1a 100644 --- a/src/components/AffixButton/AffixCopilotButton.tsx +++ b/src/components/AffixButton/AffixCopilotButton.tsx @@ -39,7 +39,7 @@ const AffixCopilotButton = () => { right: copilotDrawerCollapsed || vw?.md ? "2%" - : `calc(2% + 350px)`, + : `calc(2% + 50vw)`, display: "flex", flexDirection: "column-reverse", alignItems: "center", diff --git a/src/components/Collaborative/Components/ClaimReviewEditor.tsx b/src/components/Collaborative/Components/ClaimReviewEditor.tsx index 6860e50e9..d6c444cc4 100644 --- a/src/components/Collaborative/Components/ClaimReviewEditor.tsx +++ b/src/components/Collaborative/Components/ClaimReviewEditor.tsx @@ -4,9 +4,11 @@ import EditorSourcesList from "./Source/EditorSourceList"; import CopilotDrawer from "../../Copilot/CopilotDrawer"; import { useAppSelector } from "../../../store/store"; import { ReviewTaskMachineContext } from "../../../machines/reviewTask/ReviewTaskMachineProvider"; +import { VisualEditorContext } from "../VisualEditorProvider"; const ClaimReviewEditor = ({ manager, state, editorSources }) => { const { claim, sentenceContent } = useContext(ReviewTaskMachineContext); + const { data_hash } = useContext(VisualEditorContext); const { enableCopilotChatBot, reviewDrawerCollapsed } = useAppSelector( (state) => ({ enableCopilotChatBot: state?.enableCopilotChatBot, @@ -31,6 +33,7 @@ const ClaimReviewEditor = ({ manager, state, editorSources }) => { manager={manager} claim={claim} sentence={sentenceContent} + dataHash={data_hash} /> )} diff --git a/src/components/Copilot/CopilotConversation.tsx b/src/components/Copilot/CopilotConversation.tsx index a9449442e..9a5f53845 100644 --- a/src/components/Copilot/CopilotConversation.tsx +++ b/src/components/Copilot/CopilotConversation.tsx @@ -54,7 +54,7 @@ const CopilotConversation = ({ return (
{messages.map((message) => ( props.width}; flex-shrink: 0; - zindex: 999999; + z-index: 999999; max-height: 100vh; overflow: hidden; position: absolute; & .MuiDrawer-paper { width: ${(props) => props.width}; + min-width: 350px; height: ${(props) => props.height}; padding: 32px 16px 16px 16px; background: ${colors.lightNeutralSecondary}; + display: flex; + flex-direction: column; } .footer { font-size: 10px; text-align: center; margin-top: 8px; + flex-shrink: 0; } .copilot-form { @@ -30,6 +34,8 @@ const CopilotDrawerStyled = styled(Drawer)` padding: 8px; gap: 8px; border-radius: 4px; + flex-shrink: 0; + margin-top: 8px; } .submit-message-button { diff --git a/src/components/Copilot/CopilotDrawer.tsx b/src/components/Copilot/CopilotDrawer.tsx index 7ef4a08a2..7059b239e 100644 --- a/src/components/Copilot/CopilotDrawer.tsx +++ b/src/components/Copilot/CopilotDrawer.tsx @@ -1,4 +1,10 @@ -import React, { Suspense, useEffect, useMemo, useState } from "react"; +import React, { + Suspense, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import { useAppSelector } from "../../store/store"; import CopilotForm from "./CopilotForm"; import { useTranslation } from "next-i18next"; @@ -29,64 +35,155 @@ interface CopilotDrawerProps { claim: Claim; sentence: string; manager: RemirrorManager>; + dataHash: string; } -const CopilotDrawer = ({ manager, claim, sentence }: CopilotDrawerProps) => { +const CopilotDrawer = ({ + manager, + claim, + sentence, + dataHash, +}: CopilotDrawerProps) => { const { t } = useTranslation(); - const { vw, copilotDrawerCollapsed } = useAppSelector((state) => ({ - vw: state?.vw, - copilotDrawerCollapsed: - state?.copilotDrawerCollapsed !== undefined - ? state?.copilotDrawerCollapsed - : true, - })); - - const CHAT_DEFAULT_CONVERSATION = [ - { - type: ChatMessageType.info, - content: t("copilotChatBot:chatBotGreetings"), - sender: SenderEnum.Assistant, - }, - ]; + const { vw, copilotDrawerCollapsed, selectedContent } = useAppSelector( + (state) => ({ + vw: state?.vw, + copilotDrawerCollapsed: + state?.copilotDrawerCollapsed !== undefined + ? state?.copilotDrawerCollapsed + : true, + selectedContent: state?.selectedContent, + }) + ); + + const CHAT_DEFAULT_CONVERSATION: ChatMessage[] = useMemo( + () => [ + { + type: ChatMessageType.info, + content: t("copilotChatBot:chatBotGreetings"), + sender: SenderEnum.Assistant, + }, + ], + [t] + ); const [open, setOpen] = useState(!copilotDrawerCollapsed); const [isLoading, setIsLoading] = useState(false); + const [isSessionLoading, setIsSessionLoading] = useState(true); const [editorReport, setEditorReport] = useState(null); const [size, setSize] = useState({ width: "350px", height: "100%" }); const [messages, setMessages] = useState( CHAT_DEFAULT_CONVERSATION ); + const [sessionId, setSessionId] = useState(null); + const { topPosition, rightPosition, rotate } = useMemo( () => calculatePosition(open, vw), [open, vw.sm, vw.md] ); - const handleClearConversation = () => { - setMessages(CHAT_DEFAULT_CONVERSATION); - }; - const context: MessageContext = useMemo( () => ({ claimDate: claim?.date, sentence: sentence, - personalityName: claim?.personalities[0]?.name || null, + personalityName: claim?.personalities?.[0]?.name || "", + personalityNames: + claim?.personalities?.map((p) => p.name).filter(Boolean) || [], claimTitle: claim?.title, + contentModel: claim?.contentModel || "", + topics: + selectedContent?.topics?.map((t: any) => + typeof t === "string" ? t : t.label || t.name || "" + ).filter(Boolean) || [], }), - [claim, sentence] + [claim, sentence, selectedContent] ); + // Load or create session on mount + useEffect(() => { + if (!dataHash) return; + + const initSession = async () => { + try { + setIsSessionLoading(true); + const { session } = await copilotApi.getSession(dataHash); + + if (session) { + setSessionId(session._id); + // Restore messages from session + if (session.messages?.length > 0) { + const restoredMessages: ChatMessage[] = + session.messages.map((msg) => ({ + type: + msg.type === "error" + ? ChatMessageType.error + : ChatMessageType.info, + sender: msg.sender, + content: msg.content, + })); + setMessages([ + ...CHAT_DEFAULT_CONVERSATION, + ...restoredMessages, + ]); + + // Restore the last editorReport if one was saved + const lastReportMsg = [...session.messages] + .reverse() + .find((msg) => msg.editorReport); + if (lastReportMsg?.editorReport) { + setEditorReport(lastReportMsg.editorReport); + } + } + } else { + // Create a new session + const { session: newSession } = + await copilotApi.createSession(dataHash, context); + setSessionId(newSession._id); + } + } catch (error) { + console.error("Failed to initialize copilot session:", error); + } finally { + setIsSessionLoading(false); + } + }; + + initSession(); + }, [dataHash]); + + const handleClearConversation = useCallback(async () => { + try { + if (sessionId) { + await copilotApi.clearSession(sessionId); + } + // Create a new session + const { session: newSession } = await copilotApi.createSession( + dataHash, + context + ); + setSessionId(newSession._id); + setMessages(CHAT_DEFAULT_CONVERSATION); + setEditorReport(null); + } catch (error) { + console.error("Failed to clear conversation:", error); + } + }, [sessionId, dataHash, context, CHAT_DEFAULT_CONVERSATION]); + const handleSendMessage = async ( newChatMessage: ChatMessage ): Promise => { + if (!sessionId) return; + try { setIsLoading(true); addNewMessage(newChatMessage); + const { data: { sender, content, editorReport }, - } = (await copilotApi.agentChat({ - messages: [...messages, newChatMessage], - context: context, - })) as { data: ChatResponse }; + } = (await copilotApi.sendMessage( + sessionId, + newChatMessage.content + )) as { data: ChatResponse }; + setEditorReport(editorReport); addNewMessage({ type: ChatMessageType.info, sender, content }); } catch (error) { @@ -108,7 +205,7 @@ const CopilotDrawer = ({ manager, claim, sentence }: CopilotDrawerProps) => { if (open && vw?.sm) { setSize({ width: "100%", height: "50%" }); } else if (open) { - setSize({ width: "350px", height: "100%" }); + setSize({ width: "50vw", height: "100%" }); } }, [open, vw?.sm]); @@ -126,15 +223,19 @@ const CopilotDrawer = ({ manager, claim, sentence }: CopilotDrawerProps) => { aria-hidden={!open} > - }> - - + {isSessionLoading ? ( + + ) : ( + }> + + + )} {t("copilotChatBot:footer")} diff --git a/src/components/Copilot/CopilotForm.tsx b/src/components/Copilot/CopilotForm.tsx index b65958ecc..be1f898f5 100644 --- a/src/components/Copilot/CopilotForm.tsx +++ b/src/components/Copilot/CopilotForm.tsx @@ -21,7 +21,7 @@ const CopilotForm = ({ handleSendMessage }) => { return (
setMessage(target.value)} diff --git a/src/components/Copilot/CopilotToolbar.tsx b/src/components/Copilot/CopilotToolbar.tsx index d62b09f61..774295f8e 100644 --- a/src/components/Copilot/CopilotToolbar.tsx +++ b/src/components/Copilot/CopilotToolbar.tsx @@ -10,14 +10,27 @@ import { useTranslation } from "next-i18next"; const CopilotToolbarStyled = styled.div` display: flex; - justify-content: flex-end; + justify-content: space-between; + align-items: center; padding-bottom: 8px; + .toolbar-title { + font-size: 16px; + font-weight: 600; + color: ${colors.primary}; + } + + .toolbar-actions { + display: flex; + align-items: center; + } + .toolbar-item, .toolbar-item:active, .toolbar-item:focus { background-color: transparent; color: ${colors.primary}; + min-width: unset; } .toolbar-item:hover { @@ -38,31 +51,36 @@ const CopilotToolbar = ({ handleClearConversation }) => { return ( - - - + + - - - + + +
); }; diff --git a/src/components/Copilot/utils/calculatePositions.ts b/src/components/Copilot/utils/calculatePositions.ts index da23db14b..e02a516a2 100644 --- a/src/components/Copilot/utils/calculatePositions.ts +++ b/src/components/Copilot/utils/calculatePositions.ts @@ -17,7 +17,7 @@ export const calculatePosition = ( return { topPosition: "50%", rotate: open ? "rotateY(45deg)" : "rotateY(135deg)", - rightPosition: open ? "350px" : "16px", + rightPosition: open ? "50vw" : "16px", }; } diff --git a/src/types/Copilot.ts b/src/types/Copilot.ts index bd53245a8..027dbbb14 100644 --- a/src/types/Copilot.ts +++ b/src/types/Copilot.ts @@ -21,7 +21,34 @@ type MessageContext = { claimDate: string | Date; sentence: string; personalityName: string; + personalityNames: string[]; claimTitle: string; + contentModel: string; + topics: string[]; }; -export type { ChatResponse, ChatMessage, MessageContext }; +interface CopilotSessionMessage { + sender: string; + content: string; + type: string; + editorReport?: any; +} + +interface CopilotSession { + _id: string; + userId: string; + claimReviewDataHash: string; + messages: CopilotSessionMessage[]; + context: MessageContext; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export type { + ChatResponse, + ChatMessage, + MessageContext, + CopilotSession, + CopilotSessionMessage, +};