websocket相关

This commit is contained in:
2026-08-13 20:03:14 +08:00
parent 8e7948cff5
commit c088894546
9 changed files with 621 additions and 225 deletions
+70 -4
View File
@@ -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 {
+2 -2
View File
@@ -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 = '';
+24
View File
@@ -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>