diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index afb2b8cfd7..898d71a273 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -10,6 +10,7 @@ import { import { SendRegular, AttachRegular, DismissRegular, InfoRegular, AddRegular, CopyRegular, WarningRegular, SettingsRegular, ArrowShuffleRegular, OpenRegular } from '@fluentui/react-icons' import { MessageAttachment, TargetInstance } from '../../types' import { useChatInputAreaStyles } from './ChatInputArea.styles' +import SystemPromptSetup from './SystemPromptSetup' import { PIECE_TYPE_TO_DATA_TYPE } from './converterTypes' // --------------------------------------------------------------------------- @@ -329,9 +330,15 @@ interface ChatInputAreaProps { /** Chip describing a text→file conversion (e.g. PDFConverter output). */ convertedFileChip?: ConvertedFileChip | null onClearConvertedFileChip?: () => void + /** Whether to show the system-prompt setup (only for a brand-new conversation). */ + showSystemPrompt?: boolean + /** Whether the active target supports system prompts (gates the setup's enabled state). */ + supportsSystemPrompt?: boolean + systemPrompt?: string + onSystemPromptChange?: (value: string) => void } -const ChatInputArea = forwardRef(function ChatInputArea({ onSend, disabled = false, activeTarget, singleTurnLimitReached = false, onNewConversation, operatorLocked = false, crossTargetLocked = false, onUseAsTemplate, attackOperator, noTargetSelected = false, onConfigureTarget, onToggleConverterPanel, isConverterPanelOpen = false, onInputChange, onAttachmentsChange, convertedValue, originalValue: _originalValue, onClearConversion, onConvertedValueChange, converterOutputDataTypes = [], mediaConversions = [], onClearMediaConversion, convertedFileChip, onClearConvertedFileChip }, ref) { +const ChatInputArea = forwardRef(function ChatInputArea({ onSend, disabled = false, activeTarget, singleTurnLimitReached = false, onNewConversation, operatorLocked = false, crossTargetLocked = false, onUseAsTemplate, attackOperator, noTargetSelected = false, onConfigureTarget, onToggleConverterPanel, isConverterPanelOpen = false, onInputChange, onAttachmentsChange, convertedValue, originalValue: _originalValue, onClearConversion, onConvertedValueChange, converterOutputDataTypes = [], mediaConversions = [], onClearMediaConversion, convertedFileChip, onClearConvertedFileChip, showSystemPrompt = false, supportsSystemPrompt = false, systemPrompt = '', onSystemPromptChange }, ref) { const styles = useChatInputAreaStyles() const [input, setInput] = useState('') const [attachments, setAttachments] = useState([]) @@ -537,6 +544,13 @@ const ChatInputArea = forwardRef(functi ) : ( <>
+ {showSystemPrompt && onSystemPromptChange && ( + + )} { }); }); + // ----------------------------------------------------------------------- + // System prompt (system_prompt) wiring + // ----------------------------------------------------------------------- + + describe("system prompt", () => { + const supportedTarget: TargetInstance = { + ...mockTarget, + capabilities: buildCapabilities({ supports_system_prompt: true }), + }; + + function primeSendMocks() { + mockedMapper.buildMessagePieces.mockResolvedValue([ + { data_type: "text", original_value: "Hello" }, + ]); + mockedAttacksApi.createAttack.mockResolvedValue({ + attack_result_id: "ar-sys", + conversation_id: "conv-sys", + created_at: "2026-01-01T00:00:00Z", + }); + mockedAttacksApi.addMessage.mockResolvedValue( + makeTextResponse("Hi") as never + ); + mockedMapper.backendMessagesToFrontend.mockReturnValue([ + { role: "assistant", content: "Hi", timestamp: "2026-01-01T00:00:01Z" }, + ]); + } + + it("renders the system prompt toggle for a new conversation", () => { + render( + + + + ); + + expect( + screen.getByRole("button", { name: /system prompt/i }) + ).toBeInTheDocument(); + }); + + it("hides the system prompt toggle once an attack exists", async () => { + mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] }); + mockedMapper.backendMessagesToFrontend.mockReturnValue([]); + + render( + + + + ); + + await waitFor(() => { + expect(screen.queryByTestId("loading-state")).not.toBeInTheDocument(); + }); + expect( + screen.queryByRole("button", { name: /system prompt/i }) + ).not.toBeInTheDocument(); + }); + + it("renders a system prompt banner when the loaded conversation has a system message", async () => { + mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] }); + mockedMapper.backendMessagesToFrontend.mockReturnValue([ + { role: "system", content: "You are a pirate.", timestamp: "2026-01-01T00:00:00Z" }, + { role: "user", content: "Ahoy", timestamp: "2026-01-01T00:00:01Z" }, + ]); + + render( + + + + ); + + expect(await screen.findByTestId("system-prompt-banner")).toBeInTheDocument(); + expect(screen.getByText("You are a pirate.")).toBeInTheDocument(); + }); + + it("forwards the typed system prompt when the target supports it", async () => { + const user = userEvent.setup(); + primeSendMocks(); + + render( + + + + ); + + await user.click(screen.getByRole("button", { name: /system prompt/i })); + await user.type( + screen.getByRole("textbox", { name: /system prompt/i }), + "You are helpful" + ); + await user.type(screen.getByPlaceholderText("Type prompt here"), "Hello"); + await user.click(screen.getByRole("button", { name: /send/i })); + + await waitFor(() => { + expect(mockedAttacksApi.createAttack).toHaveBeenCalledWith( + expect.objectContaining({ system_prompt: "You are helpful" }) + ); + }); + }); + + it("omits the system prompt when the target does not support it", async () => { + const user = userEvent.setup(); + primeSendMocks(); + + render( + + + + ); + + await user.type(screen.getByPlaceholderText("Type prompt here"), "Hello"); + await user.click(screen.getByRole("button", { name: /send/i })); + + await waitFor(() => { + expect(mockedAttacksApi.createAttack).toHaveBeenCalled(); + }); + const createArgs = mockedAttacksApi.createAttack.mock.calls[0][0]; + expect(createArgs.system_prompt).toBeUndefined(); + }); + + it("disables the toggle and drops the prompt for an explicitly unsupported target", async () => { + const user = userEvent.setup(); + primeSendMocks(); + + const unsupportedTarget: TargetInstance = { + ...mockTarget, + capabilities: buildCapabilities({ supports_system_prompt: false }), + }; + + render( + + + + ); + + expect( + screen.getByRole("button", { name: /system prompt/i }) + ).toBeDisabled(); + + await user.type(screen.getByPlaceholderText("Type prompt here"), "Hello"); + await user.click(screen.getByRole("button", { name: /send/i })); + + await waitFor(() => { + expect(mockedAttacksApi.createAttack).toHaveBeenCalled(); + }); + const createArgs = mockedAttacksApi.createAttack.mock.calls[0][0]; + expect(createArgs.system_prompt).toBeUndefined(); + }); + + it("omits the system prompt when left blank on a supporting target", async () => { + const user = userEvent.setup(); + primeSendMocks(); + + render( + + + + ); + + await user.type(screen.getByPlaceholderText("Type prompt here"), "Hello"); + await user.click(screen.getByRole("button", { name: /send/i })); + + await waitFor(() => { + expect(mockedAttacksApi.createAttack).toHaveBeenCalled(); + }); + const createArgs = mockedAttacksApi.createAttack.mock.calls[0][0]; + expect(createArgs.system_prompt).toBeUndefined(); + }); + + it("clears a retained system prompt when switching to an unsupported target", async () => { + const user = userEvent.setup(); + primeSendMocks(); + + const supportedA: TargetInstance = { + ...mockTarget, + target_registry_name: "supports_a", + capabilities: buildCapabilities({ supports_system_prompt: true }), + }; + const unsupportedB: TargetInstance = { + ...mockTarget, + target_registry_name: "no_support_b", + capabilities: buildCapabilities({ supports_system_prompt: false }), + }; + const supportedC: TargetInstance = { + ...mockTarget, + target_registry_name: "supports_c", + capabilities: buildCapabilities({ supports_system_prompt: true }), + }; + + const { rerender } = render( + + + + ); + + await user.click(screen.getByRole("button", { name: /system prompt/i })); + await user.type( + screen.getByRole("textbox", { name: /system prompt/i }), + "You are helpful" + ); + + // Switch to an unsupported target (should clear), then to another + // supporting one so the cleared value is observable on send. + rerender( + + + + ); + rerender( + + + + ); + + await user.type(screen.getByPlaceholderText("Type prompt here"), "Hello"); + await user.click(screen.getByRole("button", { name: /send/i })); + + await waitFor(() => { + expect(mockedAttacksApi.createAttack).toHaveBeenCalled(); + }); + const createArgs = mockedAttacksApi.createAttack.mock.calls[0][0]; + expect(createArgs.system_prompt).toBeUndefined(); + }); + + it("preserves the system prompt across supporting targets", async () => { + const user = userEvent.setup(); + primeSendMocks(); + + const supportedA: TargetInstance = { + ...mockTarget, + target_registry_name: "supports_a", + capabilities: buildCapabilities({ supports_system_prompt: true }), + }; + const supportedB: TargetInstance = { + ...mockTarget, + target_registry_name: "supports_b", + capabilities: buildCapabilities({ supports_system_prompt: true }), + }; + + const { rerender } = render( + + + + ); + + await user.click(screen.getByRole("button", { name: /system prompt/i })); + await user.type( + screen.getByRole("textbox", { name: /system prompt/i }), + "You are helpful" + ); + + rerender( + + + + ); + + await user.type(screen.getByPlaceholderText("Type prompt here"), "Hello"); + await user.click(screen.getByRole("button", { name: /send/i })); + + await waitFor(() => { + expect(mockedAttacksApi.createAttack).toHaveBeenCalledWith( + expect.objectContaining({ system_prompt: "You are helpful" }) + ); + }); + }); + }); + // ----------------------------------------------------------------------- // Subsequent messages → reuse conversation ID // ----------------------------------------------------------------------- diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 0865ed331b..c1155f4604 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -6,6 +6,7 @@ import { } from '@fluentui/react-components' import { AddRegular, PanelRightRegular } from '@fluentui/react-icons' import MessageList from './MessageList' +import SystemPromptBanner from './SystemPromptBanner' import ChatInputArea from './ChatInputArea' import ConversationPanel from './ConversationPanel' import ConverterPanel from './ConverterPanel' @@ -70,6 +71,7 @@ export default function ChatWindow({ const [isPanelOpen, setIsPanelOpen] = useState(false) const [isConverterPanelOpen, setIsConverterPanelOpen] = useState(false) const [chatInputText, setChatInputText] = useState('') + const [systemPrompt, setSystemPrompt] = useState('') const [attachmentTypes, setAttachmentTypes] = useState([]) const [attachmentData, setAttachmentData] = useState>({}) const [pieceConversions, setPieceConversions] = useState>({}) @@ -132,6 +134,8 @@ export default function ChatWindow({ // Used to restore the user's input when switching back to an in-flight conversation. const pendingUserMessagesRef = useRef>(new Map()) + const supportsSystemPrompt = activeTarget?.capabilities?.supports_system_prompt === true + // Clear internal messages when attack state is reset (e.g. New Attack). // Uses the "adjust state during render" pattern (see React docs: // https://react.dev/reference/react/useState#storing-information-from-previous-renders) @@ -142,6 +146,18 @@ export default function ChatWindow({ if (!attackResultId) { setMessages([]) setLoadedConversationId(null) + setSystemPrompt('') + } + } + + // Clear a retained system prompt when switching to a target that can't use it, + // so it isn't silently dropped on send. Preserved across supporting targets to + // keep the A/B-testing workflow intact. + const [prevTargetName, setPrevTargetName] = useState(activeTarget?.target_registry_name) + if (activeTarget?.target_registry_name !== prevTargetName) { + setPrevTargetName(activeTarget?.target_registry_name) + if (!supportsSystemPrompt) { + setSystemPrompt('') } } @@ -289,6 +305,7 @@ export default function ChatWindow({ const createResponse = await attacksApi.createAttack({ target_registry_name: activeTarget.target_registry_name, labels: labels, + system_prompt: supportsSystemPrompt ? systemPrompt.trim() || undefined : undefined, }) currentAttackResultId = createResponse.attack_result_id currentConversationId = createResponse.conversation_id @@ -549,6 +566,8 @@ export default function ChatWindow({ } }, [attackResultId, activeTarget, activeConversationId, messages, labels, onConversationCreated]) + const systemMessage = messages.find(message => message.role === 'system') + return (
{isConverterPanelOpen && ( @@ -602,6 +621,7 @@ export default function ChatWindow({
+ {systemMessage && } { expect(screen.getByText("Can you help me?")).toBeInTheDocument(); }); + it("should not render system messages as transcript bubbles", () => { + const withSystem: Message[] = [ + { + role: "system", + content: "You are a pirate.", + timestamp: new Date().toISOString(), + }, + ...mockMessages, + ]; + + render( + + + + ); + + expect(screen.queryByText("You are a pirate.")).not.toBeInTheDocument(); + expect(screen.getByText("Hello, how are you?")).toBeInTheDocument(); + }); + it("should render user messages", () => { const userMessages: Message[] = [ { diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index 066fd3cf50..f0e0a7d49e 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -154,6 +154,7 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConver return (
{messages.map((message, index) => { + if (message.role === 'system') return null const isUser = message.role === 'user' const isSimulated = message.role === 'simulated_assistant' const timestamp = new Date(message.timestamp).toLocaleTimeString() diff --git a/frontend/src/components/Chat/SystemPromptBanner.styles.ts b/frontend/src/components/Chat/SystemPromptBanner.styles.ts new file mode 100644 index 0000000000..f188c5c5b0 --- /dev/null +++ b/frontend/src/components/Chat/SystemPromptBanner.styles.ts @@ -0,0 +1,38 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useSystemPromptBannerStyles = makeStyles({ + root: { + flexShrink: 0, + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalL}`, + backgroundColor: tokens.colorNeutralBackground3, + borderBottom: `1px solid ${tokens.colorNeutralStroke1}`, + }, + header: { + alignSelf: 'flex-start', + color: tokens.colorNeutralForeground2, + }, + label: { + alignSelf: 'flex-start', + color: tokens.colorNeutralForeground2, + fontWeight: tokens.fontWeightSemibold, + }, + content: { + color: tokens.colorNeutralForeground3, + fontSize: tokens.fontSizeBase200, + paddingLeft: tokens.spacingHorizontalL, + }, + contentCollapsed: { + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + contentExpanded: { + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + maxHeight: '30vh', + overflowY: 'auto', + }, +}) diff --git a/frontend/src/components/Chat/SystemPromptBanner.test.tsx b/frontend/src/components/Chat/SystemPromptBanner.test.tsx new file mode 100644 index 0000000000..7ed83796ba --- /dev/null +++ b/frontend/src/components/Chat/SystemPromptBanner.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import SystemPromptBanner from './SystemPromptBanner' + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +// jsdom has no layout engine, so scrollWidth/clientWidth are 0 by default (no overflow). +// Force overflow by overriding the prototype getters for the duration of a test. +function mockOverflow(scrollWidth: number, clientWidth: number) { + Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { configurable: true, get: () => scrollWidth }) + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => clientWidth }) +} + +describe('SystemPromptBanner', () => { + afterEach(() => { + delete (HTMLElement.prototype as { scrollWidth?: number }).scrollWidth + delete (HTMLElement.prototype as { clientWidth?: number }).clientWidth + }) + + it('renders the label and the system prompt content', () => { + render( + + + + ) + + expect(screen.getByText('System Prompt')).toBeInTheDocument() + expect(screen.getByText('You are a pirate.')).toBeInTheDocument() + }) + + it('does not render an expand toggle when the content fits on one line', () => { + render( + + + + ) + + expect(screen.queryByRole('button', { name: /system prompt/i })).not.toBeInTheDocument() + }) + + it('renders a collapsed expand toggle when the content overflows', () => { + mockOverflow(1000, 200) + render( + + + + ) + + expect(screen.getByRole('button', { name: /system prompt/i })).toHaveAttribute('aria-expanded', 'false') + }) + + it('expands when the overflowing header is clicked', async () => { + const user = userEvent.setup() + mockOverflow(1000, 200) + render( + + + + ) + + const toggle = screen.getByRole('button', { name: /system prompt/i }) + await user.click(toggle) + + expect(toggle).toHaveAttribute('aria-expanded', 'true') + }) +}) diff --git a/frontend/src/components/Chat/SystemPromptBanner.tsx b/frontend/src/components/Chat/SystemPromptBanner.tsx new file mode 100644 index 0000000000..1c976b7c41 --- /dev/null +++ b/frontend/src/components/Chat/SystemPromptBanner.tsx @@ -0,0 +1,54 @@ +import { useLayoutEffect, useRef, useState } from 'react' +import { Button, Caption1, Text, mergeClasses } from '@fluentui/react-components' +import { ChevronDownRegular, ChevronRightRegular } from '@fluentui/react-icons' +import { useSystemPromptBannerStyles } from './SystemPromptBanner.styles' + +interface SystemPromptBannerProps { + content: string +} + +export default function SystemPromptBanner({ content }: SystemPromptBannerProps) { + const styles = useSystemPromptBannerStyles() + const [expanded, setExpanded] = useState(false) + const [overflowing, setOverflowing] = useState(false) + const contentRef = useRef(null) + + useLayoutEffect(() => { + const el = contentRef.current + if (!el) return + const measure = () => setOverflowing(el.scrollWidth > el.clientWidth) + measure() + const observer = new ResizeObserver(measure) + observer.observe(el) + return () => observer.disconnect() + }, [content]) + + const expandable = overflowing || expanded + + return ( +
+ {expandable ? ( + + ) : ( + System Prompt + )} + + {content} + +
+ ) +} diff --git a/frontend/src/components/Chat/SystemPromptSetup.styles.ts b/frontend/src/components/Chat/SystemPromptSetup.styles.ts new file mode 100644 index 0000000000..623771e2f0 --- /dev/null +++ b/frontend/src/components/Chat/SystemPromptSetup.styles.ts @@ -0,0 +1,45 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useSystemPromptSetupStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalL} 0`, + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + }, + headerRow: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalS, + }, + header: { + color: tokens.colorNeutralForeground2, + }, + body: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + paddingBottom: tokens.spacingVerticalS, + }, + textareaRoot: { + width: '100%', + }, + textareaInner: { + minHeight: '96px', + maxHeight: '30vh', + }, + counter: { + alignSelf: 'flex-end', + color: tokens.colorNeutralForeground3, + }, + counterOver: { + color: tokens.colorPaletteYellowForeground2, + }, + reason: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalXXS, + color: tokens.colorPaletteYellowForeground2, + }, +}) diff --git a/frontend/src/components/Chat/SystemPromptSetup.test.tsx b/frontend/src/components/Chat/SystemPromptSetup.test.tsx new file mode 100644 index 0000000000..99039039c3 --- /dev/null +++ b/frontend/src/components/Chat/SystemPromptSetup.test.tsx @@ -0,0 +1,101 @@ +import { useState } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import SystemPromptSetup from './SystemPromptSetup' + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +/** Stateful harness mirroring how ChatWindow owns the systemPrompt value. */ +function Harness({ + initial = '', + disabled = false, +}: { + initial?: string + disabled?: boolean +}) { + const [value, setValue] = useState(initial) + return ( + + + + ) +} + +describe('SystemPromptSetup', () => { + it('renders collapsed by default with no textarea visible', () => { + render() + expect(screen.getByRole('button', { name: /system prompt/i })).toBeInTheDocument() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + + it('expands to reveal the textarea when the toggle is clicked', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /system prompt/i })) + + expect(screen.getByRole('textbox', { name: /system prompt/i })).toBeInTheDocument() + }) + + it('reflects typed text and updates the character counter', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /system prompt/i })) + await user.type(screen.getByRole('textbox', { name: /system prompt/i }), 'hello') + + expect(screen.getByRole('textbox', { name: /system prompt/i })).toHaveValue('hello') + expect(screen.getByText(/5 characters/i)).toBeInTheDocument() + }) + + it('calls onChange with the new value as the user types', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + render( + + + + ) + + await user.click(screen.getByRole('button', { name: /system prompt/i })) + await user.type(screen.getByRole('textbox', { name: /system prompt/i }), 'H') + + expect(onChange).toHaveBeenCalledWith('H') + }) + + it('flags the counter when the value exceeds the soft limit', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /system prompt/i })) + + expect(screen.getByTestId('system-prompt-counter')).toHaveTextContent('2001 characters') + }) + + describe('when the target does not support system prompts', () => { + const disabledReason = 'This target does not support system prompts.' + + it('disables the toggle and shows the reason', () => { + render() + + expect(screen.getByRole('button', { name: /system prompt/i })).toBeDisabled() + expect(screen.getByText(disabledReason)).toBeInTheDocument() + }) + + it('does not expand when the disabled toggle is clicked', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /system prompt/i })) + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/components/Chat/SystemPromptSetup.tsx b/frontend/src/components/Chat/SystemPromptSetup.tsx new file mode 100644 index 0000000000..d362058bc7 --- /dev/null +++ b/frontend/src/components/Chat/SystemPromptSetup.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react' +import { Button, Caption1, Textarea, mergeClasses } from '@fluentui/react-components' +import { ChevronDownRegular, ChevronRightRegular, WarningRegular } from '@fluentui/react-icons' +import { useSystemPromptSetupStyles } from './SystemPromptSetup.styles' + +const SYSTEM_PROMPT_SOFT_LIMIT = 2000 + +interface SystemPromptSetupProps { + value: string + onChange: (value: string) => void + disabled?: boolean +} + +export default function SystemPromptSetup({ value, onChange, disabled = false }: SystemPromptSetupProps) { + const styles = useSystemPromptSetupStyles() + const [expanded, setExpanded] = useState(false) + const overLimit = value.length > SYSTEM_PROMPT_SOFT_LIMIT + + return ( +
+
+ + {disabled && ( + + + This target does not support system prompts. + + )} +
+ {expanded && !disabled && ( +
+