diff --git a/backend-single/src/main/java/com/emotion/controller/AiRoutingController.java b/backend-single/src/main/java/com/emotion/controller/AiRoutingController.java index c7ec864..28eef15 100644 --- a/backend-single/src/main/java/com/emotion/controller/AiRoutingController.java +++ b/backend-single/src/main/java/com/emotion/controller/AiRoutingController.java @@ -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 endpointTestTemplate(@RequestParam String id) { + return Result.success(runtimeService.buildEndpointTestTemplate(id)); + } + @PostMapping("/endpoints") public Result 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 sceneTestTemplate(@RequestParam String sceneCode) { + return Result.success(runtimeService.buildSceneTestTemplate(sceneCode)); + } + @PostMapping("/scenes") public Result createScene(@RequestBody AiSceneBinding scene) { if (scene.getIsEnabled() == null) { diff --git a/backend-single/src/main/java/com/emotion/dto/response/ai/AiTestTemplateResponse.java b/backend-single/src/main/java/com/emotion/dto/response/ai/AiTestTemplateResponse.java new file mode 100644 index 0000000..da82f59 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/ai/AiTestTemplateResponse.java @@ -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 inputs = new LinkedHashMap<>(); + + @Builder.Default + private List 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; + } +} diff --git a/backend-single/src/main/java/com/emotion/service/AiRuntimeService.java b/backend-single/src/main/java/com/emotion/service/AiRuntimeService.java index 1843711..99b00d3 100644 --- a/backend-single/src/main/java/com/emotion/service/AiRuntimeService.java +++ b/backend-single/src/main/java/com/emotion/service/AiRuntimeService.java @@ -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 inputs); void invokeEndpointStream(String endpointId, Map inputs, Consumer consumer); + + AiTestTemplateResponse buildEndpointTestTemplate(String endpointId); + + AiTestTemplateResponse buildSceneTestTemplate(String sceneCode); } diff --git a/backend-single/src/main/java/com/emotion/service/ai/CozeProviderAdapter.java b/backend-single/src/main/java/com/emotion/service/ai/CozeProviderAdapter.java index ff3369e..2e28f08 100644 --- a/backend-single/src/main/java/com/emotion/service/ai/CozeProviderAdapter.java +++ b/backend-single/src/main/java/com/emotion/service/ai/CozeProviderAdapter.java @@ -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)) { diff --git a/backend-single/src/main/java/com/emotion/service/ai/DifyProviderAdapter.java b/backend-single/src/main/java/com/emotion/service/ai/DifyProviderAdapter.java index e7d3ecd..910f0c6 100644 --- a/backend-single/src/main/java/com/emotion/service/ai/DifyProviderAdapter.java +++ b/backend-single/src/main/java/com/emotion/service/ai/DifyProviderAdapter.java @@ -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); diff --git a/backend-single/src/main/java/com/emotion/service/ai/ProviderHttpSupport.java b/backend-single/src/main/java/com/emotion/service/ai/ProviderHttpSupport.java index 49c6446..88e6906 100644 --- a/backend-single/src/main/java/com/emotion/service/ai/ProviderHttpSupport.java +++ b/backend-single/src/main/java/com/emotion/service/ai/ProviderHttpSupport.java @@ -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; diff --git a/backend-single/src/main/java/com/emotion/service/impl/AiRuntimeServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/AiRuntimeServiceImpl.java index da85531..b438bca 100644 --- a/backend-single/src/main/java/com/emotion/service/impl/AiRuntimeServiceImpl.java +++ b/backend-single/src/main/java/com/emotion/service/impl/AiRuntimeServiceImpl.java @@ -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 inputs = new LinkedHashMap<>(); + applyDefaultInputs(inputs, endpoint.getDefaultInputs()); + applyProviderSampleInputs(inputs, sceneCode, endpoint, provider); + + List 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 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 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 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"); diff --git a/docs/superpowers/plans/2026-05-23-ai-endpoint-test-fix.md b/docs/superpowers/plans/2026-05-23-ai-endpoint-test-fix.md new file mode 100644 index 0000000..56a644d --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-ai-endpoint-test-fix.md @@ -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. diff --git a/sql/2026-05-22-ai-scene-routing.sql b/sql/2026-05-22-ai-scene-routing.sql index dea81f3..ab5cee0 100644 --- a/sql/2026-05-22-ai-scene-routing.sql +++ b/sql/2026-05-22-ai-scene-routing.sql @@ -144,6 +144,34 @@ WHERE c.`is_deleted` = 0 ORDER BY c.`create_time` DESC 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` (`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', @@ -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 ); +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 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 聊天工作流' @@ -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 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; + +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` = '{}'); diff --git a/web-admin/.env.development b/web-admin/.env.development index 4149811..56e3469 100644 --- a/web-admin/.env.development +++ b/web-admin/.env.development @@ -1,4 +1,4 @@ # 开发环境配置 VITE_APP_TITLE=情绪博物馆管理后台 -VITE_APP_BASE_API=http://localhost:19089/api +VITE_APP_BASE_API=https://lifescript.happylifeos.com/api VITE_APP_PORT=5174 diff --git a/web-admin/src/api/aiconfig.ts b/web-admin/src/api/aiconfig.ts index e331700..c28b344 100644 --- a/web-admin/src/api/aiconfig.ts +++ b/web-admin/src/api/aiconfig.ts @@ -237,6 +237,10 @@ export function listAiEndpoints() { 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) { 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' }) } +export function getSceneTestTemplate(sceneCode: string) { + return request({ url: '/ai/scenes/test-template', method: 'get', params: { sceneCode } }) +} + export function saveAiScene(data: AiSceneBinding) { return request({ url: '/ai/scenes', method: data.id ? 'put' : 'post', data }) } diff --git a/web-admin/src/types/aiconfig.ts b/web-admin/src/types/aiconfig.ts index 7fc61ea..ab79d5c 100644 --- a/web-admin/src/types/aiconfig.ts +++ b/web-admin/src/types/aiconfig.ts @@ -273,16 +273,17 @@ export interface AiEndpointRuntimeRequest { export interface TestParamField { name: string label: string - type: 'string' | 'textarea' | 'number' | 'boolean' + type: 'string' | 'textarea' | 'number' | 'boolean' | 'json' value: any defaultValue: any required: boolean + placeholder?: string } export interface ParamDefinition { name: string label: string - type: 'string' | 'textarea' | 'number' | 'boolean' + type: 'string' | 'textarea' | 'number' | 'boolean' | 'json' defaultValue: any required: boolean } @@ -296,3 +297,13 @@ export interface AiRuntimeTestResponse { errorCode?: string errorMessage?: string } + +export interface AiTestTemplateResponse { + sceneCode?: string + endpointId?: string + endpointCode?: string + endpointName?: string + providerType?: string + inputs: Record + paramFields: TestParamField[] +} diff --git a/web-admin/src/views/aiconfig/AiRoutingList.vue b/web-admin/src/views/aiconfig/AiRoutingList.vue index b7cc955..c5aa3bf 100644 --- a/web-admin/src/views/aiconfig/AiRoutingList.vue +++ b/web-admin/src/views/aiconfig/AiRoutingList.vue @@ -218,6 +218,7 @@ + 必填 @@ -298,6 +299,7 @@ + @@ -336,6 +338,8 @@ import { deleteAiEndpoint, deleteAiProvider, deleteAiScene, + getEndpointTestTemplate, + getSceneTestTemplate, listAiCallLogs, listAiEndpoints, listAiProviders, @@ -348,7 +352,7 @@ import { testAiRuntime, testEndpointRuntime } 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 loading = ref(false) @@ -478,27 +482,67 @@ function openScene(row?: AiSceneBinding) { sceneDialog.value = true } -function openRuntimeTest() { +async function openRuntimeTest() { testForm.sceneCode = scenes.value.find(item => item.isEnabled === 1 && item.endpointId)?.sceneCode || scenes.value[0]?.sceneCode || '' testResult.value = null nonStreamResult.value = null + await loadSceneTemplate(testForm.sceneCode) testDialog.value = true } -function openSceneRuntimeTest(row: AiSceneBinding) { +async function openSceneRuntimeTest(row: AiSceneBinding) { testForm.sceneCode = row.sceneCode testResult.value = null nonStreamResult.value = null + await loadSceneTemplate(row.sceneCode) testDialog.value = true } -function openEndpointTest(row: AiEndpointConfig) { +async function openEndpointTest(row: AiEndpointConfig) { + if (!row.id) return endpointTestRow.value = row endpointParamFields.value = parseParamFields(row.defaultInputs) endpointInputsJson.value = buildInputsJson(endpointParamFields.value) endpointTestResult.value = null endpointNonStreamResult.value = null 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[] { @@ -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 === 'boolean') return 'boolean' + if (val && typeof val === 'object') return 'json' if (typeof val === 'string' && val.length > 80) return 'textarea' return 'string' } function buildInputsJson(fields: TestParamField[]): string { const obj: Record = {} - 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) } @@ -555,7 +611,9 @@ function syncEndpointFieldsFromJson() { const parsed = JSON.parse(endpointInputsJson.value || '{}') endpointParamFields.value.forEach(field => { 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 */ } @@ -726,6 +784,7 @@ async function submitRuntimeTest() { async function submitEndpointNonStreamTest() { if (!endpointTestRow.value) return + if (!validateEndpointFields()) return let inputs: Record try { inputs = JSON.parse(endpointInputsJson.value || '{}') @@ -757,6 +816,7 @@ async function submitEndpointNonStreamTest() { async function submitEndpointStreamTest() { if (!endpointTestRow.value) return + if (!validateEndpointFields()) return let inputs: Record try { 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) diff --git a/web-admin/vite.config.ts b/web-admin/vite.config.ts index 255ecab..e6b7e92 100644 --- a/web-admin/vite.config.ts +++ b/web-admin/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ port: 5174, proxy: { '/api': { - target: 'http://localhost:19089', + target: 'https://lifescript.happylifeos.com', changeOrigin: true, rewrite: (path) => path } diff --git a/web/.env.development b/web/.env.development index 269d2fb..a325c48 100644 --- a/web/.env.development +++ b/web/.env.development @@ -4,9 +4,9 @@ VITE_APP_TITLE=情绪博物馆 - 开发 VITE_APP_VERSION=1.0.0 # API配置 -VITE_API_BASE_URL=http://localhost:19089/api -VITE_WS_BASE_URL=ws://localhost:19089/api -VITE_UPLOAD_URL=http://localhost:19089/api/upload +VITE_API_BASE_URL=https://lifescript.happylifeos.com/api +VITE_WS_BASE_URL=wss://lifescript.happylifeos.com/ws +VITE_UPLOAD_URL=https://lifescript.happylifeos.com/api/upload # 调试配置 VITE_DEBUG=true diff --git a/web/src/config/env.ts b/web/src/config/env.ts index 5755d19..b6a0411 100644 --- a/web/src/config/env.ts +++ b/web/src/config/env.ts @@ -79,9 +79,9 @@ export const getEnvConfig = (): EnvConfig => { case 'local': return { name: '本地环境', - apiBaseUrl: 'http://localhost:19089/api', - wsBaseUrl: 'ws://localhost:19089/api', - uploadUrl: 'http://localhost:19089/api/upload', + apiBaseUrl: 'https://lifescript.happylifeos.com/api', + wsBaseUrl: 'wss://lifescript.happylifeos.com/ws', + uploadUrl: 'https://lifescript.happylifeos.com/api/upload', debug: true, mock: false, appTitle: '情绪博物馆 - 本地', @@ -91,9 +91,9 @@ export const getEnvConfig = (): EnvConfig => { case 'dev': return { name: '开发环境', - apiBaseUrl: 'http://localhost:19089/api', - wsBaseUrl: 'ws://localhost:19089/api', - uploadUrl: 'http://localhost:19089/api/upload', + apiBaseUrl: 'https://lifescript.happylifeos.com/api', + wsBaseUrl: 'wss://lifescript.happylifeos.com/ws', + uploadUrl: 'https://lifescript.happylifeos.com/api/upload', debug: true, mock: false, appTitle: '情绪博物馆 - 开发', diff --git a/web/vite.config.ts b/web/vite.config.ts index 2b9acfc..59bd773 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ open: true, proxy: { '/api': { - target: 'http://localhost:19089', + target: 'https://lifescript.happylifeos.com', changeOrigin: true, secure: false, }