Add match archiving, chat enhancements, and daily limits

- Match archives with swipe-to-archive and unread filtering
- Chat settings sheet and full-screen media modals
- Match notification modal for new matches
- Admin AI settings with model/provider configuration
- Daily response limit feature with recommended questions
- New test coverage for stores, hooks, and components
- Claude Code agent and command configurations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Opus 4.5 committed 2026-01-09 20:18:34 +00:00
1 parent 595d6bd394
commit b0f1700e40
57 files changed
+8359 -1661

No files matched your search

+10
View File
@@ -23,6 +23,16 @@ export interface AiConfig {
classifier_enabled: boolean | null;
classifier_model: string | null;
classifier_prompt: string | null;
heuristics_enabled: boolean | null;
heuristic_min_text_length: number | null;
heuristic_whitelist_max_length: number | null;
heuristic_skip_if_no_alnum: boolean | null;
heuristic_skip_media_without_text: boolean | null;
heuristic_record_reason: boolean | null;
heuristic_use_default_whitelist: boolean | null;
heuristic_use_default_keywords: boolean | null;
heuristic_whitelist: string | null;
heuristic_keyword_triggers: string | null;
updated_at: string | null;
updated_by: string | null;
}
+1
View File
@@ -4,6 +4,7 @@ import { supabase } from '@/config';
export interface AppConfig {
id: string;
answer_gap_threshold: number;
daily_response_limit: number;
updated_at: string | null;
updated_by: string | null;
}
+305 -1
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import type { KeyboardEvent } from 'react';
import { useAiConfig, AiConfig, CouncilGenerator } from '@/hooks/useAiConfig';
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
@@ -10,11 +11,35 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Separator } from '@/components/ui/separator';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Textarea } from '@/components/ui/textarea';
import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry, Shield } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry, Shield, X } from 'lucide-react';
import { toast } from 'sonner';
import { format } from 'date-fns';
import { ModelCombobox } from '@/components/ModelCombobox';
type ListField = 'heuristic_whitelist' | 'heuristic_keyword_triggers';
const parseListValue = (value?: string | null): string[] => {
if (!value) return [];
return value
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
};
const appendUniqueItem = (items: string[], value: string): string[] => {
const trimmed = value.trim();
if (!trimmed) return items;
const existing = new Set(items.map((item) => item.toLowerCase()));
if (existing.has(trimmed.toLowerCase())) return items;
return [...items, trimmed];
};
const removeItem = (items: string[], value: string): string[] => {
const target = value.toLowerCase();
return items.filter((item) => item.toLowerCase() !== target);
};
export function AiSettingsPage() {
const { hasPermission, isSuperAdmin } = useAuth();
const { config, loading, error, updateConfig, refetch } = useAiConfig();
@@ -24,6 +49,8 @@ export function AiSettingsPage() {
const [showApiKey, setShowApiKey] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [whitelistInput, setWhitelistInput] = useState('');
const [keywordInput, setKeywordInput] = useState('');
// Permission check - only super admins or those with MANAGE_AI_CONFIG can access
const canManage = isSuperAdmin || hasPermission(PERMISSION_KEYS.MANAGE_AI_CONFIG);
@@ -51,6 +78,16 @@ export function AiSettingsPage() {
classifier_enabled: config.classifier_enabled ?? true,
classifier_model: config.classifier_model || 'openai/gpt-4o',
classifier_prompt: config.classifier_prompt || '',
heuristics_enabled: config.heuristics_enabled ?? false,
heuristic_min_text_length: config.heuristic_min_text_length ?? 12,
heuristic_whitelist_max_length: config.heuristic_whitelist_max_length ?? 30,
heuristic_skip_if_no_alnum: config.heuristic_skip_if_no_alnum ?? true,
heuristic_skip_media_without_text: config.heuristic_skip_media_without_text ?? false,
heuristic_record_reason: config.heuristic_record_reason ?? false,
heuristic_use_default_whitelist: config.heuristic_use_default_whitelist ?? true,
heuristic_use_default_keywords: config.heuristic_use_default_keywords ?? true,
heuristic_whitelist: config.heuristic_whitelist || '',
heuristic_keyword_triggers: config.heuristic_keyword_triggers || '',
});
setHasChanges(false);
}
@@ -61,6 +98,46 @@ export function AiSettingsPage() {
setHasChanges(true);
};
const whitelistItems = parseListValue(formData.heuristic_whitelist ?? '');
const keywordItems = parseListValue(formData.heuristic_keyword_triggers ?? '');
const updateListField = (field: ListField, items: string[]) => {
handleChange(field, items.length > 0 ? items.join('\n') : '');
};
const handleAddListItem = (
field: ListField,
value: string,
clearValue: (next: string) => void
) => {
const current = field === 'heuristic_whitelist' ? whitelistItems : keywordItems;
const nextItems = appendUniqueItem(current, value);
if (nextItems !== current) {
updateListField(field, nextItems);
}
if (value.trim()) {
clearValue('');
}
};
const handleRemoveListItem = (field: ListField, value: string) => {
const current = field === 'heuristic_whitelist' ? whitelistItems : keywordItems;
const nextItems = removeItem(current, value);
updateListField(field, nextItems);
};
const handleListKeyDown = (
event: KeyboardEvent<HTMLInputElement>,
field: ListField,
value: string,
clearValue: (next: string) => void
) => {
if (event.key === 'Enter') {
event.preventDefault();
handleAddListItem(field, value, clearValue);
}
};
const handleSave = async () => {
setIsSaving(true);
try {
@@ -99,6 +176,16 @@ export function AiSettingsPage() {
classifier_enabled: config.classifier_enabled ?? true,
classifier_model: config.classifier_model || 'openai/gpt-4o',
classifier_prompt: config.classifier_prompt || '',
heuristics_enabled: config.heuristics_enabled ?? false,
heuristic_min_text_length: config.heuristic_min_text_length ?? 12,
heuristic_whitelist_max_length: config.heuristic_whitelist_max_length ?? 30,
heuristic_skip_if_no_alnum: config.heuristic_skip_if_no_alnum ?? true,
heuristic_skip_media_without_text: config.heuristic_skip_media_without_text ?? false,
heuristic_record_reason: config.heuristic_record_reason ?? false,
heuristic_use_default_whitelist: config.heuristic_use_default_whitelist ?? true,
heuristic_use_default_keywords: config.heuristic_use_default_keywords ?? true,
heuristic_whitelist: config.heuristic_whitelist || '',
heuristic_keyword_triggers: config.heuristic_keyword_triggers || '',
});
setHasChanges(false);
}
@@ -305,6 +392,223 @@ export function AiSettingsPage() {
Instructions for the AI classifier.
</p>
</div>
<Separator />
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Enable Heuristic Pre-Filter</Label>
<p className="text-xs text-muted-foreground">
Skip AI calls for low-risk messages using simple rules
</p>
</div>
<Switch
checked={formData.heuristics_enabled ?? false}
onCheckedChange={(checked) => handleChange('heuristics_enabled', checked)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="heuristic-min-length">Minimum Text Length</Label>
<Input
id="heuristic-min-length"
type="number"
min={0}
value={formData.heuristic_min_text_length ?? 12}
onChange={(e) =>
handleChange(
'heuristic_min_text_length',
e.target.value === '' ? null : Number(e.target.value)
)
}
/>
<p className="text-xs text-muted-foreground">
Skip AI for shorter messages without media
</p>
</div>
<div className="space-y-2">
<Label htmlFor="heuristic-whitelist-max">Whitelist Max Length</Label>
<Input
id="heuristic-whitelist-max"
type="number"
min={0}
value={formData.heuristic_whitelist_max_length ?? 30}
onChange={(e) =>
handleChange(
'heuristic_whitelist_max_length',
e.target.value === '' ? null : Number(e.target.value)
)
}
/>
<p className="text-xs text-muted-foreground">
Only apply whitelist to messages below this length
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Skip If No Letters/Numbers</Label>
<p className="text-xs text-muted-foreground">
Treat emoji-only or punctuation-only messages as safe
</p>
</div>
<Switch
checked={formData.heuristic_skip_if_no_alnum ?? true}
onCheckedChange={(checked) => handleChange('heuristic_skip_if_no_alnum', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Skip Media Without Text</Label>
<p className="text-xs text-muted-foreground">
Skip AI when only media is present
</p>
</div>
<Switch
checked={formData.heuristic_skip_media_without_text ?? false}
onCheckedChange={(checked) => handleChange('heuristic_skip_media_without_text', checked)}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Use Default Whitelist</Label>
<p className="text-xs text-muted-foreground">
Include built-in common phrases
</p>
</div>
<Switch
checked={formData.heuristic_use_default_whitelist ?? true}
onCheckedChange={(checked) => handleChange('heuristic_use_default_whitelist', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Use Default Keywords</Label>
<p className="text-xs text-muted-foreground">
Include built-in safety keywords
</p>
</div>
<Switch
checked={formData.heuristic_use_default_keywords ?? true}
onCheckedChange={(checked) => handleChange('heuristic_use_default_keywords', checked)}
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Record Heuristic Reason</Label>
<p className="text-xs text-muted-foreground">
Store skip reason in flag_reason for debugging
</p>
</div>
<Switch
checked={formData.heuristic_record_reason ?? false}
onCheckedChange={(checked) => handleChange('heuristic_record_reason', checked)}
/>
</div>
<div className="space-y-2">
<Label>Custom Whitelist</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={whitelistInput}
onChange={(e) => setWhitelistInput(e.target.value)}
onKeyDown={(event) =>
handleListKeyDown(event, 'heuristic_whitelist', whitelistInput, setWhitelistInput)
}
placeholder="Add phrase"
/>
<Button
type="button"
variant="outline"
onClick={() =>
handleAddListItem('heuristic_whitelist', whitelistInput, setWhitelistInput)
}
disabled={!whitelistInput.trim()}
>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{whitelistItems.length > 0 ? (
<div className="flex flex-wrap gap-2">
{whitelistItems.map((item) => (
<Badge key={item} variant="secondary" className="flex items-center gap-1">
<span className="font-mono text-xs">{item}</span>
<button
type="button"
onClick={() => handleRemoveListItem('heuristic_whitelist', item)}
className="rounded-full p-0.5 text-muted-foreground hover:text-foreground"
aria-label={`Remove ${item}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No phrases added yet.</p>
)}
<p className="text-xs text-muted-foreground">
Messages matching these phrases are marked safe
</p>
</div>
<div className="space-y-2">
<Label>Keyword Triggers</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={keywordInput}
onChange={(e) => setKeywordInput(e.target.value)}
onKeyDown={(event) =>
handleListKeyDown(event, 'heuristic_keyword_triggers', keywordInput, setKeywordInput)
}
placeholder="Add keyword"
/>
<Button
type="button"
variant="outline"
onClick={() =>
handleAddListItem('heuristic_keyword_triggers', keywordInput, setKeywordInput)
}
disabled={!keywordInput.trim()}
>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{keywordItems.length > 0 ? (
<div className="flex flex-wrap gap-2">
{keywordItems.map((item) => (
<Badge key={item} variant="secondary" className="flex items-center gap-1">
<span className="font-mono text-xs">{item}</span>
<button
type="button"
onClick={() => handleRemoveListItem('heuristic_keyword_triggers', item)}
className="rounded-full p-0.5 text-muted-foreground hover:text-foreground"
aria-label={`Remove ${item}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No keywords added yet.</p>
)}
<p className="text-xs text-muted-foreground">
If any term appears, the message will be sent to AI
</p>
</div>
</div>
</CardContent>
</Card>
+39
View File
@@ -28,6 +28,7 @@ export function AppSettingsPage() {
if (config) {
setFormData({
answer_gap_threshold: config.answer_gap_threshold ?? 10,
daily_response_limit: config.daily_response_limit ?? 0,
});
setHasChanges(false);
}
@@ -59,6 +60,7 @@ export function AppSettingsPage() {
if (config) {
setFormData({
answer_gap_threshold: config.answer_gap_threshold ?? 10,
daily_response_limit: config.daily_response_limit ?? 0,
});
setHasChanges(false);
}
@@ -176,6 +178,43 @@ export function AppSettingsPage() {
</div>
</CardContent>
</Card>
{/* Daily Response Limit */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Daily Response Limit (Free Users)
</CardTitle>
<CardDescription>
Limit the number of questions non-premium users can answer per day (UTC 00:00-23:59)
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="dailyLimit">Daily Response Limit</Label>
<Input
id="dailyLimit"
type="number"
min="0"
max="1000"
value={formData.daily_response_limit ?? 0}
onChange={(e) => handleChange('daily_response_limit', parseInt(e.target.value) || 0)}
className="max-w-[200px]"
/>
<p className="text-xs text-muted-foreground">
Maximum number of questions a non-premium user can answer per UTC day.
Premium users (via personal or partner subscription) bypass this limit.
</p>
<p className="text-xs text-muted-foreground">
Set to <strong>0</strong> to disable daily limits entirely.
</p>
<p className="text-xs text-muted-foreground">
Limit resets daily at 00:00 UTC. Editing existing answers does not count toward the limit.
</p>
</div>
</CardContent>
</Card>
</div>
);
}