feat(security-audit): add OpenAI-compatible prompt auditing

This commit is contained in:
mt21625457
2026-07-17 00:39:39 +08:00
parent a2779cd5f3
commit d11bdb13f5
103 changed files with 17549 additions and 70 deletions
+12 -1
View File
@@ -771,7 +771,18 @@ const adminNavItems = computed((): NavItem[] => {
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{ path: '/admin/risk-control', label: t('nav.riskControl'), icon: ShieldIcon, hideInSimpleMode: true, featureFlag: flagRiskControl },
{
path: '/admin/security-audit',
label: t('nav.securityAudit'),
icon: ShieldIcon,
hideInSimpleMode: true,
expandOnly: true,
featureFlag: flagRiskControl,
children: [
{ path: '/admin/risk-control', label: t('nav.contentModeration'), icon: ShieldIcon },
{ path: '/admin/prompt-audit', label: t('nav.promptAudit'), icon: ShieldIcon },
],
},
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
{
@@ -0,0 +1,350 @@
<template>
<AppLayout>
<div class="mx-auto max-w-[1600px] pb-28">
<header class="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-primary-600 dark:text-primary-400">{{ t('nav.securityAudit') }}</p>
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">{{ t('admin.promptAudit.title') }}</h1>
<p class="mt-2 max-w-3xl text-sm text-gray-500 dark:text-dark-300">{{ t('admin.promptAudit.description') }}</p>
</div>
<div v-if="draft" class="text-right text-xs text-gray-500 dark:text-dark-400">
<p>{{ t('admin.promptAudit.configVersion', { version: draft.config_version }) }}</p>
<p v-if="draft.updated_at" class="mt-1">{{ formatDate(draft.updated_at) }}</p>
</div>
</header>
<div v-if="loadErrors.config && !draft" role="alert" class="rounded-xl border border-red-200 bg-red-50 p-5 dark:border-red-900 dark:bg-red-950/30">
<p class="text-sm text-red-700 dark:text-red-300">{{ loadErrors.config }}</p>
<button type="button" class="btn btn-secondary btn-sm mt-3" @click="loadConfig">{{ t('admin.promptAudit.actions.retry') }}</button>
</div>
<main v-else class="rounded-2xl border border-gray-200 bg-white px-4 shadow-sm dark:border-dark-700 dark:bg-dark-850 sm:px-6 lg:px-8">
<RuntimeOverview :runtime="runtime" :loading="loading.runtime" :error="loadErrors.runtime" @refresh="loadRuntime" />
<template v-if="draft">
<EndpointPool
:endpoints="draft.endpoints"
:probe-results="probeResults"
:probing-ids="probingIds"
@update:endpoints="updateEndpoints"
@probe="runProbe"
/>
<div v-if="loadErrors.groups" role="alert" class="mt-5 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">{{ loadErrors.groups }}</div>
<PolicyPanel :draft="draft" :groups="groups" @update:draft="replaceDraft" />
</template>
<EventWorkspace
:events="events.items"
:total="events.total"
:page="events.page"
:page-size="events.page_size"
:filters="filters"
:selected-ids="selectedEventIds"
:loading="loading.events"
:error="loadErrors.events"
@filters-change="handleFiltersChanged"
@search="applyEventFilters"
@selection="selectedEventIds = $event"
@page="changePage"
@page-size="changePageSize"
@view="openEvent"
@delete="requestSingleDelete"
@batch-delete="requestBatchDelete"
@preview-delete="requestFilterDeletePreview"
/>
</main>
</div>
<div v-if="draft" class="fixed inset-x-0 bottom-0 z-30 border-t border-gray-200 bg-white/95 px-4 py-3 shadow-[0_-12px_35px_rgba(15,23,42,0.08)] backdrop-blur dark:border-dark-700 dark:bg-dark-900/95 lg:left-64">
<div class="mx-auto flex max-w-[1600px] flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<SaveToggle :label="t('admin.promptAudit.saveBar.enabled')" :model-value="draft.enabled" data-test="enabled-toggle" @update:model-value="setEnabled" />
<SaveToggle :label="t('admin.promptAudit.saveBar.blocking')" :model-value="draft.blocking_enabled" :disabled="!draft.enabled" data-test="blocking-toggle" @update:model-value="setBlocking" />
<SaveToggle :label="t('admin.promptAudit.saveBar.storePass')" :model-value="draft.store_pass_events" data-test="store-pass-toggle" @update:model-value="replaceDraft({ ...draft!, store_pass_events: $event })" />
</div>
<div class="flex items-center gap-3">
<span class="text-sm" :class="dirty ? 'text-amber-700 dark:text-amber-300' : 'text-gray-500 dark:text-dark-400'">
{{ dirty ? t('admin.promptAudit.saveBar.dirty') : t('admin.promptAudit.saveBar.synced') }}
</span>
<button type="button" class="btn btn-secondary" :disabled="!dirty || loading.saving" @click="resetDraft">{{ t('common.reset') }}</button>
<button type="button" class="btn btn-primary" :disabled="!dirty || loading.saving" data-test="save-config" @click="saveConfig">
{{ loading.saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</div>
</div>
<ConfirmDialog
:show="showBlockingConfirmation"
:title="t('admin.promptAudit.blockingConfirm.title')"
:message="t('admin.promptAudit.blockingConfirm.message')"
:confirm-text="t('admin.promptAudit.blockingConfirm.confirm')"
danger
@confirm="confirmBlocking"
@cancel="showBlockingConfirmation = false"
/>
<ConfirmDialog
:show="deleteRequest.mode !== ''"
:title="t('admin.promptAudit.events.deleteConfirmTitle')"
:message="t('admin.promptAudit.events.deleteConfirmMessage', { count: deleteRequest.ids.length })"
:confirm-text="t('common.delete')"
danger
@confirm="confirmIDDelete"
@cancel="clearDeleteRequest"
/>
<BaseDialog :show="Boolean(deletePreview)" :title="t('admin.promptAudit.events.filterDeleteTitle')" width="normal" @close="deletePreview = null">
<div v-if="deletePreview" class="space-y-4 text-sm text-gray-600 dark:text-dark-300">
<p>{{ t('admin.promptAudit.events.filterDeleteCount', { count: deletePreview.matched_count }) }}</p>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2">
<dt>{{ t('admin.promptAudit.events.snapshotMax') }}</dt><dd>{{ deletePreview.snapshot_max_id }}</dd>
<dt>Filter SHA-256</dt><dd class="break-all font-mono text-xs">{{ deletePreview.filter_hash }}</dd>
<dt>{{ t('admin.promptAudit.events.expiresAt') }}</dt><dd>{{ formatDate(deletePreview.expires_at) }}</dd>
</dl>
<p class="rounded-lg bg-amber-50 px-3 py-2 text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">{{ t('admin.promptAudit.events.filterDeleteWarning') }}</p>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" @click="deletePreview = null">{{ t('common.cancel') }}</button>
<button type="button" class="btn btn-danger" :disabled="loading.deleting" data-test="confirm-filter-delete" @click="confirmFilterDelete">{{ t('admin.promptAudit.events.confirmFilterDelete') }}</button>
</div>
</template>
</BaseDialog>
<EventDetailDialog :show="showEventDetail" :event="activeEvent" :loading="loading.detail" @close="closeEventDetail" />
</AppLayout>
</template>
<script setup lang="ts">
import { computed, defineComponent, h, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AppLayout from '@/components/layout/AppLayout.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import { useAppStore } from '@/stores/app'
import { extractApiErrorCode, extractApiErrorMessage } from '@/utils/apiError'
import RuntimeOverview from './components/RuntimeOverview.vue'
import EndpointPool from './components/EndpointPool.vue'
import PolicyPanel from './components/PolicyPanel.vue'
import EventWorkspace from './components/EventWorkspace.vue'
import EventDetailDialog from './components/EventDetailDialog.vue'
import promptAuditAPI from './api'
import type {
PromptAuditDraft,
PromptAuditEndpointDraft,
PromptAuditEvent,
PromptAuditGroup,
PromptAuditRuntime,
PromptDeletePreview,
PromptEventFilters,
PromptEventPage,
PromptLoadErrors,
PromptProbeResult,
} from './types'
import { buildUpdateRequest, cloneData, configToDraft, draftFingerprint, emptyEventFilters } from './viewModel'
const { t, locale } = useI18n()
const appStore = useAppStore()
const serverConfig = ref<PromptAuditDraft | null>(null)
const draft = ref<PromptAuditDraft | null>(null)
const runtime = ref<PromptAuditRuntime | null>(null)
const groups = ref<PromptAuditGroup[]>([])
const events = reactive<PromptEventPage>({ items: [], total: 0, page: 1, page_size: 20, pages: 0 })
const filters = ref<PromptEventFilters>(emptyEventFilters())
const appliedFilters = ref<PromptEventFilters>(emptyEventFilters())
const selectedEventIds = ref<number[]>([])
const activeEvent = ref<PromptAuditEvent | null>(null)
const showEventDetail = ref(false)
const probeResults = reactive<Record<string, PromptProbeResult>>({})
const probingIds = ref<string[]>([])
const deletePreview = ref<PromptDeletePreview | null>(null)
const showBlockingConfirmation = ref(false)
const deleteRequest = reactive<{ mode: '' | 'single' | 'batch'; ids: number[] }>({ mode: '', ids: [] })
const loading = reactive({ config: false, runtime: false, groups: false, events: false, saving: false, detail: false, deleting: false })
const loadErrors = reactive<PromptLoadErrors>({ config: '', runtime: '', groups: '', events: '' })
const dirty = computed(() => draftFingerprint(draft.value) !== draftFingerprint(serverConfig.value))
const SaveToggle = defineComponent({
inheritAttrs: false,
props: { label: { type: String, required: true }, modelValue: { type: Boolean, required: true }, disabled: { type: Boolean, default: false } },
emits: ['update:modelValue'],
setup(props, { emit, attrs }) {
return () => h('label', { class: ['flex items-center gap-2 text-sm', props.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'] }, [
h('button', {
...attrs, type: 'button', role: 'switch', 'aria-checked': props.modelValue, 'aria-label': props.label, disabled: props.disabled,
class: ['relative h-6 w-11 rounded-full transition-colors', props.modelValue ? 'bg-primary-600' : 'bg-gray-300 dark:bg-dark-600'],
onClick: () => !props.disabled && emit('update:modelValue', !props.modelValue),
}, [h('span', { class: ['absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform', props.modelValue ? 'translate-x-5' : 'translate-x-0.5'] })]),
h('span', { class: 'text-gray-700 dark:text-dark-200' }, props.label),
])
},
})
function errorMessage(error: unknown, fallbackKey: string): string {
const code = extractApiErrorCode(error)
if (code) {
const key = `admin.promptAudit.errors.${code}`
const translated = t(key)
if (translated !== key) return translated
}
return extractApiErrorMessage(error, t(fallbackKey))
}
async function loadConfig() {
loading.config = true
loadErrors.config = ''
try {
const config = await promptAuditAPI.getConfig()
serverConfig.value = configToDraft(config)
draft.value = configToDraft(config)
} catch (error) {
loadErrors.config = errorMessage(error, 'admin.promptAudit.errors.loadConfig')
} finally {
loading.config = false
}
}
async function loadRuntime() {
loading.runtime = true
loadErrors.runtime = ''
try { runtime.value = await promptAuditAPI.getRuntime() }
catch (error) { loadErrors.runtime = errorMessage(error, 'admin.promptAudit.errors.loadRuntime') }
finally { loading.runtime = false }
}
async function loadGroups() {
loading.groups = true
loadErrors.groups = ''
try { groups.value = await promptAuditAPI.listGroups() }
catch (error) { loadErrors.groups = errorMessage(error, 'admin.promptAudit.errors.loadGroups') }
finally { loading.groups = false }
}
async function loadEvents() {
loading.events = true
loadErrors.events = ''
try {
const result = await promptAuditAPI.listEvents(appliedFilters.value, events.page, events.page_size)
Object.assign(events, result)
selectedEventIds.value = []
} catch (error) {
loadErrors.events = errorMessage(error, 'admin.promptAudit.errors.loadEvents')
} finally {
loading.events = false
}
}
async function loadInitial() {
await Promise.allSettled([loadConfig(), loadRuntime(), loadGroups(), loadEvents()])
}
function replaceDraft(value: PromptAuditDraft) { draft.value = cloneData(value) }
function updateEndpoints(value: PromptAuditEndpointDraft[]) {
if (!draft.value) return
replaceDraft({ ...draft.value, endpoints: value })
}
function setEnabled(value: boolean) {
if (!draft.value) return
replaceDraft({ ...draft.value, enabled: value, blocking_enabled: value ? draft.value.blocking_enabled : false })
}
function setBlocking(value: boolean) {
if (!draft.value || !draft.value.enabled) return
if (value && !draft.value.blocking_enabled) { showBlockingConfirmation.value = true; return }
replaceDraft({ ...draft.value, blocking_enabled: value })
}
function confirmBlocking() {
showBlockingConfirmation.value = false
if (draft.value) replaceDraft({ ...draft.value, blocking_enabled: true })
}
function resetDraft() {
if (serverConfig.value) draft.value = cloneData(serverConfig.value)
}
async function saveConfig() {
if (!draft.value || !dirty.value) return
loading.saving = true
try {
const saved = await promptAuditAPI.updateConfig(buildUpdateRequest(draft.value))
serverConfig.value = configToDraft(saved)
draft.value = configToDraft(saved)
appStore.showSuccess(t('admin.promptAudit.messages.saved'))
await loadRuntime()
} catch (error) {
const code = extractApiErrorCode(error)
appStore.showError(errorMessage(error, code === 'prompt_audit_config_conflict' ? 'admin.promptAudit.errors.prompt_audit_config_conflict' : 'admin.promptAudit.errors.saveConfig'))
} finally {
loading.saving = false
}
}
async function runProbe(endpoint: PromptAuditEndpointDraft) {
if (probingIds.value.includes(endpoint.id)) return
probingIds.value = [...probingIds.value, endpoint.id]
try {
const result = await promptAuditAPI.probeEndpoint(endpoint)
probeResults[endpoint.id] = result
if (result.ok) appStore.showSuccess(t('admin.promptAudit.messages.probeSucceeded'))
else appStore.showError(`${result.error_code || result.status}: ${result.message}`)
} catch (error) {
appStore.showError(errorMessage(error, 'admin.promptAudit.errors.probe'))
} finally {
probingIds.value = probingIds.value.filter((id) => id !== endpoint.id)
}
}
function handleFiltersChanged(value: PromptEventFilters) {
filters.value = cloneData(value)
deletePreview.value = null
}
function applyEventFilters(value: PromptEventFilters) {
filters.value = cloneData(value)
appliedFilters.value = cloneData(value)
events.page = 1
deletePreview.value = null
void loadEvents()
}
function changePage(value: number) { events.page = value; void loadEvents() }
function changePageSize(value: number) { events.page_size = value; events.page = 1; void loadEvents() }
async function openEvent(id: number) {
showEventDetail.value = true
loading.detail = true
activeEvent.value = null
try { activeEvent.value = await promptAuditAPI.getEvent(id) }
catch (error) { appStore.showError(errorMessage(error, 'admin.promptAudit.errors.loadDetail')); showEventDetail.value = false }
finally { loading.detail = false }
}
function closeEventDetail() { showEventDetail.value = false; activeEvent.value = null }
function requestSingleDelete(id: number) { deleteRequest.mode = 'single'; deleteRequest.ids = [id] }
function requestBatchDelete() { if (selectedEventIds.value.length) { deleteRequest.mode = 'batch'; deleteRequest.ids = [...selectedEventIds.value] } }
function clearDeleteRequest() { deleteRequest.mode = ''; deleteRequest.ids = [] }
async function confirmIDDelete() {
const mode = deleteRequest.mode
const ids = [...deleteRequest.ids]
clearDeleteRequest()
if (!mode || ids.length === 0) return
loading.deleting = true
try {
const result = mode === 'single' ? await promptAuditAPI.deleteEvent(ids[0]) : await promptAuditAPI.batchDeleteEvents(ids)
appStore.showSuccess(t('admin.promptAudit.messages.deleted', { count: result.deleted_events }))
await Promise.allSettled([loadEvents(), loadRuntime()])
} catch (error) { appStore.showError(errorMessage(error, 'admin.promptAudit.errors.delete')) }
finally { loading.deleting = false }
}
async function requestFilterDeletePreview() {
loading.deleting = true
try { deletePreview.value = await promptAuditAPI.previewDelete(filters.value) }
catch (error) { appStore.showError(errorMessage(error, 'admin.promptAudit.errors.previewDelete')) }
finally { loading.deleting = false }
}
async function confirmFilterDelete() {
if (!deletePreview.value) return
const preview = deletePreview.value
loading.deleting = true
try {
const result = await promptAuditAPI.deleteEventsByFilter(filters.value, preview)
deletePreview.value = null
appStore.showSuccess(t('admin.promptAudit.messages.deleted', { count: result.deleted_events }))
await Promise.allSettled([loadEvents(), loadRuntime()])
} catch (error) {
deletePreview.value = null
appStore.showError(errorMessage(error, 'admin.promptAudit.errors.deleteConfirmation'))
} finally { loading.deleting = false }
}
function formatDate(value: string): string {
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value))
}
onMounted(loadInitial)
</script>
@@ -0,0 +1,161 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import type { PromptAuditConfig, PromptAuditRuntime } from '../types'
import { SCANNER_CATALOG } from '../viewModel'
import PromptAuditView from '../PromptAuditView.vue'
const mocks = vi.hoisted(() => ({
getConfig: vi.fn(), updateConfig: vi.fn(), probeEndpoint: vi.fn(), getRuntime: vi.fn(), listEvents: vi.fn(),
getEvent: vi.fn(), deleteEvent: vi.fn(), batchDeleteEvents: vi.fn(), previewDelete: vi.fn(), deleteEventsByFilter: vi.fn(), listGroups: vi.fn(),
showSuccess: vi.fn(), showError: vi.fn(),
}))
vi.mock('../api', () => ({ default: mocks }))
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showSuccess: mocks.showSuccess, showError: mocks.showError }) }))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return { ...actual, useI18n: () => ({ locale: { value: 'en' }, t: (key: string, params?: Record<string, unknown>) => key.replace(/\{(\w+)\}/g, (_, token) => String(params?.[token] ?? `{${token}}`)) }) }
})
const baseConfig = (): PromptAuditConfig => ({
enabled: true, blocking_enabled: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority',
worker_count: 4, queue_capacity: 100, scanners: SCANNER_CATALOG.map((item) => item.id), all_groups: true, group_ids: [],
endpoints: [{ id: 'guard-1', name: 'Guard One', protocol: 'openai_compatible', base_url: 'http://127.0.0.1:8000', model: 'guard-model', timeout_ms: 3000, input_limit: 4000, enabled: true, has_token: true, token_status: 'configured' }],
config_version: 7, updated_at: '2026-07-16T00:00:00Z', updated_by: 1, change_summary: '{}',
})
const runtime = (): PromptAuditRuntime => ({
process_status: 'running', effective_mode: 'async_audit', expected_config_version: 7, active_config_version: 7,
worker_total: 4, worker_active: 1, queue_capacity: 100,
queue: { staging: 0, queued: 0, processing: 1, retry: 0, done: 5, failed: 0, active: 1 },
processed_total: 5, failed_total: 0, enqueued_total: 5, dropped_total: 0, database_status: 'ok', redis_status: 'ok', endpoints: {},
guard_metrics: { total: 1, allowed: 1, flagged: 0, blocked: 0, unavailable: 0, invalid: 0, timeouts: 0, failovers: 0, bulkhead_full: 0, record_failed: 0 },
})
const AppLayoutStub = { template: '<div><slot /></div>' }
const RuntimeStub = defineComponent({ props: ['runtime', 'loading', 'error'], emits: ['refresh'], template: '<div data-test="runtime">{{ error }}</div>' })
const EndpointStub = defineComponent({
props: ['endpoints', 'probeResults', 'probingIds'], emits: ['update:endpoints', 'probe'],
template: '<div data-test="endpoint"><button data-test="inject-secret" @click="$emit(\'update:endpoints\', endpoints.map((e) => ({ ...e, token: \'PROMPT_AUDIT_CANARY_SECRET_DO_NOT_PERSIST\' })))">secret</button><button data-test="probe" @click="$emit(\'probe\', endpoints[0])">probe</button></div>',
})
const PolicyStub = defineComponent({ props: ['draft', 'groups'], emits: ['update:draft'], template: '<div data-test="policy" />' })
const EventsStub = defineComponent({
props: ['events', 'filters', 'selectedIds', 'loading', 'error', 'total', 'page', 'pageSize'],
emits: ['filters-change', 'search', 'selection', 'page', 'page-size', 'view', 'delete', 'batch-delete', 'preview-delete'],
template: '<div data-test="events"><button data-test="preview" @click="$emit(\'preview-delete\')">preview</button><button data-test="change-filter" @click="$emit(\'filters-change\', { ...filters, keyword: \'changed\' })">change</button><button data-test="delete-one" @click="$emit(\'delete\', 5)">delete</button><button data-test="select-batch" @click="$emit(\'selection\', [5, 6])">select</button><button data-test="delete-batch" @click="$emit(\'batch-delete\')">batch</button></div>',
})
const DetailStub = defineComponent({ props: ['show', 'event', 'loading'], emits: ['close'], template: '<div data-test="detail" />' })
const DialogStub = defineComponent({ props: ['show', 'title'], emits: ['close'], template: '<div v-if="show" data-test="dialog"><slot /><slot name="footer" /></div>' })
const ConfirmStub = defineComponent({ props: ['show', 'title', 'message'], emits: ['confirm', 'cancel'], template: '<div v-if="show" data-test="confirm"><button data-test="confirm-action" @click="$emit(\'confirm\')">confirm</button></div>' })
function mountView() {
return mount(PromptAuditView, {
global: { stubs: { AppLayout: AppLayoutStub, RuntimeOverview: RuntimeStub, EndpointPool: EndpointStub, PolicyPanel: PolicyStub, EventWorkspace: EventsStub, EventDetailDialog: DetailStub, BaseDialog: DialogStub, ConfirmDialog: ConfirmStub } },
})
}
describe('PromptAuditView', () => {
beforeEach(() => {
Object.values(mocks).forEach((mock) => mock.mockReset())
mocks.getConfig.mockResolvedValue(baseConfig())
mocks.getRuntime.mockResolvedValue(runtime())
mocks.listGroups.mockResolvedValue([])
mocks.listEvents.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 0 })
mocks.updateConfig.mockImplementation(async () => ({ ...baseConfig(), config_version: 8 }))
mocks.probeEndpoint.mockResolvedValue({ ok: true, status: 'healthy', message: 'ok', latency_ms: 2, http_status: 200, retryable: false, checked_at: '2026-07-16T00:00:00Z', token_applied: true })
mocks.previewDelete.mockResolvedValue({ matched_count: 2, filter_summary: {}, snapshot_max_id: 10, filter_hash: 'a'.repeat(64), confirmation_token: 'opaque-confirmation', expires_at: '2026-07-16T00:05:00Z' })
mocks.deleteEventsByFilter.mockResolvedValue({ deleted_events: 2, deleted_jobs: 2 })
mocks.deleteEvent.mockResolvedValue({ deleted_events: 1, deleted_jobs: 1 })
mocks.batchDeleteEvents.mockResolvedValue({ deleted_events: 2, deleted_jobs: 2 })
})
it('starts config, runtime, groups, and events loads independently', async () => {
mocks.getRuntime.mockRejectedValue(new Error('runtime offline'))
const wrapper = mountView()
expect(mocks.getConfig).toHaveBeenCalledOnce()
expect(mocks.getRuntime).toHaveBeenCalledOnce()
expect(mocks.listGroups).toHaveBeenCalledOnce()
expect(mocks.listEvents).toHaveBeenCalledOnce()
await flushPromises()
expect(wrapper.get('[data-test="runtime"]').text()).toContain('runtime offline')
expect(wrapper.find('[data-test="endpoint"]').exists()).toBe(true)
expect(wrapper.find('[data-test="events"]').exists()).toBe(true)
})
it('requires confirmation for blocking and disables it when audit is turned off', async () => {
const wrapper = mountView()
await flushPromises()
await wrapper.get('[data-test="blocking-toggle"]').trigger('click')
expect(wrapper.find('[data-test="confirm"]').exists()).toBe(true)
await wrapper.get('[data-test="confirm-action"]').trigger('click')
expect(wrapper.get('[data-test="blocking-toggle"]').attributes('aria-checked')).toBe('true')
await wrapper.get('[data-test="enabled-toggle"]').trigger('click')
expect(wrapper.get('[data-test="enabled-toggle"]').attributes('aria-checked')).toBe('false')
expect(wrapper.get('[data-test="blocking-toggle"]').attributes('aria-checked')).toBe('false')
expect(wrapper.get('[data-test="blocking-toggle"]').attributes()).toHaveProperty('disabled')
})
it('clears plaintext token state after a successful save', async () => {
const wrapper = mountView()
await flushPromises()
await wrapper.get('[data-test="inject-secret"]').trigger('click')
expect(wrapper.text()).toContain('admin.promptAudit.saveBar.dirty')
await wrapper.get('[data-test="save-config"]').trigger('click')
await flushPromises()
expect(mocks.updateConfig).toHaveBeenCalledWith(expect.objectContaining({ endpoints: [expect.objectContaining({ token: 'PROMPT_AUDIT_CANARY_SECRET_DO_NOT_PERSIST' })] }))
const endpointProps = wrapper.getComponent(EndpointStub).props('endpoints') as Array<{ token: string }>
expect(endpointProps[0].token).toBe('')
expect(wrapper.html()).not.toContain('PROMPT_AUDIT_CANARY_SECRET_DO_NOT_PERSIST')
})
it('reports real probe progress/results and invalidates filter confirmation when filters change', async () => {
const wrapper = mountView()
await flushPromises()
await wrapper.get('[data-test="probe"]').trigger('click')
await flushPromises()
expect(mocks.probeEndpoint).toHaveBeenCalledOnce()
expect((wrapper.getComponent(EndpointStub).props('probeResults') as Record<string, unknown>)).toHaveProperty('guard-1')
await wrapper.get('[data-test="preview"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-test="dialog"]').exists()).toBe(true)
await wrapper.get('[data-test="change-filter"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-test="dialog"]').exists()).toBe(false)
})
it('uses native labeled switches and a responsive fixed save surface', async () => {
const wrapper = mountView()
await flushPromises()
const switches = wrapper.findAll('[role="switch"]')
expect(switches).toHaveLength(3)
expect(switches.every((item) => Boolean(item.attributes('aria-label')))).toBe(true)
expect(wrapper.html()).toContain('fixed inset-x-0 bottom-0')
expect(wrapper.html()).toContain('flex-wrap')
})
it('executes single, selected-batch, and preview-confirmed filter deletion flows', async () => {
const wrapper = mountView()
await flushPromises()
await wrapper.get('[data-test="delete-one"]').trigger('click')
await wrapper.get('[data-test="confirm-action"]').trigger('click')
await flushPromises()
expect(mocks.deleteEvent).toHaveBeenCalledWith(5)
await wrapper.get('[data-test="select-batch"]').trigger('click')
await wrapper.get('[data-test="delete-batch"]').trigger('click')
await wrapper.get('[data-test="confirm-action"]').trigger('click')
await flushPromises()
expect(mocks.batchDeleteEvents).toHaveBeenCalledWith([5, 6])
await wrapper.get('[data-test="preview"]').trigger('click')
await flushPromises()
await wrapper.get('[data-test="confirm-filter-delete"]').trigger('click')
await flushPromises()
expect(mocks.deleteEventsByFilter).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({
snapshot_max_id: 10,
confirmation_token: 'opaque-confirmation',
}))
})
})
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { emptyEventFilters } from '../viewModel'
const client = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn(), post: vi.fn(), delete: vi.fn() }))
vi.mock('@/api/client', () => ({ apiClient: client }))
import promptAuditAPI from '../api'
describe('Prompt Audit API', () => {
beforeEach(() => Object.values(client).forEach((mock) => mock.mockReset()))
it('uses the independent admin route namespace', async () => {
client.get.mockResolvedValue({ data: { config_version: 1 } })
await promptAuditAPI.getConfig()
expect(client.get).toHaveBeenCalledWith('/admin/prompt-audit/config')
client.get.mockResolvedValue({ data: { process_status: 'running' } })
await promptAuditAPI.getRuntime()
expect(client.get).toHaveBeenCalledWith('/admin/prompt-audit/runtime')
})
it('sends a temporary probe token only in the request and never invents response credentials', async () => {
client.post.mockResolvedValue({ data: { ok: true, token_applied: true } })
const result = await promptAuditAPI.probeEndpoint({
id: 'guard-1', name: 'Guard', protocol: 'openai_compatible', base_url: 'http://127.0.0.1:8000', model: 'guard',
token: 'api-canary-secret', clear_token: false, timeout_ms: 1000, input_limit: 1000, enabled: true, has_token: false, token_status: 'missing',
})
expect(client.post).toHaveBeenCalledWith('/admin/prompt-audit/endpoints/probe', expect.objectContaining({ endpoint: expect.objectContaining({ token: 'api-canary-secret' }) }))
expect(JSON.stringify(result)).not.toContain('api-canary-secret')
})
it('passes a server preview token through the confirmed filter-delete contract', async () => {
client.post.mockResolvedValue({ data: { deleted_events: 2, deleted_jobs: 2 } })
const filters = emptyEventFilters()
filters.start_at = '2026-07-15T00:00'
filters.end_at = '2026-07-16T00:00'
await promptAuditAPI.deleteEventsByFilter(filters, {
matched_count: 2, filter_summary: {}, snapshot_max_id: 10, filter_hash: 'a'.repeat(64), confirmation_token: 'opaque-token', expires_at: '2026-07-16T00:05:00Z',
})
expect(client.post).toHaveBeenCalledWith('/admin/prompt-audit/events/delete-by-filter', expect.objectContaining({
snapshot_max_id: 10, filter_hash: 'a'.repeat(64), confirmation_token: 'opaque-token', confirm: true,
}))
})
})
@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import EndpointPool from '../components/EndpointPool.vue'
import PolicyPanel from '../components/PolicyPanel.vue'
import EventWorkspace from '../components/EventWorkspace.vue'
import type { PromptAuditDraft, PromptAuditEndpointDraft, PromptAuditEvent } from '../types'
import { emptyEventFilters, SCANNER_CATALOG } from '../viewModel'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return { ...actual, useI18n: () => ({ locale: { value: 'en' }, t: (key: string, params?: Record<string, unknown>) => key.replace(/\{(\w+)\}/g, (_, token) => String(params?.[token] ?? `{${token}}`)) }) }
})
const DialogStub = defineComponent({ props: ['show', 'title'], emits: ['close'], template: '<div v-if="show" data-test="dialog"><slot /><slot name="footer" /></div>' })
const PaginationStub = defineComponent({ props: ['total', 'page', 'pageSize'], emits: ['update:page', 'update:pageSize'], template: '<div data-test="pagination" />' })
const endpoint = (): PromptAuditEndpointDraft => ({
id: 'guard-1', name: 'Guard One', protocol: 'openai_compatible', base_url: 'http://127.0.0.1:8000',
model: 'guard-model', timeout_ms: 3000, input_limit: 4000, enabled: true,
has_token: true, token_status: 'configured', token: '', clear_token: false,
})
describe('Prompt Audit components', () => {
beforeEach(() => vi.restoreAllMocks())
it('edits a saved endpoint with blank-secret keep, explicit clear, replacement, and probe actions', async () => {
const wrapper = mount(EndpointPool, {
props: { endpoints: [endpoint()], probeResults: {}, probingIds: [] },
global: { stubs: { BaseDialog: DialogStub } },
})
expect(wrapper.text()).toContain('admin.promptAudit.pool.configured')
const edit = wrapper.findAll('button').find((button) => button.text().includes('common.edit'))
expect(edit).toBeTruthy()
await edit!.trigger('click')
const token = wrapper.get<HTMLInputElement>('[aria-label="admin.promptAudit.pool.apiKey"]')
expect(token.element.value).toBe('')
expect(token.attributes('placeholder')).toContain('admin.promptAudit.pool.keepSecret')
await wrapper.get<HTMLInputElement>('[aria-label="admin.promptAudit.pool.clearSecret"]').setValue(true)
await token.setValue('replacement-canary')
await wrapper.get('[data-test="save-endpoint"]').trigger('click')
const updated = wrapper.emitted('update:endpoints')?.at(-1)?.[0] as PromptAuditEndpointDraft[]
expect(updated[0]).toMatchObject({ token: 'replacement-canary', clear_token: false })
const probe = wrapper.findAll('button').find((button) => button.text().includes('admin.promptAudit.pool.probe'))
await probe!.trigger('click')
expect(wrapper.emitted('probe')?.[0]?.[0]).toMatchObject({ id: 'guard-1' })
})
it('supports group search, stale configured groups, nine scanners, and bounded worker inputs', async () => {
const draft: PromptAuditDraft = {
enabled: true, blocking_enabled: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority',
worker_count: 4, queue_capacity: 100, scanners: SCANNER_CATALOG.map((item) => item.id), all_groups: false, group_ids: [1, 99],
endpoints: [endpoint()], config_version: 1, updated_at: '', updated_by: 0, change_summary: '',
}
const wrapper = mount(PolicyPanel, {
props: { draft, groups: [{ id: 1, name: 'Alpha', platform: 'openai', status: 'active' }, { id: 2, name: 'Beta', platform: 'claude', status: 'inactive' }] },
})
expect(wrapper.text()).toContain('99')
expect(wrapper.findAll('input[type="checkbox"]').filter((input) => SCANNER_CATALOG.some((scanner) => input.attributes('aria-label') === scanner.label))).toHaveLength(9)
await wrapper.get('[aria-label="admin.promptAudit.policy.searchGroups"]').setValue('Beta')
expect(wrapper.text()).toContain('Beta')
expect(wrapper.text()).not.toContain('Alpha')
await wrapper.get('[aria-label="admin.promptAudit.policy.workerCount"]').setValue('6')
const emitted = wrapper.emitted('update:draft')?.at(-1)?.[0] as PromptAuditDraft
expect(emitted.worker_count).toBe(6)
})
it('keeps identity fields separate, supports selection, and gates filter deletion on a time range', async () => {
const event: PromptAuditEvent = {
id: 1, job_id: 1, decision: 'critical', risk_level: 'critical', action: 'Block', categories: ['pii'], matched_scanners: ['pii'], scanner_scores: { pii: 1 }, scanner_evidence: { pii: 'redacted' }, scanner_backend: 'qwen3guard-openai', scanner_version: '1', guard_endpoint_id: 'guard-1', policy_id: 'priority', policy_version: 1, config_version: 1, chunk_total: 1, latency_ms: 10, issue_summaries: [], created_at: '2026-07-16T00:00:00Z',
snapshot: { request_id: 'req-1', user_id: 1, username: 'alice', user_email: 'alice@example.test', api_key_id: 2, api_key_name: 'alice-key', group_id: 3, group_name: 'Alpha', provider: 'openai', endpoint: '/v1/chat/completions', protocol: 'openai_chat', model: 'gpt-test', prompt_hash: 'a'.repeat(64), redacted_preview: 'redacted preview', prompt_length: 10, message_count: 1, stage: 'http' },
}
const wrapper = mount(EventWorkspace, {
props: { events: [event], total: 1, page: 1, pageSize: 20, filters: emptyEventFilters(), selectedIds: [], loading: false, error: '' },
global: { stubs: { Pagination: PaginationStub } },
})
expect(wrapper.text()).toContain('alice')
expect(wrapper.text()).toContain('alice@example.test')
expect(wrapper.text()).toContain('alice-key')
expect(wrapper.get('[data-test="filter-delete"]').attributes()).toHaveProperty('disabled')
await wrapper.get('[aria-label="admin.promptAudit.events.startAt"]').setValue('2026-07-15T00:00')
await wrapper.get('[aria-label="admin.promptAudit.events.endAt"]').setValue('2026-07-16T00:00')
expect(wrapper.get('[data-test="filter-delete"]').attributes()).not.toHaveProperty('disabled')
await wrapper.get('[aria-label="admin.promptAudit.events.selectEvent"]').setValue(true)
expect(wrapper.emitted('selection')?.at(-1)?.[0]).toEqual([1])
})
})
@@ -0,0 +1,40 @@
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import en from '@/i18n/locales/en'
import zh from '@/i18n/locales/zh'
const here = dirname(fileURLToPath(import.meta.url))
const read = (path: string) => readFileSync(resolve(here, path), 'utf8')
describe('Prompt Audit integration surface', () => {
it('registers an admin and risk-control guarded route', () => {
const router = read('../../../router/index.ts')
expect(router).toContain("path: '/admin/prompt-audit'")
const route = router.slice(router.indexOf("path: '/admin/prompt-audit'"), router.indexOf("path: '/admin/usage'"))
expect(route).toContain('requiresAuth: true')
expect(route).toContain('requiresAdmin: true')
expect(route).toContain('requiresRiskControl: true')
})
it('keeps the legacy content moderation route and adds both pages under an expand-only security group', () => {
const sidebar = read('../../../components/layout/AppSidebar.vue')
const group = sidebar.slice(sidebar.indexOf("path: '/admin/security-audit'"), sidebar.indexOf("path: '/admin/redeem'"))
expect(group).toContain('expandOnly: true')
expect(group).toContain("path: '/admin/risk-control'")
expect(group).toContain("path: '/admin/prompt-audit'")
})
it('keeps Prompt Audit locale trees symmetric and all operational controls named', () => {
expect(Object.keys(zh.admin.promptAudit)).toEqual(Object.keys(en.admin.promptAudit))
expect(zh.nav.securityAudit).toBeTruthy()
expect(en.nav.securityAudit).toBeTruthy()
const endpoint = read('../components/EndpointPool.vue')
const events = read('../components/EventWorkspace.vue')
expect(endpoint).toContain('aria-label')
expect(events).toContain('aria-label')
expect(events).toContain('overflow-x-auto')
expect(events).toContain('sm:grid-cols-2')
})
})
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import type { PromptAuditConfig } from '../types'
import {
buildUpdateRequest,
configToDraft,
draftFingerprint,
emptyEventFilters,
eventFilterPayload,
hasExplicitDeleteRange,
SCANNER_CATALOG,
} from '../viewModel'
const config = (): PromptAuditConfig => ({
enabled: true,
blocking_enabled: false,
store_pass_events: false,
effective_mode: 'async_audit',
strategy: 'priority',
worker_count: 4,
queue_capacity: 100,
scanners: SCANNER_CATALOG.map((item) => item.id),
all_groups: true,
group_ids: [],
endpoints: [{
id: 'guard-1', name: 'Guard One', protocol: 'openai_compatible', base_url: 'http://127.0.0.1:8000',
model: 'sileader/qwen3guard:0.6b', timeout_ms: 3000, input_limit: 4000, enabled: true,
has_token: true, token_status: 'configured',
}],
config_version: 7,
updated_at: '2026-07-16T00:00:00Z',
updated_by: 1,
change_summary: '{}',
})
describe('Prompt Audit view model', () => {
it('normalizes legacy null collections from the public config', () => {
const legacy = { ...config(), group_ids: null, scanners: null, endpoints: null } as unknown as PromptAuditConfig
expect(configToDraft(legacy)).toMatchObject({ group_ids: [], scanners: [], endpoints: [] })
})
it('models all nine official input scanners', () => {
expect(SCANNER_CATALOG).toHaveLength(9)
expect(SCANNER_CATALOG.map((item) => item.id)).toContain('suicide_and_self_harm')
})
it('keeps, replaces, or explicitly clears a saved token without copying plaintext from the server', () => {
const draft = configToDraft(config())
expect(draft.endpoints[0].token).toBe('')
expect(buildUpdateRequest(draft).endpoints[0]).toMatchObject({ token: undefined, clear_token: false })
draft.endpoints[0].token = 'temporary-canary-token'
expect(buildUpdateRequest(draft).endpoints[0]).toMatchObject({ token: 'temporary-canary-token', clear_token: false })
draft.endpoints[0].token = ''
draft.endpoints[0].clear_token = true
expect(buildUpdateRequest(draft).endpoints[0]).toMatchObject({ token: undefined, clear_token: true })
})
it('tracks dirty state from the full normalized save payload', () => {
const original = configToDraft(config())
const changed = configToDraft(config())
expect(draftFingerprint(changed)).toBe(draftFingerprint(original))
changed.queue_capacity += 1
expect(draftFingerprint(changed)).not.toBe(draftFingerprint(original))
})
it('requires a valid explicit range and sends canonical ISO timestamps for filter deletion', () => {
const filters = emptyEventFilters()
expect(hasExplicitDeleteRange(filters)).toBe(false)
filters.start_at = '2026-07-15T10:00'
filters.end_at = '2026-07-16T10:00'
filters.group_id = '9'
expect(hasExplicitDeleteRange(filters)).toBe(true)
expect(eventFilterPayload(filters)).toMatchObject({
group_id: 9,
start_at: new Date(filters.start_at).toISOString(),
end_at: new Date(filters.end_at).toISOString(),
})
})
})
+120
View File
@@ -0,0 +1,120 @@
import { apiClient } from '@/api/client'
import type {
PromptAuditConfig,
PromptAuditEvent,
PromptAuditGroup,
PromptAuditRuntime,
PromptAuditUpdateRequest,
PromptDeletePreview,
PromptDeleteResult,
PromptEventFilters,
PromptEventPage,
PromptProbeResult,
PromptAuditEndpointDraft,
} from './types'
import { eventFilterPayload, eventQueryParams } from './viewModel'
const basePath = '/admin/prompt-audit'
export async function getConfig(): Promise<PromptAuditConfig> {
const { data } = await apiClient.get<PromptAuditConfig>(`${basePath}/config`)
return data
}
export async function updateConfig(payload: PromptAuditUpdateRequest): Promise<PromptAuditConfig> {
const { data } = await apiClient.put<PromptAuditConfig>(`${basePath}/config`, payload)
return data
}
export async function probeEndpoint(endpoint: PromptAuditEndpointDraft): Promise<PromptProbeResult> {
const { data } = await apiClient.post<PromptProbeResult>(`${basePath}/endpoints/probe`, {
endpoint: {
id: endpoint.id,
name: endpoint.name,
protocol: 'openai_compatible',
base_url: endpoint.base_url,
model: endpoint.model,
token: endpoint.token || undefined,
timeout_ms: endpoint.timeout_ms,
input_limit: endpoint.input_limit,
enabled: endpoint.enabled,
},
})
return data
}
export async function getRuntime(): Promise<PromptAuditRuntime> {
const { data } = await apiClient.get<PromptAuditRuntime>(`${basePath}/runtime`)
return data
}
export async function listEvents(
filters: PromptEventFilters,
page: number,
pageSize: number,
): Promise<PromptEventPage> {
const { data } = await apiClient.get<PromptEventPage>(`${basePath}/events`, {
params: { page, page_size: pageSize, ...eventQueryParams(filters) },
})
return data
}
export async function getEvent(id: number): Promise<PromptAuditEvent> {
const { data } = await apiClient.get<PromptAuditEvent>(`${basePath}/events/${id}`)
return data
}
export async function deleteEvent(id: number): Promise<PromptDeleteResult> {
const { data } = await apiClient.delete<PromptDeleteResult>(`${basePath}/events/${id}`)
return data
}
export async function batchDeleteEvents(ids: number[]): Promise<PromptDeleteResult> {
const { data } = await apiClient.post<PromptDeleteResult>(`${basePath}/events/batch-delete`, { ids })
return data
}
export async function previewDelete(filters: PromptEventFilters): Promise<PromptDeletePreview> {
const { data } = await apiClient.post<PromptDeletePreview>(
`${basePath}/events/delete-preview`,
eventFilterPayload(filters),
)
return data
}
export async function deleteEventsByFilter(
filters: PromptEventFilters,
preview: PromptDeletePreview,
): Promise<PromptDeleteResult> {
const { data } = await apiClient.post<PromptDeleteResult>(`${basePath}/events/delete-by-filter`, {
filter: eventFilterPayload(filters),
snapshot_max_id: preview.snapshot_max_id,
filter_hash: preview.filter_hash,
confirmation_token: preview.confirmation_token,
confirm: true,
})
return data
}
export async function listGroups(): Promise<PromptAuditGroup[]> {
const { data } = await apiClient.get<PromptAuditGroup[]>('/admin/groups/all', {
params: { include_inactive: true },
})
return data
}
export const promptAuditAPI = {
getConfig,
updateConfig,
probeEndpoint,
getRuntime,
listEvents,
getEvent,
deleteEvent,
batchDeleteEvents,
previewDelete,
deleteEventsByFilter,
listGroups,
}
export default promptAuditAPI
@@ -0,0 +1,172 @@
<template>
<section aria-labelledby="prompt-pool-title" class="border-b border-gray-200 py-6 dark:border-dark-700">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 id="prompt-pool-title" class="text-base font-semibold text-gray-950 dark:text-white">{{ t('admin.promptAudit.pool.title') }}</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-300">{{ t('admin.promptAudit.pool.description') }}</p>
</div>
<button type="button" class="btn btn-primary btn-sm" data-test="add-endpoint" @click="openCreate">
{{ t('admin.promptAudit.pool.add') }}
</button>
</div>
<div v-if="endpoints.length === 0" class="mt-5 rounded-lg border border-dashed border-gray-300 px-5 py-8 text-center text-sm text-gray-500 dark:border-dark-600 dark:text-dark-300">
{{ t('admin.promptAudit.pool.empty') }}
</div>
<div v-else class="mt-5 overflow-x-auto">
<table class="min-w-full text-left text-sm">
<thead class="border-b border-gray-200 text-xs uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:text-dark-400">
<tr>
<th class="px-3 py-2 font-medium">{{ t('admin.promptAudit.pool.node') }}</th>
<th class="px-3 py-2 font-medium">{{ t('admin.promptAudit.pool.model') }}</th>
<th class="px-3 py-2 font-medium">{{ t('admin.promptAudit.pool.limits') }}</th>
<th class="px-3 py-2 font-medium">{{ t('admin.promptAudit.pool.credential') }}</th>
<th class="px-3 py-2 text-right font-medium">{{ t('admin.promptAudit.common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-dark-800">
<tr v-for="endpoint in endpoints" :key="endpoint.id" :data-test="`endpoint-${endpoint.id}`">
<td class="px-3 py-3">
<div class="flex items-center gap-2">
<button
type="button"
role="switch"
:aria-checked="endpoint.enabled"
:aria-label="t('admin.promptAudit.pool.toggleNode', { name: endpoint.name })"
class="relative h-5 w-9 rounded-full transition-colors"
:class="endpoint.enabled ? 'bg-primary-600' : 'bg-gray-300 dark:bg-dark-600'"
@click="toggleEndpoint(endpoint.id)"
>
<span class="absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform" :class="endpoint.enabled ? 'translate-x-4' : 'translate-x-0.5'" />
</button>
<div class="min-w-0">
<p class="font-medium text-gray-900 dark:text-white">{{ endpoint.name }}</p>
<p class="max-w-xs truncate text-xs text-gray-500 dark:text-dark-400">{{ endpoint.base_url }}</p>
</div>
</div>
</td>
<td class="px-3 py-3 text-gray-700 dark:text-dark-200">{{ endpoint.model }}</td>
<td class="whitespace-nowrap px-3 py-3 text-gray-600 dark:text-dark-300">{{ endpoint.timeout_ms }} ms · {{ endpoint.input_limit }} chars</td>
<td class="px-3 py-3">
<span :class="hasCredential(endpoint) ? 'text-emerald-600 dark:text-emerald-300' : 'text-gray-500 dark:text-dark-400'">
{{ hasCredential(endpoint) ? t('admin.promptAudit.pool.configured') : t('admin.promptAudit.pool.missing') }}
</span>
<p v-if="probingIds.includes(endpoint.id)" class="mt-1 text-xs text-primary-600 dark:text-primary-300">
{{ t('admin.promptAudit.pool.probeProgress') }}
</p>
<p v-if="probeResults[endpoint.id]" class="mt-1 text-xs" :class="probeResults[endpoint.id].ok ? 'text-emerald-600' : 'text-red-600'">
{{ t('admin.promptAudit.pool.probeResult', { status: probeResults[endpoint.id].status, http: probeResults[endpoint.id].http_status || '—', latency: probeResults[endpoint.id].latency_ms }) }}
· {{ probeResults[endpoint.id].message }}
</p>
</td>
<td class="whitespace-nowrap px-3 py-3 text-right">
<button type="button" class="btn btn-ghost btn-sm" :disabled="probingIds.includes(endpoint.id)" @click="$emit('probe', endpoint)">
{{ probingIds.includes(endpoint.id) ? t('admin.promptAudit.pool.probing') : t('admin.promptAudit.pool.probe') }}
</button>
<button type="button" class="btn btn-ghost btn-sm" @click="openEdit(endpoint)">{{ t('common.edit') }}</button>
<button type="button" class="btn btn-ghost btn-sm text-red-600" @click="removeEndpoint(endpoint)">{{ t('common.delete') }}</button>
</td>
</tr>
</tbody>
</table>
</div>
<BaseDialog :show="Boolean(editing)" :title="editingIndex < 0 ? t('admin.promptAudit.pool.add') : t('admin.promptAudit.pool.edit')" width="wide" @close="closeEditor">
<form v-if="editing" class="grid gap-4 sm:grid-cols-2" @submit.prevent="saveEditor">
<label class="space-y-1 text-sm text-gray-700 dark:text-dark-200">
<span>{{ t('admin.promptAudit.pool.name') }}</span>
<input v-model="editing.name" class="input w-full" required :aria-label="t('admin.promptAudit.pool.name')" />
</label>
<label class="space-y-1 text-sm text-gray-700 dark:text-dark-200">
<span>{{ t('admin.promptAudit.pool.id') }}</span>
<input v-model="editing.id" class="input w-full" required :disabled="editingIndex >= 0" :aria-label="t('admin.promptAudit.pool.id')" />
</label>
<label class="space-y-1 text-sm text-gray-700 dark:text-dark-200 sm:col-span-2">
<span>{{ t('admin.promptAudit.pool.baseUrl') }}</span>
<input v-model="editing.base_url" class="input w-full" required inputmode="url" :aria-label="t('admin.promptAudit.pool.baseUrl')" />
</label>
<label class="space-y-1 text-sm text-gray-700 dark:text-dark-200 sm:col-span-2">
<span>{{ t('admin.promptAudit.pool.apiKey') }}</span>
<input v-model="editing.token" class="input w-full" type="password" autocomplete="new-password" :placeholder="editing.has_token ? t('admin.promptAudit.pool.keepSecret') : ''" :aria-label="t('admin.promptAudit.pool.apiKey')" />
<span class="block text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.pool.secretHint') }}</span>
</label>
<label v-if="editing.has_token" class="flex items-center gap-2 text-sm text-red-600 dark:text-red-300 sm:col-span-2">
<input v-model="editing.clear_token" type="checkbox" :aria-label="t('admin.promptAudit.pool.clearSecret')" />
{{ t('admin.promptAudit.pool.clearSecret') }}
</label>
<label class="space-y-1 text-sm text-gray-700 dark:text-dark-200 sm:col-span-2">
<span>{{ t('admin.promptAudit.pool.model') }}</span>
<input v-model="editing.model" class="input w-full" :aria-label="t('admin.promptAudit.pool.model')" />
</label>
<label class="space-y-1 text-sm text-gray-700 dark:text-dark-200">
<span>{{ t('admin.promptAudit.pool.timeout') }}</span>
<input v-model.number="editing.timeout_ms" class="input w-full" type="number" min="100" max="30000" required :aria-label="t('admin.promptAudit.pool.timeout')" />
</label>
<label class="space-y-1 text-sm text-gray-700 dark:text-dark-200">
<span>{{ t('admin.promptAudit.pool.inputLimit') }}</span>
<input v-model.number="editing.input_limit" class="input w-full" type="number" min="128" max="100000" required :aria-label="t('admin.promptAudit.pool.inputLimit')" />
</label>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" @click="closeEditor">{{ t('common.cancel') }}</button>
<button type="button" class="btn btn-primary" data-test="save-endpoint" @click="saveEditor">{{ t('common.save') }}</button>
</div>
</template>
</BaseDialog>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import type { PromptAuditEndpointDraft, PromptProbeResult } from '../types'
import { cloneData, createDefaultEndpoint } from '../viewModel'
const props = defineProps<{
endpoints: PromptAuditEndpointDraft[]
probeResults: Record<string, PromptProbeResult>
probingIds: string[]
}>()
const emit = defineEmits<{
(event: 'update:endpoints', value: PromptAuditEndpointDraft[]): void
(event: 'probe', endpoint: PromptAuditEndpointDraft): void
}>()
const { t } = useI18n()
const editing = ref<PromptAuditEndpointDraft | null>(null)
const editingIndex = ref(-1)
function openCreate() {
editingIndex.value = -1
editing.value = createDefaultEndpoint(props.endpoints.length + 1)
}
function openEdit(endpoint: PromptAuditEndpointDraft) {
editingIndex.value = props.endpoints.findIndex((item) => item.id === endpoint.id)
editing.value = cloneData(endpoint)
}
function closeEditor() {
editing.value = null
editingIndex.value = -1
}
function saveEditor() {
if (!editing.value?.id.trim() || !editing.value.name.trim() || !editing.value.base_url.trim()) return
const next = props.endpoints.map((item) => cloneData(item))
const value = cloneData(editing.value)
if (value.token.trim()) value.clear_token = false
if (editingIndex.value < 0) next.push(value)
else next.splice(editingIndex.value, 1, value)
emit('update:endpoints', next)
closeEditor()
}
function toggleEndpoint(id: string) {
emit('update:endpoints', props.endpoints.map((item) => item.id === id ? { ...item, enabled: !item.enabled } : cloneData(item)))
}
function removeEndpoint(endpoint: PromptAuditEndpointDraft) {
if (!window.confirm(t('admin.promptAudit.pool.deleteConfirm', { name: endpoint.name }))) return
emit('update:endpoints', props.endpoints.filter((item) => item.id !== endpoint.id).map((item) => cloneData(item)))
}
function hasCredential(endpoint: PromptAuditEndpointDraft): boolean {
return Boolean(endpoint.token.trim() || (endpoint.has_token && !endpoint.clear_token))
}
</script>
@@ -0,0 +1,66 @@
<template>
<BaseDialog :show="show" :title="t('admin.promptAudit.events.detailTitle')" width="extra-wide" @close="$emit('close')">
<div v-if="loading" class="py-12 text-center text-sm text-gray-500" aria-busy="true">{{ t('common.loading') }}</div>
<div v-else-if="event" class="space-y-5">
<div class="flex flex-wrap gap-2 border-b border-gray-200 pb-3 dark:border-dark-700" role="tablist">
<button v-for="tab in tabs" :key="tab" type="button" role="tab" :aria-selected="activeTab === tab" class="rounded-md px-3 py-1.5 text-sm" :class="activeTab === tab ? 'bg-primary-50 text-primary-700 dark:bg-primary-950/40 dark:text-primary-300' : 'text-gray-600 dark:text-dark-300'" @click="activeTab = tab">
{{ t(`admin.promptAudit.events.tabs.${tab}`) }}
</button>
</div>
<div v-if="activeTab === 'summary'" class="grid gap-5 lg:grid-cols-2">
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.events.redactedPreview') }}</h4>
<pre class="mt-2 max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-gray-50 p-4 text-sm text-gray-700 dark:bg-dark-900 dark:text-dark-200">{{ event.snapshot.redacted_preview || '—' }}</pre>
</div>
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
<dt class="text-gray-500">{{ t('admin.promptAudit.events.decision') }}</dt><dd class="font-medium text-gray-900 dark:text-white">{{ event.decision }} · {{ event.action }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.user') }}</dt><dd>{{ event.snapshot.username || '—' }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.email') }}</dt><dd>{{ event.snapshot.user_email || '—' }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.apiKey') }}</dt><dd>{{ event.snapshot.api_key_name || '—' }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.group') }}</dt><dd>{{ event.snapshot.group_name || '—' }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.model') }}</dt><dd>{{ event.snapshot.model || '—' }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.categories') }}</dt><dd>{{ event.categories.join(', ') || '—' }}</dd>
</dl>
</div>
<div v-else-if="activeTab === 'risks'" class="space-y-3">
<article v-for="issue in event.issue_summaries" :key="`${issue.scanner_id}-${issue.code}`" class="border-l-2 border-red-400 pl-4">
<div class="flex flex-wrap items-center gap-2">
<h4 class="font-medium text-gray-900 dark:text-white">{{ issue.title }}</h4>
<span class="text-xs uppercase text-red-600 dark:text-red-300">{{ issue.severity_label }} · {{ issue.action_label }}</span>
</div>
<p class="mt-1 text-sm text-gray-600 dark:text-dark-300">{{ issue.description }}</p>
<p class="mt-2 break-words text-xs text-gray-500 dark:text-dark-400">{{ issue.scanner_id }} · {{ issue.code }} · {{ issue.score }} · {{ issue.evidence }}</p>
</article>
<p v-if="event.issue_summaries.length === 0" class="py-8 text-center text-sm text-gray-500">{{ t('admin.promptAudit.events.noRisks') }}</p>
</div>
<dl v-else class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm">
<dt class="text-gray-500">Request ID</dt><dd class="break-all font-mono">{{ event.snapshot.request_id || '—' }}</dd>
<dt class="text-gray-500">Prompt SHA-256</dt><dd class="break-all font-mono">{{ event.snapshot.prompt_hash }}</dd>
<dt class="text-gray-500">Scanner</dt><dd>{{ event.scanner_backend }} · {{ event.scanner_version }}</dd>
<dt class="text-gray-500">Policy</dt><dd>{{ event.policy_id }} · v{{ event.policy_version }}</dd>
<dt class="text-gray-500">Guard endpoint</dt><dd>{{ event.guard_endpoint_id }}</dd>
<dt class="text-gray-500">Config</dt><dd>v{{ event.config_version }}</dd>
<dt class="text-gray-500">Chunks</dt><dd>{{ event.chunk_total }}</dd>
<dt class="text-gray-500">Latency</dt><dd>{{ event.latency_ms }} ms</dd>
<dt class="text-gray-500">Protocol</dt><dd>{{ event.snapshot.protocol }} · {{ event.snapshot.endpoint }} · {{ event.snapshot.stage }}</dd>
</dl>
</div>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import type { PromptAuditEvent } from '../types'
const props = defineProps<{ show: boolean; event: PromptAuditEvent | null; loading: boolean }>()
defineEmits<{ (event: 'close'): void }>()
const { t } = useI18n()
const tabs = ['summary', 'risks', 'technical'] as const
const activeTab = ref<(typeof tabs)[number]>('summary')
watch(() => props.event?.id, () => { activeTab.value = 'summary' })
</script>
@@ -0,0 +1,186 @@
<template>
<section aria-labelledby="prompt-events-title" class="py-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 id="prompt-events-title" class="text-base font-semibold text-gray-950 dark:text-white">{{ t('admin.promptAudit.events.title') }}</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-300">{{ t('admin.promptAudit.events.description') }}</p>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" class="btn btn-secondary btn-sm" :disabled="selectedIds.length === 0" @click="$emit('batch-delete')">
{{ t('admin.promptAudit.events.deleteSelected', { count: selectedIds.length }) }}
</button>
<button type="button" class="btn btn-danger btn-sm" :disabled="!hasExplicitDeleteRange(localFilters)" data-test="filter-delete" @click="$emit('preview-delete')">
{{ t('admin.promptAudit.events.deleteByFilter') }}
</button>
</div>
</div>
<form class="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5" @submit.prevent="applyFilters">
<label class="text-xs text-gray-600 dark:text-dark-300">
<span>{{ t('admin.promptAudit.events.decision') }}</span>
<select v-model="localFilters.decision" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.decision')" @change="filtersChanged">
<option value="">{{ t('common.all') }}</option><option value="pass">pass</option><option value="flag">flag</option><option value="critical">critical</option>
</select>
</label>
<label class="text-xs text-gray-600 dark:text-dark-300">
<span>{{ t('admin.promptAudit.events.risk') }}</span>
<select v-model="localFilters.risk_level" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.risk')" @change="filtersChanged">
<option value="">{{ t('common.all') }}</option><option value="low">low</option><option value="medium">medium</option><option value="high">high</option><option value="critical">critical</option>
</select>
</label>
<FilterInput v-model="localFilters.endpoint" :label="t('admin.promptAudit.events.endpoint')" @change="filtersChanged" />
<FilterInput v-model="localFilters.group_id" :label="t('admin.promptAudit.events.groupId')" type="number" @change="filtersChanged" />
<FilterInput v-model="localFilters.user_id" :label="t('admin.promptAudit.events.userId')" type="number" @change="filtersChanged" />
<FilterInput v-model="localFilters.api_key_id" :label="t('admin.promptAudit.events.apiKeyId')" type="number" @change="filtersChanged" />
<FilterInput v-model="localFilters.request_id" label="Request ID" @change="filtersChanged" />
<FilterInput v-model="localFilters.prompt_hash" label="Prompt SHA-256" @change="filtersChanged" />
<FilterInput v-model="localFilters.keyword" :label="t('admin.promptAudit.events.keyword')" @change="filtersChanged" />
<label class="text-xs text-gray-600 dark:text-dark-300">
<span>{{ t('admin.promptAudit.events.startAt') }}</span>
<input v-model="localFilters.start_at" type="datetime-local" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.startAt')" @change="filtersChanged" />
</label>
<label class="text-xs text-gray-600 dark:text-dark-300">
<span>{{ t('admin.promptAudit.events.endAt') }}</span>
<input v-model="localFilters.end_at" type="datetime-local" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.endAt')" @change="filtersChanged" />
</label>
<div class="flex items-end gap-2 sm:col-span-2">
<button type="submit" class="btn btn-primary btn-sm">{{ t('common.search') }}</button>
<button type="button" class="btn btn-ghost btn-sm" @click="resetFilters">{{ t('common.reset') }}</button>
</div>
</form>
<p class="mt-2 text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.events.deleteRangeHint') }}</p>
<div v-if="error" role="alert" class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">{{ error }}</div>
<div class="mt-5 overflow-x-auto rounded-lg border border-gray-200 dark:border-dark-700">
<table class="min-w-[1120px] w-full text-left text-sm">
<thead class="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-dark-900 dark:text-dark-400">
<tr>
<th class="w-10 px-3 py-3"><input type="checkbox" :checked="allSelected" :aria-label="t('admin.promptAudit.events.selectAll')" @change="toggleAll" /></th>
<th class="px-3 py-3 font-medium">{{ t('admin.promptAudit.events.time') }}</th>
<th class="px-3 py-3 font-medium">{{ t('admin.promptAudit.events.identity') }}</th>
<th class="px-3 py-3 font-medium">{{ t('admin.promptAudit.events.group') }}</th>
<th class="px-3 py-3 font-medium">{{ t('admin.promptAudit.events.route') }}</th>
<th class="px-3 py-3 font-medium">{{ t('admin.promptAudit.events.result') }}</th>
<th class="px-3 py-3 font-medium">{{ t('admin.promptAudit.events.preview') }}</th>
<th class="px-3 py-3 text-right font-medium">{{ t('admin.promptAudit.common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white dark:divide-dark-800 dark:bg-dark-850">
<tr v-if="loading"><td colspan="8" class="px-4 py-12 text-center text-gray-500" aria-busy="true">{{ t('common.loading') }}</td></tr>
<tr v-else-if="events.length === 0"><td colspan="8" class="px-4 py-12 text-center text-gray-500">{{ t('admin.promptAudit.events.empty') }}</td></tr>
<tr v-for="event in events" v-else :key="event.id" :data-test="`event-${event.id}`" class="align-top hover:bg-gray-50/70 dark:hover:bg-dark-800/70">
<td class="px-3 py-3"><input type="checkbox" :checked="selectedIds.includes(event.id)" :aria-label="t('admin.promptAudit.events.selectEvent', { id: event.id })" @change="toggleOne(event.id)" /></td>
<td class="whitespace-nowrap px-3 py-3 text-xs text-gray-600 dark:text-dark-300">{{ formatDate(event.created_at) }}</td>
<td class="px-3 py-3">
<CopyLine :label="t('admin.promptAudit.events.user')" :value="event.snapshot.username" />
<CopyLine :label="t('admin.promptAudit.events.email')" :value="event.snapshot.user_email" />
<CopyLine :label="t('admin.promptAudit.events.apiKey')" :value="event.snapshot.api_key_name" />
</td>
<td class="px-3 py-3 text-gray-700 dark:text-dark-200">{{ event.snapshot.group_name || '—' }}</td>
<td class="px-3 py-3">
<p class="font-medium text-gray-900 dark:text-white">{{ event.snapshot.endpoint }}</p>
<p class="mt-1 text-xs text-gray-500">{{ event.snapshot.model }} · {{ event.snapshot.protocol }}</p>
</td>
<td class="px-3 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="decisionClass(event.decision)">{{ event.decision }} · {{ event.risk_level }}</span>
<p class="mt-2 max-w-48 truncate text-xs text-gray-500">{{ event.categories.join(', ') || '—' }}</p>
</td>
<td class="max-w-xs px-3 py-3"><p class="line-clamp-2 break-words text-gray-600 dark:text-dark-300">{{ event.snapshot.redacted_preview || '—' }}</p></td>
<td class="whitespace-nowrap px-3 py-3 text-right">
<button type="button" class="btn btn-ghost btn-sm" @click="$emit('view', event.id)">{{ t('common.view') }}</button>
<button type="button" class="btn btn-ghost btn-sm text-red-600" @click="$emit('delete', event.id)">{{ t('common.delete') }}</button>
</td>
</tr>
</tbody>
</table>
<Pagination :total="total" :page="page" :page-size="pageSize" @update:page="$emit('page', $event)" @update:page-size="$emit('page-size', $event)" />
</div>
</section>
</template>
<script setup lang="ts">
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'
const props = defineProps<{
events: PromptAuditEvent[]; total: number; page: number; pageSize: number
filters: PromptEventFilters; selectedIds: number[]; loading: boolean; error: string
}>()
const emit = defineEmits<{
(event: 'filters-change', value: PromptEventFilters): void
(event: 'search', value: PromptEventFilters): void
(event: 'selection', value: number[]): void
(event: 'page', value: number): void
(event: 'page-size', value: number): void
(event: 'view', id: number): void
(event: 'delete', id: number): void
(event: 'batch-delete'): void
(event: 'preview-delete'): void
}>()
const { t, locale } = useI18n()
const localFilters = reactive<PromptEventFilters>(cloneData(props.filters))
watch(() => props.filters, (value) => Object.assign(localFilters, cloneData(value)), { deep: true })
const allSelected = computed(() => props.events.length > 0 && props.events.every((event) => props.selectedIds.includes(event.id)))
const FilterInput = defineComponent({
props: { modelValue: { type: String, required: true }, label: { type: String, required: true }, type: { type: String, default: 'text' } },
emits: ['update:modelValue', 'change'],
setup(componentProps, { emit: componentEmit }) {
return () => h('label', { class: 'text-xs text-gray-600 dark:text-dark-300' }, [
h('span', componentProps.label),
h('input', {
value: componentProps.modelValue, type: componentProps.type, class: 'input mt-1 w-full', 'aria-label': componentProps.label,
onInput: (event: Event) => componentEmit('update:modelValue', (event.target as HTMLInputElement).value),
onChange: () => componentEmit('change'),
}),
])
},
})
const CopyLine = defineComponent({
props: { label: { type: String, required: true }, value: { type: String, default: '' } },
setup(componentProps) {
return () => h('div', { class: 'flex max-w-56 items-center gap-1 text-xs' }, [
h('span', { class: 'w-16 flex-none text-gray-500' }, componentProps.label),
h('span', { class: 'min-w-0 flex-1 truncate text-gray-800 dark:text-dark-100' }, componentProps.value || '—'),
componentProps.value ? h('button', {
type: 'button', class: 'text-primary-600 hover:underline', 'aria-label': `${t('common.copy')} ${componentProps.label}`,
onClick: () => navigator.clipboard?.writeText(componentProps.value),
}, t('common.copy')) : null,
])
},
})
function filtersChanged() {
emit('filters-change', cloneData(localFilters))
}
function applyFilters() {
const value = cloneData(localFilters)
emit('filters-change', value)
emit('search', value)
}
function resetFilters() {
Object.assign(localFilters, emptyEventFilters())
applyFilters()
}
function toggleOne(id: number) {
const selected = new Set(props.selectedIds)
if (selected.has(id)) selected.delete(id)
else selected.add(id)
emit('selection', [...selected])
}
function toggleAll() {
emit('selection', allSelected.value ? [] : props.events.map((event) => event.id))
}
function formatDate(value: string): string {
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'short', timeStyle: 'medium' }).format(new Date(value))
}
function decisionClass(decision: string): string {
if (decision === 'critical') return 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
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'
}
</script>
@@ -0,0 +1,108 @@
<template>
<section aria-labelledby="prompt-policy-title" class="border-b border-gray-200 py-6 dark:border-dark-700">
<div>
<h2 id="prompt-policy-title" class="text-base font-semibold text-gray-950 dark:text-white">{{ t('admin.promptAudit.policy.title') }}</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-300">{{ t('admin.promptAudit.policy.description') }}</p>
</div>
<div class="mt-5 grid gap-7 lg:grid-cols-[minmax(0,1fr)_minmax(280px,0.55fr)]">
<div>
<fieldset>
<legend class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.policy.scope') }}</legend>
<div class="mt-3 flex flex-wrap gap-5 text-sm text-gray-700 dark:text-dark-200">
<label class="flex items-center gap-2">
<input type="radio" name="prompt-audit-scope" :checked="draft.all_groups" @change="patch({ all_groups: true, group_ids: [] })" />
{{ t('admin.promptAudit.policy.allGroups') }}
</label>
<label class="flex items-center gap-2">
<input type="radio" name="prompt-audit-scope" :checked="!draft.all_groups" @change="patch({ all_groups: false })" />
{{ t('admin.promptAudit.policy.selectedGroups') }}
</label>
</div>
</fieldset>
<div v-if="!draft.all_groups" class="mt-4">
<label class="block text-sm text-gray-700 dark:text-dark-200">
<span>{{ t('admin.promptAudit.policy.searchGroups') }}</span>
<input v-model="groupSearch" type="search" class="input mt-1.5 w-full" :aria-label="t('admin.promptAudit.policy.searchGroups')" />
</label>
<div class="mt-3 max-h-52 overflow-y-auto rounded-lg border border-gray-200 p-2 dark:border-dark-700">
<label v-for="group in filteredGroups" :key="group.id" class="flex cursor-pointer items-center justify-between gap-3 rounded-md px-2 py-2 text-sm hover:bg-gray-50 dark:hover:bg-dark-800">
<span class="flex items-center gap-2 text-gray-800 dark:text-dark-100">
<input type="checkbox" :checked="draft.group_ids.includes(group.id)" @change="toggleGroup(group.id)" />
{{ group.name }}
</span>
<span class="text-xs text-gray-500 dark:text-dark-400">{{ group.platform }} · {{ group.status }}</span>
</label>
<p v-if="filteredGroups.length === 0" class="px-2 py-4 text-center text-sm text-gray-500">{{ t('admin.promptAudit.policy.noGroups') }}</p>
</div>
<div v-if="missingGroupIds.length" class="mt-3 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
{{ t('admin.promptAudit.policy.missingGroups') }}: {{ missingGroupIds.join(', ') }}
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.policy.selectedCount', { count: draft.group_ids.length }) }}</p>
</div>
<fieldset class="mt-6">
<legend class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.policy.scanners') }}</legend>
<div class="mt-3 grid gap-2 sm:grid-cols-2">
<label v-for="scanner in SCANNER_CATALOG" :key="scanner.id" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:text-dark-200 dark:hover:bg-dark-800">
<input type="checkbox" :checked="draft.scanners.includes(scanner.id)" :aria-label="scanner.label" @change="toggleScanner(scanner.id)" />
<span>{{ scanner.label }}</span>
</label>
</div>
</fieldset>
</div>
<div class="space-y-5 border-gray-200 lg:border-l lg:pl-7 dark:border-dark-700">
<label class="block text-sm text-gray-700 dark:text-dark-200">
<span>{{ t('admin.promptAudit.policy.workerCount') }}</span>
<input :value="draft.worker_count" type="number" min="1" max="32" class="input mt-1.5 w-full" :aria-label="t('admin.promptAudit.policy.workerCount')" @input="patch({ worker_count: Number(($event.target as HTMLInputElement).value) })" />
</label>
<label class="block text-sm text-gray-700 dark:text-dark-200">
<span>{{ t('admin.promptAudit.policy.queueCapacity') }}</span>
<input :value="draft.queue_capacity" type="number" min="1" max="100000" class="input mt-1.5 w-full" :aria-label="t('admin.promptAudit.policy.queueCapacity')" @input="patch({ queue_capacity: Number(($event.target as HTMLInputElement).value) })" />
</label>
<div class="rounded-lg bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:bg-dark-800 dark:text-dark-300">
<p class="font-medium text-gray-800 dark:text-dark-100">{{ t('admin.promptAudit.policy.strategy') }}</p>
<p class="mt-1">priority · {{ t('admin.promptAudit.policy.strategyHint') }}</p>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { PromptAuditDraft, PromptAuditGroup } from '../types'
import { cloneData, SCANNER_CATALOG } from '../viewModel'
const props = defineProps<{ draft: PromptAuditDraft; groups: PromptAuditGroup[] }>()
const emit = defineEmits<{ (event: 'update:draft', value: PromptAuditDraft): void }>()
const { t } = useI18n()
const groupSearch = ref('')
const filteredGroups = computed(() => {
const query = groupSearch.value.trim().toLowerCase()
if (!query) return props.groups
return props.groups.filter((group) => `${group.name} ${group.id} ${group.platform}`.toLowerCase().includes(query))
})
const knownGroupIds = computed(() => new Set(props.groups.map((group) => group.id)))
const missingGroupIds = computed(() => props.draft.group_ids.filter((id) => !knownGroupIds.value.has(id)))
function patch(value: Partial<PromptAuditDraft>) {
emit('update:draft', { ...cloneData(props.draft), ...value })
}
function toggleGroup(id: number) {
const selected = new Set(props.draft.group_ids)
if (selected.has(id)) selected.delete(id)
else selected.add(id)
patch({ group_ids: [...selected].sort((a, b) => a - b) })
}
function toggleScanner(id: string) {
const selected = new Set(props.draft.scanners)
if (selected.has(id)) selected.delete(id)
else selected.add(id)
patch({ scanners: SCANNER_CATALOG.map((item) => item.id).filter((item) => selected.has(item)) })
}
</script>
@@ -0,0 +1,119 @@
<template>
<section aria-labelledby="prompt-runtime-title" class="border-b border-gray-200 pb-6 dark:border-dark-700">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 id="prompt-runtime-title" class="text-base font-semibold text-gray-950 dark:text-white">
{{ t('admin.promptAudit.runtime.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-300">
{{ t('admin.promptAudit.runtime.description') }}
</p>
</div>
<button type="button" class="btn btn-secondary btn-sm" :disabled="loading" @click="$emit('refresh')">
{{ t('admin.promptAudit.actions.refresh') }}
</button>
</div>
<div v-if="error" role="alert" class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">
{{ error }}
</div>
<div v-else-if="loading && !runtime" class="mt-5 grid grid-cols-2 gap-4 lg:grid-cols-6" aria-busy="true">
<div v-for="index in 6" :key="index" class="h-14 animate-pulse rounded-lg bg-gray-100 dark:bg-dark-800" />
</div>
<template v-else-if="runtime">
<dl class="mt-5 grid grid-cols-2 gap-x-6 gap-y-5 lg:grid-cols-6">
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.process') }}</dt>
<dd class="mt-1 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<span class="h-2 w-2 rounded-full" :class="statusDot(runtime.process_status)" />
{{ t(`admin.promptAudit.status.${runtime.process_status}`) }}
</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.mode') }}</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">{{ t(`admin.promptAudit.mode.${runtime.effective_mode}`) }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.version') }}</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">
{{ runtime.active_config_version }} / {{ runtime.expected_config_version }}
</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.workers') }}</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">{{ runtime.worker_active }} / {{ runtime.worker_total }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.queue') }}</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">{{ runtime.queue.active }} / {{ runtime.queue_capacity }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.dependencies') }}</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">DB {{ runtime.database_status }} · Redis {{ runtime.redis_status }}</dd>
</div>
</dl>
<div class="mt-5 grid gap-4 border-t border-gray-100 pt-5 dark:border-dark-800 lg:grid-cols-[1.2fr_1fr]">
<div class="min-w-0">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.runtime.guardMetrics') }}</h3>
<div class="mt-3 flex flex-wrap gap-x-5 gap-y-2 text-sm text-gray-600 dark:text-dark-300">
<span>{{ t('admin.promptAudit.metrics.total') }} <strong>{{ runtime.guard_metrics.total }}</strong></span>
<span>{{ t('admin.promptAudit.metrics.allowed') }} <strong>{{ runtime.guard_metrics.allowed }}</strong></span>
<span>{{ t('admin.promptAudit.metrics.flagged') }} <strong>{{ runtime.guard_metrics.flagged }}</strong></span>
<span>{{ t('admin.promptAudit.metrics.blocked') }} <strong>{{ runtime.guard_metrics.blocked }}</strong></span>
<span>{{ t('admin.promptAudit.metrics.unavailable') }} <strong>{{ runtime.guard_metrics.unavailable }}</strong></span>
<span>{{ t('admin.promptAudit.metrics.timeouts') }} <strong>{{ runtime.guard_metrics.timeouts }}</strong></span>
<span>{{ t('admin.promptAudit.metrics.failovers') }} <strong>{{ runtime.guard_metrics.failovers }}</strong></span>
<span v-if="runtime.guard_metrics.latency_p95_ms != null">P95 <strong>{{ runtime.guard_metrics.latency_p95_ms }} ms</strong></span>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-dark-400">
{{ t('admin.promptAudit.runtime.queueBreakdown', {
queued: runtime.queue.queued,
processing: runtime.queue.processing,
retry: runtime.queue.retry,
done: runtime.queue.done,
failed: runtime.queue.failed,
}) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-dark-400">
{{ t('admin.promptAudit.runtime.deliveryTotals', { enqueued: runtime.enqueued_total, dropped: runtime.dropped_total, processed: runtime.processed_total, failed: runtime.failed_total }) }}
</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.runtime.latest') }}</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-dark-300">
{{ runtime.last_processed_at ? formatDate(runtime.last_processed_at) : t('admin.promptAudit.common.never') }}
</p>
<p v-if="runtime.last_error_code" class="mt-1 break-words text-sm text-red-600 dark:text-red-300">
{{ runtime.last_error_code }}<span v-if="runtime.last_error_message"> · {{ runtime.last_error_message }}</span>
</p>
<div v-if="Object.keys(runtime.endpoints).length" class="mt-3 flex flex-wrap gap-2">
<span v-for="(probe, id) in runtime.endpoints" :key="id" class="rounded-full px-2 py-1 text-xs" :class="probe.ok ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300' : 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300'">
{{ id }} · {{ probe.status }} · {{ probe.latency_ms }} ms
</span>
</div>
</div>
</div>
</template>
</section>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { PromptAuditRuntime } from '../types'
defineProps<{ runtime: PromptAuditRuntime | null; loading: boolean; error: string }>()
defineEmits<{ (event: 'refresh'): void }>()
const { t, locale } = useI18n()
function formatDate(value: string): string {
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value))
}
function statusDot(status: string): string {
if (status === 'running') return 'bg-emerald-500'
if (status === 'disabled') return 'bg-gray-400'
if (status === 'degraded') return 'bg-amber-500'
return 'bg-red-500'
}
</script>
+243
View File
@@ -0,0 +1,243 @@
export type PromptAuditMode = 'off' | 'async_audit' | 'blocking'
export type PromptDecision = 'pass' | 'flag' | 'critical'
export type PromptRiskLevel = 'low' | 'medium' | 'high' | 'critical'
export interface PromptAuditEndpoint {
id: string
name: string
protocol: 'openai_compatible'
base_url: string
model: string
timeout_ms: number
input_limit: number
enabled: boolean
has_token: boolean
token_status: 'configured' | 'missing' | string
}
export interface PromptAuditEndpointDraft extends PromptAuditEndpoint {
token: string
clear_token: boolean
}
export interface PromptAuditConfig {
enabled: boolean
blocking_enabled: boolean
store_pass_events: boolean
effective_mode: PromptAuditMode
strategy: 'priority'
worker_count: number
queue_capacity: number
scanners: string[]
all_groups: boolean
group_ids: number[]
endpoints: PromptAuditEndpoint[]
config_version: number
updated_at: string
updated_by: number
change_summary: string
}
export interface PromptAuditDraft extends Omit<PromptAuditConfig, 'endpoints'> {
endpoints: PromptAuditEndpointDraft[]
}
export interface PromptAuditUpdateRequest {
expected_config_version: number
enabled: boolean
blocking_enabled: boolean
store_pass_events: boolean
strategy: 'priority'
worker_count: number
queue_capacity: number
scanners: string[]
all_groups: boolean
group_ids: number[]
endpoints: Array<{
id: string
name: string
protocol: 'openai_compatible'
base_url: string
model: string
token?: string
clear_token: boolean
timeout_ms: number
input_limit: number
enabled: boolean
}>
}
export interface PromptProbeResult {
ok: boolean
status: string
error_code?: string
message: string
latency_ms: number
http_status: number
retryable: boolean
checked_at: string
token_applied: boolean
}
export interface PromptQueueStats {
staging: number
queued: number
processing: number
retry: number
done: number
failed: number
active: number
}
export interface PromptGuardMetrics {
total: number
allowed: number
flagged: number
blocked: number
unavailable: number
invalid: number
timeouts: number
failovers: number
bulkhead_full: number
record_failed: number
latency_avg_ms?: number
latency_p50_ms?: number
latency_p95_ms?: number
latency_p99_ms?: number
latency_max_ms?: number
}
export interface PromptAuditRuntime {
process_status: 'disabled' | 'running' | 'degraded' | 'error' | string
effective_mode: PromptAuditMode
expected_config_version: number
active_config_version: number
config_loaded_at?: string
config_load_error?: string
worker_total: number
worker_active: number
worker_heartbeat_at?: string
queue_capacity: number
queue: PromptQueueStats
processed_total: number
failed_total: number
enqueued_total: number
dropped_total: number
last_processed_at?: string
last_error_code?: string
last_error_message?: string
database_status: string
redis_status: string
endpoints: Record<string, PromptProbeResult>
guard_metrics: PromptGuardMetrics
}
export interface PromptSnapshot {
request_id: string
user_id: number
username: string
user_email: string
api_key_id: number
api_key_name: string
group_id?: number
group_name: string
provider: string
endpoint: string
protocol: string
model: string
prompt_hash: string
redacted_preview: string
prompt_length: number
message_count: number
stage: string
}
export interface PromptIssueSummary {
category: string
scanner_id: string
title: string
description: string
severity: string
severity_label: string
action: string
action_label: string
code: string
score: number
evidence: string
evidence_hash: string
start_rune?: number
end_rune?: number
}
export interface PromptAuditEvent {
id: number
job_id: number
snapshot: PromptSnapshot
decision: PromptDecision
risk_level: PromptRiskLevel
action: 'Allow' | 'Warn' | 'Block' | string
categories: string[]
matched_scanners: string[]
scanner_scores: Record<string, number>
scanner_evidence: Record<string, string>
scanner_backend: string
scanner_version: string
guard_endpoint_id: string
policy_id: string
policy_version: number
config_version: number
chunk_total: number
latency_ms: number
issue_summaries: PromptIssueSummary[]
created_at: string
}
export interface PromptEventFilters {
decision: string
risk_level: string
endpoint: string
group_id: string
user_id: string
api_key_id: string
request_id: string
prompt_hash: string
keyword: string
start_at: string
end_at: string
}
export interface PromptEventPage {
items: PromptAuditEvent[]
total: number
page: number
page_size: number
pages: number
}
export interface PromptDeleteResult {
deleted_events: number
deleted_jobs: number
}
export interface PromptDeletePreview {
matched_count: number
filter_summary: Record<string, unknown>
snapshot_max_id: number
filter_hash: string
confirmation_token: string
expires_at: string
}
export interface PromptAuditGroup {
id: number
name: string
status: 'active' | 'inactive'
platform: string
}
export interface PromptLoadErrors {
config: string
runtime: string
groups: string
events: string
}
@@ -0,0 +1,139 @@
import type {
PromptAuditConfig,
PromptAuditDraft,
PromptAuditEndpointDraft,
PromptAuditUpdateRequest,
PromptEventFilters,
} from './types'
export const DEFAULT_GUARD_MODEL = 'sileader/qwen3guard:0.6b'
export const SCANNER_CATALOG = [
{ id: 'violent', label: 'Violent' },
{ id: 'non_violent_illegal_acts', label: 'Non-violent Illegal Acts' },
{ id: 'sexual_content_or_sexual_acts', label: 'Sexual Content or Sexual Acts' },
{ id: 'pii', label: 'PII' },
{ id: 'suicide_and_self_harm', label: 'Suicide & Self-Harm' },
{ id: 'unethical_acts', label: 'Unethical Acts' },
{ id: 'politically_sensitive_topics', label: 'Politically Sensitive Topics' },
{ id: 'copyright_violation', label: 'Copyright Violation' },
{ id: 'jailbreak', label: 'Jailbreak' },
] as const
// Vue props/refs are proxies and cannot be passed to structuredClone in every
// browser. Prompt Audit state is JSON-only, so this produces a detached draft
// without retaining reactive proxies or browser storage references.
export function cloneData<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
export function configToDraft(config: PromptAuditConfig): PromptAuditDraft {
return {
...cloneData(config),
group_ids: [...(config.group_ids ?? [])],
scanners: [...(config.scanners ?? [])],
endpoints: (config.endpoints ?? []).map((endpoint) => ({
...endpoint,
token: '',
clear_token: false,
})),
}
}
export function createDefaultEndpoint(index = 1): PromptAuditEndpointDraft {
return {
id: `guard-${Date.now()}-${index}`,
name: `Guard ${index}`,
protocol: 'openai_compatible',
base_url: 'http://127.0.0.1:8000',
model: DEFAULT_GUARD_MODEL,
timeout_ms: 3000,
input_limit: 4000,
enabled: true,
has_token: false,
token_status: 'missing',
token: '',
clear_token: false,
}
}
export function buildUpdateRequest(draft: PromptAuditDraft): PromptAuditUpdateRequest {
return {
expected_config_version: draft.config_version,
enabled: draft.enabled,
blocking_enabled: draft.enabled && draft.blocking_enabled,
store_pass_events: draft.store_pass_events,
strategy: 'priority',
worker_count: Number(draft.worker_count),
queue_capacity: Number(draft.queue_capacity),
scanners: [...draft.scanners],
all_groups: draft.all_groups,
group_ids: draft.all_groups ? [] : [...draft.group_ids].sort((a, b) => a - b),
endpoints: draft.endpoints.map((endpoint) => ({
id: endpoint.id.trim(),
name: endpoint.name.trim(),
protocol: 'openai_compatible',
base_url: endpoint.base_url.trim(),
model: endpoint.model.trim() || DEFAULT_GUARD_MODEL,
token: endpoint.token.trim() || undefined,
clear_token: endpoint.clear_token,
timeout_ms: Number(endpoint.timeout_ms),
input_limit: Number(endpoint.input_limit),
enabled: endpoint.enabled,
})),
}
}
export function draftFingerprint(draft: PromptAuditDraft | null): string {
if (!draft) return ''
return JSON.stringify(buildUpdateRequest(draft))
}
export function emptyEventFilters(): PromptEventFilters {
return {
decision: '',
risk_level: '',
endpoint: '',
group_id: '',
user_id: '',
api_key_id: '',
request_id: '',
prompt_hash: '',
keyword: '',
start_at: '',
end_at: '',
}
}
function toISO(value: string): string | undefined {
if (!value.trim()) return undefined
const date = new Date(value)
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
}
export function eventQueryParams(filters: PromptEventFilters): Record<string, string | number> {
const result: Record<string, string | number> = {}
for (const key of ['decision', 'risk_level', 'endpoint', 'request_id', 'prompt_hash', 'keyword'] as const) {
const value = filters[key].trim()
if (value) result[key] = value
}
for (const key of ['group_id', 'user_id', 'api_key_id'] as const) {
const value = Number(filters[key])
if (Number.isInteger(value) && value > 0) result[key] = value
}
const start = toISO(filters.start_at)
const end = toISO(filters.end_at)
if (start) result.start_at = start
if (end) result.end_at = end
return result
}
export function eventFilterPayload(filters: PromptEventFilters): Record<string, unknown> {
return eventQueryParams(filters)
}
export function hasExplicitDeleteRange(filters: PromptEventFilters): boolean {
const start = toISO(filters.start_at)
const end = toISO(filters.end_at)
return Boolean(start && end && new Date(start).getTime() < new Date(end).getTime())
}
@@ -5,6 +5,7 @@ import resources from './resources'
import ops from './ops'
import settings from './settings'
import audit from './audit'
import promptAudit from './promptAudit'
export default {
...overview,
@@ -14,4 +15,5 @@ export default {
...ops,
...settings,
...audit,
...promptAudit,
}
@@ -0,0 +1,53 @@
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.',
configVersion: 'Config version v{version}',
actions: { refresh: 'Refresh runtime', retry: 'Retry' },
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' },
runtime: {
title: 'Runtime overview',
description: 'Shows the configuration currently active on the server. Unsaved draft changes do not affect these values.',
process: 'Process status', mode: 'Effective mode', version: 'Active / expected version', workers: 'Active / total workers',
queue: 'Active jobs / capacity', dependencies: 'Dependencies', guardMetrics: 'Synchronous Guard metrics', latest: 'Latest processing and error',
queueBreakdown: 'queued {queued} · processing {processing} · retry {retry} · done {done} · failed {failed}',
deliveryTotals: 'Total enqueued {enqueued} · dropped {dropped} · processed {processed} · failed {failed}',
},
metrics: { total: 'Total', allowed: 'Allowed', flagged: 'Flagged', blocked: 'Blocked', unavailable: 'Unavailable', timeouts: 'Timeouts', failovers: 'Failovers' },
pool: {
title: 'Audit pool', description: 'Enabled OpenAI-compatible nodes are tried in order. Probes run from the server network.',
add: 'Add node', edit: 'Edit node', empty: 'No audit nodes configured.', node: 'Node', model: 'Model', limits: 'Timeout / chunk limit', credential: 'Credential and probe',
configured: 'API Key configured', missing: 'API Key missing', probe: 'Test connection', probing: 'Probing…',
probeProgress: 'Config validated ✓ · request sent · awaiting service response…', probeResult: 'Config ✓ · request ✓ · HTTP {http} · {status} · {latency} ms',
name: 'Node name', id: 'Stable node ID', baseUrl: 'Base URL', apiKey: 'API Key', keepSecret: 'Leave blank to keep the saved API Key',
secretHint: 'Plaintext exists only in this editor and is cleared immediately after a successful save.', clearSecret: 'Explicitly clear the saved API Key', timeout: 'Total timeout (ms)', inputLimit: 'Unicode characters per chunk',
toggleNode: 'Toggle node {name}', deleteConfirm: 'Remove “{name}” from the draft? It takes effect after saving.',
},
policy: {
title: 'Audit policy', description: 'Configure group scope, nine input-risk categories, workers, and queue bounds.', scope: 'Scope', allGroups: 'All groups', selectedGroups: 'Selected groups',
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' },
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.',
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.',
detailTitle: 'Prompt audit event details', tabs: { summary: 'Audit summary', risks: 'Specific risks', technical: 'Technical details' }, redactedPreview: 'Irreversible redacted preview', categories: 'Categories', model: 'Model', 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',
},
messages: { saved: 'Prompt Audit configuration saved; plaintext API Key state was cleared.', probeSucceeded: 'The audit node is reachable.', deleted: 'Deleted {count} audit events.' },
errors: {
loadConfig: 'Unable to load Prompt Audit configuration.', loadRuntime: 'Unable to load Prompt Audit runtime.', loadGroups: 'Unable to load groups.', loadEvents: 'Unable to load audit events.', loadDetail: 'Unable to load event details.', saveConfig: 'Unable to save the configuration.', probe: 'Node probe failed.', delete: 'Unable to delete events.', previewDelete: 'Unable to create a deletion preview. Check the time range.', deleteConfirmation: 'The deletion confirmation is invalid or expired. Preview again.',
prompt_audit_config_conflict: 'Another administrator updated this configuration. Reload the server version before deciding how to merge your draft.',
prompt_guard_requires_audit_enabled: 'Enable Prompt Audit before synchronous blocking.', prompt_audit_invalid_endpoint: 'The audit node configuration is invalid.', prompt_audit_endpoint_required: 'Enable at least one audit node before enabling Prompt Audit.', prompt_audit_groups_required: 'Select at least one group in selected-group mode.', prompt_audit_scanners_required: 'Enable at least one risk category.',
},
},
}
+3
View File
@@ -190,6 +190,9 @@ export default {
channelMonitor: 'Channel Monitor',
channelStatus: 'Channel Status',
riskControl: 'Risk Control',
securityAudit: 'Security Audit',
contentModeration: 'Content Moderation',
promptAudit: 'Prompt Audit',
auditLogs: 'Audit Logs',
},
@@ -5,6 +5,7 @@ import resources from './resources'
import ops from './ops'
import settings from './settings'
import audit from './audit'
import promptAudit from './promptAudit'
export default {
...overview,
@@ -14,4 +15,5 @@ export default {
...ops,
...settings,
...audit,
...promptAudit,
}
@@ -0,0 +1,53 @@
export default {
promptAudit: {
title: '提示词审计',
description: '通过 OpenAI 兼容 Qwen3Guard 节点异步复核或同步阻止用户输入;完整提示词不会进入数据库或页面。',
configVersion: '配置版本 v{version}',
actions: { refresh: '刷新运行态', retry: '重试' },
common: { actions: '操作', never: '从未' },
mode: { off: '已关闭', async_audit: '异步只审计', blocking: '同步审计并阻止' },
status: { disabled: '未启用', running: '运行中', degraded: '降级', error: '错误', healthy: '健康', failed: '失败', stale: '心跳过期' },
runtime: {
title: '运行概览',
description: '显示服务端当前生效状态;未保存的草稿不会改变这些数值。',
process: '进程状态', mode: '生效模式', version: '生效 / 期望版本', workers: '活动 / 总 Worker',
queue: '活动任务 / 容量', dependencies: '依赖', guardMetrics: '同步 Guard 指标', latest: '最近处理与错误',
queueBreakdown: 'queued {queued} · processing {processing} · retry {retry} · done {done} · failed {failed}',
deliveryTotals: '累计入队 {enqueued} · 丢弃 {dropped} · 处理 {processed} · 失败 {failed}',
},
metrics: { total: '总计', allowed: '放行', flagged: '标记', blocked: '阻止', unavailable: '不可用', timeouts: '超时', failovers: '故障切换' },
pool: {
title: '审计池', description: '按顺序使用启用的 OpenAI 兼容节点;探测由服务端真实网络环境发起。',
add: '新增节点', edit: '编辑节点', empty: '尚未配置审计节点。', node: '节点', model: '模型', limits: '超时 / 单片上限', credential: '凭据与探测',
configured: 'API Key 已配置', missing: '未配置 API Key', probe: '连接测试', probing: '探测中…',
probeProgress: '配置校验 ✓ · 请求已发送 · 等待服务响应…', probeResult: '配置校验 ✓ · 请求 ✓ · HTTP {http} · {status} · {latency} ms',
name: '节点名称', id: '稳定节点 ID', baseUrl: 'Base URL', apiKey: 'API Key', keepSecret: '留空以保留已保存的 API Key',
secretHint: '明文只在本次编辑内存中存在;保存成功后会立即清除。', clearSecret: '显式清除已保存的 API Key', timeout: '总超时(毫秒)', inputLimit: '单片 Unicode 字符上限',
toggleNode: '切换节点 {name}', deleteConfirm: '从草稿中删除节点“{name}”?保存配置后生效。',
},
policy: {
title: '审计策略', description: '配置适用分组、九类输入风险、Worker 与队列边界。', scope: '适用范围', allGroups: '全部分组', selectedGroups: '指定分组',
searchGroups: '搜索分组', noGroups: '没有匹配分组', missingGroups: '配置中包含已删除的分组 ID', selectedCount: '已选择 {count} 个分组',
scanners: 'Qwen3Guard 输入风险分类', workerCount: 'Worker 数量', queueCapacity: '持久队列容量', strategy: '节点策略', strategyHint: '按配置顺序优先尝试,必要时故障切换。',
},
saveBar: { enabled: '启用提示词审计', blocking: '同步阻止', storePass: '保存 Pass 事件', 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: '按筛选删除必须明确选择开始和结束时间,并先取得服务端删除预览。',
selectAll: '选择当前页全部事件', selectEvent: '选择事件 {id}', time: '时间', identity: '用户 / 邮箱 / API Key', user: '用户名', email: '用户邮箱', apiKey: 'API Key 名称', group: '分组', route: '入口 / 模型', result: '判定 / 风险', preview: '脱敏预览', empty: '没有符合条件的事件。',
detailTitle: '提示词审计事件详情', tabs: { summary: '审计摘要', risks: '具体风险', technical: '技术信息' }, redactedPreview: '不可逆脱敏预览', categories: '分类', model: '模型', noRisks: '本事件没有派生风险摘要。',
deleteConfirmTitle: '删除审计事件?', deleteConfirmMessage: '将永久删除 {count} 条事件及符合条件的孤立任务。', filterDeleteTitle: '确认按筛选删除', filterDeleteCount: '服务端快照匹配 {count} 条事件。', snapshotMax: '快照最大事件 ID', expiresAt: '确认令牌过期时间', filterDeleteWarning: '只删除预览高水位内的事件;预览后产生的新事件会保留。筛选一旦变化,必须重新预览。', confirmFilterDelete: '确认永久删除',
},
messages: { saved: '提示词审计配置已保存,明文 API Key 状态已清除。', probeSucceeded: '审计节点连接正常。', deleted: '已删除 {count} 条审计事件。' },
errors: {
loadConfig: '无法加载提示词审计配置。', loadRuntime: '无法加载提示词审计运行态。', loadGroups: '无法加载分组列表。', loadEvents: '无法加载审计事件。', loadDetail: '无法加载事件详情。', saveConfig: '配置保存失败。', probe: '节点探测失败。', delete: '事件删除失败。', previewDelete: '无法生成删除预览,请检查时间范围。', deleteConfirmation: '删除确认无效或已过期,请重新预览。',
prompt_audit_config_conflict: '配置已被其他管理员更新。请重新加载服务端配置,再决定如何合并本地草稿。',
prompt_guard_requires_audit_enabled: '开启同步阻止前必须先启用提示词审计。', prompt_audit_invalid_endpoint: '审计节点配置无效。', prompt_audit_endpoint_required: '启用审计前至少需要一个启用节点。', prompt_audit_groups_required: '指定分组模式至少需要选择一个分组。', prompt_audit_scanners_required: '至少需要启用一个风险分类。',
},
},
}
+3
View File
@@ -190,6 +190,9 @@ export default {
channelMonitor: '渠道监控',
channelStatus: '渠道状态',
riskControl: '风控中心',
securityAudit: '安全审计',
contentModeration: '内容审核',
promptAudit: '提示词审计',
auditLogs: '操作日志',
},
+13
View File
@@ -587,6 +587,19 @@ const routes: RouteRecordRaw[] = [
requiresRiskControl: true
}
},
{
path: '/admin/prompt-audit',
name: 'AdminPromptAudit',
component: () => import('@/features/prompt-audit/PromptAuditView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Prompt Audit',
titleKey: 'admin.promptAudit.title',
descriptionKey: 'admin.promptAudit.description',
requiresRiskControl: true
}
},
{
path: '/admin/usage',
name: 'AdminUsage',