diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx index 357b15e832..18ccba31a4 100644 --- a/frontend/src/components/Chat/ChatWindow.test.tsx +++ b/frontend/src/components/Chat/ChatWindow.test.tsx @@ -32,6 +32,8 @@ jest.mock("../../services/api", () => ({ getConversations: jest.fn(), createConversation: jest.fn(), changeMainConversation: jest.fn(), + scoreConversation: jest.fn(), + scoreMessagePiece: jest.fn(), }, convertersApi: { listConverterCatalog: jest.fn(), @@ -40,6 +42,9 @@ jest.mock("../../services/api", () => ({ createConverter: jest.fn(), previewConversion: jest.fn(), }, + scorersApi: { + listScorers: jest.fn().mockResolvedValue({ items: [] }), + }, labelsApi: { getLabels: jest.fn().mockImplementation(() => new Promise(() => {})), }, @@ -2159,6 +2164,37 @@ describe("ChatWindow Integration", () => { expect(toggleBtn).toBe(screen.getByTestId("toggle-panel-btn")); }); + it("ribbon Score button is disabled until a conversation is active", () => { + render( + + + + ); + expect(screen.getByTestId("score-conversation-btn")).toBeDisabled(); + }); + + it("ribbon Score button opens the score dialog for the active conversation", async () => { + render( + + + + ); + + const scoreBtn = screen.getByTestId("score-conversation-btn"); + expect(scoreBtn).toBeEnabled(); + await userEvent.click(scoreBtn); + + // ScoreDialog mounts and fetches scorers (mock resolves to empty list). + await waitFor(() => { + expect(screen.getByTestId("score-dialog-empty")).toBeInTheDocument(); + }); + }); + it("should toggle converter panel when convert button is clicked", async () => { render( diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 230678e220..d2cbf57af2 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -4,12 +4,13 @@ import { Text, Tooltip, } from '@fluentui/react-components' -import { AddRegular, PanelRightRegular } from '@fluentui/react-icons' +import { AddRegular, PanelRightRegular, DataBarVerticalRegular } from '@fluentui/react-icons' import MessageList from './MessageList' import ChatInputArea from './ChatInputArea' import ConversationPanel from './ConversationPanel' import ConverterPanel from './ConverterPanel' import TargetBadge from './TargetBadge' +import ScoreDialog, { type ScoreTarget } from './ScoreDialog' import type { PieceConversion } from './converterTypes' import { PIECE_TYPE_TO_DATA_TYPE, basenameFromValue, buildMediaUrl, dataTypeToAttachmentKind, isPathDataType } from './converterTypes' import LabelsBar from '../Labels/LabelsBar' @@ -74,6 +75,15 @@ export default function ChatWindow({ const [attachmentData, setAttachmentData] = useState>({}) const [pieceConversions, setPieceConversions] = useState>({}) const [panelRefreshKey, setPanelRefreshKey] = useState(0) + const [scoreTarget, setScoreTarget] = useState(null) + // Last-used scorer per conversation id. Lets the score dialog pre-select the + // scorer the user previously picked for the same conversation. Persists for + // the lifetime of the ChatWindow (not across page reloads); the user can + // still pick a different scorer at any time. + const [scorerByConversation, setScorerByConversation] = useState>({}) + // Last-typed objective per conversation id. Mirrors scorerByConversation so + // re-opening the dialog pre-fills the objective the user previously typed. + const [objectiveByConversation, setObjectiveByConversation] = useState>({}) const inputBoxRef = useRef(null) const handleAttachmentsChange = useCallback((types: string[], data: Record) => { @@ -485,6 +495,28 @@ export default function ChatWindow({ } }, [attackResultId]) + // Open the score dialog for a specific assistant message piece. + const handleScoreMessage = useCallback((messageIndex: number) => { + if (!attackResultId || !activeConversationId) return + const msg = messages[messageIndex] + if (!msg?.pieceId) return + setScoreTarget({ + kind: 'piece', + attackResultId, + conversationId: activeConversationId, + pieceId: msg.pieceId, + }) + }, [attackResultId, activeConversationId, messages]) + + // After any score completes, refetch messages so the new score badges appear + // and bump the conversation panel refresh so its scoreboard / count stays current. + const handleScored = useCallback(() => { + if (attackResultId && activeConversationId) { + loadConversation(attackResultId, activeConversationId) + } + setPanelRefreshKey(k => k + 1) + }, [attackResultId, activeConversationId, loadConversation]) + const singleTurnLimitReached = activeTarget?.capabilities?.supports_multi_turn === false && messages.some(m => m.role === 'user') // Operator locking: if the loaded attack's operator differs from the current @@ -564,6 +596,32 @@ export default function ChatWindow({ )}
+ + +
) } diff --git a/frontend/src/components/Chat/ConversationPanel.tsx b/frontend/src/components/Chat/ConversationPanel.tsx index 267b0feaf1..1dd80c60e0 100644 --- a/frontend/src/components/Chat/ConversationPanel.tsx +++ b/frontend/src/components/Chat/ConversationPanel.tsx @@ -17,11 +17,13 @@ import { DismissRegular, StarRegular, StarFilled, + DataBarVerticalRegular, } from '@fluentui/react-icons' import { attacksApi } from '../../services/api' import { toApiError } from '../../services/errors' -import type { ConversationSummary } from '../../types' +import type { BackendScore, ConversationSummary } from '../../types' import { useConversationPanelStyles } from './ConversationPanel.styles' +import ScoreDialog, { type ScoreTarget } from './ScoreDialog' interface ConversationPanelProps { attackResultId: string | null @@ -34,6 +36,8 @@ interface ConversationPanelProps { lockedReason?: string /** Increment to trigger a conversation list refresh (e.g. after sending a message) */ refreshKey?: number + /** Called after a conversation is scored so the parent can refetch messages. */ + onConversationScored?: (conversationId: string, scores: BackendScore[]) => void } export default function ConversationPanel({ @@ -45,12 +49,14 @@ export default function ConversationPanel({ onClose, lockedReason, refreshKey, + onConversationScored, }: ConversationPanelProps) { const styles = useConversationPanelStyles() const [conversations, setConversations] = useState([]) const [mainConversationId, setMainConversationId] = useState(null) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) + const [scoreTarget, setScoreTarget] = useState(null) const fetchConversations = useCallback(async () => { if (!attackResultId) { @@ -202,6 +208,25 @@ export default function ConversationPanel({ style={{ minWidth: 'auto', padding: '2px' }} /> + + + + + + + + ) +} + +// --------------------------------------------------------------------- // +// Per-kind subforms +// --------------------------------------------------------------------- // + +function FloatScaleFields({ + config, + onChange, +}: { + config: GeneralFloatScaleConfig + onChange: (c: GeneralFloatScaleConfig) => void +}) { + const rangeInvalid = config.max_value <= config.min_value + return ( + <> + +