Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,9 @@
"hideAvatar": "Hide AI Avatar",
"showAvatar": "Show AI Avatar",
"avatarGreeting": "Namaste. I am your AI Legal Assistant. You can speak to me anytime.",
"history": "History"
"history": "History",
"injectionAdvisory": "Your message contains phrasing that may not produce useful legal results. Please describe your legal question directly.",
"inputTooLong": "Your message is too long. Please shorten it to {{limit}} characters or fewer."
},

"common": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,9 @@
"hideAvatar": "एआई अवतार छिपाएँ",
"showAvatar": "एआई अवतार दिखाएँ",
"avatarGreeting": "नमस्ते। मैं आपका एआई कानूनी सहायक हूँ। आप मुझसे कभी भी बात कर सकते हैं।",
"history": "इतिहास"
"history": "इतिहास",
"injectionAdvisory": "आपके संदेश में ऐसी भाषा है जिससे उपयोगी कानूनी उत्तर नहीं मिल सकता। कृपया अपना कानूनी प्रश्न सीधे लिखें।",
"inputTooLong": "आपका संदेश बहुत लंबा है। कृपया इसे {{limit}} अक्षरों या उससे कम तक सीमित करें।"
},

"common": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,9 @@

"avatarGreeting": "नमस्कार. मी तुमचा AI कायदेशीर सहाय्यक आहे. तुम्ही कधीही माझ्याशी बोलू शकता.",

"history": "इतिहास"
"history": "इतिहास",
"injectionAdvisory": "तुमच्या संदेशातील मजकुरामुळे उपयुक्त कायदेशीर उत्तर मिळणार नाही. कृपया तुमचा कायदेशीर प्रश्न थेट लिहा.",
"inputTooLong": "तुमचा संदेश खूप मोठा आहे. कृपया तो {{limit}} अक्षरांपर्यंत कमी करा."
},


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,9 @@
"hideAvatar": "AI Avatar மறைக்கவும்",
"showAvatar": "AI Avatar காட்டவும்",
"avatarGreeting": "வணக்கம். நான் உங்கள் AI சட்ட உதவியாளர். நீங்கள் எப்போதும் என்னிடம் பேசலாம்.",
"history": "வரலாறு"
"history": "வரலாறு",
"injectionAdvisory": "உங்கள் செய்தியில் உள்ள சொற்றொடர்கள் பயனுள்ள சட்ட விளக்கத்தை அளிக்காமல் போகலாம். உங்கள் சட்டக் கேள்வியை நேரடியாக விவரிக்கவும்.",
"inputTooLong": "உங்கள் செய்தி மிக நீளமாக உள்ளது. தயவுசெய்து அதை {{limit}} எழுத்துகளுக்குள் சுருக்கவும்."
},

"common": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,9 @@
"hideAvatar": "AI Avatar దాచండి",
"showAvatar": "AI Avatar చూపించండి",
"avatarGreeting": "నమస్కారం. నేను మీ AI న్యాయ సహాయకుడిని. మీరు ఎప్పుడైనా నాతో మాట్లాడవచ్చు.",
"history": "చరిత్ర"
"history": "చరిత్ర",
"injectionAdvisory": "మీ సందేశంలోని పదజాలం ఉపయోగకరమైన న్యాయ సమాధానాన్ని ఇవ్వకపోవచ్చు. దయచేసి మీ న్యాయ ప్రశ్నను నేరుగా వివరించండి.",
"inputTooLong": "మీ సందేశం చాలా పొడవుగా ఉంది. దయచేసి దానిని {{limit}} అక్షరాలలోపు తగ్గించండి."
},

