From 6476bd868f8d60f509ec1fd8144dd04e66f75978 Mon Sep 17 00:00:00 2001 From: hailin Date: Wed, 25 Feb 2026 22:32:25 -0800 Subject: [PATCH] =?UTF-8?q?feat(llm-gateway):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=AF=B9=E5=A4=96=20LLM=20API=20=E4=BB=A3=E7=90=86=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=20=E2=80=94=20=E5=AE=8C=E6=95=B4=E7=9A=84=E7=9B=91?= =?UTF-8?q?=E7=AE=A1=E6=B3=A8=E5=85=A5=E3=80=81=E5=86=85=E5=AE=B9=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E5=92=8C=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 新增微服务: llm-gateway (端口 3008) 对外提供与 Anthropic/OpenAI 完全兼容的 API 接口,中间拦截实现: - API Key 认证:由我们分配 Key 给外部用户,SHA-256 哈希存储 - System Prompt 注入:在请求转发前注入监管合规内容(支持 prepend/append) - 内容审查过滤:对用户消息进行关键词/正则匹配,支持 block/warn/log 三种动作 - 用量记录:异步批量写入,跟踪 token 消耗和费用估算 - 审计日志:记录每次请求的来源 IP、过滤状态、注入状态等 - 速率限制:基于内存滑动窗口的 RPM 限制 ### 技术选型 - Fastify (非 NestJS):纯代理场景无需 DI 容器,路由开销 ~2ms - SSE 流式管道:零缓冲直通,支持 Anthropic streaming 和 OpenAI streaming - 规则缓存:30 秒 TTL,避免每次请求查库 ### API 端点 - POST /v1/messages — Anthropic Messages API 代理(流式+非流式) - POST /v1/embeddings — OpenAI Embeddings API 代理 - POST /v1/chat/completions — OpenAI Chat Completions API 代理 - GET /health — 健康检查 ## 数据库 (5 张新表) - gateway_api_keys: 外部用户 API Key(权限、限速、预算、过期时间) - gateway_injection_rules: 监管内容注入规则(位置、匹配模型、匹配 Key) - gateway_content_rules: 内容审查规则(关键词/正则、block/warn/log) - gateway_usage_logs: Token 用量记录(按 Key、模型、提供商统计) - gateway_audit_logs: 请求审计日志(IP、过滤状态、注入状态) ## Admin 后端 (conversation-service) 4 个 NestJS 控制器,挂载在 /conversations/admin/gateway/ 下: - AdminGatewayKeysController: Key 的 CRUD + toggle - AdminGatewayInjectionRulesController: 注入规则 CRUD + toggle - AdminGatewayContentRulesController: 内容审查规则 CRUD + toggle - AdminGatewayDashboardController: 仪表盘汇总、用量查询、审计日志查询 5 个 ORM 实体文件对应 5 张数据库表。 ## Admin 前端 (admin-client) 新增 features/llm-gateway 模块,Tabs 布局包含 5 个管理面板: - API Key Tab: 创建/删除/启停 Key,创建时一次性显示完整 Key - 注入规则 Tab: 配置监管内容(前置/追加到 system prompt) - 内容审查 Tab: 配置关键词/正则过滤规则 - 用量统计 Tab: 查看 token 消耗、费用、响应时间 - 审计日志 Tab: 查看请求记录、过滤命中、注入状态 菜单项: GatewayOutlined + "LLM 网关",位于"系统总监"和"数据分析"之间。 ## 基础设施 - docker-compose.yml: 新增 llm-gateway 服务定义 - kong.yml: 新增 /v1/messages、/v1/embeddings、/v1/chat/completions 路由 - 超时设置 300 秒(LLM 长响应) - CORS 新增 X-Api-Key、anthropic-version、anthropic-beta 头 - init-db.sql: 新增 5 张 gateway 表的建表语句 ## 架构说明 内部服务(conversation-service、knowledge-service、evolution-service)继续直连 API, llm-gateway 仅服务外部用户。两者通过共享 PostgreSQL 数据库关联配置。 Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 34 + kong/kong.yml | 25 + packages/admin-client/src/App.tsx | 2 + .../llm-gateway/application/useLLMGateway.ts | 209 ++++++ .../src/features/llm-gateway/index.ts | 1 + .../infrastructure/llm-gateway.api.ts | 176 +++++ .../presentation/components/ApiKeysTab.tsx | 137 ++++ .../presentation/components/AuditLogsTab.tsx | 55 ++ .../components/ContentRulesTab.tsx | 109 ++++ .../components/InjectionRulesTab.tsx | 99 +++ .../components/UsageDashboardTab.tsx | 55 ++ .../presentation/pages/LLMGatewayPage.tsx | 103 +++ .../src/shared/components/MainLayout.tsx | 6 + .../inbound/admin-gateway.controller.ts | 605 ++++++++++++++++++ .../src/conversation/conversation.module.ts | 15 +- .../postgres/entities/gateway-api-key.orm.ts | 60 ++ .../entities/gateway-audit-log.orm.ts | 47 ++ .../entities/gateway-content-rule.orm.ts | 49 ++ .../entities/gateway-injection-rule.orm.ts | 52 ++ .../entities/gateway-usage-log.orm.ts | 44 ++ packages/services/llm-gateway/Dockerfile | 57 ++ packages/services/llm-gateway/package.json | 26 + .../llm-gateway/sql/init-gateway-tables.sql | 104 +++ packages/services/llm-gateway/src/config.ts | 29 + packages/services/llm-gateway/src/db.ts | 43 ++ .../src/injection/system-prompt-injector.ts | 119 ++++ .../llm-gateway/src/logging/audit-logger.ts | 79 +++ .../llm-gateway/src/logging/usage-tracker.ts | 142 ++++ packages/services/llm-gateway/src/main.ts | 73 +++ .../llm-gateway/src/middleware/auth.ts | 213 ++++++ .../src/middleware/content-filter.ts | 116 ++++ .../llm-gateway/src/proxy/anthropic-proxy.ts | 205 ++++++ .../llm-gateway/src/proxy/openai-proxy.ts | 216 +++++++ .../llm-gateway/src/proxy/stream-pipe.ts | 94 +++ .../llm-gateway/src/routes/anthropic.ts | 13 + .../services/llm-gateway/src/routes/health.ts | 15 + .../services/llm-gateway/src/routes/openai.ts | 19 + packages/services/llm-gateway/src/types.ts | 146 +++++ packages/services/llm-gateway/tsconfig.json | 23 + scripts/init-db.sql | 111 ++++ 40 files changed, 3724 insertions(+), 2 deletions(-) create mode 100644 packages/admin-client/src/features/llm-gateway/application/useLLMGateway.ts create mode 100644 packages/admin-client/src/features/llm-gateway/index.ts create mode 100644 packages/admin-client/src/features/llm-gateway/infrastructure/llm-gateway.api.ts create mode 100644 packages/admin-client/src/features/llm-gateway/presentation/components/ApiKeysTab.tsx create mode 100644 packages/admin-client/src/features/llm-gateway/presentation/components/AuditLogsTab.tsx create mode 100644 packages/admin-client/src/features/llm-gateway/presentation/components/ContentRulesTab.tsx create mode 100644 packages/admin-client/src/features/llm-gateway/presentation/components/InjectionRulesTab.tsx create mode 100644 packages/admin-client/src/features/llm-gateway/presentation/components/UsageDashboardTab.tsx create mode 100644 packages/admin-client/src/features/llm-gateway/presentation/pages/LLMGatewayPage.tsx create mode 100644 packages/services/conversation-service/src/adapters/inbound/admin-gateway.controller.ts create mode 100644 packages/services/conversation-service/src/infrastructure/database/postgres/entities/gateway-api-key.orm.ts create mode 100644 packages/services/conversation-service/src/infrastructure/database/postgres/entities/gateway-audit-log.orm.ts create mode 100644 packages/services/conversation-service/src/infrastructure/database/postgres/entities/gateway-content-rule.orm.ts create mode 100644 packages/services/conversation-service/src/infrastructure/database/postgres/entities/gateway-injection-rule.orm.ts create mode 100644 packages/services/conversation-service/src/infrastructure/database/postgres/entities/gateway-usage-log.orm.ts create mode 100644 packages/services/llm-gateway/Dockerfile create mode 100644 packages/services/llm-gateway/package.json create mode 100644 packages/services/llm-gateway/sql/init-gateway-tables.sql create mode 100644 packages/services/llm-gateway/src/config.ts create mode 100644 packages/services/llm-gateway/src/db.ts create mode 100644 packages/services/llm-gateway/src/injection/system-prompt-injector.ts create mode 100644 packages/services/llm-gateway/src/logging/audit-logger.ts create mode 100644 packages/services/llm-gateway/src/logging/usage-tracker.ts create mode 100644 packages/services/llm-gateway/src/main.ts create mode 100644 packages/services/llm-gateway/src/middleware/auth.ts create mode 100644 packages/services/llm-gateway/src/middleware/content-filter.ts create mode 100644 packages/services/llm-gateway/src/proxy/anthropic-proxy.ts create mode 100644 packages/services/llm-gateway/src/proxy/openai-proxy.ts create mode 100644 packages/services/llm-gateway/src/proxy/stream-pipe.ts create mode 100644 packages/services/llm-gateway/src/routes/anthropic.ts create mode 100644 packages/services/llm-gateway/src/routes/health.ts create mode 100644 packages/services/llm-gateway/src/routes/openai.ts create mode 100644 packages/services/llm-gateway/src/types.ts create mode 100644 packages/services/llm-gateway/tsconfig.json diff --git a/docker-compose.yml b/docker-compose.yml index 1d03da1..df77c58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -426,6 +426,40 @@ services: networks: - iconsulting-network + #============================================================================= + # LLM Gateway - 对外 API 代理服务 + #============================================================================= + + llm-gateway: + build: + context: . + dockerfile: packages/services/llm-gateway/Dockerfile + container_name: iconsulting-llm-gateway + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3008 + DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} + ANTHROPIC_UPSTREAM_URL: ${ANTHROPIC_BASE_URL:-https://api.anthropic.com} + OPENAI_API_KEY: ${OPENAI_API_KEY} + OPENAI_UPSTREAM_URL: ${OPENAI_BASE_URL:-https://api.openai.com} + RULES_CACHE_TTL_MS: 30000 + LOG_LEVEL: info + ports: + - "3008:3008" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3008/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + networks: + - iconsulting-network + #============================================================================= # 前端 Nginx #============================================================================= diff --git a/kong/kong.yml b/kong/kong.yml index f2e866d..c9dac6c 100644 --- a/kong/kong.yml +++ b/kong/kong.yml @@ -169,6 +169,28 @@ services: - DELETE - OPTIONS + #----------------------------------------------------------------------------- + # LLM Gateway - 对外 API 代理服务 + # 注意: 需要长超时以支持 LLM 流式响应 + #----------------------------------------------------------------------------- + - name: llm-gateway + url: http://llm-gateway:3008 + connect_timeout: 60000 + write_timeout: 300000 + read_timeout: 300000 + retries: 2 + routes: + - name: llm-gateway-routes + paths: + - /v1/messages + - /v1/embeddings + - /v1/chat/completions + strip_path: false + preserve_host: true + methods: + - POST + - OPTIONS + #=============================================================================== # 全局插件配置 #=============================================================================== @@ -198,6 +220,9 @@ plugins: - Authorization - X-User-Id - X-Request-Id + - X-Api-Key + - anthropic-version + - anthropic-beta exposed_headers: - X-Request-Id credentials: true diff --git a/packages/admin-client/src/App.tsx b/packages/admin-client/src/App.tsx index 273d61d..3acd1e7 100644 --- a/packages/admin-client/src/App.tsx +++ b/packages/admin-client/src/App.tsx @@ -15,6 +15,7 @@ import { ObservabilityPage } from './features/observability'; import { AssessmentConfigPage } from './features/assessment-config'; import { CollectionConfigPage } from './features/collection-config'; import { SupervisorPage } from './features/supervisor'; +import { LLMGatewayPage } from './features/llm-gateway'; function App() { return ( @@ -44,6 +45,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/packages/admin-client/src/features/llm-gateway/application/useLLMGateway.ts b/packages/admin-client/src/features/llm-gateway/application/useLLMGateway.ts new file mode 100644 index 0000000..f1f3065 --- /dev/null +++ b/packages/admin-client/src/features/llm-gateway/application/useLLMGateway.ts @@ -0,0 +1,209 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { message } from 'antd'; +import { gatewayApi } from '../infrastructure/llm-gateway.api'; + +const KEYS = { + dashboard: 'gateway-dashboard', + keys: 'gateway-keys', + injectionRules: 'gateway-injection-rules', + contentRules: 'gateway-content-rules', + usage: 'gateway-usage', + auditLogs: 'gateway-audit-logs', +}; + +// ─── Dashboard ─── + +export function useDashboard() { + return useQuery({ + queryKey: [KEYS.dashboard], + queryFn: () => gatewayApi.getDashboard(), + refetchInterval: 30_000, + }); +} + +// ─── API Keys ─── + +export function useApiKeys() { + return useQuery({ + queryKey: [KEYS.keys], + queryFn: () => gatewayApi.listKeys(), + }); +} + +export function useCreateKey() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: gatewayApi.createKey, + onSuccess: () => { + qc.invalidateQueries({ queryKey: [KEYS.keys] }); + qc.invalidateQueries({ queryKey: [KEYS.dashboard] }); + message.success('API Key 创建成功'); + }, + onError: () => message.error('创建失败'), + }); +} + +export function useUpdateKey() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, dto }: { id: string; dto: any }) => gatewayApi.updateKey(id, dto), + onSuccess: () => { + qc.invalidateQueries({ queryKey: [KEYS.keys] }); + message.success('更新成功'); + }, + onError: () => message.error('更新失败'), + }); +} + +export function useDeleteKey() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: gatewayApi.deleteKey, + onSuccess: () => { + qc.invalidateQueries({ queryKey: [KEYS.keys] }); + qc.invalidateQueries({ queryKey: [KEYS.dashboard] }); + message.success('删除成功'); + }, + onError: () => message.error('删除失败'), + }); +} + +export function useToggleKey() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: gatewayApi.toggleKey, + onSuccess: () => { + qc.invalidateQueries({ queryKey: [KEYS.keys] }); + qc.invalidateQueries({ queryKey: [KEYS.dashboard] }); + }, + onError: () => message.error('操作失败'), + }); +} + +// ─── Injection Rules ─── + +export function useInjectionRules() { + return useQuery({ + queryKey: [KEYS.injectionRules], + queryFn: () => gatewayApi.listInjectionRules(), + }); +} + +export function useCreateInjectionRule() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: gatewayApi.createInjectionRule, + onSuccess: () => { + qc.invalidateQueries({ queryKey: [KEYS.injectionRules] }); + message.success('注入规则创建成功'); + }, + onError: () => message.error('创建失败'), + }); +} + +export function useUpdateInjectionRule() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, dto }: { id: string; dto: any }) => gatewayApi.updateInjectionRule(id, dto), + onSuccess: () => { + qc.invalidateQueries({ queryKey: [KEYS.injectionRules] }); + message.success('更新成功'); + }, + onError: () => message.error('更新失败'), + }); +} + +export function useDeleteInjectionRule() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: gatewayApi.deleteInjectionRule, + onSuccess: () => { + qc.invalidateQueries({ queryKey: [KEYS.injectionRules] }); + message.success('删除成功'); + }, + onError: () => message.error('删除失败'), + }); +} + +export function useToggleInjectionRule() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: gatewayApi.toggleInjectionRule, + onSuccess: () => { + qc.invalidateQueries({ queryKey: [KEYS.injectionRules] }); + }, + onError: () => message.error('操作失败'), + }); +} + +// ─── Content Rules ─── + +export function useContentRules() { + return useQuery({ + queryKey: [KEYS.contentRules], + queryFn: () => gatewayApi.listContentRules(), + }); +} + +export function useCreateContentRule() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: gatewayApi.createContentRule, + onSuccess: () => { + qc.invalidateQueries({ queryKey: [KEYS.contentRules] }); + message.success('审查规则创建成功'); + }, + onError: () => message.error('创建失败'), + }); +} + +export function useUpdateContentRule() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, dto }: { id: string; dto: any }) => gatewayApi.updateContentRule(id, dto), + onSuccess: () => { + qc.invalidateQueries({ queryKey: [KEYS.contentRules] }); + message.success('更新成功'); + }, + onError: () => message.error('更新失败'), + }); +} + +export function useDeleteContentRule() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: gatewayApi.deleteContentRule, + onSuccess: () => { + qc.invalidateQueries({ queryKey: [KEYS.contentRules] }); + message.success('删除成功'); + }, + onError: () => message.error('删除失败'), + }); +} + +export function useToggleContentRule() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: gatewayApi.toggleContentRule, + onSuccess: () => { + qc.invalidateQueries({ queryKey: [KEYS.contentRules] }); + }, + onError: () => message.error('操作失败'), + }); +} + +// ─── Usage & Audit ─── + +export function useUsageLogs(params?: { keyId?: string; startDate?: string; endDate?: string }) { + return useQuery({ + queryKey: [KEYS.usage, params], + queryFn: () => gatewayApi.getUsage(params), + }); +} + +export function useAuditLogs(params?: { keyId?: string; filtered?: boolean; startDate?: string; endDate?: string }) { + return useQuery({ + queryKey: [KEYS.auditLogs, params], + queryFn: () => gatewayApi.getAuditLogs(params), + }); +} diff --git a/packages/admin-client/src/features/llm-gateway/index.ts b/packages/admin-client/src/features/llm-gateway/index.ts new file mode 100644 index 0000000..b15184e --- /dev/null +++ b/packages/admin-client/src/features/llm-gateway/index.ts @@ -0,0 +1 @@ +export { LLMGatewayPage } from './presentation/pages/LLMGatewayPage'; diff --git a/packages/admin-client/src/features/llm-gateway/infrastructure/llm-gateway.api.ts b/packages/admin-client/src/features/llm-gateway/infrastructure/llm-gateway.api.ts new file mode 100644 index 0000000..5226053 --- /dev/null +++ b/packages/admin-client/src/features/llm-gateway/infrastructure/llm-gateway.api.ts @@ -0,0 +1,176 @@ +import api from '../../../shared/utils/api'; + +const BASE = '/conversations/admin/gateway'; + +// ─── Types ─── + +export interface GatewayApiKey { + id: string; + tenantId: string | null; + keyHash: string; + keyPrefix: string; + name: string; + owner: string; + permissions: { + allowedModels: string[]; + allowStreaming: boolean; + allowTools: boolean; + }; + rateLimitRpm: number; + rateLimitTpd: number; + monthlyBudget: number | null; + enabled: boolean; + expiresAt: string | null; + lastUsedAt: string | null; + createdBy: string | null; + createdAt: string; + rawKey?: string; // only on creation +} + +export interface InjectionRule { + id: string; + name: string; + description: string | null; + position: 'prepend' | 'append'; + content: string; + matchModels: string[]; + matchKeyIds: string[] | null; + priority: number; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +export interface ContentRule { + id: string; + name: string; + type: 'keyword' | 'regex'; + pattern: string; + action: 'block' | 'warn' | 'log'; + rejectMessage: string | null; + priority: number; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +export interface UsageLog { + id: string; + apiKeyId: string; + model: string; + provider: string; + inputTokens: number; + outputTokens: number; + totalTokens: number; + costUsd: number | null; + durationMs: number; + statusCode: number; + createdAt: string; +} + +export interface AuditLog { + id: string; + apiKeyId: string; + requestMethod: string; + requestPath: string; + requestModel: string | null; + requestIp: string; + contentFiltered: boolean; + filterRuleId: string | null; + injectionApplied: boolean; + responseStatus: number; + durationMs: number; + createdAt: string; +} + +export interface DashboardData { + keys: { total: number; active: number }; + today: { requests: number; tokens: number; cost: number; filtered: number }; + month: { requests: number; tokens: number; cost: number }; +} + +// ─── API ─── + +export const gatewayApi = { + // Dashboard + getDashboard: async (): Promise => { + const res = await api.get(`${BASE}/dashboard`); + return res.data.data; + }, + + // Keys + listKeys: async () => { + const res = await api.get(`${BASE}/keys`); + return res.data.data; + }, + createKey: async (dto: { name: string; owner?: string; rateLimitRpm?: number; rateLimitTpd?: number; monthlyBudget?: number; expiresAt?: string }) => { + const res = await api.post(`${BASE}/keys`, dto); + return res.data.data; + }, + updateKey: async (id: string, dto: Partial) => { + const res = await api.put(`${BASE}/keys/${id}`, dto); + return res.data.data; + }, + deleteKey: async (id: string) => { + const res = await api.delete(`${BASE}/keys/${id}`); + return res.data; + }, + toggleKey: async (id: string) => { + const res = await api.post(`${BASE}/keys/${id}/toggle`); + return res.data.data; + }, + + // Injection Rules + listInjectionRules: async () => { + const res = await api.get(`${BASE}/injection-rules`); + return res.data.data; + }, + createInjectionRule: async (dto: { name: string; position?: string; content: string; description?: string; matchModels?: string[]; priority?: number }) => { + const res = await api.post(`${BASE}/injection-rules`, dto); + return res.data.data; + }, + updateInjectionRule: async (id: string, dto: Partial) => { + const res = await api.put(`${BASE}/injection-rules/${id}`, dto); + return res.data.data; + }, + deleteInjectionRule: async (id: string) => { + const res = await api.delete(`${BASE}/injection-rules/${id}`); + return res.data; + }, + toggleInjectionRule: async (id: string) => { + const res = await api.post(`${BASE}/injection-rules/${id}/toggle`); + return res.data.data; + }, + + // Content Rules + listContentRules: async () => { + const res = await api.get(`${BASE}/content-rules`); + return res.data.data; + }, + createContentRule: async (dto: { name: string; type?: string; pattern: string; action?: string; rejectMessage?: string; priority?: number }) => { + const res = await api.post(`${BASE}/content-rules`, dto); + return res.data.data; + }, + updateContentRule: async (id: string, dto: Partial) => { + const res = await api.put(`${BASE}/content-rules/${id}`, dto); + return res.data.data; + }, + deleteContentRule: async (id: string) => { + const res = await api.delete(`${BASE}/content-rules/${id}`); + return res.data; + }, + toggleContentRule: async (id: string) => { + const res = await api.post(`${BASE}/content-rules/${id}/toggle`); + return res.data.data; + }, + + // Usage & Audit + getUsage: async (params?: { keyId?: string; startDate?: string; endDate?: string; limit?: number }) => { + const res = await api.get(`${BASE}/usage`, { params }); + return res.data.data; + }, + getAuditLogs: async (params?: { keyId?: string; filtered?: boolean; startDate?: string; endDate?: string; limit?: number }) => { + const res = await api.get(`${BASE}/audit-logs`, { params }); + return res.data.data; + }, +}; diff --git a/packages/admin-client/src/features/llm-gateway/presentation/components/ApiKeysTab.tsx b/packages/admin-client/src/features/llm-gateway/presentation/components/ApiKeysTab.tsx new file mode 100644 index 0000000..062bc17 --- /dev/null +++ b/packages/admin-client/src/features/llm-gateway/presentation/components/ApiKeysTab.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react'; +import { Table, Button, Modal, Form, Input, InputNumber, Switch, Space, Tag, Popconfirm, Typography, message as antMsg } from 'antd'; +import { PlusOutlined, CopyOutlined } from '@ant-design/icons'; +import { useApiKeys, useCreateKey, useDeleteKey, useToggleKey } from '../../application/useLLMGateway'; +import type { GatewayApiKey } from '../../infrastructure/llm-gateway.api'; + +const { Text } = Typography; + +export function ApiKeysTab() { + const { data, isLoading } = useApiKeys(); + const createMutation = useCreateKey(); + const deleteMutation = useDeleteKey(); + const toggleMutation = useToggleKey(); + const [createOpen, setCreateOpen] = useState(false); + const [newKeyModal, setNewKeyModal] = useState<{ rawKey: string } | null>(null); + const [form] = Form.useForm(); + + const handleCreate = async () => { + const values = await form.validateFields(); + const result = await createMutation.mutateAsync(values); + setCreateOpen(false); + form.resetFields(); + if (result.rawKey) { + setNewKeyModal({ rawKey: result.rawKey }); + } + }; + + const columns = [ + { + title: '名称', + dataIndex: 'name', + key: 'name', + }, + { + title: 'Key 前缀', + dataIndex: 'keyPrefix', + key: 'keyPrefix', + render: (v: string) => {v}..., + }, + { + title: '所属', + dataIndex: 'owner', + key: 'owner', + }, + { + title: '限速 (RPM)', + dataIndex: 'rateLimitRpm', + key: 'rateLimitRpm', + }, + { + title: '状态', + dataIndex: 'enabled', + key: 'enabled', + render: (v: boolean, record: GatewayApiKey) => ( + toggleMutation.mutate(record.id)} + /> + ), + }, + { + title: '最后使用', + dataIndex: 'lastUsedAt', + key: 'lastUsedAt', + render: (v: string | null) => v ? new Date(v).toLocaleString() : '-', + }, + { + title: '操作', + key: 'actions', + render: (_: any, record: GatewayApiKey) => ( + deleteMutation.mutate(record.id)}> + + + ), + }, + ]; + + return ( + <> +
+ +
+ + + + {/* Create Modal */} + setCreateOpen(false)} + confirmLoading={createMutation.isPending} + > +
+ + + + + + + + + + + + + +
+ + {/* New Key Display Modal */} + setNewKeyModal(null)} + onCancel={() => setNewKeyModal(null)} + cancelButtonProps={{ style: { display: 'none' } }} + > +

