feat: Add GraalVM JavaScript engine to backend and update frontend dependencies.
This commit is contained in:
+40
-1
@@ -1 +1,40 @@
|
||||
/target/
|
||||
# Compiled class file
|
||||
*.class
|
||||
|
||||
# Log file
|
||||
*.log
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
.mtj.tmp/
|
||||
|
||||
# Package Files #
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
|
||||
# Build folders
|
||||
target/
|
||||
backend/target/
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
# Local Dev Configs (Do not commit passwords!)
|
||||
application-dev.yml
|
||||
*-dev.yml
|
||||
|
||||
@@ -1,2 +1,160 @@
|
||||
# enterprise-ai-agent-gateway
|
||||
企业级 AI 智能体网关
|
||||
# Enterprise AI Agent Gateway (企业级 AI 智能体网关)
|
||||
|
||||
🌍 *[English Version Below](#english-version)*
|
||||
|
||||
**Enterprise AI Agent Gateway** 是一个定位于大模型(LLM/AI Agent)与企业老旧业务系统(Legacy Systems)之间的安全中间件与协议转换枢纽。
|
||||
|
||||
在企业内部署 AI Agent 时,直接让 LLM 调用复杂的企业内网 API 存在极大的**数据泄露风险**、**越权风险**以及由于老旧系统返回冗余数据而导致的 **LLM Token 灾难消耗**问题。本项目通过提供轻量级的前端控制面与高性能的后端数据面,完美解决了这些痛点。
|
||||
|
||||
---
|
||||
|
||||
## 🌟 核心特性 (Key Features)
|
||||
|
||||
* 🛡️ **纯粹的数据洗涤沙箱 (GraalVM Data Washing)**:内置安全隔离的 GraalVM Polyglot 引擎,允许管理员为主机 API 配置动态 JavaScript 洗涤脚本,在网关层将数百 KB 的冗余老旧 JSON 裁剪为只剩核心业务字段的极致精简 Payload,大幅节省 LLM Token。
|
||||
* ⛓️ **高扩展责任链架构 (Filter Chain)**:采用标准的 GatewayContext 穿透全链路,内置鉴权防刷 (Redis)、权限穿透 (Identity OBO)、危险动作人工挂起 (HITL Suspender) 以及全异步数据审计 (Async Audit)。
|
||||
* 🔌 **多协议执行策略 (Protocol Strategy)**:不仅支持基础的 HTTP 代理透传,底层执行器已完全抽象为 `ExecutionStrategy` 策略模式,未来从 HTTP 无缝切换/并存至 Dubbo、Spring Cloud Feign 毫无架构包袱。
|
||||
* ⚡ **极速内存直读 (Caffeine Local Cache)**:数据面网关对 API 配置信息的读取不会疯狂查询 DB,而是通过 Caffeine 提供带 TTL 的本地高速缓存。
|
||||
* 🎨 **开箱即用的控制台 (Control Plane)**:内置 Vue3 + Vite + Element Plus 编写的现代化管理后台,可以实现租户鉴权发放、工具注册配置、清洗脚本在线测试及全生命周期审计日志的可视化检索。
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈 (Tech Stack)
|
||||
|
||||
* **后端 (Data Plane & Control Plane API)**
|
||||
* Java 17 / 21
|
||||
* Spring Boot 3.2.x
|
||||
* GraalVM Polyglot (JavaScript Sandbox)
|
||||
* MyBatis-Plus (MySQL 8.0)
|
||||
* Caffeine & Redis
|
||||
* OkHttp3 (HTTP Execution Proxy)
|
||||
* **前端 (Control Plane UI)**
|
||||
* Vue 3 (Composition API)
|
||||
* Vite 5
|
||||
* Element Plus
|
||||
* Axios & Vue Router
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目结构 (Monorepo Structure)
|
||||
|
||||
```text
|
||||
enterprise-ai-agent-gateway/
|
||||
├── backend/ # Java Spring Boot 后端工程
|
||||
│ ├── pom.xml
|
||||
│ └── src/main/
|
||||
│ ├── java/.../gateway/
|
||||
│ │ ├── controller/ # 管理台 CRUD API & 代理网关入口 API
|
||||
│ │ ├── engine/ # GraalVM / Cache 引擎组件
|
||||
│ │ ├── filter/ # 核心责任链路拦截器
|
||||
│ │ ├── model/ # 数据库实体
|
||||
│ │ └── service/ # 业务逻辑与执行策略抽象
|
||||
│ └── resources/
|
||||
│ ├── application.yml # 配置文件
|
||||
│ └── schema.sql # 数据库初始化建表脚本
|
||||
│
|
||||
└── frontend/ # Vue 3 管理台前端工程
|
||||
├── package.json
|
||||
├── vite.config.js
|
||||
└── src/
|
||||
├── api/ # 后端接口 Axios 封装
|
||||
├── views/ # 管理台视图 (Tenant, Tool, Script, Audit)
|
||||
└── App.vue # 侧边栏整体布局
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始 (Quick Start)
|
||||
|
||||
### 1. 数据库准备
|
||||
* 运行 MySQL 8.x 服务。
|
||||
* 创建数据库 `db_agent_gateway`。
|
||||
* 执行 `backend/src/main/resources/schema.sql` 完成表结构与测试数据的初始化。
|
||||
|
||||
### 2. 启动后端 (Backend)
|
||||
* 修改 `backend/src/main/resources/application.yml` 中的数据库账号密码及 Redis 配置(支持无 Redis 本地优雅降级)。
|
||||
* 通过 IDE 或 Maven 启动应用:
|
||||
```bash
|
||||
cd backend
|
||||
mvn spring-boot:run
|
||||
```
|
||||
* 后端服务默认运行于 `http://localhost:8080`。
|
||||
|
||||
### 3. 启动管理后台 (Frontend)
|
||||
需要 Node.js 环境 (推荐 v18+)。
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
* 浏览器访问 `http://localhost:5173` 进入 AI Gateway 管理控制台。
|
||||
|
||||
### 4. 链路测试 (API Invocation Test)
|
||||
通过终端向网关数据面发起 API 请求验证洗涤链路(确保请求头携带控制台分配的对应 `X-App-Key`):
|
||||
```bash
|
||||
curl -X GET 'http://localhost:8080/api/v1/gw/invoke/get_time' \
|
||||
-H "X-App-Key: test_key" --noproxy '*'
|
||||
```
|
||||
随后你可以在控制台的「审计日志」页面看到刚刚这笔调用的清洗前后比对!
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
<a name="english-version"></a>
|
||||
|
||||
# Enterprise AI Agent Gateway
|
||||
|
||||
**Enterprise AI Agent Gateway** is a secure middleware and protocol conversion hub positioned between Large Language Models (LLM / AI Agents) and legacy enterprise systems.
|
||||
|
||||
When deploying AI Agents within an enterprise, allowing LLMs to directly invoke complex intranet APIs poses significant **data leakage risks**, **unauthorized access risks**, and **catastrophic LLM Token consumption** caused by redundant data returned by outdated systems. This project perfectly solves these pain points by providing a lightweight frontend Control Plane and a high-performance backend Data Plane.
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Key Features
|
||||
|
||||
* 🛡️ **GraalVM Data Washing Sandbox**: Built-in securely isolated GraalVM Polyglot engine, allowing administrators to configure dynamic JavaScript washing scripts for host APIs. At the gateway layer, hundreds of KBs of redundant legacy JSON can be trimmed down to a minimal payload containing only core business fields, drastically saving LLM Tokens.
|
||||
* ⛓️ **Highly Extensible Context Filter Chain**: Utilizes a standard `GatewayContext` penetrating the entire traffic chain, with built-in configurations for auth & rate limiting (Redis), Identity OBO (On-Behalf-Of), Human-In-The-Loop Suspender (for WRITE risky actions), and fully asynchronous data auditing.
|
||||
* 🔌 **Multi-Protocol Execution Strategy**: Not only supports basic HTTP proxy passthrough; the underlying executor is fully abstracted into an `ExecutionStrategy` pattern. Seamless switching or coexisting with Dubbo or Spring Cloud Feign in the future requires zero architectural burden.
|
||||
* ⚡ **Lightning-Fast Memory Read**: Configuration fetching on the high-concurrency Data Plane does not bombard the DB. Instead, it relies on Caffeine to provide TTL-based local high-speed caching.
|
||||
* 🎨 **Out-of-the-Box Control Panel**: A modern admin backend written in Vue3 + Vite + Element Plus is included, offering visual management for tenant auth, tool registry, online wash script testing, and full-lifecycle audit log retrieval.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
* **Backend (Data Plane & Control Plane API)**: Java 21, Spring Boot 3.2.x, GraalVM Polyglot (JS Sandbox), MyBatis-Plus (MySQL 8), Caffeine cache, OkHttp3.
|
||||
* **Frontend (Control Plane UI)**: Vue 3 (Composition API), Vite 5, Element Plus, Axios, Vue Router.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Database Setup
|
||||
* Run MySQL 8.x.
|
||||
* Create a database named `db_agent_gateway`.
|
||||
* Execute `backend/src/main/resources/schema.sql` to initialize tables and test data.
|
||||
|
||||
### 2. Start Backend
|
||||
* Update DB and Redis credentials in `backend/src/main/resources/application.yml` (supports graceful degradation if local Redis is down).
|
||||
* Run the application:
|
||||
```bash
|
||||
cd backend
|
||||
mvn spring-boot:run
|
||||
```
|
||||
* The backend runs on `http://localhost:8080` by default.
|
||||
|
||||
### 3. Start Frontend Console
|
||||
Requires Node.js (v18+ recommended).
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
* Access `http://localhost:5173` in your browser to view the admin console.
|
||||
|
||||
### 4. End-to-End Test
|
||||
Send a request to the Data Plane to verify the washing chain (ensure to include the valid `X-App-Key` allocated in the console):
|
||||
```bash
|
||||
curl -X GET 'http://localhost:8080/api/v1/gw/invoke/get_time' \
|
||||
-H "X-App-Key: test_key" --noproxy '*'
|
||||
```
|
||||
You can instantly view the before-and-after payload contrast of this transaction in the "Audit Log" page of the Admin Console!
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package com.cicc.enterprise.gateway.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.cicc.enterprise.gateway.model.*;
|
||||
import com.cicc.enterprise.gateway.service.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin")
|
||||
@RequiredArgsConstructor
|
||||
@CrossOrigin(origins = "*")
|
||||
public class ControlPlaneAdminController {
|
||||
|
||||
private final AiTenantService tenantService;
|
||||
private final AiToolRegistryService toolRegistryService;
|
||||
private final AiWashScriptService washScriptService;
|
||||
private final AiAuditLogService auditLogService;
|
||||
|
||||
// ================== Tenant CRUD ==================
|
||||
|
||||
@GetMapping("/tenant")
|
||||
public List<AiTenant> listTenants() {
|
||||
return tenantService.list();
|
||||
}
|
||||
|
||||
@PostMapping("/tenant")
|
||||
public AiTenant createTenant(@RequestBody AiTenant tenant) {
|
||||
tenantService.save(tenant);
|
||||
return tenant;
|
||||
}
|
||||
|
||||
@PutMapping("/tenant/{id}")
|
||||
public AiTenant updateTenant(@PathVariable Long id, @RequestBody AiTenant tenant) {
|
||||
tenant.setId(id);
|
||||
tenantService.updateById(tenant);
|
||||
return tenant;
|
||||
}
|
||||
|
||||
@DeleteMapping("/tenant/{id}")
|
||||
public void deleteTenant(@PathVariable Long id) {
|
||||
tenantService.removeById(id);
|
||||
}
|
||||
|
||||
// ================== Tool Registry CRUD ==================
|
||||
|
||||
@GetMapping("/tool")
|
||||
public List<AiToolRegistry> listTools() {
|
||||
return toolRegistryService.list();
|
||||
}
|
||||
|
||||
@PostMapping("/tool")
|
||||
public AiToolRegistry createTool(@RequestBody AiToolRegistry tool) {
|
||||
toolRegistryService.save(tool);
|
||||
return tool;
|
||||
}
|
||||
|
||||
@PutMapping("/tool/{id}")
|
||||
public AiToolRegistry updateTool(@PathVariable Long id, @RequestBody AiToolRegistry tool) {
|
||||
tool.setId(id);
|
||||
toolRegistryService.updateById(tool);
|
||||
return tool;
|
||||
}
|
||||
|
||||
@DeleteMapping("/tool/{id}")
|
||||
public void deleteTool(@PathVariable Long id) {
|
||||
toolRegistryService.removeById(id);
|
||||
}
|
||||
|
||||
// ================== Wash Script ==================
|
||||
|
||||
@GetMapping("/wash-script/{toolId}")
|
||||
public AiWashScript getWashScript(@PathVariable Long toolId) {
|
||||
return washScriptService.getOne(
|
||||
new LambdaQueryWrapper<AiWashScript>().eq(AiWashScript::getToolId, toolId)
|
||||
);
|
||||
}
|
||||
|
||||
@PostMapping("/wash-script")
|
||||
public AiWashScript saveWashScript(@RequestBody AiWashScript script) {
|
||||
AiWashScript existing = washScriptService.getOne(
|
||||
new LambdaQueryWrapper<AiWashScript>().eq(AiWashScript::getToolId, script.getToolId())
|
||||
);
|
||||
if (existing != null) {
|
||||
existing.setJsScript(script.getJsScript());
|
||||
existing.setVersion(existing.getVersion() + 1);
|
||||
washScriptService.updateById(existing);
|
||||
return existing;
|
||||
} else {
|
||||
script.setVersion(1);
|
||||
washScriptService.save(script);
|
||||
return script;
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/wash-script/test")
|
||||
public Map<String, String> testWashScript(@RequestBody Map<String, String> request) {
|
||||
// TODO: invoke GraalVM engine to test the script
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("status", "Test endpoint placeholder — will invoke GraalVM sandbox in next phase");
|
||||
result.put("input", request.getOrDefault("rawJson", ""));
|
||||
return result;
|
||||
}
|
||||
|
||||
// ================== Audit Log ==================
|
||||
|
||||
@GetMapping("/audit-log")
|
||||
public List<AiAuditLog> listAuditLogs(
|
||||
@RequestParam(required = false) String traceId,
|
||||
@RequestParam(required = false) String toolName
|
||||
) {
|
||||
LambdaQueryWrapper<AiAuditLog> qw = new LambdaQueryWrapper<>();
|
||||
if (traceId != null && !traceId.isBlank()) qw.eq(AiAuditLog::getTraceId, traceId);
|
||||
if (toolName != null && !toolName.isBlank()) qw.eq(AiAuditLog::getToolName, toolName);
|
||||
qw.orderByDesc(AiAuditLog::getId);
|
||||
qw.last("LIMIT 200");
|
||||
return auditLogService.list(qw);
|
||||
}
|
||||
|
||||
// ================== AI Copilot Mock ==================
|
||||
|
||||
@PostMapping("/copilot/generate-wash-script")
|
||||
public Map<String, String> generateWashScript(@RequestBody Map<String, String> request) {
|
||||
String systemPrompt = """
|
||||
# Role
|
||||
你是一个资深的 JavaScript 数据处理专家与大模型工程架构师。
|
||||
|
||||
# Objective
|
||||
根据用户提供的【原始庞大 JSON 样例】和【自然语言提取要求】,编写一段精简、安全、高效的 JavaScript 代码,用于在 GraalVM 环境下对 JSON 进行瘦身清洗。
|
||||
""";
|
||||
|
||||
Map<String, String> response = new HashMap<>();
|
||||
response.put("mockedSystemPrompt", systemPrompt);
|
||||
response.put("status", "This endpoint should delegate to an LLM provider to generate the JS string.");
|
||||
response.put("dummyOutput", "function wash(rawJsonString) { return '{}'; }");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
server:
|
||||
port: 8080
|
||||
spring:
|
||||
profiles:
|
||||
active: dev
|
||||
application:
|
||||
name: ai-agent-gateway
|
||||
datasource:
|
||||
url: jdbc:mysql://127.0.0.1:3306/db_agent_gateway?useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8&useSSL=false&allowPublicKeyRetrieval=true
|
||||
url: jdbc:mysql://127.0.0.1:3306/db_agent_gateway?useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8&useSSL=false
|
||||
username: root
|
||||
password: Gmcc@123
|
||||
password: YOUR_PASSWORD_HERE # Please configure actual password in application-dev.yml
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
data:
|
||||
redis:
|
||||
@@ -1,12 +1,3 @@
|
||||
# enterprise-ai-agent-gateway
|
||||
企业级 AI 智能体网关
|
||||
|
||||
这份终稿设计书已经为你全面整合完毕。它不仅包含了底层的**数据库模型**和**网关责任链**,还把你刚刚关心的**“前端如何降低脚本编写成本”**的设计思路,以及核心的**系统提示词(System Prompt)**全部固化了进去。
|
||||
|
||||
你可以直接将以下全部内容复制,喂给 Antigravity,让它开始生成骨架代码。
|
||||
|
||||
***
|
||||
|
||||
# 🚀 Enterprise AI Agent Gateway (企业级 AI 智能体网关) - 核心架构与开发设计书
|
||||
|
||||
## 1. 架构定位与核心设计理念
|
||||
@@ -128,7 +119,4 @@ function wash(rawJsonString) {
|
||||
return JSON.stringify({ error: "数据清洗失败" });
|
||||
}
|
||||
}
|
||||
```
|
||||
***
|
||||
|
||||
把这份文档丢给 Antigravity 后,你可以直接下达指令:**“请先根据第 3 节生成所有 MySQL 建表语句,然后生成第 5 节的 Filter 责任链代码骨架。”** 期待你的架构早日跑通!
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Vue</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1694
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.13.6",
|
||||
"element-plus": "^2.13.5",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<span class="logo-icon">⚡</span>
|
||||
<h1>AI Gateway</h1>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
router
|
||||
text-color="#ffffffb3"
|
||||
active-text-color="#409eff"
|
||||
background-color="transparent"
|
||||
>
|
||||
<el-menu-item
|
||||
v-for="route in menuRoutes"
|
||||
:key="route.path"
|
||||
:index="route.path"
|
||||
>
|
||||
<el-icon><component :is="route.meta.icon" /></el-icon>
|
||||
<span>{{ route.meta.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</aside>
|
||||
|
||||
<div class="main-area">
|
||||
<header class="top-header">
|
||||
<span class="page-title">{{ currentTitle }}</span>
|
||||
<div class="header-right">
|
||||
<el-tag type="success" size="small" effect="plain">v1.0.0</el-tag>
|
||||
<span>Admin</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="content-area">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const menuRoutes = router.getRoutes().filter(r => r.meta && r.meta.title)
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
const currentTitle = computed(() => route.meta?.title || 'Dashboard')
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 15000,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
// ========== Tenant ==========
|
||||
export const tenantApi = {
|
||||
list: () => api.get('/admin/tenant'),
|
||||
create: (data) => api.post('/admin/tenant', data),
|
||||
update: (id, data) => api.put(`/admin/tenant/${id}`, data),
|
||||
delete: (id) => api.delete(`/admin/tenant/${id}`)
|
||||
}
|
||||
|
||||
// ========== Tool Registry ==========
|
||||
export const toolApi = {
|
||||
list: () => api.get('/admin/tool'),
|
||||
create: (data) => api.post('/admin/tool', data),
|
||||
update: (id, data) => api.put(`/admin/tool/${id}`, data),
|
||||
delete: (id) => api.delete(`/admin/tool/${id}`)
|
||||
}
|
||||
|
||||
// ========== Wash Script ==========
|
||||
export const washScriptApi = {
|
||||
getByToolId: (toolId) => api.get(`/admin/wash-script/${toolId}`),
|
||||
save: (data) => api.post('/admin/wash-script', data),
|
||||
testScript: (data) => api.post('/admin/wash-script/test', data)
|
||||
}
|
||||
|
||||
// ========== Audit Log ==========
|
||||
export const auditApi = {
|
||||
list: (params) => api.get('/admin/audit-log', { params })
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
msg: String,
|
||||
})
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(ElementPlus)
|
||||
app.use(router)
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/tenant'
|
||||
},
|
||||
{
|
||||
path: '/tenant',
|
||||
name: 'TenantManage',
|
||||
component: () => import('../views/TenantManage.vue'),
|
||||
meta: { title: '租户管理', icon: 'User' }
|
||||
},
|
||||
{
|
||||
path: '/tool',
|
||||
name: 'ToolRegistry',
|
||||
component: () => import('../views/ToolRegistry.vue'),
|
||||
meta: { title: '工具注册', icon: 'SetUp' }
|
||||
},
|
||||
{
|
||||
path: '/wash-script',
|
||||
name: 'WashScript',
|
||||
component: () => import('../views/WashScript.vue'),
|
||||
meta: { title: '清洗脚本', icon: 'MagicStick' }
|
||||
},
|
||||
{
|
||||
path: '/audit-log',
|
||||
name: 'AuditLog',
|
||||
component: () => import('../views/AuditLog.vue'),
|
||||
meta: { title: '审计日志', icon: 'Document' }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,117 @@
|
||||
:root {
|
||||
--sidebar-bg: #1d1e2c;
|
||||
--sidebar-active: #409eff;
|
||||
--header-bg: #ffffff;
|
||||
--body-bg: #f0f2f5;
|
||||
--card-bg: #ffffff;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background: var(--body-bg);
|
||||
color: #303133;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: var(--sidebar-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 2px 0 8px rgba(0,0,0,0.15);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.sidebar-logo h1 {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.sidebar-logo .logo-icon {
|
||||
font-size: 22px;
|
||||
margin-right: 8px;
|
||||
background: linear-gradient(135deg, #409eff, #67c23a);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.sidebar .el-menu {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.main-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top-header {
|
||||
height: 56px;
|
||||
background: var(--header-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.top-header .page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1d1e2c;
|
||||
}
|
||||
|
||||
.top-header .header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.page-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.page-card .toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-card .toolbar-left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-input v-model="searchTraceId" placeholder="Trace ID" clearable style="width: 240px" />
|
||||
<el-input v-model="searchToolName" placeholder="工具名称" clearable style="width: 160px" />
|
||||
<el-button type="primary" :icon="Search" @click="fetchLogs">查询</el-button>
|
||||
</div>
|
||||
<el-tag type="info">共 {{ list.length }} 条记录</el-tag>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" stripe border style="width: 100%" max-height="600">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="traceId" label="Trace ID" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<el-text type="info" size="small" style="font-family: monospace">{{ row.traceId }}</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tenantId" label="租户ID" width="80" align="center" />
|
||||
<el-table-column prop="toolName" label="工具名称" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ row.toolName }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="costTimeMs" label="耗时(ms)" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.costTimeMs > 1000 ? 'danger' : row.costTimeMs > 500 ? 'warning' : 'success'" size="small">
|
||||
{{ row.costTimeMs }}ms
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="时间" width="170" />
|
||||
<el-table-column label="详情" width="120" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="showDetail(row)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="审计详情" width="800px" destroy-on-close>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="Trace ID">{{ detail.traceId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="工具名称">{{ detail.toolName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="耗时">{{ detail.costTimeMs }}ms</el-descriptions-item>
|
||||
<el-descriptions-item label="请求时间">{{ detail.createTime }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-divider />
|
||||
<el-tabs>
|
||||
<el-tab-pane label="原始请求">
|
||||
<pre class="json-block">{{ formatJson(detail.rawrequest) }}</pre>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="原始响应">
|
||||
<pre class="json-block">{{ formatJson(detail.rawResponse) }}</pre>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="清洗结果">
|
||||
<pre class="json-block">{{ formatJson(detail.washedResponse) }}</pre>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { auditApi } from '../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const list = ref([])
|
||||
const searchTraceId = ref('')
|
||||
const searchToolName = ref('')
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref({})
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
const params = {}
|
||||
if (searchTraceId.value) params.traceId = searchTraceId.value
|
||||
if (searchToolName.value) params.toolName = searchToolName.value
|
||||
const { data } = await auditApi.list(params)
|
||||
list.value = data
|
||||
} catch { ElMessage.error('加载审计日志失败') }
|
||||
}
|
||||
|
||||
const showDetail = (row) => {
|
||||
detail.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const formatJson = (str) => {
|
||||
if (!str) return '(空)'
|
||||
try { return JSON.stringify(JSON.parse(str), null, 2) } catch { return str }
|
||||
}
|
||||
|
||||
onMounted(fetchLogs)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.json-block {
|
||||
background: #1e1e2e;
|
||||
color: #a6e3a1;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
font-family: 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-input v-model="searchKey" placeholder="搜索租户名/AppKey" clearable style="width: 260px" :prefix-icon="Search" />
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openDialog()">新增租户</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredList" stripe border style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="tenantName" label="租户名称" min-width="140" />
|
||||
<el-table-column prop="appKey" label="App Key" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="info">{{ row.appKey }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="appSecret" label="App Secret" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span style="filter: blur(3px); cursor: pointer" @click="$event.target.style.filter = 'none'">{{ row.appSecret }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rateLimitQps" label="QPS限制" width="100" align="center" />
|
||||
<el-table-column prop="status" label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">{{ row.status === 1 ? '启用' : '禁用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确定要删除吗?" @confirm="handleDelete(row.id)">
|
||||
<template #reference>
|
||||
<el-button link type="danger">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑租户' : '新增租户'" width="520px" destroy-on-close>
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="租户名称"><el-input v-model="form.tenantName" /></el-form-item>
|
||||
<el-form-item label="App Key"><el-input v-model="form.appKey" :disabled="isEdit" /></el-form-item>
|
||||
<el-form-item label="App Secret"><el-input v-model="form.appSecret" /></el-form-item>
|
||||
<el-form-item label="QPS 限制"><el-input-number v-model="form.rateLimitQps" :min="1" :max="10000" /></el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Search, Plus } from '@element-plus/icons-vue'
|
||||
import { tenantApi } from '../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const list = ref([])
|
||||
const searchKey = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const form = ref({ tenantName: '', appKey: '', appSecret: '', rateLimitQps: 100, status: 1 })
|
||||
|
||||
const filteredList = computed(() => {
|
||||
if (!searchKey.value) return list.value
|
||||
const key = searchKey.value.toLowerCase()
|
||||
return list.value.filter(t => t.tenantName?.toLowerCase().includes(key) || t.appKey?.toLowerCase().includes(key))
|
||||
})
|
||||
|
||||
const fetchList = async () => {
|
||||
try {
|
||||
const { data } = await tenantApi.list()
|
||||
list.value = data
|
||||
} catch { ElMessage.error('加载失败') }
|
||||
}
|
||||
|
||||
const openDialog = (row) => {
|
||||
isEdit.value = !!row
|
||||
form.value = row ? { ...row } : { tenantName: '', appKey: '', appSecret: '', rateLimitQps: 100, status: 1 }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await tenantApi.update(form.value.id, form.value)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await tenantApi.create(form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await fetchList()
|
||||
} catch { ElMessage.error('操作失败') }
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await tenantApi.delete(id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchList()
|
||||
} catch { ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-input v-model="searchKey" placeholder="搜索工具名/描述" clearable style="width: 260px" :prefix-icon="Search" />
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openDialog()">注册工具</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredList" stripe border style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="toolName" label="工具标识" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="plain">{{ row.toolName }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="executionProtocol" label="协议" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.executionProtocol === 'HTTP' ? '' : 'warning'" size="small">{{ row.executionProtocol || 'HTTP' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="targetUrl" label="目标地址" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column prop="httpMethod" label="HTTP方法" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.httpMethod === 'GET' ? 'success' : 'primary'" size="small">{{ row.httpMethod }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actionType" label="动作类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.actionType === 'WRITE' ? 'danger' : 'info'" size="small">{{ row.actionType }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确定要删除吗?" @confirm="handleDelete(row.id)">
|
||||
<template #reference>
|
||||
<el-button link type="danger">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑工具' : '注册工具'" width="600px" destroy-on-close>
|
||||
<el-form :model="form" label-width="110px">
|
||||
<el-form-item label="工具标识"><el-input v-model="form.toolName" placeholder="英文下划线命名,如 get_stock_price" :disabled="isEdit" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" :rows="3" placeholder="LLM将据此決定是否调用该工具" /></el-form-item>
|
||||
<el-form-item label="执行协议">
|
||||
<el-select v-model="form.executionProtocol" style="width: 100%">
|
||||
<el-option label="HTTP" value="HTTP" />
|
||||
<el-option label="DUBBO (预留)" value="DUBBO" disabled />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标 URL"><el-input v-model="form.targetUrl" placeholder="http://internal-service/api/..." /></el-form-item>
|
||||
<el-form-item label="HTTP 方法">
|
||||
<el-radio-group v-model="form.httpMethod">
|
||||
<el-radio value="GET">GET</el-radio>
|
||||
<el-radio value="POST">POST</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="动作类型">
|
||||
<el-radio-group v-model="form.actionType">
|
||||
<el-radio value="READ">READ (免审批)</el-radio>
|
||||
<el-radio value="WRITE">WRITE (需审批)</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="请求 Schema"><el-input v-model="form.requestSchema" type="textarea" :rows="4" placeholder="标准 JSON Schema (可选)" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Search, Plus } from '@element-plus/icons-vue'
|
||||
import { toolApi } from '../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const list = ref([])
|
||||
const searchKey = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const defaultForm = { toolName: '', description: '', executionProtocol: 'HTTP', targetUrl: '', httpMethod: 'GET', actionType: 'READ', requestSchema: '' }
|
||||
const form = ref({ ...defaultForm })
|
||||
|
||||
const filteredList = computed(() => {
|
||||
if (!searchKey.value) return list.value
|
||||
const key = searchKey.value.toLowerCase()
|
||||
return list.value.filter(t => t.toolName?.toLowerCase().includes(key) || t.description?.toLowerCase().includes(key))
|
||||
})
|
||||
|
||||
const fetchList = async () => {
|
||||
try {
|
||||
const { data } = await toolApi.list()
|
||||
list.value = data
|
||||
} catch { ElMessage.error('加载失败') }
|
||||
}
|
||||
|
||||
const openDialog = (row) => {
|
||||
isEdit.value = !!row
|
||||
form.value = row ? { ...row } : { ...defaultForm }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await toolApi.update(form.value.id, form.value)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await toolApi.create(form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await fetchList()
|
||||
} catch { ElMessage.error('操作失败') }
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await toolApi.delete(id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchList()
|
||||
} catch { ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-select v-model="selectedToolId" placeholder="选择关联工具" style="width: 280px" filterable @change="loadScript">
|
||||
<el-option v-for="tool in toolList" :key="tool.id" :label="`${tool.toolName} — ${tool.description?.substring(0, 30)}`" :value="tool.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px">
|
||||
<el-button type="success" :icon="MagicStick" @click="aiGenerate" :loading="aiLoading">AI 生成脚本</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving" :disabled="!selectedToolId">保存脚本</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<div style="margin-bottom: 8px; font-weight: 600; color: #606266;">📝 JavaScript 清洗脚本</div>
|
||||
<el-input
|
||||
v-model="jsScript"
|
||||
type="textarea"
|
||||
:rows="20"
|
||||
placeholder="function wash(rawJsonString) { const data = JSON.parse(rawJsonString); // 在这里编写清洗逻辑... return JSON.stringify(result); }"
|
||||
style="font-family: 'Fira Code', 'Consolas', monospace; font-size: 13px;"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div style="margin-bottom: 8px; font-weight: 600; color: #606266;">🧪 测试面板</div>
|
||||
<el-input
|
||||
v-model="testInput"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder='粘贴原始 JSON 样例...'
|
||||
style="font-family: monospace; font-size: 13px; margin-bottom: 8px;"
|
||||
/>
|
||||
<el-button type="warning" size="small" @click="runTest" :loading="testing" style="margin-bottom: 8px">执行测试</el-button>
|
||||
<el-input
|
||||
v-model="testOutput"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="清洗结果将在此展示..."
|
||||
readonly
|
||||
style="font-family: monospace; font-size: 13px; background: #fafafa;"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { MagicStick } from '@element-plus/icons-vue'
|
||||
import { toolApi, washScriptApi } from '../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const toolList = ref([])
|
||||
const selectedToolId = ref(null)
|
||||
const jsScript = ref('')
|
||||
const testInput = ref('')
|
||||
const testOutput = ref('')
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const aiLoading = ref(false)
|
||||
|
||||
const fetchTools = async () => {
|
||||
try {
|
||||
const { data } = await toolApi.list()
|
||||
toolList.value = data
|
||||
} catch { ElMessage.error('加载工具列表失败') }
|
||||
}
|
||||
|
||||
const loadScript = async (toolId) => {
|
||||
try {
|
||||
const { data } = await washScriptApi.getByToolId(toolId)
|
||||
jsScript.value = data?.jsScript || ''
|
||||
} catch {
|
||||
jsScript.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedToolId.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await washScriptApi.save({ toolId: selectedToolId.value, jsScript: jsScript.value })
|
||||
ElMessage.success('脚本保存成功')
|
||||
} catch { ElMessage.error('保存失败') }
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
const runTest = async () => {
|
||||
testing.value = true
|
||||
try {
|
||||
const { data } = await washScriptApi.testScript({ jsScript: jsScript.value, rawJson: testInput.value })
|
||||
testOutput.value = typeof data === 'string' ? data : JSON.stringify(data, null, 2)
|
||||
} catch (e) {
|
||||
testOutput.value = 'ERROR: ' + (e.response?.data?.error || e.message)
|
||||
}
|
||||
testing.value = false
|
||||
}
|
||||
|
||||
const aiGenerate = () => {
|
||||
aiLoading.value = true
|
||||
ElMessage.info('AI 脚本生成功能将在 Phase 2 中接入大模型 API')
|
||||
setTimeout(() => { aiLoading.value = false }, 1000)
|
||||
}
|
||||
|
||||
onMounted(fetchTools)
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
package com.cicc.enterprise.gateway.controller;
|
||||
|
||||
import com.cicc.enterprise.gateway.model.AiToolRegistry;
|
||||
import com.cicc.enterprise.gateway.service.AiToolRegistryService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin")
|
||||
@RequiredArgsConstructor
|
||||
public class ControlPlaneAdminController {
|
||||
|
||||
private final AiToolRegistryService toolRegistryService;
|
||||
|
||||
// Standard CRUD
|
||||
@GetMapping("/tools")
|
||||
public List<AiToolRegistry> listTools() {
|
||||
return toolRegistryService.list();
|
||||
}
|
||||
|
||||
@PostMapping("/tools")
|
||||
public AiToolRegistry createTool(@RequestBody AiToolRegistry tool) {
|
||||
toolRegistryService.save(tool);
|
||||
return tool;
|
||||
}
|
||||
|
||||
// AI Copilot Mock API for Wash Script Generation
|
||||
@PostMapping("/copilot/generate-wash-script")
|
||||
public Map<String, String> generateWashScript(@RequestBody Map<String, String> request) {
|
||||
// Extract parameters from request if needed for dynamic prompt building
|
||||
// String rawJsonSample = request.get("rawJsonSample");
|
||||
// String requirement = request.get("requirement");
|
||||
String systemPrompt = """
|
||||
# Role
|
||||
你是一个资深的 JavaScript 数据处理专家与大模型工程架构师。
|
||||
|
||||
# Objective
|
||||
根据用户提供的【原始庞大 JSON 样例】和【自然语言提取要求】,编写一段精简、安全、高效的 JavaScript 代码,用于在 GraalVM 环境下对 JSON 进行瘦身清洗。这段清洗后的数据将被直接喂给大模型(LLM)作为上下文。
|
||||
|
||||
# Constraints & Rules
|
||||
1. 你的输出必须且只能包含一个名为 `wash` 的 JavaScript 函数。
|
||||
2. 该函数的入参必须是 `rawJsonString` (字符串类型)。
|
||||
3. 该函数的返回值必须是清洗并序列化后的 JSON 字符串,以确保后续直接使用。
|
||||
...
|
||||
""";
|
||||
|
||||
Map<String, String> response = new HashMap<>();
|
||||
response.put("mockedSystemPrompt", systemPrompt);
|
||||
response.put("status", "This endpoint should delegate to an LLM provider to generate the JS string.");
|
||||
response.put("dummyOutput", "function wash(rawJsonString) { return '{}'; }");
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user