"common": {
Expand Down
134 changes: 117 additions & 17 deletions frontend/nyaysetu-frontend/src/pages/litigant/VakilFriendPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import AvatarPanel from '../../components/avatar/AvatarPanel';
import { useTranslation } from 'react-i18next';
import useChatStore from '../../store/chatStore';
import CaseSummaryViewer from '../../components/Summary/CaseSummaryViewer';
import {
MAX_CHAT_INPUT_LENGTH,
formatCharacterCount,
hasPromptInjectionPattern,
isChatInputNearLimit,
isChatInputOverLimit,
sanitizeChatInput,
} from '../../utils/chatInputSafety';

export default function VakilFriendChat() {
const { t } = useTranslation('litigant');
Expand Down Expand Up @@ -268,7 +276,20 @@ const {
const textToSend = overrideText || inputMessage;
if ((!textToSend.trim() && !audioData) || isLoading || isStarting) return;

const userMessage = textToSend.trim();
// The send button is already disabled past the limit, but voice input and
// the wake-word buffer call sendMessage() directly, so re-check here.
if (isChatInputOverLimit(textToSend)) {
setError(t('vakilFriend.inputTooLong', {
limit: MAX_CHAT_INPUT_LENGTH.toLocaleString('en-IN')
}));
return;
}

// Strip tags and control characters before the text reaches the AI prompt
const userMessage = sanitizeChatInput(textToSend);
if (!userMessage && !audioData) return;

setError(null);
// Only clear the input message box if we aren't overriding it (standard UI flow)
if (!overrideText) setInputMessage('');

Expand Down Expand Up @@ -972,6 +993,12 @@ const startDeepResearch = async (query) => {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};

// Input safety state derived from the current draft message
const inputOverLimit = isChatInputOverLimit(inputMessage);
const inputNearLimit = isChatInputNearLimit(inputMessage);
const showInjectionAdvisory = hasPromptInjectionPattern(inputMessage);
const sendDisabled = !inputMessage.trim() || inputOverLimit || isLoading || isStarting || rateLimited;

return (
<div style={{ maxWidth: '100%', position: 'relative' }}>
{/* History Sidebar - Full screen modal */}
Expand Down Expand Up @@ -1278,6 +1305,8 @@ const startDeepResearch = async (query) => {
}}>
<div style={{ color: '#64748b', fontSize: '0.8rem', fontWeight: '700', marginBottom: '0.75rem' }}>{t('vakilFriend.aiSummary')}</div>
<p style={{ color: '#334155', fontSize: '1rem', lineHeight: '1.6', margin: 0 }}>
{documentAnalysis.summary || t('vakilFriend.summaryPending')}
</p>
</div>

{/* Case Summary Viewer */}
Expand Down Expand Up @@ -1612,15 +1641,33 @@ const startDeepResearch = async (query) => {
: '1rem 1rem 1rem 0.25rem',
boxShadow: msg.role === 'user' ? 'none' : '0 4px 12px rgba(30, 42, 68, 0.04)'
}}>
<div className="markdown-content" style={{
color: 'var(--text-main)',
fontSize: '0.95rem',
lineHeight: '1.6',
}}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{/*
* User text is rendered as a plain React text child rather than
* as Markdown. React HTML-entity-encodes text children, so any
* tags a user types are displayed literally instead of being
* interpreted by the renderer.
*/}
{msg.role === 'user' ? (
<div style={{
color: 'var(--text-main)',
fontSize: '0.95rem',
lineHeight: '1.6',
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere'
}}>
{msg.content}
</ReactMarkdown>
</div>
</div>
) : (
<div className="markdown-content" style={{
color: 'var(--text-main)',
fontSize: '0.95rem',
lineHeight: '1.6',
}}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{msg.content}
</ReactMarkdown>
</div>
)}
{msg.role === 'assistant' && (
<button
onClick={() => speakText(msg.content, index)}
Expand Down Expand Up @@ -1827,11 +1874,15 @@ const startDeepResearch = async (query) => {
placeholder={t('vakilFriend.placeholder')}
disabled={isLoading || isStarting}
rows={2}
aria-invalid={inputOverLimit}
aria-describedby="vakil-friend-character-counter"
style={{
flex: 1,
padding: '0.75rem 1rem',
background: 'var(--bg-glass)',
border: 'var(--border-glass)',
border: inputOverLimit
? '1px solid var(--color-error)'
: 'var(--border-glass)',
borderRadius: '0.625rem',
color: 'var(--text-main)',
fontSize: '0.9rem',
Expand All @@ -1840,8 +1891,8 @@ const startDeepResearch = async (query) => {
fontFamily: 'inherit',
transition: 'all 0.2s'
}}
onFocus={e => e.currentTarget.style.borderColor = 'var(--color-primary)'}
onBlur={e => e.currentTarget.style.borderColor = 'var(--border-glass)'}
onFocus={e => e.currentTarget.style.borderColor = inputOverLimit ? 'var(--color-error)' : 'var(--color-primary)'}
onBlur={e => e.currentTarget.style.borderColor = inputOverLimit ? 'var(--color-error)' : 'var(--border-glass)'}
/>

{/* Mic Button */}
Expand Down Expand Up @@ -1905,20 +1956,23 @@ const startDeepResearch = async (query) => {

<button
onClick={() => sendMessage()}
disabled={!inputMessage.trim() || isLoading || isStarting || rateLimited}
disabled={sendDisabled}
title={inputOverLimit
? t('vakilFriend.inputTooLong', { limit: MAX_CHAT_INPUT_LENGTH.toLocaleString('en-IN') })
: undefined}
style={{
padding: '0.75rem 1rem',
background: (!inputMessage.trim() || isLoading || isStarting || rateLimited)
background: sendDisabled
? 'var(--bg-glass-strong)'
: 'var(--color-primary)',
border: 'none',
borderRadius: '0.625rem',
color: (!inputMessage.trim() || isLoading || isStarting || rateLimited) ? 'var(--text-secondary)' : 'white',
cursor: (!inputMessage.trim() || isLoading || isStarting || rateLimited) ? 'not-allowed' : 'pointer',
color: sendDisabled ? 'var(--text-secondary)' : 'white',
cursor: sendDisabled ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: (!inputMessage.trim() || isLoading || isStarting || rateLimited)
boxShadow: sendDisabled
? 'none'
: '0 4px 15px rgba(30, 42, 68, 0.4)',
transition: 'all 0.2s'
Expand All @@ -1927,6 +1981,52 @@ const startDeepResearch = async (query) => {
{rateLimited ? <span style={{fontSize:'0.75rem', fontWeight:'700'}}>{cooldown}s</span> : <Send size={20} />}
</button>
</div>

{/* Prompt injection advisory + live character counter */}
<div style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '1rem',
marginTop: '0.6rem'
}}>
{showInjectionAdvisory ? (
<div
role="status"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
background: 'rgba(245, 158, 11, 0.1)',
border: '1px solid rgba(245, 158, 11, 0.25)',
borderRadius: '0.5rem',
color: '#b45309',
fontSize: '0.78rem',
lineHeight: '1.5'
}}
>
<AlertTriangle size={14} style={{ flexShrink: 0, marginTop: '2px' }} />
<span>{t('vakilFriend.injectionAdvisory')}</span>
</div>
) : (
<span />
)}

<span
id="vakil-friend-character-counter"
aria-live="polite"
style={{
flexShrink: 0,
fontSize: '0.75rem',
fontWeight: inputNearLimit ? '700' : '500',
color: inputNearLimit ? 'var(--color-error)' : 'var(--text-secondary)',
fontVariantNumeric: 'tabular-nums'
}}
>
{formatCharacterCount(inputMessage.length)}
</span>
</div>
</div>
</div>

Expand Down
101 changes: 101 additions & 0 deletions frontend/nyaysetu-frontend/src/utils/chatInputSafety.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Input safety helpers for the Nyay Saarthi (Vakil Friend) chat interface.
*
* Chat text is forwarded to the NLP orchestrator and on to external AI
* providers (Groq Llama 3.1, Gemini), so an unbounded input field lets a
* single user drain the shared token quota, and injection-style phrasing can
* pull the assistant away from its legal-focus system prompt.
*
* These helpers are the first line of defence only — the orchestrator
* re-sanitizes and re-validates every query server-side.
*/

/** Maximum number of characters a single chat message may contain. */
export const MAX_CHAT_INPUT_LENGTH = 2000;

/** Fraction of the limit at which the character counter switches to red. */
export const CHAT_INPUT_WARNING_RATIO = 0.9;

const HTML_TAGS = /<[^>]*>/g;

const TAB = 9;
const LINE_FEED = 10;
const FIRST_PRINTABLE = 32;
const DELETE = 127;

// Phrasing commonly used to talk the assistant out of its system prompt.
// Matching is advisory: we warn the user, we never silently drop their text.
const INJECTION_PATTERNS = [
/ignore\s+(all\s+)?(previous|prior)\s+instructions/i,
/disregard\s+your\s+(system|initial)\s+prompt/i,
/you\s+are\s+now\s+a/i,
/act\s+as\s+if\s+you\s+are/i,
];

/**
* Control characters carry no meaning in a legal question but can corrupt
* prompt construction and downstream rendering. Tab and newline are kept so
* multi-line questions survive intact.
*/
function isControlCharacter(character) {
const code = character.codePointAt(0);
if (code === TAB || code === LINE_FEED) return false;
return code < FIRST_PRINTABLE || code === DELETE;
}

/**
* Strip HTML/XML tags and control characters from a chat message and collapse
* runs of blank lines. Mirrors `sanitize_user_input` in the NLP orchestrator.
*
* @param {string} text raw text from the input field or speech recognition
* @returns {string} text safe to send to the backend
*/
export function sanitizeChatInput(text) {
if (typeof text !== 'string') return '';

const withoutTags = text.replace(HTML_TAGS, '');
const printable = Array.from(withoutTags)
.filter((character) => !isControlCharacter(character))
.join('');

return printable.replace(/\n{3,}/g, '\n\n').trim();
}

/**
* @param {string} text current input field value
* @returns {boolean} true when the message is longer than the allowed limit
*/
export function isChatInputOverLimit(text) {
return typeof text === 'string' && text.length > MAX_CHAT_INPUT_LENGTH;
}

/**
* @param {string} text current input field value
* @returns {boolean} true once the counter should be shown in red
*/
export function isChatInputNearLimit(text) {
if (typeof text !== 'string') return false;
return text.length >= MAX_CHAT_INPUT_LENGTH * CHAT_INPUT_WARNING_RATIO;
}

/**
* Detect phrasing that commonly precedes a prompt injection attempt.
*
* @param {string} text current input field value
* @returns {boolean} true when the user should see the advisory
*/
export function hasPromptInjectionPattern(text) {
if (typeof text !== 'string' || !text.trim()) return false;
return INJECTION_PATTERNS.some((pattern) => pattern.test(text));
}

/**
* Build the live counter label, e.g. `"1,847 / 2,000"`.
*
* @param {number} length current character count
* @param {number} [limit] maximum allowed characters
* @returns {string} formatted counter label
*/
export function formatCharacterCount(length, limit = MAX_CHAT_INPUT_LENGTH) {
return `${length.toLocaleString('en-IN')} / ${limit.toLocaleString('en-IN')}`;
}
Loading
Loading