请立即复制保存此 Key,关闭后将无法再次查看:

+
+ + {newKeyModal?.rawKey} + +
+
+ + ); +} diff --git a/packages/admin-client/src/features/llm-gateway/presentation/components/AuditLogsTab.tsx b/packages/admin-client/src/features/llm-gateway/presentation/components/AuditLogsTab.tsx new file mode 100644 index 0000000..2508357 --- /dev/null +++ b/packages/admin-client/src/features/llm-gateway/presentation/components/AuditLogsTab.tsx @@ -0,0 +1,55 @@ +import { Table, Tag } from 'antd'; +import { useAuditLogs } from '../../application/useLLMGateway'; + +export function AuditLogsTab() { + const { data, isLoading } = useAuditLogs(); + + const columns = [ + { + title: '时间', + dataIndex: 'createdAt', + key: 'createdAt', + render: (v: string) => new Date(v).toLocaleString(), + width: 180, + }, + { title: '方法', dataIndex: 'requestMethod', key: 'requestMethod', width: 70 }, + { title: '路径', dataIndex: 'requestPath', key: 'requestPath', width: 160 }, + { title: '模型', dataIndex: 'requestModel', key: 'requestModel', ellipsis: true }, + { title: 'IP', dataIndex: 'requestIp', key: 'requestIp', width: 130 }, + { + title: '内容过滤', + dataIndex: 'contentFiltered', + key: 'contentFiltered', + render: (v: boolean) => v ? 已过滤 : 通过, + }, + { + title: '注入', + dataIndex: 'injectionApplied', + key: 'injectionApplied', + render: (v: boolean) => v ? 已注入 : '-', + }, + { + title: '状态码', + dataIndex: 'responseStatus', + key: 'responseStatus', + render: (v: number) => {v}, + }, + { + title: '耗时', + dataIndex: 'durationMs', + key: 'durationMs', + render: (v: number) => `${(v / 1000).toFixed(1)}s`, + }, + ]; + + return ( +
+ ); +} diff --git a/packages/admin-client/src/features/llm-gateway/presentation/components/ContentRulesTab.tsx b/packages/admin-client/src/features/llm-gateway/presentation/components/ContentRulesTab.tsx new file mode 100644 index 0000000..1f3c69e --- /dev/null +++ b/packages/admin-client/src/features/llm-gateway/presentation/components/ContentRulesTab.tsx @@ -0,0 +1,109 @@ +import { useState } from 'react'; +import { Table, Button, Modal, Form, Input, Select, Switch, Popconfirm, Tag } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import { useContentRules, useCreateContentRule, useDeleteContentRule, useToggleContentRule } from '../../application/useLLMGateway'; +import type { ContentRule } from '../../infrastructure/llm-gateway.api'; + +export function ContentRulesTab() { + const { data, isLoading } = useContentRules(); + const createMutation = useCreateContentRule(); + const deleteMutation = useDeleteContentRule(); + const toggleMutation = useToggleContentRule(); + const [createOpen, setCreateOpen] = useState(false); + const [form] = Form.useForm(); + + const handleCreate = async () => { + const values = await form.validateFields(); + await createMutation.mutateAsync(values); + setCreateOpen(false); + form.resetFields(); + }; + + const actionColors: Record = { block: 'red', warn: 'orange', log: 'blue' }; + const actionLabels: Record = { block: '拦截', warn: '警告', log: '记录' }; + + const columns = [ + { title: '名称', dataIndex: 'name', key: 'name' }, + { + title: '类型', + dataIndex: 'type', + key: 'type', + render: (v: string) => {v === 'keyword' ? '关键词' : '正则'}, + }, + { + title: '匹配模式', + dataIndex: 'pattern', + key: 'pattern', + ellipsis: true, + width: 200, + }, + { + title: '动作', + dataIndex: 'action', + key: 'action', + render: (v: string) => {actionLabels[v]}, + }, + { + title: '拒绝消息', + dataIndex: 'rejectMessage', + key: 'rejectMessage', + ellipsis: true, + width: 200, + }, + { + title: '状态', + dataIndex: 'enabled', + key: 'enabled', + render: (v: boolean, record: ContentRule) => ( + toggleMutation.mutate(record.id)} /> + ), + }, + { + title: '操作', + key: 'actions', + render: (_: any, record: ContentRule) => ( + deleteMutation.mutate(record.id)}> + + + ), + }, + ]; + + return ( + <> +
+ +
+ +
+ + setCreateOpen(false)} + confirmLoading={createMutation.isPending} + > +
+ + + + + + + + + + +
+ + ); +} diff --git a/packages/admin-client/src/features/llm-gateway/presentation/components/InjectionRulesTab.tsx b/packages/admin-client/src/features/llm-gateway/presentation/components/InjectionRulesTab.tsx new file mode 100644 index 0000000..3a5622c --- /dev/null +++ b/packages/admin-client/src/features/llm-gateway/presentation/components/InjectionRulesTab.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { Table, Button, Modal, Form, Input, Select, Switch, Space, Popconfirm, Tag } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import { useInjectionRules, useCreateInjectionRule, useDeleteInjectionRule, useToggleInjectionRule } from '../../application/useLLMGateway'; +import type { InjectionRule } from '../../infrastructure/llm-gateway.api'; + +const { TextArea } = Input; + +export function InjectionRulesTab() { + const { data, isLoading } = useInjectionRules(); + const createMutation = useCreateInjectionRule(); + const deleteMutation = useDeleteInjectionRule(); + const toggleMutation = useToggleInjectionRule(); + const [createOpen, setCreateOpen] = useState(false); + const [form] = Form.useForm(); + + const handleCreate = async () => { + const values = await form.validateFields(); + await createMutation.mutateAsync(values); + setCreateOpen(false); + form.resetFields(); + }; + + const columns = [ + { title: '名称', dataIndex: 'name', key: 'name' }, + { + title: '位置', + dataIndex: 'position', + key: 'position', + render: (v: string) => {v === 'prepend' ? '前置' : '追加'}, + }, + { + title: '内容预览', + dataIndex: 'content', + key: 'content', + ellipsis: true, + width: 300, + }, + { + title: '匹配模型', + dataIndex: 'matchModels', + key: 'matchModels', + render: (v: string[]) => v?.includes('*') ? 全部 : v?.map((m: string) => {m}), + }, + { + title: '状态', + dataIndex: 'enabled', + key: 'enabled', + render: (v: boolean, record: InjectionRule) => ( + toggleMutation.mutate(record.id)} /> + ), + }, + { + title: '操作', + key: 'actions', + render: (_: any, record: InjectionRule) => ( + deleteMutation.mutate(record.id)}> + + + ), + }, + ]; + + return ( + <> +
+ +
+ +
+ + setCreateOpen(false)} + confirmLoading={createMutation.isPending} + width={600} + > +
+ + + + +