@@ -82,8 +87,8 @@
{{ event.snapshot.model }} · {{ event.snapshot.protocol }} · {{ event.snapshot.stage || 'http' }}
- {{ event.decision }} · {{ event.risk_level }}
- {{ event.categories.join(', ') || '—' }}
+ {{ formatDecisionRisk(event.decision, event.risk_level) }}
+ {{ formatCategories(event.categories) }}
|
{{ event.snapshot.redacted_preview || '—' }} |
@@ -103,7 +108,7 @@ import { computed, defineComponent, h, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Pagination from '@/components/common/Pagination.vue'
import type { PromptAuditEvent, PromptEventFilters } from '../types'
-import { cloneData, emptyEventFilters, hasExplicitDeleteRange } from '../viewModel'
+import { cloneData, emptyEventFilters, SCANNER_CATALOG } from '../viewModel'
const props = defineProps<{
events: PromptAuditEvent[]; total: number; page: number; pageSize: number
@@ -183,4 +188,25 @@ function decisionClass(decision: string): string {
if (decision === 'flag') return 'bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300'
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300'
}
+const DECISIONS = new Set(['pass', 'flag', 'critical'])
+const RISK_LEVELS = new Set(['low', 'medium', 'high', 'critical'])
+
+function translateDecision(decision: string): string {
+ return DECISIONS.has(decision) ? t(`admin.promptAudit.decisions.${decision}`) : decision
+}
+function translateRiskLevel(riskLevel: string): string {
+ return RISK_LEVELS.has(riskLevel) ? t(`admin.promptAudit.riskLevels.${riskLevel}`) : riskLevel
+}
+function translateCategory(category: string): string {
+ return SCANNER_CATALOG.some((scanner) => scanner.id === category)
+ ? t(`admin.promptAudit.scanners.${category}`)
+ : category
+}
+function formatDecisionRisk(decision: string, riskLevel: string): string {
+ return `${translateDecision(decision)} · ${translateRiskLevel(riskLevel)}`
+}
+function formatCategories(categories: string[]): string {
+ if (!categories.length) return '—'
+ return categories.map(translateCategory).join(', ')
+}
diff --git a/frontend/src/features/prompt-audit/components/FilterDeleteDialog.vue b/frontend/src/features/prompt-audit/components/FilterDeleteDialog.vue
new file mode 100644
index 000000000..1131dd9b9
--- /dev/null
+++ b/frontend/src/features/prompt-audit/components/FilterDeleteDialog.vue
@@ -0,0 +1,166 @@
+
+
+
+ {{ t('admin.promptAudit.events.filterDeleteDialogDesc') }}
+
+
+
+
+
+
+
+
+
+ {{ t('admin.promptAudit.events.moreConditions') }}
+
+
+
+
+
+
+
+
+
+ {{ t('admin.promptAudit.events.filterDeleteCount', { count: preview.matched_count }) }}
+
+ - {{ t('admin.promptAudit.events.snapshotMax') }}
+ - {{ preview.snapshot_max_id }}
+ - Filter SHA-256
+ - {{ preview.filter_hash }}
+ - {{ t('admin.promptAudit.events.expiresAt') }}
+ - {{ formatDate(preview.expires_at) }}
+
+ {{ t('admin.promptAudit.events.filterDeleteWarning') }}
+
+
+ {{ t('admin.promptAudit.events.filterDeleteNeedPreview') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/features/prompt-audit/components/PolicyPanel.vue b/frontend/src/features/prompt-audit/components/PolicyPanel.vue
index 9a2a050a8..24d36b758 100644
--- a/frontend/src/features/prompt-audit/components/PolicyPanel.vue
+++ b/frontend/src/features/prompt-audit/components/PolicyPanel.vue
@@ -46,8 +46,8 @@
@@ -105,4 +105,7 @@ function toggleScanner(id: string) {
else selected.add(id)
patch({ scanners: SCANNER_CATALOG.map((item) => item.id).filter((item) => selected.has(item)) })
}
+function scannerLabel(id: string): string {
+ return t(`admin.promptAudit.scanners.${id}`)
+}
diff --git a/frontend/src/features/prompt-audit/types.ts b/frontend/src/features/prompt-audit/types.ts
index 57571953d..eecc33037 100644
--- a/frontend/src/features/prompt-audit/types.ts
+++ b/frontend/src/features/prompt-audit/types.ts
@@ -147,6 +147,7 @@ export interface PromptSnapshot {
model: string
prompt_hash: string
redacted_preview: string
+ full_prompt: string
prompt_length: number
message_count: number
stage: string
diff --git a/frontend/src/features/prompt-audit/viewModel.ts b/frontend/src/features/prompt-audit/viewModel.ts
index 6308ce73d..f5a06760e 100644
--- a/frontend/src/features/prompt-audit/viewModel.ts
+++ b/frontend/src/features/prompt-audit/viewModel.ts
@@ -137,3 +137,32 @@ export function hasExplicitDeleteRange(filters: PromptEventFilters): boolean {
const end = toISO(filters.end_at)
return Boolean(start && end && new Date(start).getTime() < new Date(end).getTime())
}
+
+export type DeleteRangePreset = '1d' | '7d' | '30d' | '90d' | 'all' | 'custom'
+
+export const DELETE_RANGE_PRESETS: ReadonlyArray<{ id: DeleteRangePreset; days: number | null }> = [
+ { id: '1d', days: 1 },
+ { id: '7d', days: 7 },
+ { id: '30d', days: 30 },
+ { id: '90d', days: 90 },
+ { id: 'all', days: null },
+ { id: 'custom', days: null },
+]
+
+const DAY_MS = 24 * 60 * 60 * 1000
+
+// Presets delete events older than the chosen cutoff: the range always starts
+// at the epoch and ends at (now - days) so the backend's explicit-range
+// requirement is satisfied without asking the user for a begin date.
+export function resolveDeleteRangeFilters(
+ filters: PromptEventFilters,
+ preset: DeleteRangePreset,
+ now: number = Date.now(),
+): PromptEventFilters {
+ const resolved = cloneData(filters)
+ if (preset === 'custom') return resolved
+ const days = DELETE_RANGE_PRESETS.find((item) => item.id === preset)?.days ?? null
+ resolved.start_at = new Date(0).toISOString()
+ resolved.end_at = new Date(days === null ? now : now - days * DAY_MS).toISOString()
+ return resolved
+}
diff --git a/frontend/src/i18n/locales/en/admin/promptAudit.ts b/frontend/src/i18n/locales/en/admin/promptAudit.ts
index 8dba5e93b..014429a5a 100644
--- a/frontend/src/i18n/locales/en/admin/promptAudit.ts
+++ b/frontend/src/i18n/locales/en/admin/promptAudit.ts
@@ -1,13 +1,37 @@
export default {
promptAudit: {
title: 'Prompt Audit',
- description: 'Review user input asynchronously or block it synchronously through OpenAI-compatible Qwen3Guard nodes. Full prompts never enter the database or UI.',
+ description: 'Review user input asynchronously or block it synchronously through OpenAI-compatible Qwen3Guard nodes. Full prompts are stored with events for admin review.',
configVersion: 'Config version v{version}',
tabs: { config: 'Configuration', events: 'Events' },
- actions: { refresh: 'Refresh runtime', retry: 'Retry' },
+ actions: { refresh: 'Refresh runtime', retry: 'Retry', Allow: 'Allow', Warn: 'Warn', Block: 'Block' },
common: { actions: 'Actions', never: 'Never' },
mode: { off: 'Off', async_audit: 'Async audit only', blocking: 'Synchronous audit and block' },
status: { disabled: 'Disabled', running: 'Running', degraded: 'Degraded', error: 'Error', healthy: 'Healthy', failed: 'Failed', stale: 'Stale heartbeat' },
+ decisions: { pass: 'Pass', flag: 'Flag', critical: 'Critical' },
+ riskLevels: { low: 'Low', medium: 'Medium', high: 'High', critical: 'Critical' },
+ scanners: {
+ violent: 'Violent',
+ non_violent_illegal_acts: 'Non-violent Illegal Acts',
+ sexual_content_or_sexual_acts: 'Sexual Content or Sexual Acts',
+ pii: 'PII',
+ suicide_and_self_harm: 'Suicide & Self-Harm',
+ unethical_acts: 'Unethical Acts',
+ politically_sensitive_topics: 'Politically Sensitive Topics',
+ copyright_violation: 'Copyright Violation',
+ jailbreak: 'Jailbreak',
+ },
+ scannerDescriptions: {
+ violent: 'Violence or threats of violence',
+ non_violent_illegal_acts: 'Non-violent illegal activity',
+ sexual_content_or_sexual_acts: 'Sexual content or sexual acts',
+ pii: 'Personal identifying information',
+ suicide_and_self_harm: 'Suicide or self-harm',
+ unethical_acts: 'Unethical behavior',
+ politically_sensitive_topics: 'Politically sensitive topics',
+ copyright_violation: 'Copyright infringement',
+ jailbreak: 'Prompt injection or jailbreak attempt',
+ },
runtime: {
title: 'Runtime overview',
description: 'Shows the configuration currently active on the server. Unsaved draft changes do not affect these values.',
@@ -31,19 +55,38 @@ export default {
searchGroups: 'Search groups', noGroups: 'No matching groups', missingGroups: 'Configured IDs for groups that no longer exist', selectedCount: '{count} groups selected',
scanners: 'Qwen3Guard input-risk categories', workerCount: 'Worker count', queueCapacity: 'Persistent queue capacity', strategy: 'Node strategy', strategyHint: 'Try nodes in configuration order and fail over when allowed.',
},
- saveBar: { enabled: 'Enable prompt audit', blocking: 'Synchronous blocking', storePass: 'Store Pass events', dirty: 'Unsaved changes', synced: 'Configuration synced' },
+ saveBar: { enabled: 'Enable prompt audit', blocking: 'Synchronous blocking', storePass: 'Store safe events', dirty: 'Unsaved changes', synced: 'Configuration synced' },
blockingConfirm: {
title: 'Enable synchronous blocking?',
message: 'Applicable requests wait for Guard before account selection, billing, or upstream access. Block, unavailable Guard, and invalid responses all prevent upstream access.',
confirm: 'I understand; enable it',
},
events: {
- title: 'Audit events', description: 'Review redacted events by identity, route, risk, hash, and time.', decision: 'Decision', risk: 'Risk level', endpoint: 'Endpoint', groupId: 'Group ID', userId: 'User ID', apiKeyId: 'API Key ID', keyword: 'Keyword',
- startAt: 'Start time', endAt: 'End time', deleteSelected: 'Delete selected ({count})', deleteByFilter: 'Delete by filter', deleteRangeHint: 'Filter deletion requires explicit start and end times and a server-generated preview.',
+ title: 'Audit events', description: 'Review events by identity, route, risk, hash, and time; the detail view shows the full prompt.', decision: 'Decision', risk: 'Risk level', endpoint: 'Endpoint', groupId: 'Group ID', userId: 'User ID', apiKeyId: 'API Key ID', keyword: 'Keyword',
+ startAt: 'Start time', endAt: 'End time', deleteSelected: 'Delete selected ({count})', deleteByFilter: 'Delete by filter',
+ filterDeleteDialogTitle: 'Delete audit events by filter', filterDeleteDialogDesc: 'Choose the time range and risk criteria, generate a server-side delete preview, then confirm the matched count. Deletion is permanent.',
+ filterTimeRange: 'Deletion time range', filterTimeRangeHint: 'Deletes events created before the selected cutoff. Events created after the preview are not affected.',
+ timePresets: { '1d': 'Older than 1 day', '7d': 'Older than 7 days', '30d': 'Older than 30 days', '90d': 'Older than 90 days', all: 'All time', custom: 'Custom range' },
+ customRangeInvalid: 'A custom range needs a valid start and end time, with the start before the end.',
+ moreConditions: 'More conditions (endpoint / keyword / group / user)',
+ filterDeletePreviewAction: 'Generate delete preview', filterDeletePreviewing: 'Generating preview…', filterDeleteNeedPreview: 'Generate a delete preview first. Any criteria change requires a fresh preview.',
selectAll: 'Select all events on this page', selectEvent: 'Select event {id}', time: 'Time', identity: 'User / email / API Key', user: 'Username', email: 'User email', apiKey: 'API Key name', group: 'Group', route: 'Endpoint / model', result: 'Decision / risk', preview: 'Redacted preview', empty: 'No matching events.',
- passEventsDisabled: '“Store Pass events” is off. Safe requests are still audited, but they do not appear in this list. Enable and save that option in Configuration to review redacted audit content for every request.', openConfiguration: 'Open configuration',
- detailTitle: 'Prompt audit event details', tabs: { summary: 'Audit summary', risks: 'Specific risks', technical: 'Technical details' }, redactedPreview: 'Irreversible redacted preview', categories: 'Categories', model: 'Model', stage: 'Request stage', noRisks: 'No derived risk summaries for this event.',
- deleteConfirmTitle: 'Delete audit events?', deleteConfirmMessage: 'This permanently deletes {count} events and eligible orphan jobs.', filterDeleteTitle: 'Confirm filter deletion', filterDeleteCount: 'The server snapshot matches {count} events.', snapshotMax: 'Snapshot maximum event ID', expiresAt: 'Confirmation token expires', filterDeleteWarning: 'Only events at or below the preview high-water mark are deleted. Newer events survive. Any filter change requires a new preview.', confirmFilterDelete: 'Permanently delete',
+ passEventsDisabled: '“Store safe events” is off. Safe requests are still audited but do not appear in this list; Flag and Critical risk events are still stored.', openConfiguration: 'Open configuration',
+ detailTitle: 'Prompt audit event details', tabs: { summary: 'Audit summary', risks: 'Specific risks', technical: 'Technical details' },
+ promptFull: 'Full prompt (unredacted)',
+ promptFullHint: 'The full prompt is stored with this event for admin review only. Treat it as sensitive data and do not share it.',
+ guardReturn: 'Model audit return',
+ guardReturnHint: 'Normalized Guard result (decision, categories, scores, and redacted evidence). Raw response bodies are not stored.',
+ riskSummaries: 'Risk summaries',
+ evidence: 'Redacted evidence',
+ score: 'Score',
+ categories: 'Categories', model: 'Model', stage: 'Request stage', noRisks: 'No derived risk summaries for this event.',
+ requestId: 'Request ID', promptHash: 'Prompt SHA-256',
+ technical: {
+ scanner: 'Scanner', policy: 'Policy', guardEndpoint: 'Guard endpoint', config: 'Config',
+ chunks: 'Chunks', latency: 'Latency', protocol: 'Protocol',
+ },
+ deleteConfirmTitle: 'Delete audit events?', deleteConfirmMessage: 'This permanently deletes {count} events and eligible orphan jobs.', filterDeleteCount: 'The server snapshot matches {count} events.', snapshotMax: 'Snapshot maximum event ID', expiresAt: 'Confirmation token expires', filterDeleteWarning: 'Only events at or below the preview high-water mark are deleted. Newer events survive. Any filter change requires a new preview.', confirmFilterDelete: 'Permanently delete',
},
messages: { saved: 'Prompt Audit configuration saved; plaintext API Key state was cleared.', probeSucceeded: 'The audit node is reachable.', deleted: 'Deleted {count} audit events.' },
errors: {
diff --git a/frontend/src/i18n/locales/zh/admin/promptAudit.ts b/frontend/src/i18n/locales/zh/admin/promptAudit.ts
index b903a2f1e..b961da3ef 100644
--- a/frontend/src/i18n/locales/zh/admin/promptAudit.ts
+++ b/frontend/src/i18n/locales/zh/admin/promptAudit.ts
@@ -1,13 +1,37 @@
export default {
promptAudit: {
title: '提示词审计',
- description: '通过 OpenAI 兼容 Qwen3Guard 节点异步复核或同步阻止用户输入;完整提示词不会进入数据库或页面。',
+ description: '通过 OpenAI 兼容 Qwen3Guard 节点异步复核或同步阻止用户输入;事件的完整提示词会入库保存,仅供管理员复核。',
configVersion: '配置版本 v{version}',
tabs: { config: '配置', events: '事件' },
- actions: { refresh: '刷新运行态', retry: '重试' },
+ actions: { refresh: '刷新运行态', retry: '重试', Allow: '放行', Warn: '警告', Block: '阻止' },
common: { actions: '操作', never: '从未' },
mode: { off: '已关闭', async_audit: '异步只审计', blocking: '同步审计并阻止' },
status: { disabled: '未启用', running: '运行中', degraded: '降级', error: '错误', healthy: '健康', failed: '失败', stale: '心跳过期' },
+ decisions: { pass: '通过', flag: '标记', critical: '严重' },
+ riskLevels: { low: '低', medium: '中', high: '高', critical: '严重' },
+ scanners: {
+ violent: '暴力',
+ non_violent_illegal_acts: '非暴力违法行为',
+ sexual_content_or_sexual_acts: '色情内容或性行为',
+ pii: '个人身份信息',
+ suicide_and_self_harm: '自杀与自残',
+ unethical_acts: '不道德行为',
+ politically_sensitive_topics: '政治敏感话题',
+ copyright_violation: '版权侵犯',
+ jailbreak: '越狱',
+ },
+ scannerDescriptions: {
+ violent: '暴力或暴力威胁',
+ non_violent_illegal_acts: '非暴力违法活动',
+ sexual_content_or_sexual_acts: '色情内容或性行为',
+ pii: '个人身份信息',
+ suicide_and_self_harm: '自杀或自残',
+ unethical_acts: '不道德行为',
+ politically_sensitive_topics: '政治敏感话题',
+ copyright_violation: '版权侵权',
+ jailbreak: '提示注入或越狱尝试',
+ },
runtime: {
title: '运行概览',
description: '显示服务端当前生效状态;未保存的草稿不会改变这些数值。',
@@ -31,19 +55,38 @@ export default {
searchGroups: '搜索分组', noGroups: '没有匹配分组', missingGroups: '配置中包含已删除的分组 ID', selectedCount: '已选择 {count} 个分组',
scanners: 'Qwen3Guard 输入风险分类', workerCount: 'Worker 数量', queueCapacity: '持久队列容量', strategy: '节点策略', strategyHint: '按配置顺序优先尝试,必要时故障切换。',
},
- saveBar: { enabled: '启用提示词审计', blocking: '同步阻止', storePass: '保存 Pass 事件', dirty: '有未保存的更改', synced: '配置已同步' },
+ saveBar: { enabled: '启用提示词审计', blocking: '同步阻止', storePass: '保存安全事件', dirty: '有未保存的更改', synced: '配置已同步' },
blockingConfirm: {
title: '开启同步阻止?',
message: '适用请求会在账号选择、计费和访问上游之前等待 Guard。命中 Block、Guard 不可用或响应非法时,请求都不会访问上游。',
confirm: '理解风险并开启',
},
events: {
- title: '审计事件', description: '按身份、入口、风险、Hash 和时间复核脱敏事件。', decision: '判定', risk: '风险等级', endpoint: '入口', groupId: '分组 ID', userId: '用户 ID', apiKeyId: 'API Key ID', keyword: '关键词',
- startAt: '开始时间', endAt: '结束时间', deleteSelected: '删除选中项({count})', deleteByFilter: '按筛选删除', deleteRangeHint: '按筛选删除必须明确选择开始和结束时间,并先取得服务端删除预览。',
+ title: '审计事件', description: '按身份、入口、风险、Hash 和时间复核事件,详情中可查看完整提示词。', decision: '判定', risk: '风险等级', endpoint: '入口', groupId: '分组 ID', userId: '用户 ID', apiKeyId: 'API Key ID', keyword: '关键词',
+ startAt: '开始时间', endAt: '结束时间', deleteSelected: '删除选中项({count})', deleteByFilter: '按筛选删除',
+ filterDeleteDialogTitle: '按筛选删除审计事件', filterDeleteDialogDesc: '选择删除的时间范围与风险条件,先生成服务端删除预览,确认匹配数量后再执行;删除不可恢复。',
+ filterTimeRange: '删除时间范围', filterTimeRangeHint: '将删除所选截止时间之前产生的事件;预览后新产生的事件不受影响。',
+ timePresets: { '1d': '1 天前', '7d': '7 天前', '30d': '30 天前', '90d': '90 天前', all: '全部时间', custom: '自定义范围' },
+ customRangeInvalid: '自定义范围需要有效的开始与结束时间,且开始早于结束。',
+ moreConditions: '更多条件(入口 / 关键词 / 分组 / 用户)',
+ filterDeletePreviewAction: '获取删除预览', filterDeletePreviewing: '正在生成预览…', filterDeleteNeedPreview: '请先获取删除预览;任何条件变化后都需要重新获取。',
selectAll: '选择当前页全部事件', selectEvent: '选择事件 {id}', time: '时间', identity: '用户 / 邮箱 / API Key', user: '用户名', email: '用户邮箱', apiKey: 'API Key 名称', group: '分组', route: '入口 / 模型', result: '判定 / 风险', preview: '脱敏预览', empty: '没有符合条件的事件。',
- passEventsDisabled: '当前未开启“保存 Pass 事件”:安全请求仍会完成审计,但不会出现在事件列表中。若要查看每次请求的脱敏审计内容,请在配置中开启该选项并保存。', openConfiguration: '前往配置',
- detailTitle: '提示词审计事件详情', tabs: { summary: '审计摘要', risks: '具体风险', technical: '技术信息' }, redactedPreview: '不可逆脱敏预览', categories: '分类', model: '模型', stage: '请求阶段', noRisks: '本事件没有派生风险摘要。',
- deleteConfirmTitle: '删除审计事件?', deleteConfirmMessage: '将永久删除 {count} 条事件及符合条件的孤立任务。', filterDeleteTitle: '确认按筛选删除', filterDeleteCount: '服务端快照匹配 {count} 条事件。', snapshotMax: '快照最大事件 ID', expiresAt: '确认令牌过期时间', filterDeleteWarning: '只删除预览高水位内的事件;预览后产生的新事件会保留。筛选一旦变化,必须重新预览。', confirmFilterDelete: '确认永久删除',
+ passEventsDisabled: '当前未开启“保存安全事件”:安全请求仍会完成审计,但不会出现在事件列表中;Flag 和 Critical 风险事件仍会保存。', openConfiguration: '前往配置',
+ detailTitle: '提示词审计事件详情', tabs: { summary: '审计摘要', risks: '具体风险', technical: '技术信息' },
+ promptFull: '完整提示词(未脱敏)',
+ promptFullHint: '完整提示词已随事件入库,仅供管理员复核触发内容;请按敏感数据妥善处理,切勿外泄。',
+ guardReturn: '模型审计返回',
+ guardReturnHint: '展示 Guard 归一化后的结构化结果(判定、分类、分数与脱敏证据),不含原始响应体。',
+ riskSummaries: '风险摘要',
+ evidence: '脱敏证据',
+ score: '分数',
+ categories: '分类', model: '模型', stage: '请求阶段', noRisks: '本事件没有派生风险摘要。',
+ requestId: 'Request ID', promptHash: 'Prompt SHA-256',
+ technical: {
+ scanner: '扫描器', policy: '策略', guardEndpoint: 'Guard 节点', config: '配置版本',
+ chunks: '分片数', latency: '耗时', protocol: '协议',
+ },
+ deleteConfirmTitle: '删除审计事件?', deleteConfirmMessage: '将永久删除 {count} 条事件及符合条件的孤立任务。', filterDeleteCount: '服务端快照匹配 {count} 条事件。', snapshotMax: '快照最大事件 ID', expiresAt: '确认令牌过期时间', filterDeleteWarning: '只删除预览高水位内的事件;预览后产生的新事件会保留。筛选一旦变化,必须重新预览。', confirmFilterDelete: '确认永久删除',
},
messages: { saved: '提示词审计配置已保存,明文 API Key 状态已清除。', probeSucceeded: '审计节点连接正常。', deleted: '已删除 {count} 条审计事件。' },
errors: {
|