mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Redesign sensitive info protection settings
This commit is contained in:
@@ -120,6 +120,7 @@ export interface UserExport {
|
||||
rate_limit?: number | null // null = 跟随系统默认,0 = 不限制
|
||||
rate_limit_mode?: 'inherit' | 'system' | 'custom'
|
||||
model_capability_settings?: Record<string, Record<string, boolean>>
|
||||
feature_settings?: Record<string, unknown> | null
|
||||
group_ids?: string[]
|
||||
group_names?: string[]
|
||||
unlimited?: boolean
|
||||
@@ -140,6 +141,7 @@ export interface UserApiKeyExport {
|
||||
rate_limit?: number | null // legacy/null 兼容;1.3+ standalone null = 跟随系统默认
|
||||
concurrent_limit?: number | null
|
||||
force_capabilities?: Record<string, boolean>
|
||||
feature_settings?: Record<string, unknown> | null
|
||||
is_active: boolean
|
||||
expires_at?: string | null
|
||||
auto_delete_on_expiry?: boolean
|
||||
@@ -453,6 +455,7 @@ export interface AdminApiKey {
|
||||
allowed_providers?: string[] | null // 允许的提供商列表
|
||||
allowed_api_formats?: string[] | null // 允许的 API 格式列表
|
||||
allowed_models?: string[] | null // 允许的模型列表
|
||||
feature_settings?: Record<string, unknown> | null
|
||||
auto_delete_on_expiry?: boolean // 过期后是否自动删除
|
||||
last_used_at?: string
|
||||
expires_at?: string
|
||||
@@ -472,6 +475,7 @@ export interface CreateStandaloneApiKeyRequest {
|
||||
initial_balance_usd: number | null // 初始余额,null = 无限制
|
||||
unlimited_balance?: boolean | null // 编辑时仅切换额度模式,不调整余额数值
|
||||
auto_delete_on_expiry?: boolean // 过期后是否自动删除
|
||||
feature_settings?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface AdminApiKeysResponse {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TieredPricingConfig } from './endpoints/types'
|
||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type { BillingSummary } from './auth'
|
||||
import type { UserSession } from '@/types/session'
|
||||
import type { FeatureSettingsMap } from '@/utils/featureSettings'
|
||||
|
||||
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
|
||||
|
||||
@@ -22,6 +23,7 @@ export interface Profile {
|
||||
auth_source: 'local' | 'ldap' | 'oauth'
|
||||
has_password: boolean
|
||||
preferences?: UserPreferences
|
||||
feature_settings?: FeatureSettingsMap | null
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
@@ -169,6 +171,7 @@ export interface ApiKey {
|
||||
concurrent_limit?: number | null
|
||||
allowed_providers?: ProviderConfig[]
|
||||
force_capabilities?: Record<string, boolean> | null // 强制能力配置
|
||||
feature_settings?: FeatureSettingsMap | null
|
||||
}
|
||||
|
||||
export type InstallTargetCli = 'claude_code' | 'codex_cli' | 'gemini_cli'
|
||||
@@ -205,6 +208,7 @@ export const meApi = {
|
||||
async updateProfile(data: {
|
||||
email?: string
|
||||
username?: string
|
||||
feature_settings?: FeatureSettingsMap | null
|
||||
}): Promise<{ message: string }> {
|
||||
const response = await apiClient.put('/api/users/me', data)
|
||||
return response.data
|
||||
@@ -244,7 +248,7 @@ export const meApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createApiKey(data: { name: string; rate_limit?: number | null; concurrent_limit?: number | null }): Promise<ApiKey> {
|
||||
async createApiKey(data: { name: string; rate_limit?: number | null; concurrent_limit?: number | null; feature_settings?: FeatureSettingsMap | null }): Promise<ApiKey> {
|
||||
const response = await apiClient.post<ApiKey>('/api/users/me/api-keys', data)
|
||||
return response.data
|
||||
},
|
||||
@@ -277,7 +281,7 @@ export const meApi = {
|
||||
|
||||
async updateApiKey(
|
||||
keyId: string,
|
||||
data: { name?: string; rate_limit?: number | null; concurrent_limit?: number | null }
|
||||
data: { name?: string; rate_limit?: number | null; concurrent_limit?: number | null; feature_settings?: FeatureSettingsMap | null | undefined }
|
||||
): Promise<ApiKey & { message: string }> {
|
||||
const response = await apiClient.put<ApiKey & { message: string }>(
|
||||
`/api/users/me/api-keys/${keyId}`,
|
||||
|
||||
@@ -23,84 +23,127 @@ export interface AuthModuleInfo {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type ChatPiiRedactionProviderScope = 'all_providers' | 'selected_providers'
|
||||
export type ChatPiiRedactionTtlSeconds = 300 | 3600
|
||||
export type ChatPiiRedactionEntity =
|
||||
| 'email'
|
||||
| 'cn_phone'
|
||||
| 'global_phone'
|
||||
| 'cn_id'
|
||||
| 'payment_card'
|
||||
| 'ipv4'
|
||||
| 'ipv6'
|
||||
| 'api_key'
|
||||
| 'access_token'
|
||||
| 'secret_key'
|
||||
| 'bearer_token'
|
||||
| 'jwt'
|
||||
|
||||
export interface ChatPiiRedactionRuleFeatures {
|
||||
validator?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ChatPiiRedactionRule {
|
||||
id: string
|
||||
name: string
|
||||
pattern: string
|
||||
enabled: boolean
|
||||
system?: boolean
|
||||
features?: ChatPiiRedactionRuleFeatures | null
|
||||
}
|
||||
|
||||
export interface ChatPiiRedactionConfig {
|
||||
enabled: boolean
|
||||
provider_scope: ChatPiiRedactionProviderScope
|
||||
entities: ChatPiiRedactionEntity[]
|
||||
rules: ChatPiiRedactionRule[]
|
||||
cache_ttl_seconds: ChatPiiRedactionTtlSeconds
|
||||
inject_model_instruction: boolean
|
||||
placeholder_prefix: string
|
||||
}
|
||||
|
||||
const CHAT_PII_REDACTION_ENTITIES: ChatPiiRedactionEntity[] = [
|
||||
'email',
|
||||
'cn_phone',
|
||||
'global_phone',
|
||||
'cn_id',
|
||||
'payment_card',
|
||||
'ipv4',
|
||||
'ipv6',
|
||||
'api_key',
|
||||
'access_token',
|
||||
'secret_key',
|
||||
'bearer_token',
|
||||
'jwt',
|
||||
export const CHAT_PII_REDACTION_DEFAULT_RULES: ChatPiiRedactionRule[] = [
|
||||
{ id: 'email', name: '邮箱', pattern: '(?i)[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\\.[A-Z]{2,63}', enabled: true, features: { validator: 'email' }, system: true },
|
||||
{ id: 'cn_phone', name: '手机号', pattern: '(?:\\+?86[- ]?)?(?:1[3-9]\\d[- ]?\\d{4}[- ]?\\d{4}|0\\d{2,3}[- ]\\d{7,8}(?:-\\d{1,6})?)', enabled: true, features: { validator: 'cn_phone' }, system: true },
|
||||
{ id: 'global_phone', name: '国际号码', pattern: '\\+[1-9]\\d(?:[ -]?\\d){6,13}\\d', enabled: true, features: { validator: 'global_phone' }, system: true },
|
||||
{ id: 'cn_id', name: '身份证号', pattern: '(?i)\\b\\d{17}[\\dX]\\b', enabled: true, features: { validator: 'cn_id' }, system: true },
|
||||
{ id: 'payment_card', name: '银行卡号', pattern: '\\b(?:\\d[ -]?){12,18}\\d\\b', enabled: true, features: { validator: 'payment_card' }, system: true },
|
||||
{ id: 'ipv4', name: 'IPv4', pattern: '\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b', enabled: true, features: { validator: 'ipv4' }, system: true },
|
||||
{ id: 'ipv6', name: 'IPv6', pattern: '\\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f:.]{1,39}\\b', enabled: true, features: { validator: 'ipv6' }, system: true },
|
||||
{ id: 'api_key', name: 'API Key', pattern: '\\b(?:sk-(?:proj-)?[A-Za-z0-9_-]{20,}|sk-ant-[A-Za-z0-9_-]{20,}|(?:gh[pousr]_[A-Za-z0-9_]{30,}|github_pat_[A-Za-z0-9_]{30,})|xox[baprs]-[A-Za-z0-9-]{20,}|(?:AKIA|ASIA)[0-9A-Z]{16}|[A-Za-z0-9_-]{32,})\\b', enabled: true, features: { validator: 'api_key' }, system: true },
|
||||
{ id: 'access_token', name: 'Access Token', pattern: "(?i)\\baccess[_-]?token\\s*[:=]\\s*[\"']?[A-Za-z0-9._~+/=-]{20,}", enabled: true, features: { validator: 'access_token' }, system: true },
|
||||
{ id: 'secret_key', name: 'Secret Key', pattern: "(?i)\\bsecret[_-]?key\\s*[:=]\\s*[\"']?[A-Za-z0-9._~+/=-]{20,}", enabled: true, features: { validator: 'secret_key' }, system: true },
|
||||
{ id: 'bearer_token', name: 'Bearer Token', pattern: '(?i)\\bBearer\\s+[A-Za-z0-9._~+/=-]{20,}', enabled: true, features: { validator: 'bearer_token' }, system: true },
|
||||
{ id: 'jwt', name: 'JWT', pattern: '\\b[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\b', enabled: true, features: { validator: 'jwt' }, system: true },
|
||||
]
|
||||
|
||||
const CHAT_PII_REDACTION_CONFIG_KEYS = {
|
||||
enabled: 'module.chat_pii_redaction.enabled',
|
||||
provider_scope: 'module.chat_pii_redaction.provider_scope',
|
||||
entities: 'module.chat_pii_redaction.entities',
|
||||
rules: 'module.chat_pii_redaction.rules',
|
||||
cache_ttl_seconds: 'module.chat_pii_redaction.cache_ttl_seconds',
|
||||
inject_model_instruction: 'module.chat_pii_redaction.inject_model_instruction',
|
||||
placeholder_prefix: 'module.chat_pii_redaction.placeholder_prefix',
|
||||
} as const
|
||||
|
||||
const CHAT_PII_REDACTION_DEFAULT_CONFIG: ChatPiiRedactionConfig = {
|
||||
enabled: false,
|
||||
provider_scope: 'selected_providers',
|
||||
entities: [...CHAT_PII_REDACTION_ENTITIES],
|
||||
rules: CHAT_PII_REDACTION_DEFAULT_RULES.map(rule => ({ ...rule })),
|
||||
cache_ttl_seconds: 300,
|
||||
inject_model_instruction: true,
|
||||
placeholder_prefix: 'AETHER',
|
||||
}
|
||||
|
||||
function isChatPiiRedactionEntity(value: unknown): value is ChatPiiRedactionEntity {
|
||||
return typeof value === 'string' && CHAT_PII_REDACTION_ENTITIES.includes(value as ChatPiiRedactionEntity)
|
||||
function cloneDefaultChatPiiRedactionRules(): ChatPiiRedactionRule[] {
|
||||
return CHAT_PII_REDACTION_DEFAULT_RULES.map(rule => ({ ...rule }))
|
||||
}
|
||||
|
||||
function normalizeChatPiiRedactionEntities(value: unknown): ChatPiiRedactionEntity[] {
|
||||
if (!Array.isArray(value)) return [...CHAT_PII_REDACTION_DEFAULT_CONFIG.entities]
|
||||
const unique = new Set<ChatPiiRedactionEntity>()
|
||||
for (const item of value) {
|
||||
if (isChatPiiRedactionEntity(item)) unique.add(item)
|
||||
function normalizeChatPiiRedactionRule(value: unknown, index: number): ChatPiiRedactionRule | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const item = value as Record<string, unknown>
|
||||
const id = typeof item.id === 'string' && item.id.trim()
|
||||
? item.id.trim()
|
||||
: `custom_${index + 1}`
|
||||
const name = typeof item.name === 'string' && item.name.trim()
|
||||
? item.name.trim()
|
||||
: id
|
||||
const pattern = typeof item.pattern === 'string' ? item.pattern : ''
|
||||
if (!pattern.trim()) return null
|
||||
const rawFeatures = item.features && typeof item.features === 'object' && !Array.isArray(item.features)
|
||||
? { ...(item.features as Record<string, unknown>) }
|
||||
: {}
|
||||
const legacyValidator = typeof item.kind === 'string' && item.kind.trim()
|
||||
? item.kind.trim()
|
||||
: null
|
||||
const validator = typeof rawFeatures.validator === 'string' && rawFeatures.validator.trim()
|
||||
? rawFeatures.validator.trim()
|
||||
: legacyValidator
|
||||
if (validator) {
|
||||
rawFeatures.validator = validator
|
||||
} else {
|
||||
delete rawFeatures.validator
|
||||
}
|
||||
const features = Object.keys(rawFeatures).length > 0 ? rawFeatures : null
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
pattern,
|
||||
enabled: item.enabled !== false,
|
||||
system: item.system === true,
|
||||
features,
|
||||
}
|
||||
return CHAT_PII_REDACTION_ENTITIES.filter((item) => unique.has(item))
|
||||
}
|
||||
|
||||
function normalizeChatPiiRedactionConfig(values: Record<keyof ChatPiiRedactionConfig, unknown>): ChatPiiRedactionConfig {
|
||||
function normalizeChatPiiRedactionRules(value: unknown): ChatPiiRedactionRule[] {
|
||||
if (!Array.isArray(value)) return cloneDefaultChatPiiRedactionRules()
|
||||
return value
|
||||
.map((item, index) => normalizeChatPiiRedactionRule(item, index))
|
||||
.filter((item): item is ChatPiiRedactionRule => item !== null)
|
||||
}
|
||||
|
||||
function normalizeChatPiiRedactionConfig(values: {
|
||||
enabled: unknown
|
||||
rules: unknown
|
||||
cache_ttl_seconds: unknown
|
||||
placeholder_prefix: unknown
|
||||
}): ChatPiiRedactionConfig {
|
||||
return {
|
||||
enabled: values.enabled === true,
|
||||
provider_scope: values.provider_scope === 'all_providers' ? 'all_providers' : 'selected_providers',
|
||||
entities: normalizeChatPiiRedactionEntities(values.entities),
|
||||
rules: normalizeChatPiiRedactionRules(values.rules),
|
||||
cache_ttl_seconds: values.cache_ttl_seconds === 3600 ? 3600 : 300,
|
||||
inject_model_instruction: values.inject_model_instruction !== false,
|
||||
placeholder_prefix: normalizePlaceholderPrefix(values.placeholder_prefix),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlaceholderPrefix(value: unknown): string {
|
||||
if (typeof value !== 'string') return CHAT_PII_REDACTION_DEFAULT_CONFIG.placeholder_prefix
|
||||
const normalized = value.trim().toUpperCase()
|
||||
return /^[A-Z0-9_]{1,32}$/.test(normalized)
|
||||
? normalized
|
||||
: CHAT_PII_REDACTION_DEFAULT_CONFIG.placeholder_prefix
|
||||
}
|
||||
|
||||
async function getSystemConfigValue(key: string): Promise<unknown> {
|
||||
const response = await apiClient.get<{ key: string; value: unknown }>(`/api/admin/system/configs/${key}`)
|
||||
return response.data.value
|
||||
@@ -147,38 +190,34 @@ export const modulesApi = {
|
||||
},
|
||||
|
||||
async getChatPiiRedactionConfig(): Promise<ChatPiiRedactionConfig> {
|
||||
const [enabled, providerScope, entities, cacheTtlSeconds, injectModelInstruction] = await Promise.all([
|
||||
const [enabled, rules, cacheTtlSeconds, placeholderPrefix] = await Promise.all([
|
||||
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.enabled),
|
||||
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.provider_scope),
|
||||
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.entities),
|
||||
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.rules),
|
||||
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.cache_ttl_seconds),
|
||||
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.inject_model_instruction),
|
||||
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.placeholder_prefix),
|
||||
])
|
||||
|
||||
return normalizeChatPiiRedactionConfig({
|
||||
enabled,
|
||||
provider_scope: providerScope,
|
||||
entities,
|
||||
rules,
|
||||
cache_ttl_seconds: cacheTtlSeconds,
|
||||
inject_model_instruction: injectModelInstruction,
|
||||
placeholder_prefix: placeholderPrefix,
|
||||
})
|
||||
},
|
||||
|
||||
async updateChatPiiRedactionConfig(config: ChatPiiRedactionConfig): Promise<ChatPiiRedactionConfig> {
|
||||
const [enabled, providerScope, entities, cacheTtlSeconds, injectModelInstruction] = await Promise.all([
|
||||
updateSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.enabled, config.enabled, '敏感信息替换保护总开关'),
|
||||
updateSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.provider_scope, config.provider_scope, '敏感信息替换保护启用范围'),
|
||||
updateSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.entities, config.entities, '敏感信息替换保护检测类型'),
|
||||
updateSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.cache_ttl_seconds, config.cache_ttl_seconds, '敏感信息替换保护缓存 TTL'),
|
||||
updateSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.inject_model_instruction, config.inject_model_instruction, '敏感信息替换保护模型提示说明'),
|
||||
const [enabled, rules, cacheTtlSeconds, placeholderPrefix] = await Promise.all([
|
||||
updateSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.enabled, config.enabled, '敏感信息保护总开关'),
|
||||
updateSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.rules, config.rules, '敏感信息保护替换规则'),
|
||||
updateSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.cache_ttl_seconds, config.cache_ttl_seconds, '敏感信息保护缓存 TTL'),
|
||||
updateSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.placeholder_prefix, config.placeholder_prefix, '敏感信息保护占位符前缀'),
|
||||
])
|
||||
|
||||
return normalizeChatPiiRedactionConfig({
|
||||
enabled,
|
||||
provider_scope: providerScope,
|
||||
entities,
|
||||
rules,
|
||||
cache_ttl_seconds: cacheTtlSeconds,
|
||||
inject_model_instruction: injectModelInstruction,
|
||||
placeholder_prefix: placeholderPrefix,
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { UserSession as SessionRecord } from '@/types/session'
|
||||
export type UserRole = 'admin' | 'user'
|
||||
export type ListPolicyMode = 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
|
||||
export type RateLimitPolicyMode = 'inherit' | 'system' | 'custom'
|
||||
export type FeatureSettings = Record<string, unknown>
|
||||
|
||||
export interface UserGroupSummary {
|
||||
id: string
|
||||
@@ -35,6 +36,7 @@ export interface User {
|
||||
role: UserRole
|
||||
is_active: boolean
|
||||
unlimited: boolean
|
||||
feature_settings?: FeatureSettings | null
|
||||
allowed_providers: string[] | null // 允许使用的提供商 ID 列表
|
||||
allowed_providers_mode?: ListPolicyMode
|
||||
allowed_api_formats: string[] | null // 允许使用的 API 格式列表
|
||||
@@ -60,6 +62,7 @@ export interface CreateUserRequest {
|
||||
initial_gift_usd?: number | null
|
||||
unlimited?: boolean
|
||||
group_ids?: string[]
|
||||
feature_settings?: FeatureSettings | null
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
@@ -69,6 +72,7 @@ export interface UpdateUserRequest {
|
||||
unlimited?: boolean
|
||||
password?: string
|
||||
group_ids?: string[]
|
||||
feature_settings?: FeatureSettings | null
|
||||
}
|
||||
|
||||
export interface UserBatchSelectionFilters {
|
||||
@@ -213,6 +217,7 @@ export interface ApiKey {
|
||||
is_active: boolean
|
||||
is_locked: boolean // 管理员锁定标志
|
||||
is_standalone: boolean // 是否为独立余额Key
|
||||
feature_settings?: FeatureSettings | null
|
||||
rate_limit?: number | null // 普通Key: 0 = 不限制,历史 null 视为跟随系统默认
|
||||
concurrent_limit?: number | null // 普通Key: 0 = 不限制并发,历史 null 兼容
|
||||
total_requests?: number // 总请求数
|
||||
@@ -223,6 +228,7 @@ export interface UpsertUserApiKeyRequest {
|
||||
name?: string
|
||||
rate_limit?: number | null
|
||||
concurrent_limit?: number | null
|
||||
feature_settings?: FeatureSettings | null
|
||||
}
|
||||
|
||||
export type UserSession = SessionRecord
|
||||
|
||||
@@ -236,6 +236,20 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-sm font-medium">敏感信息保护</Label>
|
||||
<Switch v-model="form.chat_pii_redaction_enabled" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-sm font-medium">占位符说明</Label>
|
||||
<Switch
|
||||
v-model="form.chat_pii_redaction_placeholder_notice"
|
||||
:disabled="!form.chat_pii_redaction_enabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 额度 -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">额度</Label>
|
||||
@@ -311,6 +325,10 @@ import { getGlobalModels } from '@/api/global-models'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import {
|
||||
mergeChatPiiRedactionFeatureSettings,
|
||||
readChatPiiRedactionFeatureSettings,
|
||||
} from '@/utils/featureSettings'
|
||||
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
|
||||
|
||||
export interface StandaloneKeyFormData {
|
||||
@@ -326,6 +344,7 @@ export interface StandaloneKeyFormData {
|
||||
allowed_providers?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
feature_settings?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
interface StandaloneKeyFormState {
|
||||
@@ -346,6 +365,8 @@ interface StandaloneKeyFormState {
|
||||
allowed_providers: string[]
|
||||
allowed_api_formats: string[]
|
||||
allowed_models: string[]
|
||||
chat_pii_redaction_enabled: boolean
|
||||
chat_pii_redaction_placeholder_notice: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -403,6 +424,8 @@ const form = ref<StandaloneKeyFormState>({
|
||||
allowed_providers: [],
|
||||
allowed_api_formats: [],
|
||||
allowed_models: [],
|
||||
chat_pii_redaction_enabled: false,
|
||||
chat_pii_redaction_placeholder_notice: true,
|
||||
})
|
||||
|
||||
function formatDateInputValue(date: Date): string {
|
||||
@@ -449,11 +472,14 @@ function resetForm() {
|
||||
allowed_providers: [],
|
||||
allowed_api_formats: [],
|
||||
allowed_models: [],
|
||||
chat_pii_redaction_enabled: false,
|
||||
chat_pii_redaction_placeholder_notice: true,
|
||||
} as typeof form.value
|
||||
}
|
||||
|
||||
function loadKeyData() {
|
||||
if (!props.apiKey) return
|
||||
const redactionFeature = readChatPiiRedactionFeatureSettings(props.apiKey.feature_settings)
|
||||
form.value = {
|
||||
id: props.apiKey.id,
|
||||
name: props.apiKey.name || '',
|
||||
@@ -472,6 +498,8 @@ function loadKeyData() {
|
||||
allowed_providers: props.apiKey.allowed_providers ? [...props.apiKey.allowed_providers] : [],
|
||||
allowed_api_formats: props.apiKey.allowed_api_formats ? [...props.apiKey.allowed_api_formats] : [],
|
||||
allowed_models: props.apiKey.allowed_models ? [...props.apiKey.allowed_models] : [],
|
||||
chat_pii_redaction_enabled: redactionFeature.enabled,
|
||||
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
|
||||
} as typeof form.value
|
||||
}
|
||||
|
||||
@@ -520,6 +548,10 @@ function handleSubmit() {
|
||||
allowed_providers: form.value.provider_unrestricted ? null : [...form.value.allowed_providers],
|
||||
allowed_api_formats: form.value.api_format_unrestricted ? null : [...form.value.allowed_api_formats],
|
||||
allowed_models: form.value.model_unrestricted ? null : [...form.value.allowed_models],
|
||||
feature_settings: mergeChatPiiRedactionFeatureSettings(props.apiKey?.feature_settings, {
|
||||
enabled: form.value.chat_pii_redaction_enabled,
|
||||
inject_model_instruction: form.value.chat_pii_redaction_placeholder_notice,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -269,31 +269,12 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 p-3 border rounded-lg bg-muted/50"
|
||||
:class="redactionModuleScope === 'all_providers' ? 'border-primary/30 bg-primary/5' : ''"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4 p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">敏感信息替换保护</span>
|
||||
<span class="text-sm font-medium">敏感信息保护</span>
|
||||
<p class="text-xs text-muted-foreground leading-relaxed">
|
||||
{{ redactionHelperText }}
|
||||
请前往模块管理-敏感信息保护中配置详细规则。
|
||||
</p>
|
||||
<p
|
||||
v-if="!redactionModuleEnabled"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
模块总开关未开启,保存此供应商不会立即生效。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ redactionSwitchLabel }}
|
||||
</span>
|
||||
<Switch
|
||||
:model-value="redactionSwitchValue"
|
||||
:disabled="redactionModuleScope === 'all_providers' || redactionModuleLoading"
|
||||
@update:model-value="(v: boolean) => form.chat_pii_redaction_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -341,12 +322,9 @@ import {
|
||||
updateProvider,
|
||||
type ProviderWithEndpointsSummary,
|
||||
} from '@/api/endpoints'
|
||||
import { modulesApi, type ChatPiiRedactionProviderScope } from '@/api/modules'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import { dateTimeLocalToRfc3339, formatDateTimeLocalInput } from '@/utils/date'
|
||||
import { getProviderRedactionConfig, withProviderRedactionConfig } from '@/features/providers/utils/providerRedactionPayload'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -362,9 +340,6 @@ const emit = defineEmits<{
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const loading = ref(false)
|
||||
const redactionModuleLoading = ref(false)
|
||||
const redactionModuleEnabled = ref(false)
|
||||
const redactionModuleScope = ref<ChatPiiRedactionProviderScope>('selected_providers')
|
||||
|
||||
// 内部状态
|
||||
const internalOpen = computed(() => props.modelValue)
|
||||
@@ -402,43 +377,8 @@ const form = ref({
|
||||
request_timeout: undefined as number | undefined,
|
||||
// 号池模式
|
||||
pool_mode_enabled: false,
|
||||
chat_pii_redaction_enabled: false,
|
||||
})
|
||||
|
||||
const redactionSwitchValue = computed(() => {
|
||||
if (redactionModuleScope.value === 'all_providers') {
|
||||
return redactionModuleEnabled.value
|
||||
}
|
||||
return form.value.chat_pii_redaction_enabled
|
||||
})
|
||||
|
||||
const redactionSwitchLabel = computed(() => {
|
||||
if (redactionModuleScope.value === 'all_providers') {
|
||||
return redactionModuleEnabled.value ? '继承开启' : '未生效'
|
||||
}
|
||||
return form.value.chat_pii_redaction_enabled ? '已开启' : '未开启'
|
||||
})
|
||||
|
||||
const redactionHelperText = computed(() => {
|
||||
if (redactionModuleScope.value === 'all_providers') {
|
||||
return '模块管理已设置为“全部供应商”,此供应商会自动执行替换保护。替换类型由模块管理中的“替换类型配置”决定。'
|
||||
}
|
||||
return '仅当“开启敏感信息替换保护”和此供应商开关都开启时生效。替换类型在模块管理的“替换类型配置”中统一选择,适用于所有已开启该功能的供应商。供应商只会看到占位符,客户端响应会自动还原。'
|
||||
})
|
||||
|
||||
async function loadRedactionModuleState() {
|
||||
redactionModuleLoading.value = true
|
||||
try {
|
||||
const config = await modulesApi.getChatPiiRedactionConfig()
|
||||
redactionModuleEnabled.value = config.enabled
|
||||
redactionModuleScope.value = config.provider_scope
|
||||
} catch (err) {
|
||||
log.warn('加载敏感信息替换保护模块配置失败', err)
|
||||
} finally {
|
||||
redactionModuleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
@@ -463,7 +403,6 @@ function resetForm() {
|
||||
request_timeout: undefined,
|
||||
// 号池模式
|
||||
pool_mode_enabled: false,
|
||||
chat_pii_redaction_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,7 +433,6 @@ function loadProviderData() {
|
||||
request_timeout: props.provider.request_timeout ?? undefined,
|
||||
// 号池模式
|
||||
pool_mode_enabled: poolAdvanced !== null,
|
||||
chat_pii_redaction_enabled: getProviderRedactionConfig(props.provider).enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,12 +446,6 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
||||
resetForm,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
loadRedactionModuleState()
|
||||
}
|
||||
})
|
||||
|
||||
// 新建模式下切换 provider_type 时不自动开启号池模式
|
||||
watch(() => form.value.provider_type, () => {
|
||||
if (!isEditMode.value) {
|
||||
@@ -548,7 +480,7 @@ const handleSubmit = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const currentPoolAdvanced = normalizePoolAdvancedConfig(props.provider?.pool_advanced)
|
||||
const basePayload = withProviderRedactionConfig({
|
||||
const basePayload = {
|
||||
name: form.value.name,
|
||||
provider_type: form.value.provider_type,
|
||||
description: form.value.description || undefined,
|
||||
@@ -568,7 +500,7 @@ const handleSubmit = async () => {
|
||||
pool_advanced: form.value.pool_mode_enabled
|
||||
? (currentPoolAdvanced ?? {})
|
||||
: null,
|
||||
}, form.value.chat_pii_redaction_enabled)
|
||||
}
|
||||
|
||||
if (isEditMode.value && props.provider) {
|
||||
// 更新提供商
|
||||
|
||||
@@ -209,6 +209,20 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-sm font-medium">敏感信息保护</Label>
|
||||
<Switch v-model="form.chat_pii_redaction_enabled" />
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between gap-3">
|
||||
<Label class="text-sm font-medium">占位符说明</Label>
|
||||
<Switch
|
||||
v-model="form.chat_pii_redaction_placeholder_notice"
|
||||
:disabled="!form.chat_pii_redaction_enabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -252,6 +266,10 @@ import { MultiSelect } from '@/components/common'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import {
|
||||
mergeChatPiiRedactionFeatureSettings,
|
||||
readChatPiiRedactionFeatureSettings,
|
||||
} from '@/utils/featureSettings'
|
||||
import {
|
||||
getPasswordPolicyHint,
|
||||
getPasswordPolicyPlaceholder,
|
||||
@@ -270,6 +288,7 @@ export interface UserFormData {
|
||||
role: 'admin' | 'user'
|
||||
is_active?: boolean
|
||||
group_ids?: string[]
|
||||
feature_settings?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -299,6 +318,8 @@ const form = ref({
|
||||
unlimited: false,
|
||||
is_active: true,
|
||||
group_ids: [] as string[],
|
||||
chat_pii_redaction_enabled: false,
|
||||
chat_pii_redaction_placeholder_notice: true,
|
||||
})
|
||||
|
||||
const groupOptions = computed(() => (props.groups || []).map((group) => ({
|
||||
@@ -322,12 +343,15 @@ function resetForm() {
|
||||
unlimited: false,
|
||||
is_active: true,
|
||||
group_ids: [],
|
||||
chat_pii_redaction_enabled: false,
|
||||
chat_pii_redaction_placeholder_notice: true,
|
||||
}
|
||||
}
|
||||
|
||||
function loadUserData() {
|
||||
if (!props.user) return
|
||||
formNonce.value = createFieldNonce()
|
||||
const redactionFeature = readChatPiiRedactionFeatureSettings(props.user.feature_settings)
|
||||
// 创建数组副本,避免与 props 数据共享引用
|
||||
form.value = {
|
||||
username: props.user.username,
|
||||
@@ -339,6 +363,8 @@ function loadUserData() {
|
||||
unlimited: props.user.unlimited ?? false,
|
||||
is_active: props.user.is_active ?? true,
|
||||
group_ids: props.user.group_ids ? [...props.user.group_ids] : [],
|
||||
chat_pii_redaction_enabled: redactionFeature.enabled,
|
||||
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +439,10 @@ async function handleSubmit() {
|
||||
unlimited: form.value.unlimited,
|
||||
role: form.value.role,
|
||||
group_ids: [...form.value.group_ids],
|
||||
feature_settings: mergeChatPiiRedactionFeatureSettings(props.user?.feature_settings, {
|
||||
enabled: form.value.chat_pii_redaction_enabled,
|
||||
inject_model_instruction: form.value.chat_pii_redaction_placeholder_notice,
|
||||
}),
|
||||
}
|
||||
|
||||
if (isEditMode.value && props.user?.id) {
|
||||
|
||||
51
frontend/src/utils/featureSettings.ts
Normal file
51
frontend/src/utils/featureSettings.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface ChatPiiRedactionFeatureSettings {
|
||||
enabled: boolean
|
||||
inject_model_instruction: boolean
|
||||
}
|
||||
|
||||
export type FeatureSettingsMap = Record<string, unknown>
|
||||
|
||||
const DEFAULT_CHAT_PII_REDACTION_FEATURE_SETTINGS: ChatPiiRedactionFeatureSettings = {
|
||||
enabled: false,
|
||||
inject_model_instruction: true,
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function readChatPiiRedactionFeatureSettings(
|
||||
featureSettings: unknown,
|
||||
): ChatPiiRedactionFeatureSettings {
|
||||
const feature = isRecord(featureSettings)
|
||||
? featureSettings.chat_pii_redaction
|
||||
: null
|
||||
if (!isRecord(feature)) {
|
||||
return { ...DEFAULT_CHAT_PII_REDACTION_FEATURE_SETTINGS }
|
||||
}
|
||||
return {
|
||||
enabled: feature.enabled === true,
|
||||
inject_model_instruction: feature.inject_model_instruction !== false,
|
||||
}
|
||||
}
|
||||
|
||||
export function hasChatPiiRedactionFeatureSettings(featureSettings: unknown): boolean {
|
||||
const feature = isRecord(featureSettings)
|
||||
? featureSettings.chat_pii_redaction
|
||||
: null
|
||||
return isRecord(feature)
|
||||
}
|
||||
|
||||
export function mergeChatPiiRedactionFeatureSettings(
|
||||
featureSettings: unknown,
|
||||
chatPiiRedaction: ChatPiiRedactionFeatureSettings,
|
||||
): FeatureSettingsMap | null {
|
||||
const settings: FeatureSettingsMap = isRecord(featureSettings)
|
||||
? { ...featureSettings }
|
||||
: {}
|
||||
settings.chat_pii_redaction = {
|
||||
enabled: chatPiiRedaction.enabled,
|
||||
inject_model_instruction: chatPiiRedaction.inject_model_instruction,
|
||||
}
|
||||
return Object.keys(settings).length > 0 ? settings : null
|
||||
}
|
||||
@@ -1206,7 +1206,8 @@ function editApiKey(apiKey: AdminApiKey) {
|
||||
auto_delete_on_expiry: apiKey.auto_delete_on_expiry || false,
|
||||
allowed_providers: apiKey.allowed_providers == null ? null : [...apiKey.allowed_providers],
|
||||
allowed_api_formats: apiKey.allowed_api_formats == null ? null : [...apiKey.allowed_api_formats],
|
||||
allowed_models: apiKey.allowed_models == null ? null : [...apiKey.allowed_models]
|
||||
allowed_models: apiKey.allowed_models == null ? null : [...apiKey.allowed_models],
|
||||
feature_settings: apiKey.feature_settings ?? null
|
||||
}
|
||||
|
||||
showKeyFormDialog.value = true
|
||||
@@ -1418,7 +1419,8 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
|
||||
// 空数组表示清除限制(允许全部),后端会将空数组存为 NULL
|
||||
allowed_providers: data.allowed_providers,
|
||||
allowed_api_formats: data.allowed_api_formats,
|
||||
allowed_models: data.allowed_models
|
||||
allowed_models: data.allowed_models,
|
||||
feature_settings: data.feature_settings ?? null
|
||||
}
|
||||
const { message: _, ...updated } = await adminApi.updateApiKey(data.id, updateData)
|
||||
// 局部更新:合并字段,避免覆盖丢失列表已有信息
|
||||
@@ -1448,7 +1450,8 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
|
||||
// 空数组表示不设置限制(允许全部),后端会将空数组存为 NULL
|
||||
allowed_providers: data.allowed_providers,
|
||||
allowed_api_formats: data.allowed_api_formats,
|
||||
allowed_models: data.allowed_models
|
||||
allowed_models: data.allowed_models,
|
||||
feature_settings: data.feature_settings ?? null
|
||||
}
|
||||
const response = await adminApi.createStandaloneApiKey(createData)
|
||||
newKeyValue.value = response.key
|
||||
|
||||
@@ -134,8 +134,8 @@
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="module.name === 'chat_pii_redaction'"
|
||||
class="mt-3 rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground"
|
||||
v-if="module.name === 'chat_pii_redaction' && getModuleStatusCopy(module)"
|
||||
class="mt-3 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ getModuleStatusCopy(module) }}
|
||||
</p>
|
||||
@@ -257,9 +257,9 @@ function getModuleStatusCopy(module: { name: string; enabled: boolean; active: b
|
||||
if (module.name !== 'chat_pii_redaction') {
|
||||
return module.enabled ? '已启用' : '已禁用'
|
||||
}
|
||||
if (!module.config_validated) return '配置异常,替换保护未生效'
|
||||
if (!module.enabled) return '全局替换开关未开启,所有供应商均不会执行替换保护'
|
||||
return '全局替换开关已开启,可在供应商中单独启用'
|
||||
if (!module.config_validated) return '配置异常'
|
||||
if (!module.enabled) return ''
|
||||
return '已开启'
|
||||
}
|
||||
|
||||
// 所有模块列表(按 admin_menu_order 排序)
|
||||
|
||||
@@ -1043,6 +1043,20 @@
|
||||
{{ editingUserApiKey ? '留空表示保持当前值,填 0 表示不限并发' : '留空表示不限并发,填 0 也表示不限并发' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-border bg-muted/30 p-3 space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-sm font-medium">敏感信息保护</Label>
|
||||
<Switch v-model="userApiKeyForm.chat_pii_redaction_enabled" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-sm font-medium">占位符说明</Label>
|
||||
<Switch
|
||||
v-model="userApiKeyForm.chat_pii_redaction_placeholder_notice"
|
||||
:disabled="!userApiKeyForm.chat_pii_redaction_enabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -1256,7 +1270,8 @@ import {
|
||||
AvatarFallback,
|
||||
Pagination,
|
||||
RefreshButton,
|
||||
Checkbox
|
||||
Checkbox,
|
||||
Switch
|
||||
} from '@/components/ui'
|
||||
|
||||
import {
|
||||
@@ -1284,6 +1299,10 @@ import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatTokens, formatRateLimitInheritable, formatRateLimitSimple, isRateLimitInherited, isRateLimitUnlimited } from '@/utils/format'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import {
|
||||
mergeChatPiiRedactionFeatureSettings,
|
||||
readChatPiiRedactionFeatureSettings,
|
||||
} from '@/utils/featureSettings'
|
||||
import { log } from '@/utils/logger'
|
||||
import { useBatchSelection } from '@/composables/useBatchSelection'
|
||||
|
||||
@@ -1315,6 +1334,8 @@ const userApiKeyForm = ref({
|
||||
name: '',
|
||||
rate_limit: undefined as number | undefined,
|
||||
concurrent_limit: undefined as number | undefined,
|
||||
chat_pii_redaction_enabled: false,
|
||||
chat_pii_redaction_placeholder_notice: true,
|
||||
})
|
||||
|
||||
// 用户统计
|
||||
@@ -1601,6 +1622,7 @@ function editUser(user: User) {
|
||||
role: user.role,
|
||||
is_active: user.is_active,
|
||||
group_ids: (user.groups || []).map(group => group.id),
|
||||
feature_settings: user.feature_settings ?? null,
|
||||
}
|
||||
showUserFormDialog.value = true
|
||||
}
|
||||
@@ -1621,6 +1643,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string; un
|
||||
unlimited: data.unlimited,
|
||||
role: data.role,
|
||||
group_ids: data.group_ids ?? [],
|
||||
feature_settings: data.feature_settings ?? null,
|
||||
}
|
||||
if (data.password) {
|
||||
updateData.password = data.password
|
||||
@@ -1638,6 +1661,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string; un
|
||||
unlimited: data.unlimited,
|
||||
role: data.role,
|
||||
group_ids: data.group_ids ?? [],
|
||||
feature_settings: data.feature_settings ?? null,
|
||||
})
|
||||
// 如果创建时指定为禁用,则更新状态
|
||||
if (data.is_active === false && newUser) {
|
||||
@@ -1684,21 +1708,27 @@ async function loadUserApiKeys(userId: string) {
|
||||
}
|
||||
|
||||
function openCreateUserApiKeyDialog() {
|
||||
const redactionFeature = readChatPiiRedactionFeatureSettings(null)
|
||||
userApiKeyForm.value = {
|
||||
name: `Key-${new Date().toISOString().split('T')[0]}`,
|
||||
rate_limit: undefined,
|
||||
concurrent_limit: undefined,
|
||||
chat_pii_redaction_enabled: redactionFeature.enabled,
|
||||
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
|
||||
}
|
||||
editingUserApiKey.value = null
|
||||
showUserApiKeyFormDialog.value = true
|
||||
}
|
||||
|
||||
function openEditUserApiKeyDialog(apiKey: ApiKey) {
|
||||
const redactionFeature = readChatPiiRedactionFeatureSettings(apiKey.feature_settings)
|
||||
editingUserApiKey.value = apiKey
|
||||
userApiKeyForm.value = {
|
||||
name: apiKey.name || '',
|
||||
rate_limit: apiKey.rate_limit ?? undefined,
|
||||
concurrent_limit: apiKey.concurrent_limit ?? undefined,
|
||||
chat_pii_redaction_enabled: redactionFeature.enabled,
|
||||
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
|
||||
}
|
||||
showUserApiKeyFormDialog.value = true
|
||||
}
|
||||
@@ -1710,6 +1740,8 @@ function closeUserApiKeyFormDialog() {
|
||||
name: '',
|
||||
rate_limit: undefined,
|
||||
concurrent_limit: undefined,
|
||||
chat_pii_redaction_enabled: false,
|
||||
chat_pii_redaction_placeholder_notice: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1727,6 +1759,10 @@ async function submitUserApiKeyForm() {
|
||||
name: userApiKeyForm.value.name,
|
||||
rate_limit: userApiKeyForm.value.rate_limit ?? 0,
|
||||
concurrent_limit: userApiKeyForm.value.concurrent_limit,
|
||||
feature_settings: mergeChatPiiRedactionFeatureSettings(editingUserApiKey.value.feature_settings, {
|
||||
enabled: userApiKeyForm.value.chat_pii_redaction_enabled,
|
||||
inject_model_instruction: userApiKeyForm.value.chat_pii_redaction_placeholder_notice,
|
||||
}),
|
||||
})
|
||||
success('API Key已更新')
|
||||
} else {
|
||||
@@ -1734,6 +1770,10 @@ async function submitUserApiKeyForm() {
|
||||
name: userApiKeyForm.value.name,
|
||||
rate_limit: userApiKeyForm.value.rate_limit ?? 0,
|
||||
concurrent_limit: userApiKeyForm.value.concurrent_limit,
|
||||
feature_settings: mergeChatPiiRedactionFeatureSettings(null, {
|
||||
enabled: userApiKeyForm.value.chat_pii_redaction_enabled,
|
||||
inject_model_instruction: userApiKeyForm.value.chat_pii_redaction_placeholder_notice,
|
||||
}),
|
||||
})
|
||||
newApiKey.value = response.key || ''
|
||||
showNewApiKeyDialog.value = true
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="敏感信息替换保护"
|
||||
description="对聊天消息中的手机号、邮箱、证件号、银行卡号、IP、API Key 与令牌进行可逆占位符替换,防止原文发送给上游供应商。"
|
||||
title="敏感信息保护"
|
||||
description="发送给供应商前将聊天消息中的敏感信息替换为占位符,返回客户端前自动还原。"
|
||||
:icon="ShieldCheck"
|
||||
>
|
||||
<template #actions>
|
||||
@@ -12,7 +12,7 @@
|
||||
@click="loadConfig"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-4 h-4 mr-2"
|
||||
class="mr-2 h-4 w-4"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
刷新
|
||||
@@ -29,27 +29,24 @@
|
||||
<div class="mt-6 space-y-6">
|
||||
<section class="rounded-2xl border border-border bg-card p-5">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="h-2.5 w-2.5 rounded-full ring-2 ring-offset-2 ring-offset-background"
|
||||
:class="redactionConfig.enabled ? 'bg-primary ring-primary/30' : 'bg-muted ring-muted/60'"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-if="statusLabel"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-primary ring-2 ring-primary/30 ring-offset-2 ring-offset-background" />
|
||||
<p class="text-sm font-semibold text-foreground">
|
||||
{{ statusLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="max-w-3xl text-sm text-muted-foreground">
|
||||
发送给供应商前将聊天消息中的敏感信息替换为占位符,返回客户端前自动还原。
|
||||
管理员只配置功能是否启用和匹配规则。用户、用户 Key、独立余额 Key 可在各自配置中附加此功能。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-xl border border-border bg-muted/40 px-4 py-3">
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
开启敏感信息替换保护
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
关闭后所有供应商均不会执行替换
|
||||
启用敏感信息保护
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -61,87 +58,133 @@
|
||||
</section>
|
||||
|
||||
<CardSection
|
||||
title="启用范围"
|
||||
description="关闭后,所有供应商都不会执行替换;开启后,按下方“启用范围”决定是全部供应商生效还是指定供应商生效。"
|
||||
title="替换类型配置"
|
||||
description="统一配置所有可用规则。"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<button
|
||||
v-for="option in scopeOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="rounded-xl border p-4 text-left transition-all duration-200"
|
||||
:class="redactionConfig.provider_scope === option.value
|
||||
? 'border-primary bg-primary/10 text-primary shadow-sm'
|
||||
: 'border-border bg-card/70 text-muted-foreground hover:border-primary/50 hover:text-foreground'"
|
||||
@click="redactionConfig.provider_scope = option.value"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm font-semibold">{{ option.label }}</span>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="redactionConfig.provider_scope === option.value ? 'bg-primary' : 'bg-muted'"
|
||||
/>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
规则按表格顺序保存。系统预置规则可直接修改,自定义规则可删除。
|
||||
</div>
|
||||
<p class="mt-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{{ option.helper }}
|
||||
</p>
|
||||
</button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="addCustomRule"
|
||||
>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
新增规则
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto rounded-xl border border-border">
|
||||
<table class="min-w-[920px] w-full text-sm">
|
||||
<thead class="bg-muted/50 text-left text-xs font-medium text-muted-foreground">
|
||||
<tr>
|
||||
<th class="w-[220px] px-4 py-3">
|
||||
规则名称
|
||||
</th>
|
||||
<th class="px-4 py-3">
|
||||
正则
|
||||
</th>
|
||||
<th class="w-[120px] px-4 py-3">
|
||||
是否启用
|
||||
</th>
|
||||
<th class="w-[150px] px-4 py-3 text-right">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(rule, index) in redactionConfig.rules"
|
||||
:key="rule.id"
|
||||
class="border-t border-border align-top"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<Input
|
||||
:model-value="rule.name"
|
||||
class="h-9"
|
||||
@update:model-value="(value) => updateRule(index, { name: String(value) })"
|
||||
/>
|
||||
<div
|
||||
v-if="rule.system"
|
||||
class="mt-1 text-[11px] text-muted-foreground"
|
||||
>
|
||||
系统预置
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<Textarea
|
||||
:model-value="rule.pattern"
|
||||
class="min-h-[72px] font-mono text-xs"
|
||||
@update:model-value="(value) => updateRule(index, { pattern: String(value) })"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<Switch
|
||||
:model-value="rule.enabled"
|
||||
@update:model-value="(value: boolean) => updateRule(index, { enabled: value })"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button
|
||||
v-if="rule.system"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="恢复默认"
|
||||
@click="resetSystemRule(index)"
|
||||
>
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!rule.system"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-destructive"
|
||||
title="删除"
|
||||
@click="removeRule(index)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<CardSection
|
||||
title="替换类型配置"
|
||||
description="适用于所有已开启该功能的供应商。"
|
||||
title="占位符配置"
|
||||
description="配置供应商侧看到的占位符前缀。"
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<div
|
||||
v-for="group in entityGroups"
|
||||
:key="group.title"
|
||||
class="rounded-xl border border-border bg-muted/30 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-foreground">
|
||||
{{ group.title }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
已选择 {{ selectedCount(group.entities) }} / {{ group.entities.length }} 项
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="setGroupSelection(group.entities, selectedCount(group.entities) !== group.entities.length)"
|
||||
>
|
||||
{{ selectedCount(group.entities) === group.entities.length ? '清除本组' : '选择本组' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label
|
||||
v-for="entity in group.entities"
|
||||
:key="entity.key"
|
||||
class="flex items-start gap-3 rounded-lg border border-border bg-card/70 px-3 py-3 text-sm transition-colors hover:border-primary/40"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="redactionConfig.entities.includes(entity.key)"
|
||||
@update:checked="(checked: boolean) => toggleEntity(entity.key, checked)"
|
||||
/>
|
||||
<span class="leading-tight">
|
||||
<span class="block font-medium text-foreground">{{ entity.label }}</span>
|
||||
<span class="mt-1 block text-xs text-muted-foreground">{{ entity.description }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-[minmax(0,320px)_1fr] md:items-start">
|
||||
<div class="space-y-2">
|
||||
<Input
|
||||
:model-value="redactionConfig.placeholder_prefix"
|
||||
class="h-9 font-mono uppercase"
|
||||
maxlength="32"
|
||||
@update:model-value="(value) => redactionConfig.placeholder_prefix = normalizePlaceholderPrefixInput(String(value))"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
仅支持字母、数字、下划线,保存后统一转为大写。
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-border bg-card px-4 py-3 text-sm text-muted-foreground">
|
||||
真实姓名、地址、公司名暂不支持自动识别,避免误判影响模型理解。
|
||||
<div class="rounded-xl border border-border bg-muted/40 px-4 py-3 text-sm">
|
||||
<span class="text-muted-foreground">示例:</span>
|
||||
<code class="ml-2 rounded bg-background px-2 py-1 font-mono text-xs text-foreground">
|
||||
<{{ redactionConfig.placeholder_prefix || 'AETHER' }}:EMAIL:ABCDEFGHIJKLMNOPQRST>
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<CardSection
|
||||
title="多轮上下文缓存"
|
||||
description="此时间控制“真实值 ↔ 占位符”映射在 Redis 中的缓存窗口。窗口内相同敏感值使用相同占位符,以减少上游 prompt cache 失效;窗口过期后新请求会生成新的占位符。缓存写入 Redis,必须设置 TTL,不写入数据库或日志。"
|
||||
description="此时间控制真实值与占位符映射在 Redis 中的缓存窗口。"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<button
|
||||
@@ -161,115 +204,46 @@
|
||||
</button>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<CardSection title="模型提示说明">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
向模型说明占位符含义
|
||||
</p>
|
||||
<p class="max-w-3xl text-xs leading-relaxed text-muted-foreground">
|
||||
开启后,Aether 会在发往供应商的请求中插入一条简短内部说明,说明 `<AETHER:TYPE:ID>` 是已保护的真实信息占位符,应按对应类型正常理解和处理,不要要求用户重新提供原文。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="redactionConfig.inject_model_instruction"
|
||||
@update:model-value="(value: boolean) => redactionConfig.inject_model_instruction = value"
|
||||
/>
|
||||
</div>
|
||||
</CardSection>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RefreshCw, ShieldCheck } from 'lucide-vue-next'
|
||||
import { Plus, RefreshCw, RotateCcw, ShieldCheck, Trash2 } from 'lucide-vue-next'
|
||||
import { PageContainer, PageHeader, CardSection } from '@/components/layout'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import { modulesApi, type ChatPiiRedactionConfig, type ChatPiiRedactionEntity, type ChatPiiRedactionProviderScope } from '@/api/modules'
|
||||
import Textarea from '@/components/ui/textarea.vue'
|
||||
import {
|
||||
CHAT_PII_REDACTION_DEFAULT_RULES,
|
||||
modulesApi,
|
||||
type ChatPiiRedactionConfig,
|
||||
type ChatPiiRedactionRule,
|
||||
} from '@/api/modules'
|
||||
import { useModuleStore } from '@/stores/modules'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
interface EntityOption {
|
||||
key: ChatPiiRedactionEntity
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const defaultConfig: ChatPiiRedactionConfig = {
|
||||
enabled: false,
|
||||
provider_scope: 'selected_providers',
|
||||
entities: [
|
||||
'email',
|
||||
'cn_phone',
|
||||
'global_phone',
|
||||
'cn_id',
|
||||
'payment_card',
|
||||
'ipv4',
|
||||
'ipv6',
|
||||
'api_key',
|
||||
'access_token',
|
||||
'secret_key',
|
||||
'bearer_token',
|
||||
'jwt',
|
||||
],
|
||||
rules: CHAT_PII_REDACTION_DEFAULT_RULES.map(rule => ({ ...rule })),
|
||||
cache_ttl_seconds: 300,
|
||||
inject_model_instruction: true,
|
||||
placeholder_prefix: 'AETHER',
|
||||
}
|
||||
|
||||
const scopeOptions: Array<{ value: ChatPiiRedactionProviderScope; label: string; helper: string }> = [
|
||||
{
|
||||
value: 'all_providers',
|
||||
label: '全部供应商',
|
||||
helper: '所有供应商都会执行替换保护,无需逐个开启。',
|
||||
},
|
||||
{
|
||||
value: 'selected_providers',
|
||||
label: '指定供应商',
|
||||
helper: '仅对在供应商管理中开启的供应商生效。',
|
||||
},
|
||||
]
|
||||
|
||||
const ttlOptions = [
|
||||
{
|
||||
value: 300 as const,
|
||||
label: '5 分钟(默认)',
|
||||
helper: '更保守,适合短对话或较高隐私偏好;同一敏感信息在 5 分钟内保持相同占位符。',
|
||||
helper: '适合短对话,同一敏感信息在 5 分钟内保持相同占位符。',
|
||||
},
|
||||
{
|
||||
value: 3600 as const,
|
||||
label: '1 小时',
|
||||
helper: '更适合长多轮对话,可减少重复上下文检测成本;同一敏感信息在 1 小时内保持相同占位符。',
|
||||
},
|
||||
]
|
||||
|
||||
const entityGroups: Array<{ title: string; entities: EntityOption[] }> = [
|
||||
{
|
||||
title: '个人信息',
|
||||
entities: [
|
||||
{ key: 'email', label: '邮箱', description: '识别常见电子邮箱地址。' },
|
||||
{ key: 'cn_phone', label: '手机号/固话', description: '识别中国大陆手机号和固定电话。' },
|
||||
{ key: 'global_phone', label: '全球电话号码', description: '识别 E.164 风格国际号码。' },
|
||||
{ key: 'cn_id', label: '中国大陆身份证号', description: '识别通过校验的居民身份证号。' },
|
||||
{ key: 'payment_card', label: '银行卡号', description: '识别通过 Luhn 校验的卡号。' },
|
||||
{ key: 'ipv4', label: 'IPv4', description: '识别 IPv4 地址。' },
|
||||
{ key: 'ipv6', label: 'IPv6', description: '识别 IPv6 地址。' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '密钥与令牌',
|
||||
entities: [
|
||||
{ key: 'api_key', label: 'API Key', description: '识别 OpenAI、Anthropic、GitHub、Slack、AWS 等常见密钥。' },
|
||||
{ key: 'access_token', label: 'Access Token', description: '识别访问令牌形态的敏感凭证。' },
|
||||
{ key: 'secret_key', label: 'Secret Key', description: '识别高熵密钥和 secret 字段值。' },
|
||||
{ key: 'bearer_token', label: 'Bearer Token', description: '识别 Authorization Bearer 凭证。' },
|
||||
{ key: 'jwt', label: 'JWT', description: '识别三段式 JWT 令牌。' },
|
||||
],
|
||||
helper: '适合长多轮对话,同一敏感信息在 1 小时内保持相同占位符。',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -278,20 +252,86 @@ const { success, error } = useToast()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const redactionConfig = ref<ChatPiiRedactionConfig>({ ...defaultConfig })
|
||||
const originalConfig = ref<ChatPiiRedactionConfig>({ ...defaultConfig })
|
||||
const redactionConfig = ref<ChatPiiRedactionConfig>(cloneConfig(defaultConfig))
|
||||
const originalConfig = ref<ChatPiiRedactionConfig>(cloneConfig(defaultConfig))
|
||||
|
||||
const hasChanges = computed(() => JSON.stringify(redactionConfig.value) !== JSON.stringify(originalConfig.value))
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const moduleStatus = moduleStore.modules.chat_pii_redaction
|
||||
if (moduleStatus && !moduleStatus.config_validated) return '配置异常,替换保护未生效'
|
||||
if (!redactionConfig.value.enabled) return '全局替换开关未开启,所有供应商均不会执行替换保护'
|
||||
return redactionConfig.value.provider_scope === 'all_providers'
|
||||
? '全局替换开关已开启,全部供应商都会执行替换保护'
|
||||
: '全局替换开关已开启,可在供应商中单独启用'
|
||||
if (moduleStatus && !moduleStatus.config_validated) return '配置异常'
|
||||
return redactionConfig.value.enabled ? '已开启' : ''
|
||||
})
|
||||
|
||||
function cloneConfig(config: ChatPiiRedactionConfig): ChatPiiRedactionConfig {
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
rules: config.rules.map(rule => ({ ...rule })),
|
||||
cache_ttl_seconds: config.cache_ttl_seconds,
|
||||
placeholder_prefix: config.placeholder_prefix || 'AETHER',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlaceholderPrefixInput(value: string): string {
|
||||
return value.toUpperCase().replace(/[^A-Z0-9_]/g, '').slice(0, 32)
|
||||
}
|
||||
|
||||
function updateRule(index: number, patch: Partial<ChatPiiRedactionRule>) {
|
||||
const rules = [...redactionConfig.value.rules]
|
||||
rules[index] = { ...rules[index], ...patch }
|
||||
redactionConfig.value.rules = rules
|
||||
}
|
||||
|
||||
function addCustomRule() {
|
||||
redactionConfig.value.rules = [
|
||||
...redactionConfig.value.rules,
|
||||
{
|
||||
id: `custom_${Date.now().toString(36)}`,
|
||||
name: '自定义规则',
|
||||
pattern: '',
|
||||
enabled: true,
|
||||
system: false,
|
||||
features: null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function removeRule(index: number) {
|
||||
redactionConfig.value.rules = redactionConfig.value.rules.filter((_, itemIndex) => itemIndex !== index)
|
||||
}
|
||||
|
||||
function resetSystemRule(index: number) {
|
||||
const rule = redactionConfig.value.rules[index]
|
||||
const defaultRule = CHAT_PII_REDACTION_DEFAULT_RULES.find(item => item.id === rule.id)
|
||||
if (!defaultRule) return
|
||||
updateRule(index, { ...defaultRule })
|
||||
}
|
||||
|
||||
function sanitizeRules(): ChatPiiRedactionRule[] | null {
|
||||
const seen = new Set<string>()
|
||||
const rules: ChatPiiRedactionRule[] = []
|
||||
for (const [index, rule] of redactionConfig.value.rules.entries()) {
|
||||
const id = (rule.id || `custom_${index + 1}`).trim()
|
||||
const name = rule.name.trim()
|
||||
const pattern = rule.pattern.trim()
|
||||
if (!name || !pattern) {
|
||||
error('规则名称和正则不能为空')
|
||||
return null
|
||||
}
|
||||
const uniqueId = seen.has(id) ? `${id}_${index + 1}` : id
|
||||
seen.add(uniqueId)
|
||||
rules.push({
|
||||
id: uniqueId,
|
||||
name,
|
||||
pattern,
|
||||
enabled: rule.enabled,
|
||||
system: rule.system === true,
|
||||
features: rule.features ?? null,
|
||||
})
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -299,57 +339,42 @@ async function loadConfig() {
|
||||
modulesApi.getChatPiiRedactionConfig(),
|
||||
moduleStore.fetchModules(),
|
||||
])
|
||||
redactionConfig.value = { ...config }
|
||||
originalConfig.value = { ...config }
|
||||
redactionConfig.value = cloneConfig(config)
|
||||
originalConfig.value = cloneConfig(config)
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '加载敏感信息替换保护配置失败'))
|
||||
log.error('加载敏感信息替换保护配置失败:', err)
|
||||
error(parseApiError(err, '加载敏感信息保护配置失败'))
|
||||
log.error('加载敏感信息保护配置失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const rules = sanitizeRules()
|
||||
if (!rules) return
|
||||
const placeholderPrefix = normalizePlaceholderPrefixInput(redactionConfig.value.placeholder_prefix).trim()
|
||||
if (!placeholderPrefix) {
|
||||
error('占位符前缀不能为空')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const saved = await modulesApi.updateChatPiiRedactionConfig(redactionConfig.value)
|
||||
redactionConfig.value = { ...saved }
|
||||
originalConfig.value = { ...saved }
|
||||
const saved = await modulesApi.updateChatPiiRedactionConfig({
|
||||
...redactionConfig.value,
|
||||
placeholder_prefix: placeholderPrefix,
|
||||
rules,
|
||||
})
|
||||
redactionConfig.value = cloneConfig(saved)
|
||||
originalConfig.value = cloneConfig(saved)
|
||||
await moduleStore.fetchModules()
|
||||
success('敏感信息替换保护配置已保存')
|
||||
success('敏感信息保护配置已保存')
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '保存敏感信息替换保护配置失败'))
|
||||
log.error('保存敏感信息替换保护配置失败:', err)
|
||||
error(parseApiError(err, '保存敏感信息保护配置失败'))
|
||||
log.error('保存敏感信息保护配置失败:', err)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectedCount(entities: EntityOption[]) {
|
||||
return entities.filter((entity) => redactionConfig.value.entities.includes(entity.key)).length
|
||||
}
|
||||
|
||||
function toggleEntity(entity: ChatPiiRedactionEntity, checked: boolean) {
|
||||
const current = new Set(redactionConfig.value.entities)
|
||||
if (checked) {
|
||||
current.add(entity)
|
||||
} else {
|
||||
current.delete(entity)
|
||||
}
|
||||
redactionConfig.value.entities = defaultConfig.entities.filter((item) => current.has(item))
|
||||
}
|
||||
|
||||
function setGroupSelection(entities: EntityOption[], checked: boolean) {
|
||||
const current = new Set(redactionConfig.value.entities)
|
||||
for (const entity of entities) {
|
||||
if (checked) {
|
||||
current.add(entity.key)
|
||||
} else {
|
||||
current.delete(entity.key)
|
||||
}
|
||||
}
|
||||
redactionConfig.value.entities = defaultConfig.entities.filter((item) => current.has(item))
|
||||
}
|
||||
|
||||
onMounted(loadConfig)
|
||||
</script>
|
||||
|
||||
@@ -450,6 +450,57 @@
|
||||
{{ editingApiKey ? '留空表示保持当前值,填 0 表示不限并发' : '留空表示不限并发,填 0 也表示不限并发' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-border/60 bg-muted/30 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<Label class="text-sm font-semibold">敏感信息保护</Label>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ keyRedactionMode === 'inherit' ? '默认跟随账户设置' : '管理员开启功能后生效' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="keyRedactionMode === 'inherit' ? 'default' : 'outline'"
|
||||
@click="keyRedactionMode = 'inherit'"
|
||||
>
|
||||
跟随账户
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="keyRedactionMode === 'custom' ? 'default' : 'outline'"
|
||||
@click="keyRedactionMode = 'custom'"
|
||||
>
|
||||
单独配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="keyRedactionMode === 'custom'"
|
||||
class="mt-4 flex items-center justify-between gap-4 border-t border-border/50 pt-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="text-sm font-medium">启用保护</Label>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
只影响此 API Key
|
||||
</p>
|
||||
</div>
|
||||
<Switch v-model="newKeyRedactionEnabled" />
|
||||
</div>
|
||||
<div
|
||||
v-if="keyRedactionMode === 'custom' && newKeyRedactionEnabled"
|
||||
class="mt-4 flex items-center justify-between gap-4 border-t border-border/50 pt-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="text-sm font-medium">占位符说明</Label>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
向模型说明占位符含义
|
||||
</p>
|
||||
</div>
|
||||
<Switch v-model="newKeyRedactionInjectNotice" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -667,6 +718,7 @@ import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import { Dialog, Pagination } from '@/components/ui'
|
||||
import { LoadingState, AlertDialog, EmptyState } from '@/components/common'
|
||||
import {
|
||||
@@ -685,6 +737,11 @@ import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatRateLimitSimple } from '@/utils/format'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import { getErrorStatus } from '@/types/api-error'
|
||||
import {
|
||||
hasChatPiiRedactionFeatureSettings,
|
||||
mergeChatPiiRedactionFeatureSettings,
|
||||
readChatPiiRedactionFeatureSettings,
|
||||
} from '@/utils/featureSettings'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
@@ -722,6 +779,9 @@ const showInstallDialog = ref(false)
|
||||
const newKeyName = ref('')
|
||||
const newKeyRateLimit = ref<number | undefined>(undefined)
|
||||
const newKeyConcurrentLimit = ref<number | undefined>(undefined)
|
||||
const keyRedactionMode = ref<'inherit' | 'custom'>('inherit')
|
||||
const newKeyRedactionEnabled = ref(false)
|
||||
const newKeyRedactionInjectNotice = ref(true)
|
||||
const newKeyValue = ref('')
|
||||
const keyToDelete = ref<ApiKey | null>(null)
|
||||
const editingApiKey = ref<ApiKey | null>(null)
|
||||
@@ -801,10 +861,15 @@ function resetInstallCopiedState() {
|
||||
}
|
||||
|
||||
function openEditApiKeyDialog(apiKey: ApiKey) {
|
||||
const hasRedactionFeature = hasChatPiiRedactionFeatureSettings(apiKey.feature_settings)
|
||||
const redactionFeature = readChatPiiRedactionFeatureSettings(apiKey.feature_settings)
|
||||
editingApiKey.value = apiKey
|
||||
newKeyName.value = apiKey.name || ''
|
||||
newKeyRateLimit.value = apiKey.rate_limit ?? undefined
|
||||
newKeyConcurrentLimit.value = apiKey.concurrent_limit ?? undefined
|
||||
keyRedactionMode.value = hasRedactionFeature ? 'custom' : 'inherit'
|
||||
newKeyRedactionEnabled.value = redactionFeature.enabled
|
||||
newKeyRedactionInjectNotice.value = redactionFeature.inject_model_instruction
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
|
||||
@@ -813,6 +878,9 @@ function openCreateApiKeyDialog() {
|
||||
newKeyName.value = ''
|
||||
newKeyRateLimit.value = undefined
|
||||
newKeyConcurrentLimit.value = undefined
|
||||
keyRedactionMode.value = 'inherit'
|
||||
newKeyRedactionEnabled.value = false
|
||||
newKeyRedactionInjectNotice.value = true
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
|
||||
@@ -889,6 +957,9 @@ function closeApiKeyDialog() {
|
||||
newKeyName.value = ''
|
||||
newKeyRateLimit.value = undefined
|
||||
newKeyConcurrentLimit.value = undefined
|
||||
keyRedactionMode.value = 'inherit'
|
||||
newKeyRedactionEnabled.value = false
|
||||
newKeyRedactionInjectNotice.value = true
|
||||
}
|
||||
|
||||
async function saveApiKey() {
|
||||
@@ -905,6 +976,12 @@ async function saveApiKey() {
|
||||
name: newKeyName.value,
|
||||
rate_limit: newKeyRateLimit.value ?? 0,
|
||||
concurrent_limit: newKeyConcurrentLimit.value,
|
||||
feature_settings: keyRedactionMode.value === 'custom'
|
||||
? mergeChatPiiRedactionFeatureSettings(editingApiKey.value.feature_settings, {
|
||||
enabled: newKeyRedactionEnabled.value,
|
||||
inject_model_instruction: newKeyRedactionInjectNotice.value,
|
||||
})
|
||||
: null,
|
||||
})
|
||||
success('API 密钥更新成功')
|
||||
} else {
|
||||
@@ -912,6 +989,14 @@ async function saveApiKey() {
|
||||
name: newKeyName.value,
|
||||
rate_limit: newKeyRateLimit.value ?? 0,
|
||||
concurrent_limit: newKeyConcurrentLimit.value,
|
||||
...(keyRedactionMode.value === 'custom'
|
||||
? {
|
||||
feature_settings: mergeChatPiiRedactionFeatureSettings(null, {
|
||||
enabled: newKeyRedactionEnabled.value,
|
||||
inject_model_instruction: newKeyRedactionInjectNotice.value,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
newKeyValue.value = newKey.key || ''
|
||||
if (isCreatingFirstApiKey) {
|
||||
|
||||
@@ -81,6 +81,49 @@
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-foreground">
|
||||
敏感信息保护
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
管理员开启功能后,可默认应用到你的账户和 API Key
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="savingFeatureSettings || !hasFeatureSettingsChanges"
|
||||
@click="updateFeatureSettings"
|
||||
>
|
||||
{{ savingFeatureSettings ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-muted/30 px-4 py-3">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">默认启用</Label>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
未单独配置的 API Key 会跟随此设置
|
||||
</p>
|
||||
</div>
|
||||
<Switch v-model="featureSettingsForm.chatPiiRedactionEnabled" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-muted/30 px-4 py-3">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">占位符说明</Label>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
向模型说明占位符含义
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
v-model="featureSettingsForm.chatPiiRedactionInjectNotice"
|
||||
:disabled="!featureSettingsForm.chatPiiRedactionEnabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 密码设置(LDAP 用户不显示) -->
|
||||
<Card
|
||||
v-if="profile?.auth_source !== 'ldap'"
|
||||
@@ -626,6 +669,10 @@ import { formatCurrency } from '@/utils/format'
|
||||
import { getApiUrl } from '@/utils/url'
|
||||
import { log } from '@/utils/logger'
|
||||
import { getErrorMessage, getErrorStatus } from '@/types/api-error'
|
||||
import {
|
||||
mergeChatPiiRedactionFeatureSettings,
|
||||
readChatPiiRedactionFeatureSettings,
|
||||
} from '@/utils/featureSettings'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
@@ -660,7 +707,13 @@ const preferencesForm = ref({
|
||||
}
|
||||
})
|
||||
|
||||
const featureSettingsForm = ref({
|
||||
chatPiiRedactionEnabled: false,
|
||||
chatPiiRedactionInjectNotice: true,
|
||||
})
|
||||
|
||||
const savingProfile = ref(false)
|
||||
const savingFeatureSettings = ref(false)
|
||||
const changingPassword = ref(false)
|
||||
const sessionsLoading = ref(false)
|
||||
const sessionActionLoading = ref<string | null>(null)
|
||||
@@ -679,6 +732,7 @@ const emailConfigured = ref(false) // 系统是否配置了邮箱服务
|
||||
// 原始值,用于检测是否有修改
|
||||
const originalProfileForm = ref({ email: '', username: '' })
|
||||
const originalPreferencesForm = ref({ avatar_url: '', bio: '' })
|
||||
const originalFeatureSettingsForm = ref({ ...featureSettingsForm.value })
|
||||
|
||||
// 检测基本信息是否有修改
|
||||
const hasProfileChanges = computed(() => {
|
||||
@@ -690,6 +744,13 @@ const hasProfileChanges = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const hasFeatureSettingsChanges = computed(() => {
|
||||
return (
|
||||
featureSettingsForm.value.chatPiiRedactionEnabled !== originalFeatureSettingsForm.value.chatPiiRedactionEnabled ||
|
||||
featureSettingsForm.value.chatPiiRedactionInjectNotice !== originalFeatureSettingsForm.value.chatPiiRedactionInjectNotice
|
||||
)
|
||||
})
|
||||
|
||||
const passwordPolicyHint = computed(() => getPasswordPolicyHint(passwordPolicyLevel.value))
|
||||
const passwordError = computed(() =>
|
||||
validatePasswordByPolicy(passwordForm.value.new_password, passwordPolicyLevel.value)
|
||||
@@ -752,14 +813,45 @@ async function loadProfile() {
|
||||
email: profile.value.email || '',
|
||||
username: profile.value.username
|
||||
}
|
||||
const redactionFeature = readChatPiiRedactionFeatureSettings(profile.value.feature_settings)
|
||||
featureSettingsForm.value = {
|
||||
chatPiiRedactionEnabled: redactionFeature.enabled,
|
||||
chatPiiRedactionInjectNotice: redactionFeature.inject_model_instruction,
|
||||
}
|
||||
// 保存原始值
|
||||
originalProfileForm.value = { ...profileForm.value }
|
||||
originalFeatureSettingsForm.value = { ...featureSettingsForm.value }
|
||||
} catch (error) {
|
||||
log.error('加载个人信息失败:', error)
|
||||
showError('加载个人信息失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function updateFeatureSettings() {
|
||||
savingFeatureSettings.value = true
|
||||
try {
|
||||
await meApi.updateProfile({
|
||||
feature_settings: mergeChatPiiRedactionFeatureSettings(profile.value?.feature_settings, {
|
||||
enabled: featureSettingsForm.value.chatPiiRedactionEnabled,
|
||||
inject_model_instruction: featureSettingsForm.value.chatPiiRedactionInjectNotice,
|
||||
}),
|
||||
})
|
||||
if (profile.value) {
|
||||
profile.value.feature_settings = mergeChatPiiRedactionFeatureSettings(profile.value.feature_settings, {
|
||||
enabled: featureSettingsForm.value.chatPiiRedactionEnabled,
|
||||
inject_model_instruction: featureSettingsForm.value.chatPiiRedactionInjectNotice,
|
||||
})
|
||||
}
|
||||
originalFeatureSettingsForm.value = { ...featureSettingsForm.value }
|
||||
success('敏感信息保护设置已保存')
|
||||
} catch (err) {
|
||||
log.error('更新敏感信息保护设置失败:', err)
|
||||
showError(getErrorMessage(err), '更新敏感信息保护设置失败')
|
||||
} finally {
|
||||
savingFeatureSettings.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
sessionsLoading.value = true
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user