主题和样式以及动画过渡的首页修改
This commit is contained in:
@@ -150,8 +150,7 @@ watch(
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 16px;
|
||||
padding: 28px 0 10px;
|
||||
padding: 8px 0 16px;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
@@ -167,10 +166,18 @@ watch(
|
||||
}
|
||||
}
|
||||
|
||||
/* 节奏化消息间距:AI↔AI 24,用户↔AI 16 */
|
||||
.message-row + .message-row {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.message-row:not(.is-user) + .message-row:not(.is-user) {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.bubble-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -183,7 +190,7 @@ watch(
|
||||
|
||||
.bubble {
|
||||
font-size: 15px;
|
||||
line-height: 1.75;
|
||||
line-height: 1.7;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
word-break: break-word;
|
||||
@@ -192,12 +199,18 @@ watch(
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
/* AI 消息:无背景卡片,纯文本左对齐(DeepSeek 式) */
|
||||
.message-row:not(.is-user) & {
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
}
|
||||
|
||||
.message-row.is-user & {
|
||||
width: fit-content;
|
||||
max-width: 85%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px 16px 4px 16px;
|
||||
background: #d3f5d8;
|
||||
max-width: 72%;
|
||||
padding: 10px 16px;
|
||||
border-radius: 18px 18px 6px 18px;
|
||||
background: #e8ecff;
|
||||
color: #1f2328;
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -223,17 +236,17 @@ watch(
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f1f2f4;
|
||||
background: #eef0f3;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
@@ -247,10 +260,10 @@ watch(
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 10px;
|
||||
padding: 5px 12px;
|
||||
margin-top: 8px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
font-size: 12px;
|
||||
@@ -271,10 +284,12 @@ watch(
|
||||
|
||||
/* 思考区:DeepSeek 风格折叠块 */
|
||||
.thinking-block {
|
||||
margin: 2px 0 10px;
|
||||
background: #f5f5f6;
|
||||
border-radius: 10px;
|
||||
margin: 0 0 12px;
|
||||
background: #f5f6f7;
|
||||
border: 1px solid #eef0f3;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
max-width: 880px;
|
||||
}
|
||||
|
||||
.thinking-toggle {
|
||||
@@ -282,7 +297,7 @@ watch(
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
padding: 9px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
@@ -321,7 +336,7 @@ watch(
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
padding: 0 12px 12px;
|
||||
padding: 0 14px 14px;
|
||||
animation: thinking-fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -332,7 +347,7 @@ watch(
|
||||
font-style: italic;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-top: 1px solid #ececee;
|
||||
border-top: 1px solid #eef0f3;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
@@ -350,9 +365,10 @@ watch(
|
||||
/* 回答区:Markdown 渲染(DeepSeek 风格排版) */
|
||||
.answer-block {
|
||||
white-space: normal;
|
||||
line-height: 1.75;
|
||||
line-height: 1.7;
|
||||
font-size: 15px;
|
||||
word-break: break-word;
|
||||
max-width: 880px;
|
||||
|
||||
& > :first-child {
|
||||
margin-top: 0;
|
||||
@@ -550,7 +566,7 @@ watch(
|
||||
.time {
|
||||
font-size: 11px;
|
||||
color: #8f9aae;
|
||||
padding: 4px 6px;
|
||||
padding: 4px 2px;
|
||||
|
||||
.message-row.is-user & {
|
||||
text-align: right;
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
<div class="input-shell">
|
||||
<div class="input-card" :class="{ 'is-focused': isFocused }">
|
||||
<!-- 已选中状态标签栏 -->
|
||||
<div v-if="selectedWorkflowId !== null" class="selected-tags">
|
||||
<div class="selected-tag workflow-tag">
|
||||
<el-icon><Promotion /></el-icon>
|
||||
<span>{{ commonWorkflows.find((w) => w.id === selectedWorkflowId)?.name }}</span>
|
||||
<el-icon v-if="!workflowLocked" class="tag-close" @click="clearWorkflow"><Close /></el-icon>
|
||||
<Transition name="capsule-slide" @enter="onCapsuleEnter" @after-enter="onCapsuleAfterEnter" @leave="onCapsuleLeave" @after-leave="onCapsuleAfterLeave">
|
||||
<div v-if="selectedWorkflowId !== null" class="selected-tags">
|
||||
<div class="selected-tag workflow-tag">
|
||||
<el-icon><Promotion /></el-icon>
|
||||
<span>{{ commonWorkflows.find((w) => w.id === selectedWorkflowId)?.name }}</span>
|
||||
<el-icon v-if="!workflowLocked" class="tag-close" @click="clearWorkflow"><Close /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 文本输入区 -->
|
||||
<el-input
|
||||
@@ -47,19 +49,21 @@
|
||||
</div>
|
||||
|
||||
<!-- 快捷工作流胶囊(输入框下方) -->
|
||||
<div v-if="visibleWorkflows.length > 0" class="workflow-shortcuts">
|
||||
<button
|
||||
v-for="wf in visibleWorkflows"
|
||||
:key="wf.id"
|
||||
class="shortcut-pill"
|
||||
:class="{ active: selectedWorkflowId === wf.id }"
|
||||
@click="toggleWorkflow(wf.id)"
|
||||
>
|
||||
<el-icon><Promotion /></el-icon>
|
||||
{{ wf.name }}
|
||||
<span v-if="wf.isTemplate" class="pill-tag">模板</span>
|
||||
</button>
|
||||
</div>
|
||||
<Transition name="capsule-slide" @enter="onCapsuleEnter" @after-enter="onCapsuleAfterEnter" @leave="onCapsuleLeave" @after-leave="onCapsuleAfterLeave">
|
||||
<div v-if="visibleWorkflows.length > 0 && !hideShortcuts" class="workflow-shortcuts">
|
||||
<button
|
||||
v-for="wf in visibleWorkflows"
|
||||
:key="wf.id"
|
||||
class="shortcut-pill"
|
||||
:class="{ active: selectedWorkflowId === wf.id }"
|
||||
@click="toggleWorkflow(wf.id)"
|
||||
>
|
||||
<el-icon><Promotion /></el-icon>
|
||||
{{ wf.name }}
|
||||
<span v-if="wf.isTemplate" class="pill-tag">模板</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -72,6 +76,7 @@ import { getWorkflowList } from '/@/api/settings/creation';
|
||||
interface Props {
|
||||
sendDisabled?: boolean;
|
||||
workflowLocked?: boolean;
|
||||
hideShortcuts?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -90,6 +95,7 @@ interface Workflow {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
sendDisabled: false,
|
||||
workflowLocked: false,
|
||||
hideShortcuts: false,
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
const message = ref('');
|
||||
@@ -167,21 +173,56 @@ onMounted(() => {
|
||||
fetchWorkflows();
|
||||
});
|
||||
|
||||
// —— 胶囊高度动画:JS 测量实际高度,让 max-height 与内容高度精确匹配,
|
||||
// 进入时从 0 展开、离开时收缩到 0,配合 CSS transition 消除底部高度突变 ——
|
||||
const onCapsuleEnter = (el: Element) => {
|
||||
const e = el as HTMLElement;
|
||||
e.style.maxHeight = '0px';
|
||||
requestAnimationFrame(() => {
|
||||
e.style.maxHeight = e.scrollHeight + 'px';
|
||||
});
|
||||
};
|
||||
const onCapsuleAfterEnter = (el: Element) => {
|
||||
(el as HTMLElement).style.maxHeight = '';
|
||||
};
|
||||
const onCapsuleLeave = (el: Element) => {
|
||||
const e = el as HTMLElement;
|
||||
e.style.maxHeight = e.scrollHeight + 'px';
|
||||
requestAnimationFrame(() => {
|
||||
e.style.maxHeight = '0px';
|
||||
});
|
||||
};
|
||||
const onCapsuleAfterLeave = (el: Element) => {
|
||||
(el as HTMLElement).style.maxHeight = '';
|
||||
};
|
||||
|
||||
defineExpose({ resetAll, clearWorkflow, selectWorkflow, fetchWorkflows, selectedWorkflowId, commonWorkflows });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* ===== 胶囊显示/隐藏过渡:淡入淡出 + 轻位移 + 高度展开/收缩 ===== */
|
||||
.capsule-slide-enter-active,
|
||||
.capsule-slide-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease, max-height 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.capsule-slide-enter-from,
|
||||
.capsule-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
.input-shell {
|
||||
padding: 0 24px 24px;
|
||||
padding: 0 20px 20px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.input-card {
|
||||
max-width: 800px;
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
border: 1.5px solid #e5e8ee;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.07);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
@@ -238,7 +279,7 @@ onMounted(() => {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
resize: none;
|
||||
padding: 14px 16px 8px;
|
||||
padding: 16px 18px 10px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: #0f172a;
|
||||
@@ -257,7 +298,7 @@ onMounted(() => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 10px 10px 12px;
|
||||
padding: 8px 12px 12px 14px;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
@@ -271,10 +312,10 @@ onMounted(() => {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 30px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
@@ -315,10 +356,10 @@ onMounted(() => {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
@@ -347,8 +388,8 @@ onMounted(() => {
|
||||
|
||||
/* 快捷工作流胶囊 */
|
||||
.workflow-shortcuts {
|
||||
max-width: 800px;
|
||||
margin: 10px auto 0;
|
||||
max-width: 880px;
|
||||
margin: 8px auto 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
@@ -358,13 +399,13 @@ onMounted(() => {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 28px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border: 1px solid #e5e8ee;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(4px);
|
||||
@@ -372,7 +413,7 @@ onMounted(() => {
|
||||
outline: none;
|
||||
|
||||
.el-icon {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div class="main-content">
|
||||
<Transition name="content-fade" mode="out-in">
|
||||
<!-- 工作流动态表单 -->
|
||||
<div v-if="workflowDetail" class="content-body workflow-form-body">
|
||||
<div v-if="workflowDetail" :key="'workflow'" class="content-body workflow-form-body">
|
||||
<div class="workflow-form-card">
|
||||
<div class="workflow-form-header">
|
||||
<div>
|
||||
@@ -130,12 +131,12 @@
|
||||
</div>
|
||||
|
||||
<!-- 对话页 — 无工作流但有消息时展示消息流 -->
|
||||
<div v-else-if="hasMessages" class="content-body chat-body">
|
||||
<div v-else-if="hasMessages" :key="'chat'" class="content-body chat-body">
|
||||
<ChatList :messages="messages" @retry="emit('retry', $event)" />
|
||||
</div>
|
||||
|
||||
<!-- 默认占位 — 无工作流时显示引导 -->
|
||||
<div v-else class="content-body placeholder-body">
|
||||
<div v-else :key="'placeholder'" class="content-body placeholder-body">
|
||||
<div class="placeholder-content">
|
||||
<h2 class="placeholder-title">你好,我能帮你解决什么问题?</h2>
|
||||
<p class="placeholder-desc">选择一个工作流,填写参数后一键生成</p>
|
||||
@@ -156,6 +157,7 @@
|
||||
<div v-else class="placeholder-empty">暂无工作流,可在工作流管理中创建</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -429,7 +431,22 @@ onMounted(() => {
|
||||
defineExpose({ formValues, fieldFiles, templates, validateFormFields });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">.main-content {
|
||||
<style scoped lang="scss">
|
||||
/* ===== 页面切换过渡:淡入淡出 + 轻微上移 ===== */
|
||||
.content-fade-enter-active,
|
||||
.content-fade-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
.content-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.content-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -446,19 +463,19 @@ defineExpose({ formValues, fieldFiles, templates, validateFormFields });
|
||||
|
||||
/* ===== 工作流表单 ===== */
|
||||
.workflow-form-body {
|
||||
width: min(800px, 100%);
|
||||
width: min(880px, calc(100% - 40px));
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 20px;
|
||||
padding: 16px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.workflow-form-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e8ee;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100%;
|
||||
@@ -569,12 +586,12 @@ defineExpose({ formValues, fieldFiles, templates, validateFormFields });
|
||||
|
||||
/* ===== 对话页 ===== */
|
||||
.chat-body {
|
||||
width: min(800px, 92%);
|
||||
width: min(880px, calc(100% - 40px));
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 16px;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
@@ -582,16 +599,16 @@ defineExpose({ formValues, fieldFiles, templates, validateFormFields });
|
||||
}
|
||||
|
||||
.placeholder-body { display: flex; align-items: center; justify-content: center; }
|
||||
.placeholder-content { text-align: center; padding: 24px 20px; width: 100%; max-width: 640px; }
|
||||
.placeholder-content { text-align: center; padding: 24px 20px; width: 100%; max-width: 740px; }
|
||||
|
||||
/* ===== 标题与描述 ===== */
|
||||
.placeholder-title { font-size: 20px; font-weight: 700; color: #0f172a; margin: 0 0 8px; letter-spacing: -0.2px; line-height: 1.4; }
|
||||
.placeholder-desc { font-size: 14px; color: #94a3b8; margin: 0 0 28px; line-height: 1.5; }
|
||||
.placeholder-title { font-size: 26px; font-weight: 700; color: #0f172a; margin: 0 0 8px; letter-spacing: -0.2px; line-height: 1.4; }
|
||||
.placeholder-desc { font-size: 15px; color: #94a3b8; margin: 0 0 24px; line-height: 1.5; }
|
||||
|
||||
/* ===== 工作流功能卡片(DeepSeek 式引导) ===== */
|
||||
.workflow-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -599,9 +616,9 @@ defineExpose({ formValues, fieldFiles, templates, validateFormFields });
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #e5e8ee;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
@@ -613,13 +630,20 @@ defineExpose({ formValues, fieldFiles, templates, validateFormFields });
|
||||
}
|
||||
}
|
||||
.wf-card-icon {
|
||||
font-size: 18px;
|
||||
color: #3b82f6;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: #eaf1ff;
|
||||
color: #2563eb;
|
||||
font-size: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wf-card-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
@@ -638,6 +662,6 @@ defineExpose({ formValues, fieldFiles, templates, validateFormFields });
|
||||
.placeholder-empty {
|
||||
font-size: 13px;
|
||||
color: #cbd5e1;
|
||||
padding: 24px 0;
|
||||
padding: 16px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -139,8 +139,8 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: #f8fafc;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
background: #f7f8fa;
|
||||
border-right: 1px solid #e5e8ee;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
@@ -152,20 +152,27 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 12px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 16px 14px 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
font-size: 20px;
|
||||
color: #2563eb;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #2563eb, #4f8bff);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
@@ -194,18 +201,17 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
background: #e8ebf0;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tab 切换 */
|
||||
/* Tab 切换(圆角胶囊) */
|
||||
.sidebar-tabs {
|
||||
display: flex;
|
||||
padding: 8px 8px 0;
|
||||
padding: 12px 12px 8px;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
@@ -213,26 +219,24 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
height: 34px;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
outline: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
|
||||
.el-icon {
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
background: #f1f5f9;
|
||||
background: #eef0f3;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
@@ -240,7 +244,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
background: #fff;
|
||||
border-bottom-color: #2563eb;
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,7 +252,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
.panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 8px 12px;
|
||||
padding: 4px 8px 12px;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
@@ -273,14 +277,14 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
margin-bottom: 1px;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
background: #eef0f3;
|
||||
|
||||
.item-del-btn {
|
||||
opacity: 1;
|
||||
@@ -288,7 +292,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #eff6ff;
|
||||
background: #e6f0ff;
|
||||
|
||||
.item-icon {
|
||||
color: #2563eb;
|
||||
@@ -325,17 +329,17 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
.item-time {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 1px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.item-del-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
@@ -365,7 +369,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
padding: 6px 4px 2px;
|
||||
padding: 8px 6px 4px;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -374,13 +378,13 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px 5px 24px;
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px 7px 24px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
background: #eef0f3;
|
||||
|
||||
.file-actions {
|
||||
opacity: 1;
|
||||
@@ -412,14 +416,6 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
}
|
||||
|
||||
.file-action-btn {
|
||||
&--del {
|
||||
color: #ef4444;
|
||||
|
||||
&:hover {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #3b82f6;
|
||||
@@ -433,7 +429,17 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
&:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
&--del {
|
||||
color: #ef4444;
|
||||
|
||||
&:hover {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
@@ -449,8 +455,6 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
|
||||
+126
-19
@@ -24,7 +24,7 @@
|
||||
@retry="handleRetry"
|
||||
@workflow-select="handlePlaceWorkflowSelect"
|
||||
/>
|
||||
<InputBar ref="inputBarRef" :send-disabled="isSendDisabled" :workflow-locked="isHistoryWorkflow" @send="handleSend" @workflow-select="handleWorkflowSelect" />
|
||||
<InputBar ref="inputBarRef" :send-disabled="isSendDisabled" :workflow-locked="isHistoryWorkflow" :hide-shortcuts="isPlaceholder" @send="handleSend" @workflow-select="handleWorkflowSelect" />
|
||||
</div>
|
||||
|
||||
<!-- 预览弹窗 -->
|
||||
@@ -62,7 +62,7 @@ import TemplateCompleteDialog from './components/TemplateCompleteDialog.vue';
|
||||
import { applyHomeFormValues } from './utils/flowDsl';
|
||||
import { getChatModel } from '/@/api/settings/modelConfigV2';
|
||||
import { openWsExecute } from './utils/wsExecute';
|
||||
import { parseWsMessage, getDelta, getAnswer, getErrorText } from './utils/wsMessage';
|
||||
import { parseWsMessage, getDelta, getAnswer, getErrorText, getToolCallName, getToolResultText } from './utils/wsMessage';
|
||||
import { getSessionMessages } from '/@/api/settings/session';
|
||||
import type { ExecutionTreeItem } from '/@/api/settings/creation';
|
||||
import {
|
||||
@@ -300,6 +300,9 @@ const currentMessages = computed(() => {
|
||||
return sessionMessages.value.get(id) || [];
|
||||
});
|
||||
|
||||
// 占位态:无工作流且无消息(MainContent 显示引导页)时隐藏输入框快捷胶囊,避免与占位卡片重复
|
||||
const isPlaceholder = computed(() => !selectedWorkflowDetail.value && currentMessages.value.length === 0);
|
||||
|
||||
const getSessionId = () => {
|
||||
return `session_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
};
|
||||
@@ -573,11 +576,18 @@ const runWorkflow = async (sid: string, sessionId: string, message: string, mc:
|
||||
systemPrompt: '',
|
||||
flowContent: updatedFlowContent,
|
||||
onMessage: (raw) => {
|
||||
let data: any = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try { data = JSON.parse(raw); } catch { data = raw; }
|
||||
// 优先按 ReAct 结构化事件判定完成/失败
|
||||
const msg = parseWsMessage(raw);
|
||||
if (msg) {
|
||||
if (msg.type === 'error') {
|
||||
finishExec(false, getErrorText(msg));
|
||||
} else if (msg.type === 'answer') {
|
||||
finishExec(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const str = typeof data === 'string' ? data : JSON.stringify(data || '');
|
||||
// 非 JSON 文本:保留旧完成标志兜底
|
||||
const str = typeof raw === 'string' ? raw : JSON.stringify(raw || '');
|
||||
if (/done|finish|complete|end|success/i.test(str)) {
|
||||
finishExec(true);
|
||||
}
|
||||
@@ -609,9 +619,79 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
|
||||
let failed = false;
|
||||
// 思考/生成过程计时起点(收到首帧时开始)
|
||||
let thinkStart = 0;
|
||||
|
||||
// —— 思考区打字机限速渲染(约 30 字/秒:每 100ms 消费 3 字符)——
|
||||
// reasoning_chunk / tool_call / tool_result 先进队列,按固定速率逐字上屏,
|
||||
// 避免后端推流太快导致思考过程看不清;回答区(answer_chunk)不受此限制。
|
||||
let twBuffer = '';
|
||||
let twTimer: number | null = null;
|
||||
const TW_CHARS_PER_TICK = 3;
|
||||
|
||||
const pushThinking = (text: string) => {
|
||||
// 若当前仍是占位提示,先清掉占位,由打字机逐字填充
|
||||
const list = sessionMessages.value.get(sid);
|
||||
const aiIdx = list ? list.findIndex((m) => m.id === aiMsgId) : -1;
|
||||
const ai = aiIdx >= 0 && list ? list[aiIdx] : null;
|
||||
if (ai && ai.thinking === '思考中…') {
|
||||
ai.thinking = '';
|
||||
saveChatCache(sid, list || []);
|
||||
}
|
||||
twBuffer += text;
|
||||
startTypewriter();
|
||||
};
|
||||
|
||||
const startTypewriter = () => {
|
||||
if (twTimer != null) return;
|
||||
twTimer = window.setInterval(() => {
|
||||
if (!twBuffer) {
|
||||
stopTypewriter();
|
||||
return;
|
||||
}
|
||||
const list = sessionMessages.value.get(sid);
|
||||
const aiIdx = list ? list.findIndex((m) => m.id === aiMsgId) : -1;
|
||||
const ai = aiIdx >= 0 && list ? list[aiIdx] : null;
|
||||
if (!ai) {
|
||||
stopTypewriter();
|
||||
return;
|
||||
}
|
||||
const take = Math.min(TW_CHARS_PER_TICK, twBuffer.length);
|
||||
ai.thinking = (ai.thinking || '') + twBuffer.slice(0, take);
|
||||
twBuffer = twBuffer.slice(take);
|
||||
ai.loading = false;
|
||||
saveChatCache(sid, list || []);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const stopTypewriter = () => {
|
||||
if (twTimer != null) {
|
||||
window.clearInterval(twTimer);
|
||||
twTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const flushThinking = () => {
|
||||
stopTypewriter();
|
||||
if (!twBuffer) return;
|
||||
const list = sessionMessages.value.get(sid);
|
||||
const aiIdx = list ? list.findIndex((m) => m.id === aiMsgId) : -1;
|
||||
const ai = aiIdx >= 0 && list ? list[aiIdx] : null;
|
||||
if (ai) {
|
||||
ai.thinking = (ai.thinking || '') + twBuffer;
|
||||
saveChatCache(sid, list || []);
|
||||
}
|
||||
twBuffer = '';
|
||||
};
|
||||
|
||||
const finishChat = (success: boolean, errorMsg?: string) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
// 结束前清理打字机:成功则把队列剩余思考一次性补全,失败则丢弃未渲染内容
|
||||
stopTypewriter();
|
||||
if (success) {
|
||||
flushThinking();
|
||||
} else {
|
||||
twBuffer = '';
|
||||
}
|
||||
const list = sessionMessages.value.get(sid);
|
||||
const aiIdx = list ? list.findIndex((m) => m.id === aiMsgId) : -1;
|
||||
if (!success) {
|
||||
@@ -678,8 +758,8 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
|
||||
const aiIdx = list ? list.findIndex((m) => m.id === aiMsgId) : -1;
|
||||
const ai = aiIdx >= 0 && list ? list[aiIdx] : null;
|
||||
|
||||
// 思考步骤:思考区为空时给出占位提示(虚化)
|
||||
if (msg.type === 'agent_step') {
|
||||
// 模型思考(step 开始):思考区为空时给出占位提示(虚化),并记录计时起点
|
||||
if (msg.type === 'model_call') {
|
||||
if (ai && !ai.thinking && !ai.content) {
|
||||
ai.loading = false;
|
||||
ai.thinking = '思考中…';
|
||||
@@ -687,19 +767,46 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
|
||||
if (!thinkStart) thinkStart = Date.now();
|
||||
return;
|
||||
}
|
||||
// 流式 token:思考区逐字累加(虚化显示)
|
||||
if (msg.type === 'agent_token') {
|
||||
// 思考内容增量:进入打字机队列,限速逐字上屏
|
||||
if (msg.type === 'reasoning_chunk') {
|
||||
const delta = getDelta(msg);
|
||||
if (ai && delta) {
|
||||
ai.loading = false;
|
||||
ai.thinking = ai.thinking && ai.thinking !== '思考中…' ? ai.thinking + delta : delta;
|
||||
saveChatCache(sid, list || []);
|
||||
if (delta) {
|
||||
pushThinking(delta);
|
||||
}
|
||||
if (!thinkStart) thinkStart = Date.now();
|
||||
return;
|
||||
}
|
||||
// 作答完成:回答区输出完整答案,记录思考用时并结束
|
||||
if (msg.type === 'agent_answer') {
|
||||
// 模型请求调用工具:思考区记录工具调用过程(ReAct 步骤,走打字机队列保持顺序)
|
||||
if (msg.type === 'tool_call') {
|
||||
const tool = getToolCallName(msg);
|
||||
if (tool) {
|
||||
const hasPrev = !!(ai && ai.thinking && ai.thinking !== '思考中…') || !!twBuffer;
|
||||
pushThinking((hasPrev ? '\n' : '') + `📎 调用工具:${tool}`);
|
||||
}
|
||||
if (!thinkStart) thinkStart = Date.now();
|
||||
return;
|
||||
}
|
||||
// 工具返回结果:思考区记录工具返回摘要(ReAct 步骤,走打字机队列保持顺序)
|
||||
if (msg.type === 'tool_result') {
|
||||
const result = getToolResultText(msg);
|
||||
if (result) {
|
||||
const hasPrev = !!(ai && ai.thinking && ai.thinking !== '思考中…') || !!twBuffer;
|
||||
pushThinking((hasPrev ? '\n' : '') + `↳ 工具返回:${result}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 回答内容增量:回答区逐 chunk 追加(Markdown 渲染)
|
||||
if (msg.type === 'answer_chunk') {
|
||||
const delta = getDelta(msg);
|
||||
if (ai && delta) {
|
||||
ai.loading = false;
|
||||
ai.content = (ai.content || '') + delta;
|
||||
saveChatCache(sid, list || []);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 最终回答:回答区输出完整答案(覆盖流式内容),记录思考用时并结束
|
||||
if (msg.type === 'answer') {
|
||||
const answer = getAnswer(msg);
|
||||
if (ai && answer) {
|
||||
ai.loading = false;
|
||||
@@ -710,12 +817,12 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
|
||||
finishChat(true);
|
||||
return;
|
||||
}
|
||||
// 错误:展示错误信息并结束
|
||||
// 出错:展示错误信息并结束
|
||||
if (msg.type === 'error') {
|
||||
finishChat(false, getErrorText(msg));
|
||||
return;
|
||||
}
|
||||
// 其余(ack / agent / 未知)忽略
|
||||
// 其余(ack / 未知)忽略
|
||||
},
|
||||
onError: () => {
|
||||
failed = true;
|
||||
@@ -866,7 +973,7 @@ onMounted(() => {
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, #f7f7f8 0%, #ffffff 100%);
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// ===== 首页 WebSocket 流式消息解析工具 =====
|
||||
// 服务端推送统一为 JSON 文本帧:
|
||||
// {"type":"ack","message":"WebSocket连接成功"} 连接/流程确认(message 为提示文案)
|
||||
// {"type":"agent","payload":{"modelId":"...","question":"..."}} agent 启动信息
|
||||
// {"type":"agent_step","message":"模型思考中","data":{"maxStep":15,"step":1}} 思考步骤进度
|
||||
// {"type":"agent_token","message":"思考中","data":{"delta":"你"}} 流式 token 增量
|
||||
// {"type":"agent_answer","message":"作答完成","data":{"answer":"..."}} 作答完成(最终答案)
|
||||
// {"type":"error","message":"..."} 执行失败
|
||||
// 服务端推送统一为 JSON 文本帧(后端 ReAct 事件协议):
|
||||
// {"type":"model_call","message":"模型思考","data":{"step":1,"maxStep":15}} 模型思考(step 开始)
|
||||
// {"type":"tool_call","message":"调用工具","data":{"tool":"web_search","arguments":{}}} 模型请求调用工具
|
||||
// {"type":"tool_result","message":"工具返回","data":{"result":"..."}} 工具返回结果
|
||||
// {"type":"reasoning_chunk","message":"思考中","data":{"delta":"你"}} 思考内容增量(逐 chunk)
|
||||
// {"type":"answer_chunk","message":"回答中","data":{"delta":"你"}} 回答内容增量(逐 chunk)
|
||||
// {"type":"answer","message":"作答完成","data":{"answer":"..."}} 最终回答
|
||||
// {"type":"error","message":"..."} 执行失败
|
||||
// 完成/失败判定均以 type 为准,不再用正则猜测文本。
|
||||
|
||||
export interface WsStreamMessage {
|
||||
@@ -15,6 +16,17 @@ export interface WsStreamMessage {
|
||||
payload?: any;
|
||||
}
|
||||
|
||||
/** 后端 ReAct WebSocket 事件类型常量 */
|
||||
export const WsEventType = {
|
||||
ModelCall: 'model_call', // 模型思考(step 开始)
|
||||
ToolCall: 'tool_call', // 模型请求调用工具
|
||||
ToolResult: 'tool_result', // 工具返回结果
|
||||
Answer: 'answer', // 最终回答
|
||||
AnswerChunk: 'answer_chunk', // 回答内容增量(逐 chunk)
|
||||
ReasoningChunk: 'reasoning_chunk', // 思考内容增量(逐 chunk)
|
||||
Error: 'error', // 出错
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 解析服务端推送帧。
|
||||
* 非 JSON、无 type 的帧返回 null(调用方忽略)。
|
||||
@@ -32,17 +44,17 @@ export function parseWsMessage(raw: any): WsStreamMessage | null {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** agent_token → data.delta 流式增量 */
|
||||
/** answer_chunk / reasoning_chunk → data.delta 流式增量 */
|
||||
export function getDelta(msg: WsStreamMessage): string {
|
||||
return typeof msg?.data?.delta === 'string' ? msg.data.delta : '';
|
||||
}
|
||||
|
||||
/** agent_answer → data.answer 最终答案 */
|
||||
/** answer → data.answer 最终答案 */
|
||||
export function getAnswer(msg: WsStreamMessage): string {
|
||||
return typeof msg?.data?.answer === 'string' ? msg.data.answer : '';
|
||||
}
|
||||
|
||||
/** agent_step → { step, maxStep },缺失时 maxStep 为 0 */
|
||||
/** model_call → { step, maxStep },缺失时 maxStep 为 0 */
|
||||
export function getStepInfo(msg: WsStreamMessage): { step: number; maxStep: number } | null {
|
||||
const step = msg?.data?.step;
|
||||
if (typeof step !== 'number') return null;
|
||||
@@ -50,6 +62,31 @@ export function getStepInfo(msg: WsStreamMessage): { step: number; maxStep: numb
|
||||
return { step, maxStep: typeof maxStep === 'number' ? maxStep : 0 };
|
||||
}
|
||||
|
||||
/** tool_call → 工具名称(tool / toolName / name 依次尝试,缺失返回空串) */
|
||||
export function getToolCallName(msg: WsStreamMessage): string {
|
||||
const d = msg?.data;
|
||||
if (!d || typeof d !== 'object') return '';
|
||||
const name = d.tool ?? d.toolName ?? d.name;
|
||||
return typeof name === 'string' ? name : '';
|
||||
}
|
||||
|
||||
/** tool_result → 工具返回文本(result / output / content / message 依次尝试,对象则序列化) */
|
||||
export function getToolResultText(msg: WsStreamMessage): string {
|
||||
const d = msg?.data;
|
||||
if (typeof d === 'string') return d;
|
||||
if (!d || typeof d !== 'object') return '';
|
||||
const raw = d.result ?? d.output ?? d.content ?? d.message;
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (raw != null) {
|
||||
try {
|
||||
return JSON.stringify(raw);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** error → 错误文案(message 优先,其次 data.message / data.error / data.msg) */
|
||||
export function getErrorText(msg: WsStreamMessage): string {
|
||||
if (typeof msg?.message === 'string' && msg.message) return msg.message;
|
||||
|
||||
Reference in New Issue
Block a user