diff --git a/application/single_app/config.py b/application/single_app/config.py index 7378b2436..0b2eb9674 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.278" +VERSION = "0.261.279" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/v2_ui/src/App.tsx b/application/v2_ui/src/App.tsx index f3ec3847a..8f2f33d6e 100644 --- a/application/v2_ui/src/App.tsx +++ b/application/v2_ui/src/App.tsx @@ -19,9 +19,12 @@ import { restorePersistedRuns } from './stores/orchestrationStore'; import { ChatPage } from './pages/ChatPage'; import { HomePage } from './pages/HomePage'; import { AdminSettingsPage } from './pages/AdminSettingsPage'; +import { AdminFeedbackReviewPage } from './pages/AdminFeedbackReviewPage'; +import { AdminSafetyViolationsPage } from './pages/AdminSafetyViolationsPage'; import { AdminActionEditorPage, AdminAgentEditorPage } from './pages/AdminGlobalEditorPages'; import { SettingsPage } from './pages/SettingsPage'; import { WorkspacePage } from './pages/workspace/WorkspacePage'; +import { safeSameOriginUrl } from './lib/adminOperations'; import { PlaceholderPage } from './pages/PlaceholderPage'; import { GroupWorkspacePage } from './pages/GroupWorkspacePage'; import { GroupDirectoryPage } from './pages/GroupDirectoryPage'; @@ -166,12 +169,15 @@ export function App() { */ useEffect(() => { const faviconUrl = data?.branding?.favicon_url; - if (!faviconUrl) { + const safeFaviconUrl = faviconUrl + ? safeSameOriginUrl(faviconUrl, window.location.origin) + : null; + if (!safeFaviconUrl) { return; } const link = document.querySelector('link[rel="icon"]'); - if (link && link.getAttribute('href') !== faviconUrl) { - link.setAttribute('href', faviconUrl); + if (link && link.getAttribute('href') !== safeFaviconUrl) { + link.setAttribute('href', safeFaviconUrl); } }, [data]); @@ -213,6 +219,8 @@ export function App() { } /> } /> } /> + } /> + } /> {/* The global editors return here, with their section in view. */} } /> } /> diff --git a/application/v2_ui/src/components/layout/Sidebar.tsx b/application/v2_ui/src/components/layout/Sidebar.tsx index 82c1b2385..13980426f 100644 --- a/application/v2_ui/src/components/layout/Sidebar.tsx +++ b/application/v2_ui/src/components/layout/Sidebar.tsx @@ -26,9 +26,11 @@ import { Globe2, LogOut, MessageSquarePlus, + MessageSquareHeart, MessagesSquare, Moon, Settings, + ShieldAlert, ShieldCheck, SlidersHorizontal, Sparkles, @@ -36,6 +38,7 @@ import { Users, } from 'lucide-react'; import { useUiStore } from '../../stores/uiStore'; +import { safeSameOriginUrl } from '../../lib/adminOperations'; import { useBootstrapStore } from '../../stores/bootstrapStore'; import { useChatStore } from '../../stores/chatStore'; import { classicChatHref } from '../../lib/conversationUrl'; @@ -144,10 +147,26 @@ function BrandMark({ collapsed }: { collapsed: boolean }) { } function UserMenu({ collapsed }: { collapsed: boolean }) { - const user = useBootstrapStore((state) => state.data?.user); - const controlCenter = useBootstrapStore((state) => state.data?.control_center); + const bootstrap = useBootstrapStore((state) => state.data); + const user = bootstrap?.user; + const controlCenter = bootstrap?.control_center; const isAdmin = Boolean(user?.is_admin); const canOpenControlCenter = Object.values(controlCenter ?? {}).some(Boolean); + const roles = user?.roles ?? []; + const settings = bootstrap?.settings; + const features = bootstrap?.features; + const canReviewFeedback = Boolean(features?.enable_user_feedback) && ( + settings?.require_member_of_feedback_admin === true + ? roles.includes('FeedbackAdmin') + : roles.includes('Admin') + ); + const canReviewSafety = Boolean( + features?.enable_content_safety || features?.enable_content_screening, + ) && ( + settings?.require_member_of_safety_violation_admin === true + ? roles.includes('SafetyViolationAdmin') + : roles.includes('Admin') + ); const activeConversationId = useChatStore((state) => state.activeConversationId); const [open, setOpen] = useState(false); const containerRef = useRef(null); @@ -225,6 +244,16 @@ function UserMenu({ collapsed }: { collapsed: boolean }) { Admin Settings )} + {canReviewFeedback && ( + setOpen(false)} className={itemClass}> + Feedback Review + + )} + {canReviewSafety && ( + setOpen(false)} className={itemClass}> + Safety Violations + + )} {canOpenControlCenter && ( setOpen(false)} className={itemClass}> Control Center @@ -457,7 +486,7 @@ export function Sidebar({ mobile = false }: { mobile?: boolean }) { const label = item.to === '/public' ? publicLabels.plural : item.label; return ( diff --git a/application/v2_ui/src/pages/AdminFeedbackReviewPage.tsx b/application/v2_ui/src/pages/AdminFeedbackReviewPage.tsx new file mode 100644 index 000000000..c7e98234f --- /dev/null +++ b/application/v2_ui/src/pages/AdminFeedbackReviewPage.tsx @@ -0,0 +1,603 @@ +// AdminFeedbackReviewPage.tsx + +import { useEffect, useMemo, useState } from 'react'; +import { Archive, Check, Download, Eye, Grid2X2, List, RefreshCw, RotateCcw, Trash2 } from 'lucide-react'; +import { api, apiUrl, ApiError } from '../lib/apiClient'; +import { PageHeader } from '../components/layout/PageHeader'; +import { AdminModal } from '../components/admin/AdminModal'; +import { ConfirmDialog } from '../components/ui/ConfirmDialog'; +import { GlassButton, GlassPanel, Skeleton } from '../components/ui/primitives'; + +const PAGE_SIZES = [10, 20, 50, 100]; +const VIEW_STORAGE_KEY = 'simplechat.v2.admin.feedback.viewMode'; + +interface AdminReview { + acknowledged?: boolean; + analysisNotes?: string; + responseToUser?: string; + actionTaken?: string; + reviewTimestamp?: string; +} + +interface FeedbackItem { + id: string; + userId?: string; + prompt?: string; + aiResponse?: string; + feedbackType?: string; + reason?: string; + timestamp?: string; + isArchived?: boolean; + adminReview?: AdminReview; +} + +interface FeedbackPageResponse { + feedback?: FeedbackItem[]; + page?: number; + page_size?: number; + total_count?: number; +} + +interface FeedbackStats { + total_count?: number; + positive_count?: number; + negative_count?: number; + neutral_count?: number; + acknowledged_count?: number; + unacknowledged_count?: number; + recent_30_day_count?: number; + latest_timestamp?: string; +} + +interface Filters { + type: string; + acknowledged: string; + archive: string; +} + +interface ReviewDraft { + acknowledged: boolean; + analysisNotes: string; + responseToUser: string; + actionTaken: string; +} + +function formatDate(value?: string): string { + if (!value) return 'N/A'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + +function queryFor(filters: Filters, page?: number, pageSize?: number): string { + const params = new URLSearchParams(); + if (page !== undefined) params.set('page', String(page)); + if (pageSize !== undefined) params.set('page_size', String(pageSize)); + if (filters.type) params.set('type', filters.type); + if (filters.acknowledged) params.set('ack', filters.acknowledged); + params.set('archive', filters.archive); + return params.toString(); +} + +function TypeBadge({ value }: { value?: string }) { + const tone = value === 'Positive' + ? 'bg-ok-soft text-ok' + : value === 'Negative' + ? 'bg-danger-soft text-danger' + : 'bg-surface-2 text-text-2'; + return {value || 'Unknown'}; +} + +function AcknowledgedBadge({ value }: { value: boolean }) { + return ( + + {value ? 'Acknowledged' : 'Awaiting review'} + + ); +} + +function Stat({ label, value }: { label: string; value?: number }) { + return ( + +
{value ?? 0}
+
{label}
+
+ ); +} + +function ActionButtons({ + item, + onReview, + onRetest, + onArchive, + onDelete, + busy, +}: { + item: FeedbackItem; + onReview: (item: FeedbackItem) => void; + onRetest: (item: FeedbackItem) => void; + onArchive: (item: FeedbackItem) => void; + onDelete: (item: FeedbackItem) => void; + busy: boolean; +}) { + return ( +
+ onReview(item)} disabled={busy}> + Review + + onRetest(item)} disabled={busy}> + Retest + + onArchive(item)} disabled={busy}> + {item.isArchived ? : } + {item.isArchived ? 'Restore' : 'Archive'} + + onDelete(item)} disabled={busy}> + Delete + +
+ ); +} + +export function AdminFeedbackReviewPage() { + const [filters, setFilters] = useState({ type: '', acknowledged: '', archive: 'active' }); + const [draftFilters, setDraftFilters] = useState(filters); + const [items, setItems] = useState([]); + const [stats, setStats] = useState(null); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [totalCount, setTotalCount] = useState(0); + const [statsError, setStatsError] = useState(null); + const [viewMode, setViewMode] = useState<'list' | 'cards'>(() => { + try { + return window.localStorage.getItem(VIEW_STORAGE_KEY) === 'cards' ? 'cards' : 'list'; + } catch { + return 'list'; + } + }); + const [loading, setLoading] = useState(true); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState<{ message: string; warning?: boolean } | null>(null); + const [selected, setSelected] = useState(null); + const [draft, setDraft] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(null); + const [saving, setSaving] = useState(false); + const [retestItem, setRetestItem] = useState(null); + const [retestText, setRetestText] = useState(''); + const [retestLoading, setRetestLoading] = useState(false); + const [retestError, setRetestError] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); + const [deleteError, setDeleteError] = useState(null); + const [refresh, setRefresh] = useState(0); + + const filterQuery = useMemo(() => queryFor(filters), [filters]); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(null); + const listQuery = queryFor(filters, page, pageSize); + void Promise.allSettled([ + api.get(`/feedback/review?${listQuery}`, controller.signal), + api.get(`/feedback/review/stats?${filterQuery}`, controller.signal), + ]).then(([listResult, statsResult]) => { + if (controller.signal.aborted) return; + if (listResult.status === 'fulfilled') { + const response = listResult.value; + setItems(response?.feedback ?? []); + setPage(response?.page ?? page); + setPageSize(response?.page_size ?? pageSize); + setTotalCount(response?.total_count ?? 0); + } else { + setError(listResult.reason instanceof Error + ? listResult.reason.message + : 'Feedback could not be loaded.'); + setItems([]); + } + setStats(statsResult.status === 'fulfilled' ? statsResult.value : null); + setStatsError(statsResult.status === 'rejected' + ? statsResult.reason instanceof Error + ? statsResult.reason.message + : 'Feedback statistics could not be loaded.' + : null); + }).finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [filters, filterQuery, page, pageSize, refresh]); + + const setMode = (mode: 'list' | 'cards') => { + setViewMode(mode); + try { + window.localStorage.setItem(VIEW_STORAGE_KEY, mode); + } catch { + // The current session's selection remains usable when storage is unavailable. + } + }; + + const openReview = async (item: FeedbackItem) => { + setSelected(item); + setDraft(null); + setDetailError(null); + setDetailLoading(true); + try { + const detail = await api.get(`/feedback/review/${encodeURIComponent(item.id)}`); + setSelected(detail); + setDraft({ + acknowledged: Boolean(detail.adminReview?.acknowledged), + analysisNotes: detail.adminReview?.analysisNotes ?? '', + responseToUser: detail.adminReview?.responseToUser ?? '', + actionTaken: detail.adminReview?.actionTaken ?? '', + }); + } catch (caught) { + setDetailError(caught instanceof Error ? caught.message : 'Feedback details could not be loaded.'); + } finally { + setDetailLoading(false); + } + }; + + const saveReview = async () => { + if (!selected || !draft) return; + setSaving(true); + setDetailError(null); + try { + await api.patch(`/feedback/review/${encodeURIComponent(selected.id)}`, draft); + setSelected(null); + setDraft(null); + setRefresh((value) => value + 1); + setNotice({ message: 'Feedback review saved.' }); + } catch (caught) { + setDetailError(caught instanceof ApiError ? caught.message : 'Feedback review could not be saved.'); + } finally { + setSaving(false); + } + }; + + const updateArchive = async (item: FeedbackItem) => { + setBusyId(item.id); + setError(null); + try { + const result = await api.patch<{ message?: string; audit_warning?: string }>( + `/feedback/review/${encodeURIComponent(item.id)}/archive`, + { archived: !item.isArchived }, + ); + setNotice({ + message: result.audit_warning || result.message || 'Feedback record updated.', + warning: Boolean(result.audit_warning), + }); + setRefresh((value) => value + 1); + if (selected?.id === item.id) setSelected(null); + } catch (caught) { + setError(caught instanceof Error ? caught.message : 'Feedback record could not be archived.'); + } finally { + setBusyId(null); + } + }; + + const deleteFeedback = async () => { + if (!pendingDelete) return; + const item = pendingDelete; + setBusyId(item.id); + setDeleteError(null); + try { + const result = await api.delete<{ message?: string; audit_warning?: string }>( + `/feedback/review/${encodeURIComponent(item.id)}`, + ); + setPendingDelete(null); + setDeleteError(null); + if (selected?.id === item.id) { + setSelected(null); + setDraft(null); + } + if (items.length === 1 && page > 1) setPage((value) => value - 1); + else setRefresh((value) => value + 1); + setNotice({ + message: result.audit_warning || result.message || 'Feedback record permanently deleted.', + warning: Boolean(result.audit_warning), + }); + } catch (caught) { + setDeleteError(caught instanceof Error ? caught.message : 'Feedback could not be deleted.'); + } finally { + setBusyId(null); + } + }; + + const runRetest = async (item: FeedbackItem) => { + setRetestItem(item); + setRetestText(''); + setRetestError(null); + setRetestLoading(true); + try { + const result = await api.post<{ retestResponse?: string }>( + `/feedback/retest/${encodeURIComponent(item.id)}`, + { prompt: item.prompt ?? '' }, + ); + setRetestText(result.retestResponse || 'No retest response was returned.'); + } catch (caught) { + setRetestError(caught instanceof Error ? caught.message : 'The prompt could not be retested.'); + } finally { + setRetestLoading(false); + } + }; + + const maxPage = Math.max(1, Math.ceil(totalCount / pageSize)); + const isBusy = (id: string) => busyId === id; + + return ( +
+ + Export CSV + + } + /> +
+ {notice && ( +

+ {notice.message} +

+ )} + {error &&

{error}

} + {statsError &&

Feedback statistics are unavailable: {statsError}

} + +
+ + + + + + +
+ + +
+ + + +
+ { + setPage(1); + setFilters(draftFilters); + }} + > + Apply filters + + { + const cleared = { type: '', acknowledged: '', archive: 'active' }; + setDraftFilters(cleared); + setFilters(cleared); + setPage(1); + }} + > + Clear + +
+
+ + +
+
+
+ + +
+
+ + {loading ? ( +
+ ) : items.length === 0 ? ( + + No feedback found for the current filters. + + ) : viewMode === 'list' ? ( + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
SubmittedPromptRatingReviewActions
{formatDate(item.timestamp)}

{item.prompt || 'No prompt captured.'}

void openReview(value)} onRetest={(value) => void runRetest(value)} onArchive={(value) => void updateArchive(value)} onDelete={(value) => { setDeleteError(null); setPendingDelete(value); }} busy={isBusy(item.id)} />
+
+ ) : ( +
+ {items.map((item) => ( + +
+ {formatDate(item.timestamp)} +
+
+

{item.prompt || 'No prompt captured.'}

+ {item.reason ?

{item.reason}

: null} + {item.adminReview?.actionTaken ?

Action: {item.adminReview.actionTaken}

: null} +
void openReview(value)} onRetest={(value) => void runRetest(value)} onArchive={(value) => void updateArchive(value)} onDelete={(value) => { setDeleteError(null); setPendingDelete(value); }} busy={isBusy(item.id)} />
+
+ ))} +
+ )} + +
+ {totalCount ? `Page ${page} of ${maxPage} · ${totalCount} submissions` : 'No submissions'} +
+ setPage((value) => Math.max(1, value - 1))}>Previous + = maxPage || loading} onClick={() => setPage((value) => Math.min(maxPage, value + 1))}>Next +
+
+
+ + {selected && ( + { + if (!saving) { + setSelected(null); + setDraft(null); + } + }} + footer={ + <> + setSelected(null)} disabled={saving}>Close + {selected.isArchived ? ( + void updateArchive(selected)} disabled={saving || isBusy(selected.id)}> + Restore + + ) : ( + void updateArchive(selected)} disabled={saving || isBusy(selected.id)}> + Archive + + )} + void saveReview()} disabled={!draft || saving || detailLoading}> + {saving ? 'Saving…' : 'Save review'} + + + } + > + {detailLoading ? : detailError ? ( +

{detailError}

+ ) : ( +
+
+

Prompt

{selected.prompt || 'No prompt captured.'}

+

Assistant response

{selected.aiResponse || 'No response captured.'}

+

User feedback

{selected.reason || 'No additional reason provided.'}

+ {draft && ( + <> + +