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.alibaba.fastjson2.JSONObject;
import com.emotion.common.Result; import com.emotion.common.Result;
import com.emotion.dto.request.ai.AiRuntimeRequest; 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.AiRuntimeTestResponse;
import com.emotion.dto.response.ai.AiStreamEvent; import com.emotion.dto.response.ai.AiStreamEvent;
import com.emotion.entity.AiCallLog; import com.emotion.entity.AiCallLog;
@@ -80,6 +81,11 @@ public class AiRoutingController {
return Result.success(endpointConfigService.listVisible()); return Result.success(endpointConfigService.listVisible());
} }
@GetMapping("/endpoints/test-template")
public Result<AiTestTemplateResponse> endpointTestTemplate(@RequestParam String id) {
return Result.success(runtimeService.buildEndpointTestTemplate(id));
}
@PostMapping("/endpoints") @PostMapping("/endpoints")
public Result<AiEndpointConfig> createEndpoint(@RequestBody AiEndpointConfig endpoint) { public Result<AiEndpointConfig> createEndpoint(@RequestBody AiEndpointConfig endpoint) {
return Result.success(endpointConfigService.saveEndpoint(endpoint)); return Result.success(endpointConfigService.saveEndpoint(endpoint));
@@ -101,6 +107,11 @@ public class AiRoutingController {
return Result.success(sceneBindingService.listVisible()); return Result.success(sceneBindingService.listVisible());
} }
@GetMapping("/scenes/test-template")
public Result<AiTestTemplateResponse> sceneTestTemplate(@RequestParam String sceneCode) {
return Result.success(runtimeService.buildSceneTestTemplate(sceneCode));
}
@PostMapping("/scenes") @PostMapping("/scenes")
public Result<AiSceneBinding> createScene(@RequestBody AiSceneBinding scene) { public Result<AiSceneBinding> createScene(@RequestBody AiSceneBinding scene) {
if (scene.getIsEnabled() == null) { 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; package com.emotion.service;
import com.emotion.dto.request.ai.AiRuntimeRequest; 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.AiRuntimeTestResponse;
import com.emotion.dto.response.ai.AiStreamEvent; import com.emotion.dto.response.ai.AiStreamEvent;
@@ -16,4 +17,8 @@ public interface AiRuntimeService {
AiRuntimeTestResponse testEndpoint(String endpointId, Map<String, Object> inputs); AiRuntimeTestResponse testEndpoint(String endpointId, Map<String, Object> inputs);
void invokeEndpointStream(String endpointId, Map<String, Object> inputs, Consumer<AiStreamEvent> consumer); 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(); JSONObject body = new JSONObject();
if ("workflow".equalsIgnoreCase(endpoint.getEndpointType())) { if ("workflow".equalsIgnoreCase(endpoint.getEndpointType())) {
body.put("workflow_id", endpoint.getWorkflowId()); 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("parameters", inputs);
body.put("is_async", false);
return body; return body;
} }
@@ -90,8 +94,13 @@ public class CozeProviderAdapter implements AiProviderAdapter {
message.put("role", "user"); message.put("role", "user");
message.put("content_type", "text"); message.put("content_type", "text");
message.put("content", templateRenderer.firstText(inputs)); message.put("content", templateRenderer.firstText(inputs));
message.put("type", "question");
messages.add(message); messages.add(message);
body.put("additional_messages", messages); body.put("additional_messages", messages);
body.put("parameters", new JSONObject());
if (StringUtils.hasText(endpoint.getWorkflowId())) {
body.put("workflow_id", endpoint.getWorkflowId());
}
return body; return body;
} }
@@ -108,19 +117,27 @@ public class CozeProviderAdapter implements AiProviderAdapter {
if (StringUtils.hasText(content) && (type == null || type.contains("answer") || type.contains("delta"))) { if (StringUtils.hasText(content) && (type == null || type.contains("answer") || type.contains("delta"))) {
return content; 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"); JSONObject message = json.getJSONObject("message");
if (message != null && StringUtils.hasText(message.getString("content"))) { if (message != null && StringUtils.hasText(message.getString("content"))) {
return message.getString("content"); return message.getString("content");
} }
JSONObject data = json.getJSONObject("data"); JSONObject data = json.getJSONObject("data");
if (data != null) { if (data != null) {
String output = data.getString("output"); String dataOutput = data.getString("output");
if (StringUtils.hasText(output)) { if (StringUtils.hasText(dataOutput)) {
return output; return dataOutput;
} }
String answer = data.getString("answer"); String dataAnswer = data.getString("answer");
if (StringUtils.hasText(answer)) { if (StringUtils.hasText(dataAnswer)) {
return answer; return dataAnswer;
} }
String dataContent = data.getString("content"); String dataContent = data.getString("content");
if (StringUtils.hasText(dataContent)) { if (StringUtils.hasText(dataContent)) {
@@ -76,6 +76,10 @@ public class DifyProviderAdapter implements AiProviderAdapter {
JSONObject body = new JSONObject(); JSONObject body = new JSONObject();
body.put("response_mode", "streaming"); body.put("response_mode", "streaming");
body.put("user", user(request)); 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())) { if ("chat".equalsIgnoreCase(endpoint.getEndpointType())) {
body.put("query", templateRenderer.firstText(inputs)); body.put("query", templateRenderer.firstText(inputs));
body.put("inputs", inputs); body.put("inputs", inputs);
@@ -10,6 +10,7 @@ import org.springframework.http.MediaType;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -30,6 +31,7 @@ public class ProviderHttpSupport {
public void applyHeaders(HttpHeaders headers, AiProvider provider, AiEndpointConfig endpoint) { public void applyHeaders(HttpHeaders headers, AiProvider provider, AiEndpointConfig endpoint) {
headers.setContentType(MediaType.APPLICATION_JSON); headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(List.of(MediaType.TEXT_EVENT_STREAM, MediaType.APPLICATION_JSON));
if (StringUtils.hasText(provider.getApiKey())) { if (StringUtils.hasText(provider.getApiKey())) {
headers.setBearerAuth(provider.getApiKey()); headers.setBearerAuth(provider.getApiKey());
} }
@@ -43,6 +45,17 @@ public class ProviderHttpSupport {
} }
try { try {
JSONObject json = JSON.parseObject(data); 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); String delta = extractor.extract(json);
if (StringUtils.hasText(delta)) { if (StringUtils.hasText(delta)) {
counter.increment(); 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) { private void applyJsonHeaders(HttpHeaders headers, String jsonText) {
if (!StringUtils.hasText(jsonText)) { if (!StringUtils.hasText(jsonText)) {
return; return;
@@ -1,7 +1,9 @@
package com.emotion.service.impl; package com.emotion.service.impl;
import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.emotion.dto.request.ai.AiRuntimeRequest; 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.AiRuntimeTestResponse;
import com.emotion.dto.response.ai.AiStreamEvent; import com.emotion.dto.response.ai.AiStreamEvent;
import com.emotion.entity.AiCallLog; import com.emotion.entity.AiCallLog;
@@ -20,6 +22,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; 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) { private RuntimeTarget resolveTarget(AiRuntimeRequest request) {
if (!StringUtils.hasText(request.getSceneCode())) { if (!StringUtils.hasText(request.getSceneCode())) {
throw new IllegalArgumentException("AI_SCENE_REQUIRED"); throw new IllegalArgumentException("AI_SCENE_REQUIRED");
@@ -0,0 +1,76 @@
# AI Endpoint Test Fix Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make every AI provider endpoint in web-admin open with complete editable test parameters and make Coze/Dify workflow tests succeed through the backend runtime without breaking existing user-facing AI flows.
**Architecture:** Keep backend as the only place that knows provider credentials and provider-specific request formats. Add backend test sample generation for endpoint and scene tests, then update web-admin to load those samples when opening the dialog. Patch Coze/Dify provider adapters to match documented and historically working request/stream formats while preserving the current `/ai/runtime/stream` and `/ai/endpoint/stream` contracts.
**Tech Stack:** Spring Boot, FastJSON2, RestTemplate streaming, Element Plus, Vue 3, TypeScript, MySQL.
---
### Task 1: Backend Test Sample Contract
**Files:**
- Create: `backend-single/src/main/java/com/emotion/dto/response/ai/AiTestTemplateResponse.java`
- Modify: `backend-single/src/main/java/com/emotion/service/AiRuntimeService.java`
- Modify: `backend-single/src/main/java/com/emotion/service/impl/AiRuntimeServiceImpl.java`
- Modify: `backend-single/src/main/java/com/emotion/controller/AiRoutingController.java`
- [x] Add a response DTO with `sceneCode`, `endpointId`, `endpointCode`, `providerType`, `inputs`, and `paramFields`.
- [x] Add service methods `buildEndpointTestTemplate(String endpointId)` and `buildSceneTestTemplate(String sceneCode)`.
- [x] Generate complete sample inputs even when `defaultInputs` is `{}` or empty.
- [x] Expose `GET /ai/endpoints/test-template?id=...` and `GET /ai/scenes/test-template?sceneCode=...`.
### Task 2: Provider Runtime Compatibility
**Files:**
- Modify: `backend-single/src/main/java/com/emotion/service/ai/ProviderHttpSupport.java`
- Modify: `backend-single/src/main/java/com/emotion/service/ai/CozeProviderAdapter.java`
- Modify: `backend-single/src/main/java/com/emotion/service/ai/DifyProviderAdapter.java`
- [x] Add `Accept: text/event-stream` for stream requests.
- [x] Make Coze workflow body match historical calls: `workflow_id`, `user_id`, `stream`, `parameters`, and optional `bot_id`.
- [x] Make Coze chat body include `type: question` in `additional_messages`.
- [x] Parse Coze workflow events from `output`, `content`, `answer`, `data.output`, `data.content`, and nested message fields.
- [x] Keep Dify chat body aligned with `docs/dify平台接口.md`: `query`, `inputs`, `response_mode`, `user`, optional `conversation_id`.
- [x] Emit provider error events when upstream SSE includes an error event.
### Task 3: web-admin Test Dialog Defaults
**Files:**
- Modify: `web-admin/src/types/aiconfig.ts`
- Modify: `web-admin/src/api/aiconfig.ts`
- Modify: `web-admin/src/views/aiconfig/AiRoutingList.vue`
- [x] Add `AiTestTemplateResponse` TypeScript type.
- [x] Add API helpers for endpoint and scene test templates.
- [x] When opening endpoint test dialog, request backend template and populate form fields plus JSON editor.
- [x] When opening scene test dialog, request backend template and populate JSON editor.
- [x] Validate required fields before running stream/non-stream tests.
- [x] Keep the existing editable JSON advanced area so testers can modify payloads.
### Task 4: Database Default Input Backfill
**Files:**
- Modify: `sql/2026-05-22-ai-scene-routing.sql`
- [x] Backfill `default_inputs` for existing Coze and Dify endpoints with realistic examples.
- [x] Apply the SQL to the test database.
- [x] Verify all enabled endpoints have non-empty `default_inputs`.
### Task 5: Verification
**Commands:**
- `mvn test`
- `mvn -DskipTests clean package`
- `npm run build` in `web-admin`
- MySQL query for endpoint default inputs.
- [x] Backend tests pass.
- [x] Backend package succeeds.
- [x] web-admin build succeeds.
- [x] Coze endpoint test returns stream output or a provider business error with full request/log context.
- [x] Dify endpoint test returns stream output or a provider business error with full request/log context.
- [x] Existing scene runtime endpoints remain unchanged for web, life-script, and mini-program clients.
+70
View File
@@ -144,6 +144,34 @@ WHERE c.`is_deleted` = 0
ORDER BY c.`create_time` DESC ORDER BY c.`create_time` DESC
LIMIT 1; LIMIT 1;
INSERT INTO `t_ai_provider`
(`id`, `provider_code`, `provider_name`, `provider_type`, `base_url`, `api_key`, `auth_type`, `default_headers`, `timeout_ms`, `is_enabled`, `description`)
SELECT UUID(),
CONCAT('coze_', REPLACE(c.`config_key`, '.', '_')),
CONCAT(c.`config_name`, '服务商'),
'coze',
CASE
WHEN c.`api_base_url` LIKE '%/v1/workflow/stream_run' THEN REPLACE(c.`api_base_url`, '/v1/workflow/stream_run', '')
WHEN c.`api_base_url` LIKE '%/v3/chat' THEN REPLACE(c.`api_base_url`, '/v3/chat', '')
ELSE c.`api_base_url`
END,
c.`api_token`,
'bearer',
NULL,
COALESCE(c.`timeout_ms`, 60000),
c.`is_enabled`,
CONCAT('由旧 AI 配置迁移生成,配置键:', c.`config_key`)
FROM `t_ai_config` c
WHERE c.`is_deleted` = 0
AND c.`provider` = 'coze'
AND c.`api_token` IS NOT NULL
AND c.`api_token` <> ''
AND NOT EXISTS (
SELECT 1 FROM `t_ai_provider` p
WHERE p.`provider_code` COLLATE utf8mb4_unicode_ci = CONCAT('coze_', REPLACE(c.`config_key`, '.', '_')) COLLATE utf8mb4_unicode_ci
AND p.`is_deleted` = 0
);
INSERT INTO `t_ai_provider` INSERT INTO `t_ai_provider`
(`id`, `provider_code`, `provider_name`, `provider_type`, `base_url`, `api_key`, `auth_type`, `default_headers`, `timeout_ms`, `is_enabled`, `description`) (`id`, `provider_code`, `provider_name`, `provider_type`, `base_url`, `api_key`, `auth_type`, `default_headers`, `timeout_ms`, `is_enabled`, `description`)
SELECT UUID(), 'dify_default', 'Dify 默认服务商', 'dify', 'http://49.232.138.53/v1', 'app-MqQOx09gCu9zzlKMpeLqHQHv', SELECT UUID(), 'dify_default', 'Dify 默认服务商', 'dify', 'http://49.232.138.53/v1', 'app-MqQOx09gCu9zzlKMpeLqHQHv',
@@ -189,6 +217,12 @@ WHERE NOT EXISTS (
SELECT 1 FROM `t_ai_endpoint_config` e WHERE e.`endpoint_code` = seed.endpoint_code AND e.`is_deleted` = 0 SELECT 1 FROM `t_ai_endpoint_config` e WHERE e.`endpoint_code` = seed.endpoint_code AND e.`is_deleted` = 0
); );
UPDATE `t_ai_endpoint_config` e
JOIN `t_ai_provider` p ON p.`provider_code` COLLATE utf8mb4_unicode_ci = CONCAT('coze_', REPLACE(e.`endpoint_code`, '.', '_')) COLLATE utf8mb4_unicode_ci AND p.`is_deleted` = 0
SET e.`provider_id` = p.`id`
WHERE e.`is_deleted` = 0
AND e.`endpoint_code` LIKE 'coze.%';
UPDATE `t_ai_scene_binding` s UPDATE `t_ai_scene_binding` s
JOIN `t_ai_endpoint_config` e ON e.`endpoint_code` = 'coze.chat.default' AND e.`is_deleted` = 0 JOIN `t_ai_endpoint_config` e ON e.`endpoint_code` = 'coze.chat.default' AND e.`is_deleted` = 0
SET s.`endpoint_id` = e.`id`, s.`is_enabled` = 1, s.`required_stream` = 1, s.`description` = '已绑定现有 Coze 聊天工作流' SET s.`endpoint_id` = e.`id`, s.`is_enabled` = 1, s.`required_stream` = 1, s.`description` = '已绑定现有 Coze 聊天工作流'
@@ -230,3 +264,39 @@ UPDATE `t_ai_scene_binding` s
JOIN `t_ai_endpoint_config` e ON e.`endpoint_code` = 'coze.user.dairy.summary' AND e.`is_deleted` = 0 JOIN `t_ai_endpoint_config` e ON e.`endpoint_code` = 'coze.user.dairy.summary' AND e.`is_deleted` = 0
SET s.`endpoint_id` = e.`id`, s.`is_enabled` = 1, s.`required_stream` = 1, s.`description` = '已绑定现有 Coze 人生事件疗愈工作流' SET s.`endpoint_id` = e.`id`, s.`is_enabled` = 1, s.`required_stream` = 1, s.`description` = '已绑定现有 Coze 人生事件疗愈工作流'
WHERE s.`scene_code` = 'life_healing' AND s.`is_deleted` = 0; WHERE s.`scene_code` = 'life_healing' AND s.`is_deleted` = 0;
UPDATE `t_ai_endpoint_config`
SET `default_inputs` = '{"input":{"_meta":{"label":"输入内容","type":"textarea","required":true},"value":"你好,请用一句中文回复:AI 接口测试成功。"},"user_id":{"_meta":{"label":"用户标识","type":"string","required":true},"value":"admin-test-user"}}'
WHERE `endpoint_code` = 'coze.chat.default'
AND `is_deleted` = 0
AND (`default_inputs` IS NULL OR `default_inputs` = '' OR `default_inputs` = '{}');
UPDATE `t_ai_endpoint_config`
SET `default_inputs` = '{"input":{"_meta":{"label":"剧本需求","type":"textarea","required":true},"value":"请生成一个关于普通人重新找回生活热情的短剧本,要求结构清晰、中文输出。"},"user_id":{"_meta":{"label":"用户标识","type":"string","required":true},"value":"admin-test-user"}}'
WHERE `endpoint_code` = 'coze.course.life.generate'
AND `is_deleted` = 0
AND (`default_inputs` IS NULL OR `default_inputs` = '' OR `default_inputs` = '{}');
UPDATE `t_ai_endpoint_config`
SET `default_inputs` = '{"input":{"_meta":{"label":"短篇小说主题","type":"textarea","required":true},"value":"请生成一篇 300 字以内的治愈短篇小说,主题是雨后的新开始。"},"user_id":{"_meta":{"label":"用户标识","type":"string","required":true},"value":"admin-test-user"}}'
WHERE `endpoint_code` = 'coze.user.chose.story'
AND `is_deleted` = 0
AND (`default_inputs` IS NULL OR `default_inputs` = '' OR `default_inputs` = '{}');
UPDATE `t_ai_endpoint_config`
SET `default_inputs` = '{"query":{"_meta":{"label":"用户问题","type":"textarea","required":true},"value":"请生成一个关于普通人重新找回生活热情的短剧本,要求结构清晰、中文输出。"},"inputs":{"_meta":{"label":"Dify 变量","type":"json","required":false},"value":{"input":"请生成一个关于普通人重新找回生活热情的短剧本,要求结构清晰、中文输出。","user_id":"admin-test-user"}},"response_mode":{"_meta":{"label":"响应模式","type":"string","required":true},"value":"streaming"},"conversation_id":{"_meta":{"label":"会话 ID","type":"string","required":false},"value":""},"user":{"_meta":{"label":"用户标识","type":"string","required":true},"value":"admin-test-user"},"user_id":{"_meta":{"label":"Dify 变量用户标识","type":"string","required":true},"value":"admin-test-user"}}'
WHERE `endpoint_code` = 'dify.script_generate.chat_messages'
AND `is_deleted` = 0
AND (`default_inputs` IS NULL OR `default_inputs` = '' OR `default_inputs` = '{}' OR `default_inputs` NOT LIKE '%"user_id"%');
UPDATE `t_ai_endpoint_config`
SET `default_inputs` = '{"query":{"_meta":{"label":"用户问题","type":"textarea","required":true},"value":"请生成一篇 300 字以内的治愈短篇小说,主题是雨后的新开始。"},"inputs":{"_meta":{"label":"Dify 变量","type":"json","required":false},"value":{"input":"请生成一篇 300 字以内的治愈短篇小说,主题是雨后的新开始。","user_id":"admin-test-user"}},"response_mode":{"_meta":{"label":"响应模式","type":"string","required":true},"value":"streaming"},"conversation_id":{"_meta":{"label":"会话 ID","type":"string","required":false},"value":""},"user":{"_meta":{"label":"用户标识","type":"string","required":true},"value":"admin-test-user"},"user_id":{"_meta":{"label":"Dify 变量用户标识","type":"string","required":true},"value":"admin-test-user"}}'
WHERE `endpoint_code` = 'dify.short_story_generate.chat_messages'
AND `is_deleted` = 0
AND (`default_inputs` IS NULL OR `default_inputs` = '' OR `default_inputs` = '{}' OR `default_inputs` NOT LIKE '%"user_id"%');
UPDATE `t_ai_endpoint_config`
SET `default_inputs` = '{"input":{"_meta":{"label":"事件或日记内容","type":"textarea","required":true},"value":"今天工作压力很大,但傍晚散步时感觉心情慢慢平静下来,请给我一段温和的回应。"},"user_id":{"_meta":{"label":"用户标识","type":"string","required":true},"value":"admin-test-user"}}'
WHERE `endpoint_code` IN ('coze.user.dairy.summary', 'coze.user.life.state', 'coze.user.query.polish')
AND `is_deleted` = 0
AND (`default_inputs` IS NULL OR `default_inputs` = '' OR `default_inputs` = '{}');
+1 -1
View File
@@ -1,4 +1,4 @@
# 开发环境配置 # 开发环境配置
VITE_APP_TITLE=情绪博物馆管理后台 VITE_APP_TITLE=情绪博物馆管理后台
VITE_APP_BASE_API=http://localhost:19089/api VITE_APP_BASE_API=https://lifescript.happylifeos.com/api
VITE_APP_PORT=5174 VITE_APP_PORT=5174
+8
View File
@@ -237,6 +237,10 @@ export function listAiEndpoints() {
return request({ url: '/ai/endpoints', method: 'get' }) return request({ url: '/ai/endpoints', method: 'get' })
} }
export function getEndpointTestTemplate(id: string) {
return request({ url: '/ai/endpoints/test-template', method: 'get', params: { id } })
}
export function saveAiEndpoint(data: AiEndpointConfig) { export function saveAiEndpoint(data: AiEndpointConfig) {
return request({ url: '/ai/endpoints', method: data.id ? 'put' : 'post', data }) return request({ url: '/ai/endpoints', method: data.id ? 'put' : 'post', data })
} }
@@ -249,6 +253,10 @@ export function listAiScenes() {
return request({ url: '/ai/scenes', method: 'get' }) return request({ url: '/ai/scenes', method: 'get' })
} }
export function getSceneTestTemplate(sceneCode: string) {
return request({ url: '/ai/scenes/test-template', method: 'get', params: { sceneCode } })
}
export function saveAiScene(data: AiSceneBinding) { export function saveAiScene(data: AiSceneBinding) {
return request({ url: '/ai/scenes', method: data.id ? 'put' : 'post', data }) return request({ url: '/ai/scenes', method: data.id ? 'put' : 'post', data })
} }
+13 -2
View File
@@ -273,16 +273,17 @@ export interface AiEndpointRuntimeRequest {
export interface TestParamField { export interface TestParamField {
name: string name: string
label: string label: string
type: 'string' | 'textarea' | 'number' | 'boolean' type: 'string' | 'textarea' | 'number' | 'boolean' | 'json'
value: any value: any
defaultValue: any defaultValue: any
required: boolean required: boolean
placeholder?: string
} }
export interface ParamDefinition { export interface ParamDefinition {
name: string name: string
label: string label: string
type: 'string' | 'textarea' | 'number' | 'boolean' type: 'string' | 'textarea' | 'number' | 'boolean' | 'json'
defaultValue: any defaultValue: any
required: boolean required: boolean
} }
@@ -296,3 +297,13 @@ export interface AiRuntimeTestResponse {
errorCode?: string errorCode?: string
errorMessage?: string errorMessage?: string
} }
export interface AiTestTemplateResponse {
sceneCode?: string
endpointId?: string
endpointCode?: string
endpointName?: string
providerType?: string
inputs: Record<string, any>
paramFields: TestParamField[]
}
+87 -7
View File
@@ -218,6 +218,7 @@
<el-option label="多行" value="textarea" /> <el-option label="多行" value="textarea" />
<el-option label="数字" value="number" /> <el-option label="数字" value="number" />
<el-option label="开关" value="boolean" /> <el-option label="开关" value="boolean" />
<el-option label="JSON" value="json" />
</el-select> </el-select>
<el-input v-model="param.defaultValue" placeholder="默认值" style="width: 100px" /> <el-input v-model="param.defaultValue" placeholder="默认值" style="width: 100px" />
<el-checkbox v-model="param.required">必填</el-checkbox> <el-checkbox v-model="param.required">必填</el-checkbox>
@@ -298,6 +299,7 @@
<el-input v-if="field.type === 'textarea'" v-model="field.value" type="textarea" :rows="3" :placeholder="field.name" @input="syncEndpointJsonFromFields" /> <el-input v-if="field.type === 'textarea'" v-model="field.value" type="textarea" :rows="3" :placeholder="field.name" @input="syncEndpointJsonFromFields" />
<el-input-number v-if="field.type === 'number'" v-model="field.value" :placeholder="field.name" @change="syncEndpointJsonFromFields" style="width: 100%" /> <el-input-number v-if="field.type === 'number'" v-model="field.value" :placeholder="field.name" @change="syncEndpointJsonFromFields" style="width: 100%" />
<el-switch v-if="field.type === 'boolean'" v-model="field.value" @change="syncEndpointJsonFromFields" /> <el-switch v-if="field.type === 'boolean'" v-model="field.value" @change="syncEndpointJsonFromFields" />
<el-input v-if="field.type === 'json'" v-model="field.value" type="textarea" :rows="4" :placeholder="field.placeholder || field.name" @input="syncEndpointJsonFromFields" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<el-collapse style="margin-top: 12px"> <el-collapse style="margin-top: 12px">
@@ -336,6 +338,8 @@ import {
deleteAiEndpoint, deleteAiEndpoint,
deleteAiProvider, deleteAiProvider,
deleteAiScene, deleteAiScene,
getEndpointTestTemplate,
getSceneTestTemplate,
listAiCallLogs, listAiCallLogs,
listAiEndpoints, listAiEndpoints,
listAiProviders, listAiProviders,
@@ -348,7 +352,7 @@ import {
testAiRuntime, testAiRuntime,
testEndpointRuntime testEndpointRuntime
} from '@/api/aiconfig' } from '@/api/aiconfig'
import type { AiCallLog, AiEndpointConfig, AiProvider, AiRuntimeTestResponse, AiSceneBinding, TestParamField, ParamDefinition } from '@/types/aiconfig' import type { AiCallLog, AiEndpointConfig, AiProvider, AiRuntimeTestResponse, AiSceneBinding, TestParamField, ParamDefinition, AiTestTemplateResponse } from '@/types/aiconfig'
const activeTab = ref('providers') const activeTab = ref('providers')
const loading = ref(false) const loading = ref(false)
@@ -478,27 +482,67 @@ function openScene(row?: AiSceneBinding) {
sceneDialog.value = true sceneDialog.value = true
} }
function openRuntimeTest() { async function openRuntimeTest() {
testForm.sceneCode = scenes.value.find(item => item.isEnabled === 1 && item.endpointId)?.sceneCode || scenes.value[0]?.sceneCode || '' testForm.sceneCode = scenes.value.find(item => item.isEnabled === 1 && item.endpointId)?.sceneCode || scenes.value[0]?.sceneCode || ''
testResult.value = null testResult.value = null
nonStreamResult.value = null nonStreamResult.value = null
await loadSceneTemplate(testForm.sceneCode)
testDialog.value = true testDialog.value = true
} }
function openSceneRuntimeTest(row: AiSceneBinding) { async function openSceneRuntimeTest(row: AiSceneBinding) {
testForm.sceneCode = row.sceneCode testForm.sceneCode = row.sceneCode
testResult.value = null testResult.value = null
nonStreamResult.value = null nonStreamResult.value = null
await loadSceneTemplate(row.sceneCode)
testDialog.value = true testDialog.value = true
} }
function openEndpointTest(row: AiEndpointConfig) { async function openEndpointTest(row: AiEndpointConfig) {
if (!row.id) return
endpointTestRow.value = row endpointTestRow.value = row
endpointParamFields.value = parseParamFields(row.defaultInputs) endpointParamFields.value = parseParamFields(row.defaultInputs)
endpointInputsJson.value = buildInputsJson(endpointParamFields.value) endpointInputsJson.value = buildInputsJson(endpointParamFields.value)
endpointTestResult.value = null endpointTestResult.value = null
endpointNonStreamResult.value = null endpointNonStreamResult.value = null
endpointTestDialog.value = true endpointTestDialog.value = true
endpointTesting.value = true
try {
const res = await getEndpointTestTemplate(row.id!)
applyEndpointTemplate(res.data as AiTestTemplateResponse)
} catch (error: any) {
ElMessage.warning(error?.message || '测试样例加载失败,已使用本地默认参数')
} finally {
endpointTesting.value = false
}
}
async function loadSceneTemplate(sceneCode: string) {
if (!sceneCode) {
testInputsJson.value = '{\n "prompt": "请用一句中文回复测试成功。"\n}'
return
}
try {
const res = await getSceneTestTemplate(sceneCode)
const template = res.data as AiTestTemplateResponse
testInputsJson.value = JSON.stringify(template.inputs || {}, null, 2)
} catch (error: any) {
testInputsJson.value = '{\n "prompt": "请用一句中文回复测试成功。"\n}'
ElMessage.warning(error?.message || '场景测试样例加载失败,已使用通用测试参数')
}
}
function applyEndpointTemplate(template: AiTestTemplateResponse) {
const inputs = template.inputs || {}
endpointInputsJson.value = JSON.stringify(inputs, null, 2)
endpointParamFields.value = (template.paramFields || []).map(field => ({
...field,
value: field.type === 'json'
? JSON.stringify(field.value ?? {}, null, 2)
: field.value,
defaultValue: field.value,
required: Boolean(field.required)
}))
} }
function parseParamFields(defaultInputs?: string): TestParamField[] { function parseParamFields(defaultInputs?: string): TestParamField[] {
@@ -533,16 +577,28 @@ function parseParamFields(defaultInputs?: string): TestParamField[] {
} }
} }
function inferParamType(val: any): 'string' | 'textarea' | 'number' | 'boolean' { function inferParamType(val: any): 'string' | 'textarea' | 'number' | 'boolean' | 'json' {
if (typeof val === 'number') return 'number' if (typeof val === 'number') return 'number'
if (typeof val === 'boolean') return 'boolean' if (typeof val === 'boolean') return 'boolean'
if (val && typeof val === 'object') return 'json'
if (typeof val === 'string' && val.length > 80) return 'textarea' if (typeof val === 'string' && val.length > 80) return 'textarea'
return 'string' return 'string'
} }
function buildInputsJson(fields: TestParamField[]): string { function buildInputsJson(fields: TestParamField[]): string {
const obj: Record<string, any> = {} const obj: Record<string, any> = {}
fields.forEach(f => { obj[f.name] = f.value }) fields.forEach(f => {
if (!f.name) return
if (f.type === 'json') {
try {
obj[f.name] = typeof f.value === 'string' ? JSON.parse(f.value || '{}') : f.value
} catch {
obj[f.name] = f.value
}
} else {
obj[f.name] = f.value
}
})
return JSON.stringify(obj, null, 2) return JSON.stringify(obj, null, 2)
} }
@@ -555,7 +611,9 @@ function syncEndpointFieldsFromJson() {
const parsed = JSON.parse(endpointInputsJson.value || '{}') const parsed = JSON.parse(endpointInputsJson.value || '{}')
endpointParamFields.value.forEach(field => { endpointParamFields.value.forEach(field => {
if (field.name in parsed) { if (field.name in parsed) {
field.value = parsed[field.name] field.value = field.type === 'json'
? JSON.stringify(parsed[field.name] ?? {}, null, 2)
: parsed[field.name]
} }
}) })
} catch { /* ignore parse errors */ } } catch { /* ignore parse errors */ }
@@ -726,6 +784,7 @@ async function submitRuntimeTest() {
async function submitEndpointNonStreamTest() { async function submitEndpointNonStreamTest() {
if (!endpointTestRow.value) return if (!endpointTestRow.value) return
if (!validateEndpointFields()) return
let inputs: Record<string, any> let inputs: Record<string, any>
try { try {
inputs = JSON.parse(endpointInputsJson.value || '{}') inputs = JSON.parse(endpointInputsJson.value || '{}')
@@ -757,6 +816,7 @@ async function submitEndpointNonStreamTest() {
async function submitEndpointStreamTest() { async function submitEndpointStreamTest() {
if (!endpointTestRow.value) return if (!endpointTestRow.value) return
if (!validateEndpointFields()) return
let inputs: Record<string, any> let inputs: Record<string, any>
try { try {
inputs = JSON.parse(endpointInputsJson.value || '{}') inputs = JSON.parse(endpointInputsJson.value || '{}')
@@ -796,6 +856,26 @@ async function submitEndpointStreamTest() {
} }
} }
function validateEndpointFields() {
for (const field of endpointParamFields.value) {
if (!field.required) continue
const value = field.value
if (value === null || value === undefined || String(value).trim() === '') {
ElMessage.error(`请填写必填参数:${field.label || field.name}`)
return false
}
}
for (const field of endpointParamFields.value.filter(item => item.type === 'json')) {
try {
JSON.parse(field.value || '{}')
} catch {
ElMessage.error(`参数 ${field.label || field.name} 不是有效 JSON`)
return false
}
}
return true
}
onMounted(loadAll) onMounted(loadAll)
</script> </script>
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineConfig({
port: 5174, port: 5174,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:19089', target: 'https://lifescript.happylifeos.com',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path rewrite: (path) => path
} }
+3 -3
View File
@@ -4,9 +4,9 @@ VITE_APP_TITLE=情绪博物馆 - 开发
VITE_APP_VERSION=1.0.0 VITE_APP_VERSION=1.0.0
# API配置 # API配置
VITE_API_BASE_URL=http://localhost:19089/api VITE_API_BASE_URL=https://lifescript.happylifeos.com/api
VITE_WS_BASE_URL=ws://localhost:19089/api VITE_WS_BASE_URL=wss://lifescript.happylifeos.com/ws
VITE_UPLOAD_URL=http://localhost:19089/api/upload VITE_UPLOAD_URL=https://lifescript.happylifeos.com/api/upload
# 调试配置 # 调试配置
VITE_DEBUG=true VITE_DEBUG=true
+6 -6
View File
@@ -79,9 +79,9 @@ export const getEnvConfig = (): EnvConfig => {
case 'local': case 'local':
return { return {
name: '本地环境', name: '本地环境',
apiBaseUrl: 'http://localhost:19089/api', apiBaseUrl: 'https://lifescript.happylifeos.com/api',
wsBaseUrl: 'ws://localhost:19089/api', wsBaseUrl: 'wss://lifescript.happylifeos.com/ws',
uploadUrl: 'http://localhost:19089/api/upload', uploadUrl: 'https://lifescript.happylifeos.com/api/upload',
debug: true, debug: true,
mock: false, mock: false,
appTitle: '情绪博物馆 - 本地', appTitle: '情绪博物馆 - 本地',
@@ -91,9 +91,9 @@ export const getEnvConfig = (): EnvConfig => {
case 'dev': case 'dev':
return { return {
name: '开发环境', name: '开发环境',
apiBaseUrl: 'http://localhost:19089/api', apiBaseUrl: 'https://lifescript.happylifeos.com/api',
wsBaseUrl: 'ws://localhost:19089/api', wsBaseUrl: 'wss://lifescript.happylifeos.com/ws',
uploadUrl: 'http://localhost:19089/api/upload', uploadUrl: 'https://lifescript.happylifeos.com/api/upload',
debug: true, debug: true,
mock: false, mock: false,
appTitle: '情绪博物馆 - 开发', appTitle: '情绪博物馆 - 开发',
+1 -1
View File
@@ -19,7 +19,7 @@ export default defineConfig({
open: true, open: true,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:19089', target: 'https://lifescript.happylifeos.com',
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
} }