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 new file mode 100644 index 0000000..e2433e9 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/impl/AiRuntimeServiceImpl.java @@ -0,0 +1,249 @@ +package com.emotion.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.emotion.dto.request.ai.AiRuntimeRequest; +import com.emotion.dto.response.ai.AiRuntimeTestResponse; +import com.emotion.dto.response.ai.AiStreamEvent; +import com.emotion.entity.AiCallLog; +import com.emotion.entity.AiEndpointConfig; +import com.emotion.entity.AiProvider; +import com.emotion.entity.AiSceneBinding; +import com.emotion.service.AiCallLogService; +import com.emotion.service.AiEndpointConfigService; +import com.emotion.service.AiProviderService; +import com.emotion.service.AiRuntimeService; +import com.emotion.service.AiSceneBindingService; +import com.emotion.service.ScriptContextService; +import com.emotion.service.ai.AiProviderAdapter; +import com.emotion.util.UserContextHolder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +@Slf4j +@Service +public class AiRuntimeServiceImpl implements AiRuntimeService { + + private final AiSceneBindingService sceneBindingService; + private final AiEndpointConfigService endpointConfigService; + private final AiProviderService providerService; + private final AiCallLogService callLogService; + private final ScriptContextService scriptContextService; + private final List adapters; + + public AiRuntimeServiceImpl(AiSceneBindingService sceneBindingService, + AiEndpointConfigService endpointConfigService, + AiProviderService providerService, + AiCallLogService callLogService, + ScriptContextService scriptContextService, + List adapters) { + this.sceneBindingService = sceneBindingService; + this.endpointConfigService = endpointConfigService; + this.providerService = providerService; + this.callLogService = callLogService; + this.scriptContextService = scriptContextService; + this.adapters = adapters; + } + + @Override + public void invokeStream(AiRuntimeRequest request, Consumer consumer) { + enrichInputs(request); + long startedAt = System.currentTimeMillis(); + AtomicLong firstTokenAt = new AtomicLong(0); + AtomicInteger chunks = new AtomicInteger(0); + String requestId = UUID.randomUUID().toString(); + StringBuilder output = new StringBuilder(); + AiCallLog callLog = new AiCallLog(); + callLog.setRequestId(requestId); + callLog.setSceneCode(request.getSceneCode()); + callLog.setUserId(resolveUserId(request)); + callLog.setInputText(JSON.toJSONString(request.getInputs())); + callLog.setStatus("running"); + + try { + RuntimeTarget target = resolveTarget(request); + callLog.setProviderCode(target.provider.getProviderCode()); + callLog.setEndpointCode(target.endpoint.getEndpointCode()); + callLogService.save(callLog); + + consumer.accept(AiStreamEvent.start(request.getSceneCode())); + target.adapter.stream(target.provider, target.endpoint, request, event -> { + if ("delta".equals(event.getType())) { + chunks.incrementAndGet(); + if (firstTokenAt.compareAndSet(0, System.currentTimeMillis())) { + log.debug("AI first token emitted, scene={}, requestId={}", request.getSceneCode(), requestId); + } + if (event.getContent() != null) { + output.append(event.getContent()); + } + } + consumer.accept(event); + }); + + callLog.setStatus("success"); + callLog.setOutputText(output.toString()); + callLog.setStreamChunks(chunks.get()); + callLog.setFirstTokenMs(firstTokenAt.get() == 0 ? null : firstTokenAt.get() - startedAt); + callLog.setDurationMs(System.currentTimeMillis() - startedAt); + callLogService.updateById(callLog); + consumer.accept(AiStreamEvent.done(Map.of( + "requestId", requestId, + "streamChunks", chunks.get(), + "durationMs", callLog.getDurationMs() + ))); + } catch (Exception e) { + String code = normalizeErrorCode(e); + callLog.setStatus("failed"); + callLog.setErrorCode(code); + callLog.setErrorMessage(e.getMessage()); + callLog.setOutputText(output.toString()); + callLog.setStreamChunks(chunks.get()); + callLog.setDurationMs(System.currentTimeMillis() - startedAt); + saveOrUpdateLog(callLog); + consumer.accept(AiStreamEvent.error(code, e.getMessage())); + } + } + + @Override + public AiRuntimeTestResponse test(AiRuntimeRequest request) { + long startedAt = System.currentTimeMillis(); + StringBuilder output = new StringBuilder(); + AtomicInteger chunks = new AtomicInteger(0); + final String[] errorCode = new String[1]; + final String[] errorMessage = new String[1]; + + invokeStream(request, event -> { + if ("delta".equals(event.getType()) && event.getContent() != null) { + chunks.incrementAndGet(); + output.append(event.getContent()); + } else if ("error".equals(event.getType())) { + errorCode[0] = event.getCode(); + errorMessage[0] = event.getMessage(); + } + }); + + return AiRuntimeTestResponse.builder() + .sceneCode(request.getSceneCode()) + .status(errorCode[0] == null ? "success" : "failed") + .output(output.toString()) + .streamChunks(chunks.get()) + .durationMs(System.currentTimeMillis() - startedAt) + .errorCode(errorCode[0]) + .errorMessage(errorMessage[0]) + .build(); + } + + private RuntimeTarget resolveTarget(AiRuntimeRequest request) { + if (!StringUtils.hasText(request.getSceneCode())) { + throw new IllegalArgumentException("AI_SCENE_REQUIRED"); + } + if (!StringUtils.hasText(request.getUserId())) { + request.setUserId(resolveUserId(request)); + } + + AiSceneBinding scene = sceneBindingService.resolveScene(request.getSceneCode()); + if (scene == null) { + throw new IllegalStateException("AI_SCENE_NOT_BOUND"); + } + AiEndpointConfig endpoint = endpointConfigService.getEnabledById(scene.getEndpointId()); + if (endpoint == null) { + throw new IllegalStateException("AI_ENDPOINT_DISABLED"); + } + if (Integer.valueOf(1).equals(scene.getRequiredStream()) && !Integer.valueOf(1).equals(endpoint.getSupportStream())) { + throw new IllegalStateException("AI_ENDPOINT_STREAM_REQUIRED"); + } + AiProvider provider = providerService.getEnabledById(endpoint.getProviderId()); + if (provider == null) { + throw new IllegalStateException("AI_PROVIDER_DISABLED"); + } + AiProviderAdapter adapter = adapters.stream() + .filter(item -> item.supports(provider.getProviderType())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("AI_PROVIDER_ADAPTER_NOT_FOUND")); + + return new RuntimeTarget(scene, endpoint, provider, adapter); + } + + private String resolveUserId(AiRuntimeRequest request) { + if (StringUtils.hasText(request.getUserId())) { + return request.getUserId(); + } + String currentUserId = UserContextHolder.getCurrentUserId(); + return StringUtils.hasText(currentUserId) ? currentUserId : "anonymous"; + } + + private void enrichInputs(AiRuntimeRequest request) { + if (request.getInputs() == null) { + request.setInputs(new com.alibaba.fastjson2.JSONObject()); + } + String userId = resolveUserId(request); + request.setUserId(userId); + request.getInputs().put("userId", userId); + request.getInputs().put("currentUserId", userId); + request.getInputs().put("user_id", userId); + if (StringUtils.hasText(request.getUserName())) { + request.getInputs().put("userName", request.getUserName()); + request.getInputs().put("username", request.getUserName()); + } + if (StringUtils.hasText(request.getUserType())) { + request.getInputs().put("userType", request.getUserType()); + } + if (StringUtils.hasText(request.getRequestId())) { + request.getInputs().put("requestId", request.getRequestId()); + } + enrichSceneInputs(request, userId); + } + + private void enrichSceneInputs(AiRuntimeRequest request, String userId) { + if (!StringUtils.hasText(request.getSceneCode())) { + return; + } + if ("script_generate".equals(request.getSceneCode()) || "short_story_generate".equals(request.getSceneCode())) { + Boolean useSocialInsights = request.getInputs().getBoolean("useSocialInsights"); + String socialInsightContext = scriptContextService.buildSocialInsightContext(userId, useSocialInsights); + if (StringUtils.hasText(socialInsightContext)) { + request.getInputs().put("socialInsightContext", socialInsightContext); + } + } + } + + private String normalizeErrorCode(Exception e) { + String message = e.getMessage(); + if (message != null && message.startsWith("AI_")) { + return message; + } + if (message != null && message.contains("timed out")) { + return "AI_STREAM_TIMEOUT"; + } + return "AI_STREAM_INTERRUPTED"; + } + + private void saveOrUpdateLog(AiCallLog callLog) { + if (StringUtils.hasText(callLog.getId())) { + callLogService.updateById(callLog); + } else { + callLogService.save(callLog); + } + } + + private static class RuntimeTarget { + private final AiSceneBinding scene; + private final AiEndpointConfig endpoint; + private final AiProvider provider; + private final AiProviderAdapter adapter; + + private RuntimeTarget(AiSceneBinding scene, AiEndpointConfig endpoint, AiProvider provider, AiProviderAdapter adapter) { + this.scene = scene; + this.endpoint = endpoint; + this.provider = provider; + this.adapter = adapter; + } + } +} diff --git a/web-admin/src/api/aiconfig.ts b/web-admin/src/api/aiconfig.ts index 8fcaf1f..e9c5a8e 100644 --- a/web-admin/src/api/aiconfig.ts +++ b/web-admin/src/api/aiconfig.ts @@ -2,7 +2,11 @@ import request from '@/utils/request' import type { AiConfigPageRequest, AiConfigCreateRequest, - AiConfigUpdateRequest + AiConfigUpdateRequest, + AiProvider, + AiEndpointConfig, + AiSceneBinding, + AiRuntimeRequest } from '@/types/aiconfig' // 分页查询AI配置 @@ -215,3 +219,119 @@ export function updateAiConfigFromTest(data: any) { data }) } + +export function listAiProviders() { + return request({ url: '/ai/providers', method: 'get' }) +} + +export function saveAiProvider(data: AiProvider) { + return request({ url: '/ai/providers', method: data.id ? 'put' : 'post', data }) +} + +export function deleteAiProvider(id: string) { + return request({ url: '/ai/providers', method: 'delete', params: { id } }) +} + +export function listAiEndpoints() { + return request({ url: '/ai/endpoints', method: 'get' }) +} + +export function saveAiEndpoint(data: AiEndpointConfig) { + return request({ url: '/ai/endpoints', method: data.id ? 'put' : 'post', data }) +} + +export function deleteAiEndpoint(id: string) { + return request({ url: '/ai/endpoints', method: 'delete', params: { id } }) +} + +export function listAiScenes() { + return request({ url: '/ai/scenes', method: 'get' }) +} + +export function saveAiScene(data: AiSceneBinding) { + return request({ url: '/ai/scenes', method: data.id ? 'put' : 'post', data }) +} + +export function deleteAiScene(id: string) { + return request({ url: '/ai/scenes', method: 'delete', params: { id } }) +} + +export function listAiCallLogs(limit = 50) { + return request({ url: '/ai/call-logs', method: 'get', params: { limit } }) +} + +export function testAiRuntime(data: AiRuntimeRequest) { + return request({ url: '/ai/runtime/test', method: 'post', data, timeout: 60000 }) +} + +export interface AiRuntimeStreamEvent { + type: string + content?: string + code?: string + message?: string + seq?: number + metadata?: Record +} + +function parseSseFrame(frame: string): AiRuntimeStreamEvent | null { + const event = { type: 'message', data: '' } + frame.split(/\r?\n/).forEach((line) => { + if (line.startsWith('event:')) event.type = line.slice(6).trim() + if (line.startsWith('data:')) event.data += line.slice(5).trim() + }) + if (!event.data) return null + try { + return JSON.parse(event.data) + } catch { + return { type: event.type, content: event.data } + } +} + +export async function streamAiRuntime( + data: AiRuntimeRequest, + onEvent: (event: AiRuntimeStreamEvent, output: string) => void +) { + const token = localStorage.getItem('adminToken') + const response = await fetch(`${import.meta.env.VITE_APP_BASE_API}/ai/runtime/stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}) + }, + body: JSON.stringify(data) + }) + if (!response.ok || !response.body) { + throw new Error(`流式测试请求失败(${response.status})`) + } + + const reader = response.body.getReader() + const decoder = new TextDecoder('utf-8') + let buffer = '' + let output = '' + + const consumeText = (text: string) => { + buffer += text + const frames = buffer.split(/\r?\n\r?\n/) + buffer = frames.pop() || '' + frames.forEach((frame) => { + const event = parseSseFrame(frame) + if (!event) return + if (event.type === 'delta') { + output += event.content || '' + } + onEvent(event, output) + if (event.type === 'error') { + throw new Error(event.message || event.code || '流式测试失败') + } + }) + } + + while (true) { + const { value, done } = await reader.read() + if (done) break + consumeText(decoder.decode(value, { stream: true })) + } + consumeText(decoder.decode()) + if (buffer.trim()) consumeText('\n\n') + return output +} diff --git a/web-admin/src/views/aiconfig/AiConfigList.vue b/web-admin/src/views/aiconfig/AiConfigList.vue deleted file mode 100644 index b374a4d..0000000 --- a/web-admin/src/views/aiconfig/AiConfigList.vue +++ /dev/null @@ -1,1856 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web-admin/src/views/aiconfig/AiRoutingList.vue b/web-admin/src/views/aiconfig/AiRoutingList.vue new file mode 100644 index 0000000..66643de --- /dev/null +++ b/web-admin/src/views/aiconfig/AiRoutingList.vue @@ -0,0 +1,633 @@ + + + + +