mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-08 16:56:23 +00:00
feat(risk-control): 内容审计支持按模型生效
This commit is contained in:
1 parent
16793d3af0
commit
0d5c6f7cc7
8 files changed
+784
-86
No files matched your search
@@ -2,6 +2,12 @@ import { apiClient } from '../client'
|
||||
|
||||
export type ModerationMode = 'off' | 'observe' | 'pre_block'
|
||||
export type KeywordBlockingMode = 'keyword_only' | 'keyword_and_api' | 'api_only'
|
||||
export type ContentModerationModelFilterType = 'all' | 'include' | 'exclude'
|
||||
|
||||
export interface ContentModerationModelFilter {
|
||||
type: ContentModerationModelFilterType
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export interface ContentModerationConfig {
|
||||
enabled: boolean
|
||||
@@ -32,6 +38,7 @@ export interface ContentModerationConfig {
|
||||
pre_hash_check_enabled: boolean
|
||||
blocked_keywords: string[]
|
||||
keyword_blocking_mode: KeywordBlockingMode
|
||||
model_filter: ContentModerationModelFilter
|
||||
}
|
||||
|
||||
export type ContentModerationAPIKeyStatusValue = 'unknown' | 'ok' | 'error' | 'frozen'
|
||||
@@ -105,6 +112,7 @@ export interface UpdateContentModerationConfig {
|
||||
pre_hash_check_enabled?: boolean
|
||||
blocked_keywords?: string[]
|
||||
keyword_blocking_mode?: KeywordBlockingMode
|
||||
model_filter?: ContentModerationModelFilter
|
||||
}
|
||||
|
||||
export interface ContentModerationRuntimeStatus {
|
||||
|
||||
@@ -2532,6 +2532,20 @@ export default {
|
||||
selectedGroups: 'Selected Groups',
|
||||
searchGroups: 'Search group name or platform',
|
||||
noGroups: 'No groups available',
|
||||
modelFilter: 'Model scope',
|
||||
modelFilterHint: 'Moderate by the client-requested model name; channel model mappings do not change this match.',
|
||||
modelFilterAll: 'All models',
|
||||
modelFilterAllDesc: 'All model requests go through content moderation.',
|
||||
modelFilterInclude: 'Only selected',
|
||||
modelFilterIncludeDesc: 'Only listed models go through content moderation.',
|
||||
modelFilterExclude: 'Exclude selected',
|
||||
modelFilterExcludeDesc: 'Listed models skip content moderation; other models are moderated.',
|
||||
modelFilterModels: 'Model list',
|
||||
modelFilterModelCount: '{count} models configured',
|
||||
modelFilterModelsRequired: 'This model scope requires at least 1 model',
|
||||
modelFilterAllSummary: 'Applies to all models',
|
||||
modelFilterIncludeSummary: 'Applies to {count} models',
|
||||
modelFilterExcludeSummary: 'Excludes {count} models',
|
||||
emptyLogs: 'No audit records',
|
||||
workerStatus: 'Worker Runtime',
|
||||
workerStatusHint: 'Queue and worker pool status for asynchronous observation tasks.',
|
||||
|
||||
@@ -2609,6 +2609,20 @@ export default {
|
||||
selectedGroups: '指定分组',
|
||||
searchGroups: '搜索分组名称或平台',
|
||||
noGroups: '暂无可用分组',
|
||||
modelFilter: '模型范围',
|
||||
modelFilterHint: '按客户端请求的模型名决定是否执行内容审计,模型映射后仍以请求模型判断。',
|
||||
modelFilterAll: '所有模型',
|
||||
modelFilterAllDesc: '所有模型请求都会进入内容审计。',
|
||||
modelFilterInclude: '仅指定模型',
|
||||
modelFilterIncludeDesc: '只有列表中的模型会执行内容审计。',
|
||||
modelFilterExclude: '排除指定模型',
|
||||
modelFilterExcludeDesc: '列表中的模型跳过内容审计,其余模型执行审计。',
|
||||
modelFilterModels: '模型列表',
|
||||
modelFilterModelCount: '已配置 {count} 个模型',
|
||||
modelFilterModelsRequired: '当前模型范围至少需要配置 1 个模型',
|
||||
modelFilterAllSummary: '全部模型生效',
|
||||
modelFilterIncludeSummary: '仅 {count} 个模型生效',
|
||||
modelFilterExcludeSummary: '排除 {count} 个模型',
|
||||
emptyLogs: '暂无审核记录',
|
||||
workerStatus: 'Worker 运行状态',
|
||||
workerStatusHint: '异步观察任务的队列和 worker 池状态。',
|
||||
|
||||
@@ -145,6 +145,26 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 rounded-lg border border-gray-100 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900/30 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
<Icon name="filter" size="sm" class="flex-shrink-0 text-gray-400" />
|
||||
<span class="font-medium">{{ t('admin.riskControl.modelFilter') }}</span>
|
||||
<span class="truncate text-gray-500 dark:text-gray-400">{{ modelFilterSummary }}</span>
|
||||
</div>
|
||||
<div v-if="modelFilterPreviewModels.length > 0" class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="model in modelFilterPreviewModels"
|
||||
:key="model"
|
||||
class="inline-flex max-w-[180px] items-center truncate rounded-md bg-white px-2 py-1 font-mono text-xs text-gray-600 shadow-sm dark:bg-dark-800 dark:text-gray-300"
|
||||
>
|
||||
{{ model }}
|
||||
</span>
|
||||
<span v-if="hiddenModelFilterModelCount > 0" class="inline-flex rounded-md bg-white px-2 py-1 text-xs text-gray-500 shadow-sm dark:bg-dark-800 dark:text-gray-400">
|
||||
+{{ hiddenModelFilterModelCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-6">
|
||||
<Select v-model="filters.result" :options="resultOptions" @change="reloadLogsFromFirstPage" />
|
||||
<Select v-model="filters.group_id" :options="groupFilterOptions" @change="reloadLogsFromFirstPage" />
|
||||
@@ -628,6 +648,52 @@
|
||||
<p v-if="filteredGroups.length === 0" class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.noGroups') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 rounded-lg border border-gray-100 p-4 dark:border-dark-700">
|
||||
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('admin.riskControl.modelFilter') }}</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.modelFilterHint') }}</p>
|
||||
</div>
|
||||
<span class="inline-flex w-fit rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600 dark:bg-dark-700 dark:text-gray-300">
|
||||
{{ modelFilterSummary }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
<button
|
||||
v-for="option in modelFilterOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="rounded-lg border p-3 text-left transition-colors"
|
||||
:class="configForm.model_filter_type === option.value
|
||||
? 'border-primary-300 bg-primary-50 text-primary-900 shadow-sm dark:border-primary-700 dark:bg-primary-900/20 dark:text-primary-100'
|
||||
: 'border-gray-100 hover:bg-gray-50 dark:border-dark-700 dark:hover:bg-dark-700/60'"
|
||||
@click="setModelFilterType(option.value)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-semibold">{{ option.label }}</span>
|
||||
<span
|
||||
class="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border"
|
||||
:class="configForm.model_filter_type === option.value
|
||||
? 'border-primary-500 bg-primary-500 text-white'
|
||||
: 'border-gray-300 text-transparent dark:border-dark-500'"
|
||||
>
|
||||
<Icon name="check" size="xs" :stroke-width="2" />
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">{{ option.description }}</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="configForm.model_filter_type !== 'all'" class="space-y-2">
|
||||
<label class="input-label">{{ t('admin.riskControl.modelFilterModels') }}</label>
|
||||
<ModelWhitelistSelector v-model="configForm.model_filter_models" />
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.riskControl.modelFilterModelCount', { count: modelFilterModelCount }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeSettingsTab === 'runtime'" class="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
@@ -887,11 +953,14 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Toggle from '@/components/common/Toggle.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type {
|
||||
ContentModerationAPIKeyStatus,
|
||||
ContentModerationConfig,
|
||||
ContentModerationLog,
|
||||
ContentModerationModelFilter,
|
||||
ContentModerationModelFilterType,
|
||||
ContentModerationRuntimeStatus,
|
||||
ContentModerationTestAuditResult,
|
||||
KeywordBlockingMode,
|
||||
@@ -987,6 +1056,8 @@ const configForm = reactive({
|
||||
pre_hash_check_enabled: false,
|
||||
blocked_keywords_text: '',
|
||||
keyword_blocking_mode: 'keyword_and_api' as KeywordBlockingMode,
|
||||
model_filter_type: 'all' as ContentModerationModelFilterType,
|
||||
model_filter_models: [] as string[],
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
@@ -1038,6 +1109,24 @@ const keywordBlockingModeOptions = computed<Array<{ value: KeywordBlockingMode;
|
||||
},
|
||||
])
|
||||
|
||||
const modelFilterOptions = computed<Array<{ value: ContentModerationModelFilterType; label: string; description: string }>>(() => [
|
||||
{
|
||||
value: 'all',
|
||||
label: t('admin.riskControl.modelFilterAll'),
|
||||
description: t('admin.riskControl.modelFilterAllDesc'),
|
||||
},
|
||||
{
|
||||
value: 'include',
|
||||
label: t('admin.riskControl.modelFilterInclude'),
|
||||
description: t('admin.riskControl.modelFilterIncludeDesc'),
|
||||
},
|
||||
{
|
||||
value: 'exclude',
|
||||
label: t('admin.riskControl.modelFilterExclude'),
|
||||
description: t('admin.riskControl.modelFilterExcludeDesc'),
|
||||
},
|
||||
])
|
||||
|
||||
type KeywordNoticeView = {
|
||||
title: string
|
||||
description: string
|
||||
@@ -1120,6 +1209,22 @@ const groupFilterOptions = computed<SelectOption[]>(() => [
|
||||
|
||||
const selectedGroupCount = computed(() => String(configForm.group_ids.length))
|
||||
|
||||
const modelFilterModelCount = computed(() => configForm.model_filter_models.length)
|
||||
|
||||
const modelFilterSummary = computed(() => {
|
||||
if (configForm.model_filter_type === 'include') {
|
||||
return t('admin.riskControl.modelFilterIncludeSummary', { count: modelFilterModelCount.value })
|
||||
}
|
||||
if (configForm.model_filter_type === 'exclude') {
|
||||
return t('admin.riskControl.modelFilterExcludeSummary', { count: modelFilterModelCount.value })
|
||||
}
|
||||
return t('admin.riskControl.modelFilterAllSummary')
|
||||
})
|
||||
|
||||
const modelFilterPreviewModels = computed(() => configForm.model_filter_models.slice(0, 6))
|
||||
|
||||
const hiddenModelFilterModelCount = computed(() => Math.max(0, configForm.model_filter_models.length - modelFilterPreviewModels.value.length))
|
||||
|
||||
const filteredGroups = computed(() => {
|
||||
const keyword = groupSearch.value.trim().toLowerCase()
|
||||
if (!keyword) return groups.value
|
||||
@@ -1238,7 +1343,7 @@ const overviewItems = computed<OverviewItem[]>(() => [
|
||||
key: 'scope',
|
||||
label: t('admin.riskControl.overview.groupScope'),
|
||||
value: configForm.all_groups ? t('admin.riskControl.allGroups') : selectedGroupCount.value,
|
||||
meta: configForm.all_groups ? t('admin.riskControl.allGroupsHint') : t('admin.riskControl.selectedGroupsHint'),
|
||||
meta: modelFilterSummary.value,
|
||||
icon: 'users',
|
||||
iconClass: 'bg-violet-50 text-violet-600 dark:bg-violet-900/20 dark:text-violet-300',
|
||||
},
|
||||
@@ -1342,6 +1447,9 @@ function applyConfig(config: ContentModerationConfig) {
|
||||
configForm.pre_hash_check_enabled = config.pre_hash_check_enabled ?? false
|
||||
configForm.blocked_keywords_text = Array.isArray(config.blocked_keywords) ? config.blocked_keywords.join('\n') : ''
|
||||
configForm.keyword_blocking_mode = normalizeKeywordBlockingMode(config.keyword_blocking_mode)
|
||||
const modelFilter = normalizeModelFilter(config.model_filter)
|
||||
configForm.model_filter_type = modelFilter.type
|
||||
configForm.model_filter_models = modelFilter.models
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
@@ -1388,6 +1496,11 @@ async function loadStatus(silent = true) {
|
||||
async function saveConfig() {
|
||||
saving.value = true
|
||||
try {
|
||||
const modelFilterPayload = buildModelFilterPayload()
|
||||
if (modelFilterPayload.type !== 'all' && modelFilterPayload.models.length === 0) {
|
||||
appStore.showError(t('admin.riskControl.modelFilterModelsRequired'))
|
||||
return
|
||||
}
|
||||
const payload: UpdateContentModerationConfig = {
|
||||
enabled: configForm.enabled,
|
||||
mode: configForm.mode,
|
||||
@@ -1413,6 +1526,7 @@ async function saveConfig() {
|
||||
pre_hash_check_enabled: configForm.pre_hash_check_enabled,
|
||||
blocked_keywords: blockedKeywordList.value,
|
||||
keyword_blocking_mode: configForm.keyword_blocking_mode,
|
||||
model_filter: modelFilterPayload,
|
||||
}
|
||||
const keys = parseApiKeys(configForm.api_keys_text)
|
||||
if (!payload.clear_api_key && configForm.api_keys_mode === 'replace' && keys.length === 0) {
|
||||
@@ -1568,6 +1682,13 @@ function setAPIKeysMode(mode: APIKeysWriteMode) {
|
||||
}
|
||||
}
|
||||
|
||||
function setModelFilterType(type: ContentModerationModelFilterType) {
|
||||
configForm.model_filter_type = type
|
||||
if (type === 'all') {
|
||||
configForm.model_filter_models = []
|
||||
}
|
||||
}
|
||||
|
||||
async function testApiKeys(useInputKeys: boolean) {
|
||||
const keys = useInputKeys ? parseApiKeys(configForm.api_keys_text) : []
|
||||
if (useInputKeys && keys.length === 0) {
|
||||
@@ -1824,6 +1945,49 @@ function normalizeKeywordBlockingMode(value: unknown): KeywordBlockingMode {
|
||||
return 'keyword_and_api'
|
||||
}
|
||||
|
||||
function normalizeModelFilter(value: unknown): ContentModerationModelFilter {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return { type: 'all', models: [] }
|
||||
}
|
||||
const raw = value as Partial<ContentModerationModelFilter>
|
||||
const type = normalizeModelFilterType(raw.type)
|
||||
const models = type === 'all' ? [] : normalizeModelNames(raw.models)
|
||||
return { type, models }
|
||||
}
|
||||
|
||||
function normalizeModelFilterType(value: unknown): ContentModerationModelFilterType {
|
||||
if (value === 'include' || value === 'exclude' || value === 'all') {
|
||||
return value
|
||||
}
|
||||
return 'all'
|
||||
}
|
||||
|
||||
function normalizeModelNames(models: unknown): string[] {
|
||||
if (!Array.isArray(models)) return []
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const item of models) {
|
||||
const model = String(item ?? '').trim()
|
||||
if (!model) continue
|
||||
const key = model.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
out.push(model)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function buildModelFilterPayload(): ContentModerationModelFilter {
|
||||
const type = normalizeModelFilterType(configForm.model_filter_type)
|
||||
if (type === 'all') {
|
||||
return { type: 'all', models: [] }
|
||||
}
|
||||
return {
|
||||
type,
|
||||
models: normalizeModelNames(configForm.model_filter_models),
|
||||
}
|
||||
}
|
||||
|
||||
function parseBlockedKeywords(value: string): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import type { DOMWrapper, VueWrapper } from '@vue/test-utils'
|
||||
|
||||
import RiskControlView from '../RiskControlView.vue'
|
||||
import type { ContentModerationConfig, UpdateContentModerationConfig } from '@/api/admin/riskControl'
|
||||
|
||||
const {
|
||||
getConfig,
|
||||
updateConfig,
|
||||
getStatus,
|
||||
listLogs,
|
||||
getGroups,
|
||||
showError,
|
||||
showSuccess,
|
||||
} = vi.hoisted(() => ({
|
||||
getConfig: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
listLogs: vi.fn(),
|
||||
getGroups: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
riskControl: {
|
||||
getConfig,
|
||||
updateConfig,
|
||||
getStatus,
|
||||
listLogs,
|
||||
testAPIKeys: vi.fn(),
|
||||
deleteFlaggedHash: vi.fn(),
|
||||
clearFlaggedHashes: vi.fn(),
|
||||
unbanUser: vi.fn(),
|
||||
},
|
||||
groups: {
|
||||
getAll: getGroups,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError,
|
||||
showSuccess,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/apiError', () => ({
|
||||
extractApiErrorMessage: (_err: unknown, fallback: string) => fallback,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string | number>) =>
|
||||
key.replace(/\{(\w+)\}/g, (_, token) => String(params?.[token] ?? `{${token}}`)),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const baseConfig = (): ContentModerationConfig => ({
|
||||
enabled: true,
|
||||
mode: 'pre_block',
|
||||
base_url: 'https://api.openai.com',
|
||||
model: 'omni-moderation-latest',
|
||||
api_key_configured: false,
|
||||
api_key_masked: '',
|
||||
api_key_count: 0,
|
||||
api_key_masks: [],
|
||||
api_key_statuses: [],
|
||||
timeout_ms: 3000,
|
||||
sample_rate: 100,
|
||||
all_groups: true,
|
||||
group_ids: [],
|
||||
record_non_hits: false,
|
||||
worker_count: 4,
|
||||
queue_size: 32768,
|
||||
block_status: 403,
|
||||
block_message: '内容审计命中风险规则,请调整输入后重试',
|
||||
email_on_hit: true,
|
||||
auto_ban_enabled: true,
|
||||
ban_threshold: 10,
|
||||
violation_window_hours: 720,
|
||||
retry_count: 2,
|
||||
hit_retention_days: 180,
|
||||
non_hit_retention_days: 3,
|
||||
pre_hash_check_enabled: false,
|
||||
blocked_keywords: [],
|
||||
keyword_blocking_mode: 'keyword_and_api',
|
||||
model_filter: {
|
||||
type: 'all',
|
||||
models: [],
|
||||
},
|
||||
})
|
||||
|
||||
const runtimeStatus = () => ({
|
||||
enabled: true,
|
||||
risk_control_enabled: true,
|
||||
mode: 'pre_block',
|
||||
worker_count: 4,
|
||||
max_workers: 32,
|
||||
active_workers: 0,
|
||||
idle_workers: 4,
|
||||
queue_size: 32768,
|
||||
queue_length: 0,
|
||||
queue_usage_percent: 0,
|
||||
enqueued: 0,
|
||||
dropped: 0,
|
||||
processed: 0,
|
||||
errors: 0,
|
||||
api_key_statuses: [],
|
||||
flagged_hash_count: 0,
|
||||
last_cleanup_deleted_hit: 0,
|
||||
last_cleanup_deleted_non_hit: 0,
|
||||
})
|
||||
|
||||
const AppLayoutStub = { template: '<div><slot /></div>' }
|
||||
const BaseDialogStub = defineComponent({
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
|
||||
})
|
||||
const ModelWhitelistSelectorStub = defineComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const onInput = (event: Event) => {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
emit(
|
||||
'update:modelValue',
|
||||
value
|
||||
.split(/[,\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
}
|
||||
return () =>
|
||||
h('input', {
|
||||
'data-test': 'model-filter-input',
|
||||
value: (props.modelValue as string[]).join('\n'),
|
||||
onInput,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function findButtonByText(wrapper: VueWrapper, text: string): DOMWrapper<HTMLButtonElement> {
|
||||
const button = wrapper.findAll<HTMLButtonElement>('button').find((item) => item.text().includes(text))
|
||||
if (!button) {
|
||||
throw new Error(`button not found: ${text}`)
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
describe('admin RiskControlView', () => {
|
||||
beforeEach(() => {
|
||||
getConfig.mockReset()
|
||||
updateConfig.mockReset()
|
||||
getStatus.mockReset()
|
||||
listLogs.mockReset()
|
||||
getGroups.mockReset()
|
||||
showError.mockReset()
|
||||
showSuccess.mockReset()
|
||||
|
||||
getConfig.mockResolvedValue(baseConfig())
|
||||
getStatus.mockResolvedValue(runtimeStatus())
|
||||
listLogs.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 1 })
|
||||
getGroups.mockResolvedValue([])
|
||||
updateConfig.mockImplementation(async (payload: UpdateContentModerationConfig) => ({
|
||||
...baseConfig(),
|
||||
...payload,
|
||||
model_filter: payload.model_filter ?? baseConfig().model_filter,
|
||||
api_key_configured: false,
|
||||
api_key_masked: '',
|
||||
api_key_count: 0,
|
||||
api_key_masks: [],
|
||||
api_key_statuses: [],
|
||||
}))
|
||||
})
|
||||
|
||||
it('saves the selected model filter mode and models', async () => {
|
||||
const wrapper = mount(RiskControlView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: AppLayoutStub,
|
||||
BaseDialog: BaseDialogStub,
|
||||
Icon: true,
|
||||
Select: true,
|
||||
Toggle: true,
|
||||
Pagination: true,
|
||||
ModelWhitelistSelector: ModelWhitelistSelectorStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
await findButtonByText(wrapper, 'admin.riskControl.openSettings').trigger('click')
|
||||
await findButtonByText(wrapper, 'admin.riskControl.tabs.scope').trigger('click')
|
||||
await findButtonByText(wrapper, 'admin.riskControl.modelFilterInclude').trigger('click')
|
||||
await wrapper.get('[data-test="model-filter-input"]').setValue('gpt-5.5, gpt-5.4')
|
||||
await findButtonByText(wrapper, 'admin.riskControl.saveConfig').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||
model_filter: {
|
||||
type: 'include',
|
||||
models: ['gpt-5.5', 'gpt-5.4'],
|
||||
},
|
||||
}))
|
||||
expect(showError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user