主题和样式以及动画过渡的首页修改

This commit is contained in:
2026-08-14 15:35:03 +08:00
parent 4ec05b507a
commit 362cb4332f
6 changed files with 382 additions and 153 deletions
+39 -23
View File
@@ -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;
+77 -36
View File
@@ -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 {
+45 -21
View File
@@ -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>
+48 -44
View File
@@ -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
View File
@@ -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 {
+47 -10
View File
@@ -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;