websocket相关
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
<template>
|
||||
<div class="chat-list">
|
||||
<div v-for="msg in messages" :key="msg.id" class="message-row" :class="{ 'is-user': msg.isUser }">
|
||||
<div ref="chatListRef" class="chat-list">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="message-row"
|
||||
:class="{ 'is-user': msg.isUser, 'is-error': !msg.isUser && isErrorMsg(msg) }"
|
||||
>
|
||||
<div class="bubble-wrap">
|
||||
<div class="bubble">{{ msg.content }}</div>
|
||||
<div class="bubble">
|
||||
<template v-if="msg.loading">
|
||||
<span class="loading-text">思考中</span><span class="loading-dots"><i></i><i></i><i></i></span>
|
||||
</template>
|
||||
<template v-else>{{ msg.content }}</template>
|
||||
</div>
|
||||
<div class="time">{{ msg.time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -10,28 +20,48 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
time: string;
|
||||
isUser: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = defineProps<Props>();
|
||||
const chatListRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const isErrorMsg = (msg: ChatMessage): boolean => !msg.isUser && String(msg.content || '').startsWith('❌');
|
||||
|
||||
// 消息新增或内容变化时自动滚动到底部
|
||||
watch(
|
||||
() => props.messages.map((m) => `${m.id}|${m.content}|${m.loading ? 1 : 0}`).join('\n'),
|
||||
() => {
|
||||
nextTick(() => {
|
||||
const el = chatListRef.value;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-list {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 16px;
|
||||
padding: 28px 0 10px;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
.message-row {
|
||||
@@ -70,6 +100,42 @@ defineProps<Props>();
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 24px rgba(37, 99, 235, 0.35);
|
||||
}
|
||||
|
||||
.message-row.is-error & {
|
||||
background: #fef2f2;
|
||||
border-color: rgba(254, 226, 226, 0.95);
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
/* 执行中占位 */
|
||||
.loading-text {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: 6px;
|
||||
|
||||
i {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
animation: dot-blink 1.2s infinite ease-in-out both;
|
||||
|
||||
&:nth-child(1) { animation-delay: 0s; }
|
||||
&:nth-child(2) { animation-delay: 0.15s; }
|
||||
&:nth-child(3) { animation-delay: 0.3s; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dot-blink {
|
||||
0%, 80%, 100% { opacity: 0.2; }
|
||||
40% { opacity: 1; }
|
||||
}
|
||||
|
||||
.time {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
<div class="toolbar-right">
|
||||
<span class="hint-text">Shift+Enter 换行</span>
|
||||
<button class="send-btn" :disabled="selectedWorkflowId === null || sendDisabled" @click="handleSend">
|
||||
<button class="send-btn" :disabled="sendDisabled" @click="handleSend">
|
||||
<el-icon><Top /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -126,7 +126,7 @@ const fetchWorkflows = async () => {
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
if (selectedWorkflowId.value === null || props.sendDisabled) return;
|
||||
if (props.sendDisabled) return;
|
||||
const msg = message.value.trim();
|
||||
emit('send', msg || '');
|
||||
message.value = '';
|
||||
|
||||
@@ -129,6 +129,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对话页 — 无工作流但有消息时展示消息流 -->
|
||||
<div v-else-if="hasMessages" class="content-body chat-body">
|
||||
<ChatList :messages="messages" />
|
||||
</div>
|
||||
|
||||
<!-- 默认占位 — 无工作流时显示引导 -->
|
||||
<div v-else class="content-body placeholder-body">
|
||||
<div class="placeholder-content">
|
||||
@@ -191,16 +196,21 @@ import { ElMessage } from 'element-plus';
|
||||
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
|
||||
import { uploadFile } from '/@/api/common/upload';
|
||||
import { collectHomeFormFields } from '../utils/flowDsl';
|
||||
import ChatList from './ChatList.vue';
|
||||
|
||||
interface Props {
|
||||
activeMenu: string;
|
||||
workflowDetail: any;
|
||||
activeHistoryId?: string | null;
|
||||
messages?: any[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
activeHistoryId: null,
|
||||
messages: () => [],
|
||||
});
|
||||
|
||||
const hasMessages = computed(() => Array.isArray(props.messages) && props.messages.length > 0);
|
||||
const formValues = reactive<Record<string, any>>({});
|
||||
const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({});
|
||||
const uploadingFields = reactive<Record<string, boolean>>({});
|
||||
@@ -558,6 +568,20 @@ defineExpose({ formValues, fieldFiles, templates, validateFormFields });
|
||||
.w100 { width: 100%; }
|
||||
.chat-container { width: min(1060px, 82%); margin: 0 auto; padding: 20px 0 130px; }
|
||||
|
||||
/* ===== 对话页 ===== */
|
||||
.chat-body {
|
||||
width: min(1060px, 82%);
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 20px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
.placeholder-body { display: flex; align-items: center; justify-content: center; }
|
||||
.placeholder-content { text-align: center; padding: 20px; max-width: 480px; }
|
||||
|
||||
|
||||
@@ -35,19 +35,21 @@
|
||||
</div>
|
||||
|
||||
<div class="tc-node-model">
|
||||
<el-button
|
||||
v-if="node.modelConfig?.modelId"
|
||||
type="warning"
|
||||
plain
|
||||
size="small"
|
||||
:loading="converting"
|
||||
@click="openApiKeyDialog(node)"
|
||||
>
|
||||
补全 API Key
|
||||
</el-button>
|
||||
<el-button type="primary" plain size="small" @click="openModelSelector(node)">
|
||||
{{ node.modelConfig?.modelId ? '重新选择模型' : '选择模型' }}
|
||||
</el-button>
|
||||
<template v-if="node.modelConfig?.modelId">
|
||||
<!-- 系统模型:直接内联显示 API Key 输入框,保存时统一转换 -->
|
||||
<div v-if="systemModelNodeIds[node.id]" class="tc-api-key-inline">
|
||||
<el-input
|
||||
v-model="apiKeyInputs[node.id]"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="该模型为系统内置模型,请输入你的 API Key"
|
||||
size="small"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<el-button type="primary" plain size="small" @click="openModelSelector(node)">重新选择模型</el-button>
|
||||
</template>
|
||||
<el-button v-else type="primary" plain size="small" @click="openModelSelector(node)">选择模型</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -66,44 +68,12 @@
|
||||
@confirm="handleModelConfirm"
|
||||
/>
|
||||
|
||||
<!-- 基于模板已有模型补全 API Key:系统模型 → 填 Key → 转用户模型后绑定 -->
|
||||
<el-dialog
|
||||
v-model="apiKeyDialogVisible"
|
||||
title="补全 API Key"
|
||||
width="480px"
|
||||
append-to-body
|
||||
:close-on-click-modal="false"
|
||||
@close="handleApiKeyClose"
|
||||
>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="该模型为系统内置模型,填写你的 API Key 后将创建一条用户模型并绑定到当前节点。"
|
||||
class="api-key-alert"
|
||||
/>
|
||||
<el-form label-position="top" class="api-key-form">
|
||||
<el-form-item label="API Key" required>
|
||||
<el-input
|
||||
v-model="apiKeyForm.apiKey"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入你的 API Key"
|
||||
@keyup.enter="handleApiKeyConfirm"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleApiKeyClose">取消</el-button>
|
||||
<el-button type="primary" :loading="converting" @click="handleApiKeyConfirm">确认并转换</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import ModelSelector from '/@/views/settings/workflow/component/ModelSelector.vue';
|
||||
import { stripReadonlyFields, deepClone } from '/@/views/settings/workflow/component/modelParamUtils';
|
||||
@@ -133,18 +103,19 @@ const modelSelectorVisible = ref(false);
|
||||
const selectorTargetNode = ref<any>(null);
|
||||
const selectorDefaultModel = ref<any>(null);
|
||||
|
||||
// 补全 API Key:基于模板已有模型(系统模型)填 Key 转用户模型
|
||||
const apiKeyDialogVisible = ref(false);
|
||||
const converting = ref(false);
|
||||
const apiKeyForm = reactive({ apiKey: '' });
|
||||
const apiKeyTargetNode = ref<any>(null);
|
||||
const apiKeySourceModel = ref<any>(null);
|
||||
// 补全 API Key:内联输入框暂存 Key,保存时统一转换为用户模型
|
||||
const apiKeyInputs = ref<Record<string, string>>({});
|
||||
const systemModelNodeIds = ref<Record<string, boolean>>({});
|
||||
|
||||
const modelNodes = computed(() => nodes.value.filter((n) => String(n?.nodeCode || '').toLowerCase() === 'model'));
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (!flowName.value.trim()) return false;
|
||||
return modelNodes.value.every((n) => !!n.modelConfig?.modelId);
|
||||
// 所有模型节点都必须已绑定模型
|
||||
if (modelNodes.value.some((n) => !n.modelConfig?.modelId)) return false;
|
||||
// 系统模型节点必须已填写 API Key(未补全不可保存)
|
||||
if (modelNodes.value.some((n) => systemModelNodeIds.value[n.id] && !(apiKeyInputs.value[n.id] || '').trim())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -156,6 +127,8 @@ watch(
|
||||
flowName.value = '我的-' + (props.template?.flowName || '系统模板');
|
||||
nodes.value = [];
|
||||
templateFlowContent.value = null;
|
||||
apiKeyInputs.value = {};
|
||||
systemModelNodeIds.value = {};
|
||||
try {
|
||||
// 优先拉取模板完整 DSL(get 接口),失败则回退列表项 flowContent
|
||||
const res = await getWorkflowDetail(props.template?.id);
|
||||
@@ -168,6 +141,8 @@ watch(
|
||||
templateFlowContent.value = fc || null;
|
||||
nodes.value = JSON.parse(JSON.stringify(fc?.nodes || []));
|
||||
} finally {
|
||||
// 先判断各已绑定模型节点是否需要补 API Key(系统模型),完成后再结束 loading,避免竞态
|
||||
await checkModelApiKey();
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
@@ -190,97 +165,71 @@ const handleModelConfirm = (model: any) => {
|
||||
// 深拷贝模型 requestBodyMapping 作为参数模板,剔除只读字段
|
||||
node.modelConfig.modelRequestParams = stripReadonlyFields(deepClone(model.requestBodyMapping ?? null));
|
||||
node.modelConfig.modelResponseBodyMapping = model.responseBodyMapping ?? null;
|
||||
// 选中为用户模型,无需再补 API Key
|
||||
systemModelNodeIds.value[node.id] = false;
|
||||
apiKeyInputs.value[node.id] = '';
|
||||
}
|
||||
modelSelectorVisible.value = false;
|
||||
selectorTargetNode.value = null;
|
||||
};
|
||||
|
||||
// 补全 API Key:拉取当前节点绑定模型详情,仅系统模型可转换
|
||||
const openApiKeyDialog = async (node: any) => {
|
||||
const modelId = node?.modelConfig?.modelId;
|
||||
if (!modelId) {
|
||||
ElMessage.warning('请先选择模型');
|
||||
return;
|
||||
}
|
||||
converting.value = true;
|
||||
try {
|
||||
const res = await getModelManage(modelId);
|
||||
const model = res?.data;
|
||||
if (!model?.id) throw new Error('模型详情获取失败');
|
||||
// 已是用户模型则无需补全
|
||||
if (model.systemModel !== true) {
|
||||
ElMessage.info('该模型已是用户模型,无需补全 API Key');
|
||||
return;
|
||||
}
|
||||
apiKeyTargetNode.value = node;
|
||||
apiKeySourceModel.value = model;
|
||||
apiKeyForm.apiKey = '';
|
||||
apiKeyDialogVisible.value = true;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '获取模型详情失败,请重试');
|
||||
} finally {
|
||||
converting.value = false;
|
||||
}
|
||||
// 打开弹窗后:判断每个已绑定模型节点是否为系统模型(需补 API Key)
|
||||
const checkModelApiKey = async () => {
|
||||
const targets = modelNodes.value.filter((n) => n.modelConfig?.modelId);
|
||||
await Promise.all(
|
||||
targets.map(async (node) => {
|
||||
try {
|
||||
const res = await getModelManage(node.modelConfig.modelId);
|
||||
const model = res?.data?.modelManage || res?.data;
|
||||
if (model?.systemModel === true) {
|
||||
systemModelNodeIds.value[node.id] = true;
|
||||
} else {
|
||||
systemModelNodeIds.value[node.id] = false;
|
||||
apiKeyInputs.value[node.id] = '';
|
||||
}
|
||||
} catch {
|
||||
// 详情获取失败:不强制补 Key,避免阻塞保存
|
||||
systemModelNodeIds.value[node.id] = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// 确认补全:调修改接口,后端克隆出用户模型并返回新 id,前端立即绑定
|
||||
const handleApiKeyConfirm = async () => {
|
||||
if (!apiKeyForm.apiKey.trim()) {
|
||||
ElMessage.warning('请输入 API Key');
|
||||
return;
|
||||
}
|
||||
const node = apiKeyTargetNode.value;
|
||||
const src = apiKeySourceModel.value;
|
||||
if (!node || !src?.id) return;
|
||||
converting.value = true;
|
||||
try {
|
||||
const res = await updateModelManage({
|
||||
id: src.id,
|
||||
// 传递系统模型全部配置,供后端克隆用户模型时继承
|
||||
modelName: src.modelName,
|
||||
modelType: src.modelType,
|
||||
modelSupplier: src.modelSupplier,
|
||||
baseUrl: src.baseUrl,
|
||||
responseType: src.responseType ?? src.invokeType,
|
||||
apiKey: apiKeyForm.apiKey.trim(),
|
||||
enabled: src.enabled,
|
||||
chatModel: src.ChatModel ?? src.chatModel,
|
||||
maxConcurrency: src.maxConcurrency,
|
||||
maxTokens: src.maxTokens,
|
||||
tokenPredictPrice: src.tokenPredictPrice,
|
||||
requestHeadMapping: src.requestHeadMapping ?? src.requestMapping,
|
||||
requestBodyMapping: src.requestBodyMapping,
|
||||
responseMapping: src.responseMapping,
|
||||
responseBodyMapping: src.responseBodyMapping,
|
||||
...(src.tokenMapping ? { tokenMapping: src.tokenMapping } : {}),
|
||||
...(src.asyncTaskMapping ? { asyncTaskMapping: src.asyncTaskMapping } : {}),
|
||||
...(src.lastFrame ? { lastFrame: src.lastFrame } : {}),
|
||||
...(src.maxDuration ? { maxDuration: src.maxDuration } : {}),
|
||||
...(src.tokenPredictPriceUnit ? { tokenPredictPriceUnit: src.tokenPredictPriceUnit } : {}),
|
||||
} as any);
|
||||
// 返回结构为 { data: { modelManage: {...} } },新用户模型 id 在 modelManage 中
|
||||
const newModelManage = res?.data?.modelManage || res?.data;
|
||||
if (!newModelManage?.id) throw new Error('接口未返回新的用户模型');
|
||||
// 更新节点模型配置:替换为新用户模型 id,其余沿用原系统模型信息
|
||||
node.modelConfig = node.modelConfig || {};
|
||||
node.modelConfig.modelId = newModelManage.id;
|
||||
node.modelConfig.modelName = src.modelName;
|
||||
node.modelConfig.modelType = src.modelType;
|
||||
apiKeyDialogVisible.value = false;
|
||||
apiKeyTargetNode.value = null;
|
||||
apiKeySourceModel.value = null;
|
||||
ElMessage.success('已创建用户模型并绑定');
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '模型转换失败,请重试');
|
||||
} finally {
|
||||
converting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKeyClose = () => {
|
||||
apiKeyDialogVisible.value = false;
|
||||
apiKeyTargetNode.value = null;
|
||||
apiKeySourceModel.value = null;
|
||||
// 将系统模型克隆为用户模型并绑定到节点(保存时统一调用)
|
||||
const convertSystemModel = async (node: any) => {
|
||||
const res = await getModelManage(node.modelConfig.modelId);
|
||||
const src = res?.data?.modelManage || res?.data;
|
||||
if (!src?.id) throw new Error('模型详情获取失败');
|
||||
const upRes = await updateModelManage({
|
||||
id: src.id,
|
||||
// 传递系统模型全部配置,供后端克隆用户模型时继承
|
||||
modelName: src.modelName,
|
||||
modelType: src.modelType,
|
||||
modelSupplier: src.modelSupplier,
|
||||
baseUrl: src.baseUrl,
|
||||
responseType: src.responseType ?? src.invokeType,
|
||||
apiKey: (apiKeyInputs.value[node.id] || '').trim(),
|
||||
enabled: src.enabled,
|
||||
chatModel: src.ChatModel ?? src.chatModel,
|
||||
maxConcurrency: src.maxConcurrency,
|
||||
maxTokens: src.maxTokens,
|
||||
tokenPredictPrice: src.tokenPredictPrice,
|
||||
requestHeadMapping: src.requestHeadMapping ?? src.requestMapping,
|
||||
requestBodyMapping: src.requestBodyMapping,
|
||||
responseMapping: src.responseMapping,
|
||||
responseBodyMapping: src.responseBodyMapping,
|
||||
...(src.tokenMapping ? { tokenMapping: src.tokenMapping } : {}),
|
||||
...(src.asyncTaskMapping ? { asyncTaskMapping: src.asyncTaskMapping } : {}),
|
||||
...(src.lastFrame ? { lastFrame: src.lastFrame } : {}),
|
||||
...(src.maxDuration ? { maxDuration: src.maxDuration } : {}),
|
||||
...(src.tokenPredictPriceUnit ? { tokenPredictPriceUnit: src.tokenPredictPriceUnit } : {}),
|
||||
} as any);
|
||||
const newModelManage = upRes?.data?.modelManage || upRes?.data;
|
||||
if (!newModelManage?.id) throw new Error('接口未返回新的用户模型');
|
||||
// 更新节点绑定为新用户模型,不再需要补 Key
|
||||
node.modelConfig.modelId = newModelManage.id;
|
||||
systemModelNodeIds.value[node.id] = false;
|
||||
apiKeyInputs.value[node.id] = '';
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -296,6 +245,10 @@ const handleSave = async () => {
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
// 保存前统一转换:系统模型节点先克隆为用户模型并绑定
|
||||
for (const node of modelNodes.value.filter((n) => systemModelNodeIds.value[n.id])) {
|
||||
await convertSystemModel(node);
|
||||
}
|
||||
const flowContent = {
|
||||
...(templateFlowContent.value || {}),
|
||||
nodes: nodes.value,
|
||||
@@ -374,11 +327,14 @@ const handleClose = () => {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.api-key-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tc-api-key-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.api-key-form {
|
||||
margin-bottom: 4px;
|
||||
.el-input {
|
||||
max-width: 340px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user