diff --git a/web/src/api/apiKey.ts b/web/src/api/apiKey.ts new file mode 100644 index 00000000..56ad79c4 --- /dev/null +++ b/web/src/api/apiKey.ts @@ -0,0 +1,33 @@ +import { request } from '@/utils/request' +import type { ApiKey } from '@/views/ApiKeyManagement/types' + +// API Key列表 +export const getApiKeyListUrl = '/apikeys' +export const getApiKeyList = (data: Record) => { + return request.get(getApiKeyListUrl, data) +} + +// API Key详情 +export const getApiKey = (id: string) => { + return request.get(`/apikeys/${id}`) +} + +// 创建API Key +export const createApiKey = (values: ApiKey) => { + return request.post('/apikeys', values) +} + +// 更新API Key +export const updateApiKey = (id: string, values: ApiKey) => { + return request.put(`/apikeys/${id}`, values) +} + +// 删除 API Key +export const deleteApiKey = (id: string) => { + return request.delete(`/apikeys/${id}`) +} + +// 使用统计 +export const getApiKeyStats = (app_key_id: string) => { + return request.get(`/apikeys/${app_key_id}/stats`) +} \ No newline at end of file diff --git a/web/src/assets/images/menu/userMemory_active.svg b/web/src/assets/images/menu/userMemory_active.svg deleted file mode 100644 index 554dc0bc..00000000 --- a/web/src/assets/images/menu/userMemory_active.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - 编组 29 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index c3683718..9fcc5585 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -33,6 +33,7 @@ export const en = { knowledgeCreateDataset: 'Create Dataset', knowledgeDocumentDetails: 'Document Details', userMemoryDetail: 'UserMemory Detail', + apiKeyManagement: 'API KEY Management', }, dashboard: { totalMemoryCapacity: 'Total Memory Capacity', @@ -117,7 +118,7 @@ export const en = { triplet_count_desc: 'Build {{entities_count}} entity nodes and {{relations_count}} relation connections', temporal_count: 'Time extraction', temporal_count_desc: 'Record {{count}} time series information', - + dialogue: 'Dialogue', chunk: 'Chunk', statement: 'Statement', @@ -735,7 +736,7 @@ export const en = { workflowDesc: 'To be opened, please stay tuned', editApplication: 'Edit Application Info', - + currentModel: 'Current Model', modelConfig: 'Model Config', parameterConfig: 'Parameter Config', @@ -929,7 +930,7 @@ export const en = { similarity_threshold: 'Semantic similarity threshold', similarity_threshold_desc: 'Only return results with semantic similarity higher than this threshold', similarity_threshold_desc1: 'The minimum similarity threshold for semantic retrieval', - + vector_similarity_weight: 'Vector Similarity Weight', vector_similarity_weight_desc: 'Only return results with BM25 scores above this threshold', vector_similarity_weight_desc1: 'The minimum BM25 score threshold for word segmentation retrieval', @@ -947,7 +948,7 @@ export const en = { versionNameTip: 'Version number format: v[major version number].[next version number].[revision number] (e.g. v1.3.0)', agentName: 'Agent Name', roleType: 'Role Type', - + coordinator: 'Coordinator', analyzer: 'Analyzer', executor: 'Executor', @@ -957,8 +958,28 @@ export const en = { capabilities: 'Capabilities', subAgent: 'Sub Agent', maxChatCount: 'Add up to 4 models', - addApiKey: 'Add API Key', - ReplyException: 'Reply exception' + ReplyException: 'Reply exception', + + endpointConfigurationSubTitle: 'Configure API access address and supported HTTP methods', + apiKeys: 'API Keys Management', + apiKeySubTitle: 'Manage API keys, view usage and traffic statistics for each key', + addApiKey: 'Add New API Key', + apiKeyName: 'Key Name', + apiKeyNamePlaceholder: 'e.g.: Production, Testing, Development', + apiKeyDescPlaceholder: 'Describe the purpose of this Key', + apiKeyTotal: 'Total Keys', + apiKeyRequestTotal: 'Total Requests', + qps: 'Average QPS', + qpsLimit: 'QPS Limit', + qpsLimitTip: '(Requests per second)', + apiLimitConfig: 'Rate Limiting Configuration', + qpsLimitDesc: 'Limit the maximum number of requests this Key can make per second', + dailyUsageLimit: 'Daily Usage Limit', + dailyUsageLimitDesc: 'Limit the maximum total number of requests this Key can make per day', + dailyUsageLimitUnit: 'times/day', + apiKeyDeleteContent: 'Once deleted, it cannot be recovered, and applications using this Key will not be able to access the API', + currentValue: 'Current Value', + qpsLimitUnit: 'times/second', }, userMemory: { userMemory: 'User Memory', @@ -1057,7 +1078,7 @@ export const en = { extractTheNumberOfEntities: 'Extract the number of entities', extractTheNumberOfEntitiesDesc: 'Merge after deduplication: {{num}} (exact: {{exact}}, fuzzy: {{fuzzy}}, LLM: {{llm}})', - + numberOfEntityDisambiguation: 'Number of entity disambiguation', numberOfEntityDisambiguationDesc: 'Total {{num}} times (blocking: {{block_count}})', @@ -1162,7 +1183,6 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re failed: 'Failed' }, time: 'Time: ', - text_preprocessing_desc: 'Text split into {{count}} semantic fragments', knowledge_extraction_desc: 'Knowledge extraction completed, identified {{entities}} entities, {{statements}} statements, {{temporal_ranges_count}} temporal extractions, {{triplets}} triplets', creating_nodes_edges_desc: 'Entity relationship creation completed, {{num}} relationships in total', @@ -1250,6 +1270,31 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re tableEmpty: 'There are currently no data', loadingEmpty: 'The content is loading…', loadingEmptyDesc: 'Your content is on its way by rocket! It will soon land on your screen' - } + }, + apiKey: { + name: 'Project Name', + createApiKey: 'Create API Key', + updateApiKey: 'Edit API Key', + id: 'ID', + created_at: 'Created At', + description: 'Description', + memoryEngine: 'Memory Engine', + knowledgeBase: 'Knowledge Base', + advancedSettings: 'Advanced Settings', + expires_at: 'Expiration At', + apiKey: 'API Key', + status: 'Status', + createdAt: 'Created At', + expiresAt: 'Expires At', + requestsPerMinute: 'Requests/Minute', + viewDetail: 'View Details', + disable: 'Disable', + enable: 'Enable', + baseInfo: 'Basic Information', + permissionInfo: 'Permission Information', + is_expired: 'Status', + active: 'Active', + inactive: 'Expired' + }, }, }; diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 95418915..3e7e204d 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -28,16 +28,18 @@ export const zh = { spaceManagement: '空间管理', memoryExtractionEngine: '记忆提取引擎', forgettingEngine: '遗忘引擎', + apiKeyManagement: 'API KEY管理', knowledgePrivate: '详情', knowledgeShare: '详情', knowledgeCreateDataset: '新建数据集', knowledgeDocumentDetails: '详情', userMemoryDetail: '用户记忆详情', + toolManagement: '工具管理', }, knowledgeBase: { home: '首页', selectSpace: '请选择空间', - preview:'预览', + preview: '预览', pleaseUploadFileFirst: '请先上传文件', shareSuccess: '分享成功', shareFailed: '分享失败', @@ -288,7 +290,7 @@ export const zh = { number: '数字', checkbox: '复选框', apiVariable: 'API变量', - + displayName: '显示名称', maxLength: '最大长度', required: '必填', @@ -467,7 +469,7 @@ export const zh = { similarity_threshold: '语义相似度阈值', similarity_threshold_desc: '仅返回语义相似度高于此阈值的结果', similarity_threshold_desc1: '语义检索的最小相似度阈值', - + vector_similarity_weight: '向量相似度权重', vector_similarity_weight_desc: '仅返回BM25分数高于此阈值的结果', vector_similarity_weight_desc1: '分词检索的最小BM25分数阈值', @@ -481,6 +483,27 @@ export const zh = { chooseKnowledge: '选择知识库', active: '活跃', inactive: '不活跃', + + endpointConfigurationSubTitle: '配置 API 访问地址和支持的 HTTP 方法', + apiKeys: 'API Keys 管理', + apiKeySubTitle: '管理 API 密钥,查看每个密钥的使用情况和流量统计', + addApiKey: '添加新 API Key', + apiKeyName: 'Key 名称', + apiKeyNamePlaceholder: '例如:生产环境、测试环境、开发环境', + apiKeyDescPlaceholder: '描述这个 Key 的用途', + apiKeyTotal: '总 Keys', + apiKeyRequestTotal: '总请求数', + qps: '平均 QPS', + qpsLimit: 'QPS 限制', + qpsLimitTip: '(每秒请求数)', + apiLimitConfig: '限流配置', + qpsLimitDesc: '限制此 Key 每秒最多可以发起的请求数', + dailyUsageLimit: '日调用量限制', + dailyUsageLimitDesc: '限制此 Key 每天最多可以发起的请求总数', + dailyUsageLimitUnit: '次/天', + apiKeyDeleteContent: '删除后将无法恢复,使用此Key的应用将无法访问 API', + currentValue: '当前值', + qpsLimitUnit: '次/秒', }, // 角色管理相关翻译 role: { @@ -618,7 +641,7 @@ export const zh = { triplet_count_desc: '构建{{entities_count}}个实体节点和{{relations_count}}个关系连接', temporal_count: '时间提取', temporal_count_desc: '记录{{count}}条时间序列信息', - + dialogue: '对话', chunk: '分块', statement: '语句', @@ -1027,7 +1050,7 @@ export const zh = { minimumRetention: '时间遗忘率 (λ_time)', minimumRetentionDesc: '控制记忆随时间的遗忘速度,值越高时间越短', - forgettingRate: '记忆遗忘率 (λ_mem)', + forgettingRate: '记忆遗忘率 (λ_mem)', forgettingRateDesc: '控制记忆遗忘的速度,值越高遗忘越快', offset: '最小保留度 (offset)', offsetDesc: '控制记忆保留的最小保留阈值 遗忘这地方改个文字描述', @@ -1130,7 +1153,7 @@ export const zh = { extractTheNumberOfEntities: '提取实体数量', extractTheNumberOfEntitiesDesc: '去重后合并:{{num}}(精确:{{exact}},模糊:{{fuzzy}},LLM:{{llm}})', - + numberOfEntityDisambiguation: '实体消歧数量', numberOfEntityDisambiguationDesc: '总计{{num}}次(阻止:{{block_count}})', @@ -1217,7 +1240,6 @@ export const zh = { 学生:那我换到唐朝史:安史之乱后,中央已开始整顿,为何藩镇割据反而加剧? 记忆熊:安史之乱后藩镇割据加剧的原因包括:节度使掌握募兵权、财政调度权与军事指挥权,形成地方军阀;中央财政因均田制瓦解和租庸调失效而衰退,难以支撑军队,导致地方军事力量依附节度使;募兵制使士兵效忠个人而非国家;宦官掌控禁军,文官集团失势,中央制衡能力削弱。`, - warning: '当您修改左侧的配置项后,点击【调试】,提取结论将在此处实时更新', processing: '配置已更新,正在重新萃取示例记忆...', success: '记忆萃取完成!', @@ -1234,7 +1256,6 @@ export const zh = { failed: '失败' }, time: '耗时: ', - text_preprocessing_desc: '文本切分为{{count}}个语义片段', knowledge_extraction_desc: '知识抽取完成,共识别{{entities}}个实体,{{statements}}个句子, {{temporal_ranges_count}}个时间提取, {{triplets}}个三元组', creating_nodes_edges_desc: '实体关系创建完成,共{{num}}条关系', @@ -1357,6 +1378,31 @@ export const zh = { title: '页面未找到', description: '请求的页面不存在。', backToHome: '返回首页' - } + }, + apiKey: { + name: '项目名称', + createApiKey: '创建API Key', + updateApiKey: '编辑API Key', + id: 'ID', + created_at: '创建时间', + description: '描述', + memoryEngine: '记忆引擎', + knowledgeBase: '知识库', + advancedSettings: '高级设置', + expires_at: '过期时间', + apiKey: 'API Key', + status: '状态', + createdAt: '创建时间', + expiresAt: '过期时间', + requestsPerMinute: '次/分钟', + viewDetail: '查看详情', + disable: '禁用', + enable: '启用', + baseInfo: '基础信息', + permissionInfo: '授权信息', + is_expired: '状态', + active: '活跃', + inactive: '过期' + }, }, } \ No newline at end of file diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index 4d7faf7a..bdcf7d78 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -54,6 +54,7 @@ const componentMap: Record>> = UserManagement: lazy(() => import('@/views/UserManagement')), ModelManagement: lazy(() => import('@/views/ModelManagement')), SpaceManagement: lazy(() => import('@/views/SpaceManagement')), + ApiKeyManagement: lazy(() => import('@/views/ApiKeyManagement')), Login: lazy(() => import('@/views/Login')), InviteRegister: lazy(() => import('@/views/InviteRegister')), NoPermission: lazy(() => import('@/views/NoPermission')), diff --git a/web/src/routes/routes.json b/web/src/routes/routes.json index 6c485a12..0b87e192 100644 --- a/web/src/routes/routes.json +++ b/web/src/routes/routes.json @@ -25,6 +25,7 @@ { "path": "/knowledge-base/:knowledgeBaseId/share", "element": "Share" }, { "path": "/knowledge-base/:knowledgeBaseId/create-dataset", "element": "CreateDataset" }, { "path": "/knowledge-base/:knowledgeBaseId/DocumentDetails", "element": "DocumentDetails" }, + { "path": "/api-key", "element": "ApiKeyManagement" }, { "path": "/no-permission", "element": "NoPermission" }, { "path": "/*", "element": "NotFound" } ] diff --git a/web/src/store/menu.json b/web/src/store/menu.json index 448400b0..cda4a6b0 100644 --- a/web/src/store/menu.json +++ b/web/src/store/menu.json @@ -243,6 +243,21 @@ "icon": null, "iconActive": null, "subs": null + }, + { + "id": 11, + "parent": 0, + "code": "apiKey", + "label": "API KEY管理", + "i18nKey": "menu.apiKeyManagement", + "path": "/api-key", + "enable": true, + "display": true, + "level": 1, + "sort": 0, + "icon": null, + "iconActive": null, + "subs": null } ] } \ No newline at end of file diff --git a/web/src/utils/apiKeyReplacer.ts b/web/src/utils/apiKeyReplacer.ts new file mode 100644 index 00000000..a2914e46 --- /dev/null +++ b/web/src/utils/apiKeyReplacer.ts @@ -0,0 +1,46 @@ +/** + * API密钥替换工具 + */ + +const API_KEY_PATTERNS = { + service: /sk-service-[A-Za-z0-9_-]+/g, + agent: /sk-agent-[A-Za-z0-9_-]+/g, + multiAgent: /sk-multi_agent-[A-Za-z0-9_-]+/g, + workflow: /sk-workflow-[A-Za-z0-9_-]+/g +} +const API_KEY_PREFIX = { + service: 'sk-service-', + agent: 'sk-agent-', + multiAgent: 'sk-multi_agent-', + workflow: 'sk-workflow-' +} + +/** + * 替换文本中的API密钥为*号 + * @param text 原始文本 + * @returns 替换后的文本 + */ +export const maskApiKeys = (text: string): string => { + if (!text) return text + let result = text + + Object.keys(API_KEY_PREFIX).map(type => { + const key = type as keyof typeof API_KEY_PREFIX + result = result.replace(API_KEY_PATTERNS[key as keyof typeof API_KEY_PREFIX], (match) => { + const prefixLength = API_KEY_PREFIX[key].length + const prefix = match.substring(0, prefixLength) + return prefix + '*'.repeat(match.length - prefixLength) + }) + }) + + return result +} + +/** + * 检测文本中是否包含API密钥 + * @param text 待检测文本 + * @returns 是否包含API密钥 + */ +export const hasApiKeys = (text: string): boolean => { + return Object.values(API_KEY_PATTERNS).some(pattern => pattern.test(text)) +} \ No newline at end of file diff --git a/web/src/views/ApiKeyManagement/components/ApiKeyDetailModal.tsx b/web/src/views/ApiKeyManagement/components/ApiKeyDetailModal.tsx new file mode 100644 index 00000000..2c154f51 --- /dev/null +++ b/web/src/views/ApiKeyManagement/components/ApiKeyDetailModal.tsx @@ -0,0 +1,102 @@ +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { Switch, Button } from 'antd'; +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; +import type { ApiKey, ApiKeyModalRef } from '../types'; +import RbModal from '@/components/RbModal' +import { getApiKey } from '@/api/apiKey'; +import { formatDateTime } from '@/utils/format' +import Tag from '@/components/Tag' +import { maskApiKeys } from '@/utils/apiKeyReplacer'; + +const ApiKeyDetailModal = forwardRef void }>(({ handleCopy }, ref) => { + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const [data, setData] = useState({} as ApiKey) + + // 封装取消方法,添加关闭弹窗逻辑 + const handleClose = () => { + setVisible(false); + }; + + const handleOpen = (apiKey?: ApiKey) => { + if (apiKey?.id) { + getApiKey(apiKey.id) + .then((res) => { + setVisible(true); + setData(res as ApiKey) + }) + } + }; + + // 暴露给父组件的方法 + useImperativeHandle(ref, () => ({ + handleOpen, + handleClose + })); + + return ( + +
{t('apiKey.baseInfo')}
+ {['id', 'name', 'is_expired', 'created_at'].map((key, index) => ( +
+ {t(`apiKey.${key}`)} + + { key === 'created_at' + ? formatDateTime(data[key], 'YYYY-MM-DD HH:mm:ss') + : key === 'is_expired' + ? {data[key] ? t('apiKey.inactive') : t('apiKey.active')} + : String(data[key as keyof ApiKey]) + } + +
+ ))} + +
+ {maskApiKeys(data.api_key)} + + +
+ +
{t('apiKey.permissionInfo')}
+ +
+ {t(`apiKey.memoryEngine`)} + + + +
+
+ {t(`apiKey.knowledgeBase`)} + + + +
+ + {/* 高级设置 */} + {data.expires_at && <> +
{t('apiKey.advancedSettings')}
+ +
+ {t(`apiKey.expires_at`)} + + {data.expires_at ? formatDateTime(data.expires_at as number, 'yyyy-MM-DD') : '-'} + +
+ } +
+ ); +}); + +export default ApiKeyDetailModal; \ No newline at end of file diff --git a/web/src/views/ApiKeyManagement/components/ApiKeyModal.tsx b/web/src/views/ApiKeyManagement/components/ApiKeyModal.tsx new file mode 100644 index 00000000..f0bf4e11 --- /dev/null +++ b/web/src/views/ApiKeyManagement/components/ApiKeyModal.tsx @@ -0,0 +1,153 @@ +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { Form, Input, Switch, App, DatePicker } from 'antd'; +import { useTranslation } from 'react-i18next'; +import type { ApiKey, ApiKeyModalRef } from '../types'; +import RbModal from '@/components/RbModal' +import dayjs from 'dayjs' +import { createApiKey, updateApiKey } from '@/api/apiKey'; + +const FormItem = Form.Item; + +interface CreateModalProps { + refresh: () => void; +} + +const ApiKeyModal = forwardRef(({ + refresh, +}, ref) => { + const { t } = useTranslation(); + const { message } = App.useApp(); + const [visible, setVisible] = useState(false); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [editVo, setEditVo] = useState(null); + + // 封装取消方法,添加关闭弹窗逻辑 + const handleClose = () => { + setVisible(false); + form.resetFields(); + setLoading(false); + setEditVo(null); + }; + + const handleOpen = (apiKey?: ApiKey) => { + if (apiKey?.id) { + const { scopes = [], expires_at } = apiKey + // 编辑模式,填充表单 + form.setFieldsValue({ + name: apiKey.name, + description: apiKey.description, + memory: scopes.includes('memory'), + rag: scopes.includes('rag'), + expires_at: expires_at ? dayjs(expires_at) : undefined + }); + setEditVo(apiKey); + } + setVisible(true); + }; + + // 封装保存方法,添加提交逻辑 + const handleSave = async () => { + form.validateFields() + .then((values) => { + const { memory, rag, expires_at, ...rest } = values + let scopes = [] + + if (memory) { + scopes.push('memory') + } + if (rag) { + scopes.push('rag') + } + // 准备新的/更新的API Key数据 + const apiKeyData = { + ...rest, + scopes, + expires_at: expires_at ? dayjs(expires_at.valueOf()).endOf('day').valueOf() : null, + type: 'service' + }; + setLoading(true) + const req = editVo?.id ? updateApiKey(editVo.id, apiKeyData as ApiKey) : createApiKey(apiKeyData as ApiKey) + + req.then(() => { + refresh(); + handleClose(); + message.success(t(editVo ? 'common.updateSuccess' : 'common.createSuccess')); + }) + .finally(() => setLoading(false)) + }) + } + + // 暴露给父组件的方法 + useImperativeHandle(ref, () => ({ + handleOpen, + handleClose + })); + + return ( + +
+
{t('apiKey.baseInfo')}
+ + + + + + + + +
{t('apiKey.permissionInfo')}
+ + + + + + + + + + {/* 高级设置 */} +
{t('apiKey.advancedSettings')}
+ + + current && current < dayjs().subtract(1, 'day').endOf('day')} + /> + +
+
+ ); +}); + +export default ApiKeyModal; \ No newline at end of file diff --git a/web/src/views/ApiKeyManagement/index.tsx b/web/src/views/ApiKeyManagement/index.tsx new file mode 100644 index 00000000..007526de --- /dev/null +++ b/web/src/views/ApiKeyManagement/index.tsx @@ -0,0 +1,125 @@ +import React, { useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, App, Space } from 'antd'; +import clsx from 'clsx'; +import { DeleteOutlined, EditOutlined, EyeOutlined } from '@ant-design/icons'; +import type { ApiKey, ApiKeyModalRef } from './types'; +import ApiKeyModal from './components/ApiKeyModal'; +import ApiKeyDetailModal from './components/ApiKeyDetailModal'; +import RbCard from '@/components/RbCard/Card' +import { getApiKeyListUrl, deleteApiKey } from '@/api/apiKey'; +import PageScrollList, { type PageScrollListRef } from '@/components/PageScrollList' +import { formatDateTime } from '@/utils/format'; +import Tag from '@/components/Tag' +import copy from 'copy-to-clipboard' +import { maskApiKeys } from '@/utils/apiKeyReplacer'; + +const ApiKeyManagement: React.FC = () => { + const { t } = useTranslation(); + const { modal, message } = App.useApp(); + const apiKeyModalRef = useRef(null); + const apiKeyDetailModalRef = useRef(null) + const scrollListRef = useRef(null) + + const refresh = () => { + scrollListRef.current?.refresh(); + } + + const handleEdit = (item?: ApiKey) => { + apiKeyModalRef.current?.handleOpen(item); + } + const handleView = (item: ApiKey) => { + apiKeyDetailModalRef.current?.handleOpen(item); + } + const handleDelete = (item: ApiKey) => { + modal.confirm({ + title: t('common.confirmDeleteDesc', { name: item.name }), + okText: t('common.delete'), + okType: 'danger', + onOk: () => { + deleteApiKey(item.id) + .then(() => { + refresh(); + message.success(t('common.deleteSuccess')) + }) + } + }) + } + const handleCopy = (content: string) => { + copy(content) + message.success(t('common.copySuccess')) + } + return ( + <> +
+ +
+ + ) => { + let apiKeyItem = item as unknown as ApiKey + return ( + + {['id', 'is_expired', 'created_at'].map((key, index) => ( +
+ {t(`apiKey.${key}`)} + + { key === 'created_at' + ? formatDateTime(apiKeyItem[key], 'YYYY-MM-DD HH:mm:ss') + : key === 'is_expired' + ? {apiKeyItem[key] ? t('apiKey.inactive') : t('apiKey.active')} + : String(apiKeyItem[key as keyof ApiKey]) + } + +
+ ))} + +
+ {maskApiKeys(apiKeyItem.api_key)} + + +
+ + + {apiKeyItem.scopes?.includes('memory') && {t('apiKey.memoryEngine')}} + {apiKeyItem.scopes?.includes('rag') && {t('apiKey.knowledgeBase')}} + + +
+ + + +
+
+ ); + }} + /> + + + + + ); +}; + +export default ApiKeyManagement; \ No newline at end of file diff --git a/web/src/views/ApiKeyManagement/types.ts b/web/src/views/ApiKeyManagement/types.ts new file mode 100644 index 00000000..2df67193 --- /dev/null +++ b/web/src/views/ApiKeyManagement/types.ts @@ -0,0 +1,40 @@ +import type { Dayjs } from 'dayjs' +import { maskApiKeys } from '@/utils/apiKeyReplacer' + +export interface ApiKey { + id: string; + name: string; + description?: string; + type: 'agent' | 'multi_agent' | 'workflow' | 'service'; + scopes?: string[]; // 'memory' | 'rag' | 'app' + + api_key: string; + is_active: boolean; + is_expired: boolean; + created_at: number; + expires_at?: number | Dayjs; + memory?: boolean; + rag?: boolean; + + + updated_at: string; + qps_limit?: number; + daily_request_limit?: number; + + rate_limit?: number; + total_requests: number; + quota_used: number; + quota_limit: number; +} + +export interface ApiKeyModalRef { + handleOpen: (apiKey?: ApiKey) => void; + handleClose: () => void; +} + +/** + * 获取掩码后的API密钥 + */ +export const getMaskedApiKey = (apiKey: string): string => { + return maskApiKeys(apiKey) +} \ No newline at end of file diff --git a/web/src/views/ApplicationConfig/Api.tsx b/web/src/views/ApplicationConfig/Api.tsx index 90612687..1281f573 100644 --- a/web/src/views/ApplicationConfig/Api.tsx +++ b/web/src/views/ApplicationConfig/Api.tsx @@ -1,153 +1,189 @@ -import { type FC, useState } from 'react'; +import { type FC, useState, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Space, App - // Slider, Input, - // Form, - // Checkbox -} from 'antd'; +import { Button, Space, App, Statistic, Row, Col, Divider } from 'antd'; import copy from 'copy-to-clipboard' import Card from './components/Card'; -// import qpsRestrictions from '@/assets/images/application/qpsRestrictions.svg' -// import dailyAdjustmentDosage from '@/assets/images/application/dailyAdjustmentDosage.svg' -// import tokenCap from '@/assets/images/application/tokenCap.svg' +import type { Application } from '@/views/ApplicationManagement/types' +import type { ApiKeyModalRef, ApiKeyConfigModalRef } from './types' +import type { ApiKey } from '@/views/ApiKeyManagement/types' +import ApiKeyModal from './components/ApiKeyModal'; +import ApiKeyConfigModal from './components/ApiKeyConfigModal'; +import Tag from '@/components/Tag' +import { getApiKeyList, getApiKeyStats } from '@/api/apiKey'; +import { maskApiKeys } from '@/utils/apiKeyReplacer' -// const limitList = [ -// { key: 'qpsRestrictions', value: '10', icon: qpsRestrictions, unit: ' times/second' }, -// { key: 'dailyAdjustmentDosage', value: '1000', icon: dailyAdjustmentDosage, unit: ' times/day' }, -// { key: 'tokenCap', value: '10', icon: tokenCap, unit: 'M Tokens/day' }, -// ] -// const sdkList = ['pythonSDK', 'nodejsSDK', 'goSDK', 'curlExample'] - -const Api: FC<{apiKeyList?: string[]}> = ({apiKeyList = []}) => { +const Api: FC<{ application: Application | null }> = ({ application }) => { const { t } = useTranslation(); const [activeMethods, setActiveMethod] = useState(['GET']); - const { message } = App.useApp() - // const [form] = Form.useForm(); + const { message, modal } = App.useApp() const copyContent = window.location.origin + '/v1/chat' + const apiKeyModalRef = useRef(null); + const apiKeyConfigModalRef = useRef(null); + const [apiKeyList, setApiKeyList] = useState([]) const handleCopy = (content: string) => { copy(content) message.success(t('common.copySuccess')) } - return ( -
- {/*
*/} - - -
- - {['GET', 'POST', 'PUT', 'DELETE'].map((method) => ( - - ))} - -
- {copyContent} - - + ))} + + +
+ {copyContent} + + +
+
+ + + {t('application.addApiKey')} + } + > +
{t('application.apiKeySubTitle')}
+ {/* 总览数据 */} + + + + + + + + + {/* API Key 列表 */} + {apiKeyList.sort((a, b) => b.created_at - a.created_at).map(item => ( +
+
+
+
{item.name}
+ ID: {item.id} +
+
handleEdit(item)} + >
+
handleDelete(item)} + >
+
+
+
+ {maskApiKeys(item.api_key)} + +
+ + + + + + + + + + + +
-
- + {t('application.addApiKey')} - // } - > -
- {t('application.apiKeyTitle')} -

{t('application.apiKeyDesc')}

-
- {apiKeyList.map((item, index) => ( -
- {item} + ))} + + - - - {/*
handleDelete(index)} - >
*/} -
-
- ))} -
- {/* -
- {t('application.requestExample')} - -
-
- curl -X POST https://api.example.com/v1/agent/execute \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d -
- -
- {t('application.responseExample')} -
-
- curl -X POST https://api.example.com/v1/agent/execute \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d -
-
- -
- {limitList.map(item => ( -
-
-
- {t(`application.${item.key}`)} -
{item.value}{item.unit}
-
- -
- -
- ))} -
-
- -
- {sdkList.map(item => ( -
- {t(`application.${item}`)} -
- ))} -
-
- - {t('application.WebhookReturnsTimeout')} ({t('application.WebhookReturnsTimeoutDesc')})} - > - - - {t('application.whitelistIP')} ({t('application.whitelistIPDesc')})} - > - - - - {t('application.publicAPIDocumentation')} - - */} - - {/* */} + +
); } diff --git a/web/src/views/ApplicationConfig/components/ApiKeyConfigModal.tsx b/web/src/views/ApplicationConfig/components/ApiKeyConfigModal.tsx new file mode 100644 index 00000000..f66c02c5 --- /dev/null +++ b/web/src/views/ApplicationConfig/components/ApiKeyConfigModal.tsx @@ -0,0 +1,127 @@ +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { Form, Slider } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import type { ApiKeyConfigModalRef } from '../types' +import RbModal from '@/components/RbModal' +import { updateApiKey } from '@/api/apiKey'; +import type { ApiKey } from '@/views/ApiKeyManagement/types' + +interface ApiKeyConfigModalProps { + refresh: () => void; +} +const ApiKeyConfigModal = forwardRef(({ + refresh +}, ref) => { + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false) + const values = Form.useWatch([], form) + const [editVo, setEditVo] = useState({} as ApiKey) + + // 封装取消方法,添加关闭弹窗逻辑 + const handleClose = () => { + setVisible(false); + form.resetFields(); + setLoading(false) + }; + + const handleOpen = (apiKey: ApiKey) => { + setVisible(true); + setEditVo(apiKey) + form.setFieldsValue({ + daily_request_limit: apiKey.daily_request_limit, + rate_limit: apiKey.rate_limit + }); + }; + // 封装保存方法,添加提交逻辑 + const handleSave = () => { + form.validateFields() + .then((values) => { + updateApiKey(editVo.id, { + ...editVo, + ...values + }) + handleClose() + refresh() + }) + } + + // 暴露给父组件的方法 + useImperativeHandle(ref, () => ({ + handleOpen, + handleClose + })); + + return ( + +
+ {/* QPS 限制(每秒请求数) */} + <> +
+ {t(`application.qpsLimit`)}({t('application.qpsLimitTip')}) +
+
+ {t('application.qpsLimitDesc')} +
+
+ + + +
+ 1 + {t('application.currentValue')}: {values?.rate_limit}{t('application.qpsLimitUnit')} +
+
+ + {/* 日调用量限制 */} + <> +
+ {t(`application.dailyUsageLimit`)} +
+
+ {t('application.dailyUsageLimitDesc')} +
+
+ + + +
+ 100 + {t('application.currentValue')}: {values?.daily_request_limit}{t('application.dailyUsageLimitUnit')} +
+
+ +
+
+ ); +}); + +export default ApiKeyConfigModal; \ No newline at end of file diff --git a/web/src/views/ApplicationConfig/components/ApiKeyModal.tsx b/web/src/views/ApplicationConfig/components/ApiKeyModal.tsx new file mode 100644 index 00000000..2b18f07a --- /dev/null +++ b/web/src/views/ApplicationConfig/components/ApiKeyModal.tsx @@ -0,0 +1,103 @@ +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { Form, Input, App } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import type { Application } from '@/views/ApplicationManagement/types' +import type { ApiKeyModalRef } from '../types' +import { createApiKey } from '@/api/apiKey'; +import RbModal from '@/components/RbModal' + +const FormItem = Form.Item; + +interface ApiKeyModalProps { + refresh: () => void; + application?: Application | null; +} + +const ApiKeyModal = forwardRef(({ + refresh, + application +}, ref) => { + const { t } = useTranslation(); + const { message } = App.useApp(); + const [visible, setVisible] = useState(false); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false) + + // 封装取消方法,添加关闭弹窗逻辑 + const handleClose = () => { + setVisible(false); + form.resetFields(); + setLoading(false) + }; + + const handleOpen = () => { + setVisible(true); + form.resetFields(); + }; + // 封装保存方法,添加提交逻辑 + const handleSave = () => { + if (!application) return + form.validateFields() + .then((values) => { + setLoading(true) + createApiKey({ + ...values, + type: application.type, + resource_id: application.id, + }) + .then(() => { + handleClose() + refresh() + message.success(t('common.createSuccess')) + }) + .finally(() => { + setLoading(false) + }) + }) + } + + // 暴露给父组件的方法 + useImperativeHandle(ref, () => ({ + handleOpen, + handleClose + })); + + return ( + +
+ {/* Key 名称 */} + + + + {/* 描述 */} + + + +
+
+ ); +}); + +export default ApiKeyModal; \ No newline at end of file diff --git a/web/src/views/ApplicationConfig/index.tsx b/web/src/views/ApplicationConfig/index.tsx index 8ad25989..4fbba8b4 100644 --- a/web/src/views/ApplicationConfig/index.tsx +++ b/web/src/views/ApplicationConfig/index.tsx @@ -8,9 +8,7 @@ import Api from './Api' import ReleasePage from './ReleasePage' import Cluster from './Cluster' import { getApplication } from '@/api/application' -import { randomString } from '@/utils/common' -const apiKeyList = [`app-${randomString(24, false)}`] const ApplicationConfig: React.FC = () => { const { id } = useParams(); const agentRef = useRef(null) @@ -52,7 +50,7 @@ const ApplicationConfig: React.FC = () => { /> {activeTab === 'arrangement' && application?.type === 'agent' && } {activeTab === 'arrangement' && application?.type === 'multi_agent' && } - {activeTab === 'api' && } + {activeTab === 'api' && } {activeTab === 'release' && } ); diff --git a/web/src/views/ApplicationConfig/types.ts b/web/src/views/ApplicationConfig/types.ts index f910a90f..c5cda44e 100644 --- a/web/src/views/ApplicationConfig/types.ts +++ b/web/src/views/ApplicationConfig/types.ts @@ -187,4 +187,10 @@ export interface SubAgentItem { } export interface SubAgentModalRef { handleOpen: (agent?: SubAgentItem) => void; +} +export interface ApiKeyModalRef { + handleOpen: () => void; +} +export interface ApiKeyConfigModalRef { + handleOpen: (apiKey: ApiKey) => void; } \ No newline at end of file diff --git a/web/src/views/ApplicationManagement/types.ts b/web/src/views/ApplicationManagement/types.ts index 4c5f928a..03d8b1b0 100644 --- a/web/src/views/ApplicationManagement/types.ts +++ b/web/src/views/ApplicationManagement/types.ts @@ -7,7 +7,7 @@ export interface Application { description?: string; icon?: string; icon_type?: string; - type: string; + type: 'agent' | 'multi_agent' | 'workflow'; visibility: string; status: string; tags: string[];