fix: 修复 Dify 非流式测试 user_id 缺失和超时问题
- enrichInputs 增加 user_id 下划线字段注入(Dify API 要求下划线格式) - testAiRuntime 接口超时从 15 秒延长到 60 秒 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<AiProviderAdapter> adapters;
|
||||||
|
|
||||||
|
public AiRuntimeServiceImpl(AiSceneBindingService sceneBindingService,
|
||||||
|
AiEndpointConfigService endpointConfigService,
|
||||||
|
AiProviderService providerService,
|
||||||
|
AiCallLogService callLogService,
|
||||||
|
ScriptContextService scriptContextService,
|
||||||
|
List<AiProviderAdapter> 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<AiStreamEvent> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,11 @@ import request from '@/utils/request'
|
|||||||
import type {
|
import type {
|
||||||
AiConfigPageRequest,
|
AiConfigPageRequest,
|
||||||
AiConfigCreateRequest,
|
AiConfigCreateRequest,
|
||||||
AiConfigUpdateRequest
|
AiConfigUpdateRequest,
|
||||||
|
AiProvider,
|
||||||
|
AiEndpointConfig,
|
||||||
|
AiSceneBinding,
|
||||||
|
AiRuntimeRequest
|
||||||
} from '@/types/aiconfig'
|
} from '@/types/aiconfig'
|
||||||
|
|
||||||
// 分页查询AI配置
|
// 分页查询AI配置
|
||||||
@@ -215,3 +219,119 @@ export function updateAiConfigFromTest(data: any) {
|
|||||||
data
|
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<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,633 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-routing-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h2 class="page-title">AI 配置管理</h2>
|
||||||
|
<p class="page-desc">统一管理服务商、接口工作流、业务场景绑定、流式测试和调用日志。</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button :icon="Refresh" :loading="loading" @click="loadAll">刷新数据</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-row :gutter="16" class="stats-row">
|
||||||
|
<el-col :span="6">
|
||||||
|
<div class="stat-card stat-provider">
|
||||||
|
<div class="stat-value">{{ providers.length }}</div>
|
||||||
|
<div class="stat-label">服务商</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<div class="stat-card stat-endpoint">
|
||||||
|
<div class="stat-value">{{ endpoints.length }}</div>
|
||||||
|
<div class="stat-label">接口工作流</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<div class="stat-card stat-scene">
|
||||||
|
<div class="stat-value">{{ enabledSceneCount }}</div>
|
||||||
|
<div class="stat-label">已启用场景</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<div class="stat-card stat-log">
|
||||||
|
<div class="stat-value">{{ logs.length }}</div>
|
||||||
|
<div class="stat-label">最近调用日志</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-card class="table-card routing-card" shadow="never">
|
||||||
|
<el-tabs v-model="activeTab" class="routing-tabs">
|
||||||
|
<el-tab-pane label="服务商配置" name="providers">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" :icon="Plus" @click="openProvider()">新增服务商</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="providers" v-loading="loading" stripe empty-text="暂无服务商配置">
|
||||||
|
<el-table-column prop="providerCode" label="服务商编码" width="170" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="providerName" label="服务商名称" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="服务商类型" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag type="primary" effect="plain">{{ providerTypeLabel(row.providerType) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="baseUrl" label="基础地址" min-width="280" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="timeoutMs" label="超时毫秒" width="110" align="center" />
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.isEnabled === 1 ? 'success' : 'danger'" effect="plain">
|
||||||
|
{{ row.isEnabled === 1 ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="openProvider(row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" @click="removeProvider(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="接口工作流" name="endpoints">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" :icon="Plus" @click="openEndpoint()">新增接口</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="endpoints" v-loading="loading" stripe empty-text="暂无接口工作流配置">
|
||||||
|
<el-table-column prop="endpointCode" label="接口编码" width="220" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="endpointName" label="接口名称" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="服务商" min-width="170" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ providerName(row.providerId) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="接口类型" width="110" align="center">
|
||||||
|
<template #default="{ row }">{{ endpointTypeLabel(row.endpointType) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="apiPath" label="接口路径" min-width="210" show-overflow-tooltip />
|
||||||
|
<el-table-column label="流式" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.supportStream === 1 ? 'success' : 'danger'" effect="plain">
|
||||||
|
{{ row.supportStream === 1 ? '支持' : '不支持' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.isEnabled === 1 ? 'success' : 'danger'" effect="plain">
|
||||||
|
{{ row.isEnabled === 1 ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="openEndpoint(row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" @click="removeEndpoint(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="场景绑定" name="scenes">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" :icon="Plus" @click="openScene()">新增场景</el-button>
|
||||||
|
<el-button :icon="VideoPlay" @click="openRuntimeTest()">流式测试</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="scenes" v-loading="loading" stripe empty-text="暂无场景绑定">
|
||||||
|
<el-table-column prop="sceneCode" label="场景编码" width="190" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="sceneName" label="场景名称" min-width="170" show-overflow-tooltip />
|
||||||
|
<el-table-column label="绑定接口" min-width="260" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="{ muted: !row.endpointId }">{{ endpointName(row.endpointId) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="priority" label="优先级" width="90" align="center" />
|
||||||
|
<el-table-column label="强制流式" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.requiredStream === 1 ? 'success' : 'warning'" effect="plain">
|
||||||
|
{{ row.requiredStream === 1 ? '是' : '否' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.isEnabled === 1 ? 'success' : 'danger'" effect="plain">
|
||||||
|
{{ row.isEnabled === 1 ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="openScene(row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" @click="removeScene(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="调用日志" name="logs">
|
||||||
|
<el-table :data="logs" v-loading="loading" stripe empty-text="暂无调用日志">
|
||||||
|
<el-table-column prop="createTime" label="调用时间" width="175" />
|
||||||
|
<el-table-column prop="sceneCode" label="场景" width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="providerCode" label="服务商" width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="endpointCode" label="接口" min-width="210" show-overflow-tooltip />
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'success' ? 'success' : row.status === 'failed' ? 'danger' : 'warning'" effect="plain">
|
||||||
|
{{ statusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="streamChunks" label="片段数" width="90" align="center" />
|
||||||
|
<el-table-column prop="firstTokenMs" label="首字耗时" width="110" align="center" />
|
||||||
|
<el-table-column prop="durationMs" label="总耗时" width="100" align="center" />
|
||||||
|
<el-table-column prop="errorCode" label="错误码" min-width="180" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="providerDialog" :title="providerForm.id ? '编辑服务商' : '新增服务商'" width="680px">
|
||||||
|
<el-form :model="providerForm" label-width="120px">
|
||||||
|
<el-form-item label="服务商编码"><el-input v-model="providerForm.providerCode" placeholder="例如 coze_default" /></el-form-item>
|
||||||
|
<el-form-item label="服务商名称"><el-input v-model="providerForm.providerName" placeholder="请输入服务商名称" /></el-form-item>
|
||||||
|
<el-form-item label="服务商类型">
|
||||||
|
<el-select v-model="providerForm.providerType" style="width: 100%">
|
||||||
|
<el-option label="Dify" value="dify" />
|
||||||
|
<el-option label="Coze" value="coze" />
|
||||||
|
<el-option label="OpenAI" value="openai" disabled />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="基础地址"><el-input v-model="providerForm.baseUrl" placeholder="例如 https://api.coze.cn" /></el-form-item>
|
||||||
|
<el-form-item label="接口密钥"><el-input v-model="providerForm.apiKey" type="password" show-password placeholder="编辑时为空表示不修改密钥" /></el-form-item>
|
||||||
|
<el-form-item label="请求头配置"><el-input v-model="providerForm.defaultHeaders" type="textarea" :rows="3" placeholder="JSON 格式,可为空" /></el-form-item>
|
||||||
|
<el-form-item label="启用状态"><el-switch v-model="providerEnabled" active-text="启用" inactive-text="禁用" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="providerDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitProvider">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="endpointDialog" :title="endpointForm.id ? '编辑接口工作流' : '新增接口工作流'" width="760px">
|
||||||
|
<el-form :model="endpointForm" label-width="130px">
|
||||||
|
<el-form-item label="接口编码"><el-input v-model="endpointForm.endpointCode" placeholder="例如 coze.script.generate" /></el-form-item>
|
||||||
|
<el-form-item label="接口名称"><el-input v-model="endpointForm.endpointName" placeholder="请输入接口名称" /></el-form-item>
|
||||||
|
<el-form-item label="所属服务商">
|
||||||
|
<el-select v-model="endpointForm.providerId" filterable style="width: 100%">
|
||||||
|
<el-option v-for="item in providers" :key="item.id" :label="`${item.providerName}(${item.providerCode})`" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="接口类型">
|
||||||
|
<el-select v-model="endpointForm.endpointType" style="width: 100%">
|
||||||
|
<el-option label="工作流" value="workflow" />
|
||||||
|
<el-option label="对话" value="chat" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="接口路径"><el-input v-model="endpointForm.apiPath" placeholder="/v1/workflow/stream_run 或 /chat-messages" /></el-form-item>
|
||||||
|
<el-form-item label="工作流 ID"><el-input v-model="endpointForm.workflowId" /></el-form-item>
|
||||||
|
<el-form-item label="机器人 ID"><el-input v-model="endpointForm.botId" /></el-form-item>
|
||||||
|
<el-form-item label="请求模板"><el-input v-model="endpointForm.requestTemplate" type="textarea" :rows="4" placeholder="JSON 模板,可使用 {{prompt}} 等变量" /></el-form-item>
|
||||||
|
<el-form-item label="默认入参"><el-input v-model="endpointForm.defaultInputs" type="textarea" :rows="3" placeholder="JSON 格式,会与用户端入参合并" /></el-form-item>
|
||||||
|
<el-form-item label="支持流式"><el-switch v-model="endpointStream" active-text="支持" inactive-text="不支持" /></el-form-item>
|
||||||
|
<el-form-item label="启用状态"><el-switch v-model="endpointEnabled" active-text="启用" inactive-text="禁用" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="endpointDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitEndpoint">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="sceneDialog" :title="sceneForm.id ? '编辑场景绑定' : '新增场景绑定'" width="720px">
|
||||||
|
<el-form :model="sceneForm" label-width="120px">
|
||||||
|
<el-form-item label="场景编码"><el-input v-model="sceneForm.sceneCode" placeholder="例如 script_generate" /></el-form-item>
|
||||||
|
<el-form-item label="场景名称"><el-input v-model="sceneForm.sceneName" placeholder="请输入中文场景名称" /></el-form-item>
|
||||||
|
<el-form-item label="绑定接口">
|
||||||
|
<el-select v-model="sceneForm.endpointId" filterable style="width: 100%" placeholder="请选择接口工作流">
|
||||||
|
<el-option v-for="item in endpoints" :key="item.id" :label="`${item.endpointName}(${item.endpointCode})`" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="入参说明"><el-input v-model="sceneForm.inputSchema" type="textarea" :rows="3" placeholder="JSON 格式,描述该场景允许的入参" /></el-form-item>
|
||||||
|
<el-form-item label="优先级"><el-input-number v-model="sceneForm.priority" :min="0" :max="9999" /></el-form-item>
|
||||||
|
<el-form-item label="强制流式"><el-switch v-model="sceneStream" active-text="是" inactive-text="否" /></el-form-item>
|
||||||
|
<el-form-item label="启用状态"><el-switch v-model="sceneEnabled" active-text="启用" inactive-text="禁用" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="sceneDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitScene">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="testDialog" title="接口测试" width="760px">
|
||||||
|
<el-form label-width="110px">
|
||||||
|
<el-form-item label="业务场景">
|
||||||
|
<el-select v-model="testForm.sceneCode" filterable style="width: 100%">
|
||||||
|
<el-option v-for="item in scenes" :key="item.id" :label="`${item.sceneName}(${item.sceneCode})`" :value="item.sceneCode" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="入参 JSON">
|
||||||
|
<el-input v-model="testInputsJson" type="textarea" :rows="6" placeholder="请输入 JSON 入参" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-alert
|
||||||
|
v-if="nonStreamResult"
|
||||||
|
:type="nonStreamResult.status === 'success' ? 'success' : 'error'"
|
||||||
|
:title="nonStreamResult.status === 'success' ? '非流式测试成功' : '非流式测试失败'"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<pre v-if="nonStreamResult" class="test-output">{{ nonStreamResult.output || nonStreamResult.errorMessage || '暂无输出' }}</pre>
|
||||||
|
<el-alert
|
||||||
|
v-if="testResult"
|
||||||
|
:type="testResult.status === 'success' ? 'success' : 'error'"
|
||||||
|
:title="testResult.status === 'success' ? '流式测试成功' : '流式测试失败'"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<pre v-if="testResult" class="test-output">{{ testResult.output || testResult.errorMessage || '暂无输出' }}</pre>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="testDialog = false">关闭</el-button>
|
||||||
|
<el-button :loading="testing" @click="submitNonStreamTest">非流式测试</el-button>
|
||||||
|
<el-button type="primary" :loading="testing" @click="submitRuntimeTest">流式测试</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Refresh, VideoPlay } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
deleteAiEndpoint,
|
||||||
|
deleteAiProvider,
|
||||||
|
deleteAiScene,
|
||||||
|
listAiCallLogs,
|
||||||
|
listAiEndpoints,
|
||||||
|
listAiProviders,
|
||||||
|
listAiScenes,
|
||||||
|
saveAiEndpoint,
|
||||||
|
saveAiProvider,
|
||||||
|
saveAiScene,
|
||||||
|
streamAiRuntime,
|
||||||
|
testAiRuntime
|
||||||
|
} from '@/api/aiconfig'
|
||||||
|
import type { AiCallLog, AiEndpointConfig, AiProvider, AiRuntimeTestResponse, AiSceneBinding } from '@/types/aiconfig'
|
||||||
|
|
||||||
|
const activeTab = ref('providers')
|
||||||
|
const loading = ref(false)
|
||||||
|
const providers = ref<AiProvider[]>([])
|
||||||
|
const endpoints = ref<AiEndpointConfig[]>([])
|
||||||
|
const scenes = ref<AiSceneBinding[]>([])
|
||||||
|
const logs = ref<AiCallLog[]>([])
|
||||||
|
|
||||||
|
const providerDialog = ref(false)
|
||||||
|
const endpointDialog = ref(false)
|
||||||
|
const sceneDialog = ref(false)
|
||||||
|
const testDialog = ref(false)
|
||||||
|
const testing = ref(false)
|
||||||
|
const testResult = ref<AiRuntimeTestResponse | null>(null)
|
||||||
|
const nonStreamResult = ref<AiRuntimeTestResponse | null>(null)
|
||||||
|
const testInputsJson = ref('{\n "prompt": "请用一句中文回复测试成功。"\n}')
|
||||||
|
|
||||||
|
const providerForm = reactive<AiProvider>(newProvider())
|
||||||
|
const endpointForm = reactive<AiEndpointConfig>(newEndpoint())
|
||||||
|
const sceneForm = reactive<AiSceneBinding>(newScene())
|
||||||
|
const testForm = reactive({ sceneCode: '' })
|
||||||
|
|
||||||
|
const enabledSceneCount = computed(() => scenes.value.filter(item => item.isEnabled === 1).length)
|
||||||
|
|
||||||
|
const providerEnabled = computed({
|
||||||
|
get: () => providerForm.isEnabled === 1,
|
||||||
|
set: value => { providerForm.isEnabled = value ? 1 : 0 }
|
||||||
|
})
|
||||||
|
const endpointEnabled = computed({
|
||||||
|
get: () => endpointForm.isEnabled === 1,
|
||||||
|
set: value => { endpointForm.isEnabled = value ? 1 : 0 }
|
||||||
|
})
|
||||||
|
const endpointStream = computed({
|
||||||
|
get: () => endpointForm.supportStream === 1,
|
||||||
|
set: value => { endpointForm.supportStream = value ? 1 : 0 }
|
||||||
|
})
|
||||||
|
const sceneEnabled = computed({
|
||||||
|
get: () => sceneForm.isEnabled === 1,
|
||||||
|
set: value => { sceneForm.isEnabled = value ? 1 : 0 }
|
||||||
|
})
|
||||||
|
const sceneStream = computed({
|
||||||
|
get: () => sceneForm.requiredStream === 1,
|
||||||
|
set: value => { sceneForm.requiredStream = value ? 1 : 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
function newProvider(): AiProvider {
|
||||||
|
return { providerCode: '', providerName: '', providerType: 'dify', baseUrl: '', authType: 'bearer', timeoutMs: 60000, isEnabled: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function newEndpoint(): AiEndpointConfig {
|
||||||
|
return { endpointCode: '', endpointName: '', providerId: '', endpointType: 'workflow', responseMode: 'streaming', supportStream: 1, isEnabled: 1, timeoutMs: 60000 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function newScene(): AiSceneBinding {
|
||||||
|
return { sceneCode: '', sceneName: '', endpointId: '', requiredStream: 1, priority: 0, isEnabled: 1, version: 'v1' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignForm<T extends object>(target: T, source: T) {
|
||||||
|
Object.keys(target).forEach(key => delete (target as Record<string, unknown>)[key])
|
||||||
|
Object.assign(target, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerName(id?: string) {
|
||||||
|
return providers.value.find(item => item.id === id)?.providerName || id || '未配置'
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpointName(id?: string) {
|
||||||
|
return endpoints.value.find(item => item.id === id)?.endpointName || id || '未绑定接口'
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerTypeLabel(type?: string) {
|
||||||
|
const map: Record<string, string> = { dify: 'Dify', coze: 'Coze', openai: 'OpenAI', custom: '自定义' }
|
||||||
|
return type ? map[type] || type : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpointTypeLabel(type?: string) {
|
||||||
|
const map: Record<string, string> = { workflow: '工作流', chat: '对话', completion: '补全', custom: '自定义' }
|
||||||
|
return type ? map[type] || type : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status?: string) {
|
||||||
|
const map: Record<string, string> = { running: '运行中', success: '成功', failed: '失败' }
|
||||||
|
return status ? map[status] || status : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [providerRes, endpointRes, sceneRes, logRes] = await Promise.all([
|
||||||
|
listAiProviders(),
|
||||||
|
listAiEndpoints(),
|
||||||
|
listAiScenes(),
|
||||||
|
listAiCallLogs(80)
|
||||||
|
])
|
||||||
|
providers.value = providerRes.data || []
|
||||||
|
endpoints.value = endpointRes.data || []
|
||||||
|
scenes.value = sceneRes.data || []
|
||||||
|
logs.value = logRes.data || []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openProvider(row?: AiProvider) {
|
||||||
|
assignForm(providerForm, row ? { ...row, apiKey: '' } : newProvider())
|
||||||
|
providerDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEndpoint(row?: AiEndpointConfig) {
|
||||||
|
assignForm(endpointForm, row ? { ...row } : newEndpoint())
|
||||||
|
endpointDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openScene(row?: AiSceneBinding) {
|
||||||
|
assignForm(sceneForm, row ? { ...row } : newScene())
|
||||||
|
sceneDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRuntimeTest() {
|
||||||
|
testForm.sceneCode = scenes.value.find(item => item.isEnabled === 1 && item.endpointId)?.sceneCode || scenes.value[0]?.sceneCode || ''
|
||||||
|
testResult.value = null
|
||||||
|
nonStreamResult.value = null
|
||||||
|
testDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitProvider() {
|
||||||
|
await saveAiProvider({ ...providerForm })
|
||||||
|
providerDialog.value = false
|
||||||
|
ElMessage.success('服务商已保存')
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEndpoint() {
|
||||||
|
await saveAiEndpoint({ ...endpointForm })
|
||||||
|
endpointDialog.value = false
|
||||||
|
ElMessage.success('接口工作流已保存')
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitScene() {
|
||||||
|
await saveAiScene({ ...sceneForm })
|
||||||
|
sceneDialog.value = false
|
||||||
|
ElMessage.success('场景绑定已保存')
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeProvider(row: AiProvider) {
|
||||||
|
if (!row.id) return
|
||||||
|
await ElMessageBox.confirm('确认删除这个服务商配置吗?', '删除确认', { type: 'warning' })
|
||||||
|
await deleteAiProvider(row.id)
|
||||||
|
ElMessage.success('服务商已删除')
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeEndpoint(row: AiEndpointConfig) {
|
||||||
|
if (!row.id) return
|
||||||
|
await ElMessageBox.confirm('确认删除这个接口工作流吗?', '删除确认', { type: 'warning' })
|
||||||
|
await deleteAiEndpoint(row.id)
|
||||||
|
ElMessage.success('接口工作流已删除')
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeScene(row: AiSceneBinding) {
|
||||||
|
if (!row.id) return
|
||||||
|
await ElMessageBox.confirm('确认删除这个场景绑定吗?', '删除确认', { type: 'warning' })
|
||||||
|
await deleteAiScene(row.id)
|
||||||
|
ElMessage.success('场景绑定已删除')
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitNonStreamTest() {
|
||||||
|
let inputs: Record<string, any>
|
||||||
|
try {
|
||||||
|
inputs = JSON.parse(testInputsJson.value || '{}')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('入参 JSON 格式不正确')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
testing.value = true
|
||||||
|
try {
|
||||||
|
const res = await testAiRuntime({ sceneCode: testForm.sceneCode, inputs })
|
||||||
|
nonStreamResult.value = res.data as AiRuntimeTestResponse
|
||||||
|
if (nonStreamResult.value.status === 'success') {
|
||||||
|
ElMessage.success('非流式测试成功')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(`测试失败: ${nonStreamResult.value.errorMessage || nonStreamResult.value.errorCode}`)
|
||||||
|
}
|
||||||
|
await loadAll()
|
||||||
|
} catch (error: any) {
|
||||||
|
nonStreamResult.value = {
|
||||||
|
sceneCode: testForm.sceneCode,
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: error?.message || '非流式测试失败'
|
||||||
|
} as AiRuntimeTestResponse
|
||||||
|
ElMessage.error(error?.message || '非流式测试失败')
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRuntimeTest() {
|
||||||
|
let inputs: Record<string, any>
|
||||||
|
try {
|
||||||
|
inputs = JSON.parse(testInputsJson.value || '{}')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('入参 JSON 格式不正确')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
testing.value = true
|
||||||
|
testResult.value = { sceneCode: testForm.sceneCode, status: 'success', output: '', streamChunks: 0, durationMs: 0 }
|
||||||
|
const startedAt = Date.now()
|
||||||
|
try {
|
||||||
|
await streamAiRuntime({ sceneCode: testForm.sceneCode, inputs }, (event, output) => {
|
||||||
|
if (!testResult.value) return
|
||||||
|
testResult.value.output = output
|
||||||
|
if (event.type === 'delta') {
|
||||||
|
testResult.value.streamChunks = (testResult.value.streamChunks || 0) + 1
|
||||||
|
}
|
||||||
|
if (event.type === 'done') {
|
||||||
|
testResult.value.status = 'success'
|
||||||
|
}
|
||||||
|
if (event.type === 'error') {
|
||||||
|
testResult.value.status = 'failed'
|
||||||
|
testResult.value.errorCode = event.code
|
||||||
|
testResult.value.errorMessage = event.message
|
||||||
|
}
|
||||||
|
testResult.value.durationMs = Date.now() - startedAt
|
||||||
|
})
|
||||||
|
await loadAll()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (testResult.value) {
|
||||||
|
testResult.value.status = 'failed'
|
||||||
|
testResult.value.errorMessage = error?.message || '流式测试失败'
|
||||||
|
testResult.value.durationMs = Date.now() - startedAt
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadAll)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ai-routing-page {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--ls-text);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-desc {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ls-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
min-height: 92px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border: 1px solid var(--ls-glass-border);
|
||||||
|
border-radius: var(--ls-radius-lg);
|
||||||
|
background: rgba(15, 17, 26, 0.38);
|
||||||
|
box-shadow: var(--ls-shadow);
|
||||||
|
backdrop-filter: blur(20px) saturate(160%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
color: var(--ls-text);
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--ls-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-provider {
|
||||||
|
border-color: rgba(255, 171, 145, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-endpoint {
|
||||||
|
border-color: rgba(129, 212, 250, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-scene {
|
||||||
|
border-color: rgba(103, 194, 58, 0.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-log {
|
||||||
|
border-color: rgba(230, 162, 60, 0.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.routing-card {
|
||||||
|
padding: 4px 6px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routing-tabs :deep(.el-tabs__header) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: rgba(226, 232, 240, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-output {
|
||||||
|
min-height: 120px;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: rgba(226, 232, 240, 0.92);
|
||||||
|
background: rgba(0, 0, 0, 0.22);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: var(--ls-radius-md);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user