feat: AI端点测试动态参数表单、接口工作流行内测试、本地开发环境改为线上域名

- 后端新增 /ai/endpoint/test 和 /ai/endpoint/stream 接口,支持直接端点测试
- 前端增加行内测试功能(场景绑定+接口工作流)
- 测试对话框增加动态参数表单和参数定义编辑
- 支持 _meta 格式的默认输入参数处理
- web、web-admin 本地开发环境 API 调用改为线上域名 https://lifescript.happylifeos.com

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 16:10:24 +08:00
parent e06b22ad69
commit d3746fa6c7
17 changed files with 559 additions and 28 deletions
@@ -3,6 +3,7 @@ package com.emotion.controller;
import com.alibaba.fastjson2.JSONObject;
import com.emotion.common.Result;
import com.emotion.dto.request.ai.AiRuntimeRequest;
import com.emotion.dto.response.ai.AiTestTemplateResponse;
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
import com.emotion.dto.response.ai.AiStreamEvent;
import com.emotion.entity.AiCallLog;
@@ -80,6 +81,11 @@ public class AiRoutingController {
return Result.success(endpointConfigService.listVisible());
}
@GetMapping("/endpoints/test-template")
public Result<AiTestTemplateResponse> endpointTestTemplate(@RequestParam String id) {
return Result.success(runtimeService.buildEndpointTestTemplate(id));
}
@PostMapping("/endpoints")
public Result<AiEndpointConfig> createEndpoint(@RequestBody AiEndpointConfig endpoint) {
return Result.success(endpointConfigService.saveEndpoint(endpoint));
@@ -101,6 +107,11 @@ public class AiRoutingController {
return Result.success(sceneBindingService.listVisible());
}
@GetMapping("/scenes/test-template")
public Result<AiTestTemplateResponse> sceneTestTemplate(@RequestParam String sceneCode) {
return Result.success(runtimeService.buildSceneTestTemplate(sceneCode));
}
@PostMapping("/scenes")
public Result<AiSceneBinding> createScene(@RequestBody AiSceneBinding scene) {
if (scene.getIsEnabled() == null) {
@@ -0,0 +1,47 @@
package com.emotion.dto.response.ai;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AiTestTemplateResponse {
private String sceneCode;
private String endpointId;
private String endpointCode;
private String endpointName;
private String providerType;
@Builder.Default
private Map<String, Object> inputs = new LinkedHashMap<>();
@Builder.Default
private List<ParamField> paramFields = new ArrayList<>();
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ParamField {
private String name;
private String label;
private String type;
private Object value;
private Boolean required;
private String placeholder;
}
}
@@ -1,6 +1,7 @@
package com.emotion.service;
import com.emotion.dto.request.ai.AiRuntimeRequest;
import com.emotion.dto.response.ai.AiTestTemplateResponse;
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
import com.emotion.dto.response.ai.AiStreamEvent;
@@ -16,4 +17,8 @@ public interface AiRuntimeService {
AiRuntimeTestResponse testEndpoint(String endpointId, Map<String, Object> inputs);
void invokeEndpointStream(String endpointId, Map<String, Object> inputs, Consumer<AiStreamEvent> consumer);
AiTestTemplateResponse buildEndpointTestTemplate(String endpointId);
AiTestTemplateResponse buildSceneTestTemplate(String sceneCode);
}
@@ -76,8 +76,12 @@ public class CozeProviderAdapter implements AiProviderAdapter {
JSONObject body = new JSONObject();
if ("workflow".equalsIgnoreCase(endpoint.getEndpointType())) {
body.put("workflow_id", endpoint.getWorkflowId());
if (StringUtils.hasText(endpoint.getBotId())) {
body.put("bot_id", endpoint.getBotId());
}
body.put("user_id", StringUtils.hasText(request.getUserId()) ? request.getUserId() : "anonymous");
body.put("stream", true);
body.put("parameters", inputs);
body.put("is_async", false);
return body;
}
@@ -90,8 +94,13 @@ public class CozeProviderAdapter implements AiProviderAdapter {
message.put("role", "user");
message.put("content_type", "text");
message.put("content", templateRenderer.firstText(inputs));
message.put("type", "question");
messages.add(message);
body.put("additional_messages", messages);
body.put("parameters", new JSONObject());
if (StringUtils.hasText(endpoint.getWorkflowId())) {
body.put("workflow_id", endpoint.getWorkflowId());
}
return body;
}
@@ -108,19 +117,27 @@ public class CozeProviderAdapter implements AiProviderAdapter {
if (StringUtils.hasText(content) && (type == null || type.contains("answer") || type.contains("delta"))) {
return content;
}
String output = json.getString("output");
if (StringUtils.hasText(output)) {
return output;
}
String answer = json.getString("answer");
if (StringUtils.hasText(answer)) {
return answer;
}
JSONObject message = json.getJSONObject("message");
if (message != null && StringUtils.hasText(message.getString("content"))) {
return message.getString("content");
}
JSONObject data = json.getJSONObject("data");
if (data != null) {
String output = data.getString("output");
if (StringUtils.hasText(output)) {
return output;
String dataOutput = data.getString("output");
if (StringUtils.hasText(dataOutput)) {
return dataOutput;
}
String answer = data.getString("answer");
if (StringUtils.hasText(answer)) {
return answer;
String dataAnswer = data.getString("answer");
if (StringUtils.hasText(dataAnswer)) {
return dataAnswer;
}
String dataContent = data.getString("content");
if (StringUtils.hasText(dataContent)) {
@@ -76,6 +76,10 @@ public class DifyProviderAdapter implements AiProviderAdapter {
JSONObject body = new JSONObject();
body.put("response_mode", "streaming");
body.put("user", user(request));
Object conversationId = inputs.get("conversation_id");
if (conversationId != null && StringUtils.hasText(String.valueOf(conversationId))) {
body.put("conversation_id", conversationId);
}
if ("chat".equalsIgnoreCase(endpoint.getEndpointType())) {
body.put("query", templateRenderer.firstText(inputs));
body.put("inputs", inputs);
@@ -10,6 +10,7 @@ import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
@@ -30,6 +31,7 @@ public class ProviderHttpSupport {
public void applyHeaders(HttpHeaders headers, AiProvider provider, AiEndpointConfig endpoint) {
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(List.of(MediaType.TEXT_EVENT_STREAM, MediaType.APPLICATION_JSON));
if (StringUtils.hasText(provider.getApiKey())) {
headers.setBearerAuth(provider.getApiKey());
}
@@ -43,6 +45,17 @@ public class ProviderHttpSupport {
}
try {
JSONObject json = JSON.parseObject(data);
String event = json.getString("event");
String type = json.getString("type");
if ("error".equalsIgnoreCase(event) || "error".equalsIgnoreCase(type)) {
String code = firstText(json, "code", "error_code", "errorCode");
String message = firstText(json, "message", "error", "error_msg", "errorMessage");
if (!StringUtils.hasText(message)) {
message = data;
}
consumer.accept(AiStreamEvent.error(StringUtils.hasText(code) ? code : "AI_PROVIDER_ERROR", message));
throw new IllegalStateException(message);
}
String delta = extractor.extract(json);
if (StringUtils.hasText(delta)) {
counter.increment();
@@ -54,6 +67,25 @@ public class ProviderHttpSupport {
}
}
private String firstText(JSONObject json, String... keys) {
for (String key : keys) {
String value = json.getString(key);
if (StringUtils.hasText(value)) {
return value;
}
}
JSONObject data = json.getJSONObject("data");
if (data != null) {
for (String key : keys) {
String value = data.getString(key);
if (StringUtils.hasText(value)) {
return value;
}
}
}
return null;
}
private void applyJsonHeaders(HttpHeaders headers, String jsonText) {
if (!StringUtils.hasText(jsonText)) {
return;
@@ -1,7 +1,9 @@
package com.emotion.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.emotion.dto.request.ai.AiRuntimeRequest;
import com.emotion.dto.response.ai.AiTestTemplateResponse;
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
import com.emotion.dto.response.ai.AiStreamEvent;
import com.emotion.entity.AiCallLog;
@@ -20,6 +22,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -247,6 +251,172 @@ public class AiRuntimeServiceImpl implements AiRuntimeService {
}
}
@Override
public AiTestTemplateResponse buildEndpointTestTemplate(String endpointId) {
AiEndpointConfig endpoint = endpointConfigService.getById(endpointId);
if (endpoint == null || Integer.valueOf(1).equals(endpoint.getIsDeleted())) {
throw new IllegalArgumentException("AI_ENDPOINT_NOT_FOUND");
}
AiProvider provider = providerService.getById(endpoint.getProviderId());
if (provider == null || Integer.valueOf(1).equals(provider.getIsDeleted())) {
throw new IllegalArgumentException("AI_PROVIDER_NOT_FOUND");
}
return buildTemplate(null, endpoint, provider);
}
@Override
public AiTestTemplateResponse buildSceneTestTemplate(String sceneCode) {
AiSceneBinding scene = sceneBindingService.resolveScene(sceneCode);
if (scene == null) {
throw new IllegalArgumentException("AI_SCENE_NOT_BOUND");
}
AiEndpointConfig endpoint = endpointConfigService.getById(scene.getEndpointId());
if (endpoint == null || Integer.valueOf(1).equals(endpoint.getIsDeleted())) {
throw new IllegalArgumentException("AI_ENDPOINT_NOT_FOUND");
}
AiProvider provider = providerService.getById(endpoint.getProviderId());
if (provider == null || Integer.valueOf(1).equals(provider.getIsDeleted())) {
throw new IllegalArgumentException("AI_PROVIDER_NOT_FOUND");
}
return buildTemplate(sceneCode, endpoint, provider);
}
private AiTestTemplateResponse buildTemplate(String sceneCode, AiEndpointConfig endpoint, AiProvider provider) {
LinkedHashMap<String, Object> inputs = new LinkedHashMap<>();
applyDefaultInputs(inputs, endpoint.getDefaultInputs());
applyProviderSampleInputs(inputs, sceneCode, endpoint, provider);
List<AiTestTemplateResponse.ParamField> fields = new ArrayList<>();
inputs.forEach((key, value) -> fields.add(AiTestTemplateResponse.ParamField.builder()
.name(key)
.label(paramLabel(key))
.type(paramType(value))
.value(value)
.required(requiredParam(key, provider.getProviderType()))
.placeholder(paramPlaceholder(key))
.build()));
return AiTestTemplateResponse.builder()
.sceneCode(sceneCode)
.endpointId(endpoint.getId())
.endpointCode(endpoint.getEndpointCode())
.endpointName(endpoint.getEndpointName())
.providerType(provider.getProviderType())
.inputs(inputs)
.paramFields(fields)
.build();
}
private void applyDefaultInputs(LinkedHashMap<String, Object> inputs, String defaultInputs) {
if (!StringUtils.hasText(defaultInputs)) {
return;
}
try {
JSONObject parsed = JSON.parseObject(defaultInputs);
parsed.forEach((key, value) -> {
if (value instanceof JSONObject && ((JSONObject) value).containsKey("_meta")) {
inputs.put(key, ((JSONObject) value).get("value"));
} else {
inputs.put(key, value);
}
});
} catch (Exception ignored) {
}
}
private void applyProviderSampleInputs(LinkedHashMap<String, Object> inputs,
String sceneCode,
AiEndpointConfig endpoint,
AiProvider provider) {
String providerType = provider.getProviderType();
String sample = samplePrompt(sceneCode, endpoint);
if ("dify".equalsIgnoreCase(providerType)) {
inputs.putIfAbsent("query", sample);
inputs.putIfAbsent("inputs", Map.of("input", sample, "user_id", "admin-test-user"));
inputs.putIfAbsent("response_mode", "streaming");
inputs.putIfAbsent("conversation_id", "");
inputs.putIfAbsent("user", "admin-test-user");
inputs.putIfAbsent("user_id", "admin-test-user");
return;
}
inputs.putIfAbsent("input", sample);
inputs.putIfAbsent("prompt", sample);
inputs.putIfAbsent("message", sample);
inputs.putIfAbsent("user_id", "admin-test-user");
if ("chat".equalsIgnoreCase(endpoint.getEndpointType())) {
inputs.putIfAbsent("conversationId", "admin-test-conversation");
}
}
private String samplePrompt(String sceneCode, AiEndpointConfig endpoint) {
String code = StringUtils.hasText(sceneCode) ? sceneCode : endpoint.getEndpointCode();
if (code == null) {
return "请用一句中文回复测试成功。";
}
if (code.contains("script") || code.contains("life.generate")) {
return "请生成一个关于普通人重新找回生活热情的短剧本,要求结构清晰、中文输出。";
}
if (code.contains("short_story") || code.contains("story")) {
return "请生成一篇 300 字以内的治愈短篇小说,主题是雨后的新开始。";
}
if (code.contains("diary") || code.contains("healing") || code.contains("life")) {
return "今天工作压力很大,但傍晚散步时感觉心情慢慢平静下来,请给我一段温和的回应。";
}
if (code.contains("summary")) {
return "用户今天先表达焦虑,随后通过散步和朋友聊天逐渐恢复平静,请生成一段情绪总结。";
}
if (code.contains("chat")) {
return "你好,请用一句中文回复:AI 接口测试成功。";
}
return "请用一句中文回复测试成功。";
}
private String paramLabel(String key) {
Map<String, String> labels = Map.ofEntries(
Map.entry("input", "输入内容"),
Map.entry("prompt", "提示词"),
Map.entry("message", "消息内容"),
Map.entry("query", "用户问题"),
Map.entry("inputs", "Dify 变量"),
Map.entry("response_mode", "响应模式"),
Map.entry("conversation_id", "会话 ID"),
Map.entry("user", "用户标识"),
Map.entry("user_id", "用户标识"),
Map.entry("conversationId", "会话 ID")
);
return labels.getOrDefault(key, key);
}
private String paramType(Object value) {
if (value instanceof Number) {
return "number";
}
if (value instanceof Boolean) {
return "boolean";
}
if (value instanceof Map || value instanceof JSONObject) {
return "json";
}
String text = value == null ? "" : String.valueOf(value);
return text.length() > 60 ? "textarea" : "string";
}
private Boolean requiredParam(String key, String providerType) {
if ("dify".equalsIgnoreCase(providerType)) {
return "query".equals(key) || "response_mode".equals(key) || "user".equals(key);
}
return "input".equals(key) || "user_id".equals(key);
}
private String paramPlaceholder(String key) {
if ("inputs".equals(key)) {
return "{\"input\":\"测试内容\"}";
}
return "请输入" + paramLabel(key);
}
private RuntimeTarget resolveTarget(AiRuntimeRequest request) {
if (!StringUtils.hasText(request.getSceneCode())) {
throw new IllegalArgumentException("AI_SCENE_REQUIRED");