diff --git a/src/views/home/components/ChatList.vue b/src/views/home/components/ChatList.vue
index e14c50a..ce31b1e 100644
--- a/src/views/home/components/ChatList.vue
+++ b/src/views/home/components/ChatList.vue
@@ -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;
diff --git a/src/views/home/components/InputBar.vue b/src/views/home/components/InputBar.vue
index 1b13cef..f58ba5d 100644
--- a/src/views/home/components/InputBar.vue
+++ b/src/views/home/components/InputBar.vue
@@ -2,13 +2,15 @@
@@ -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 {
diff --git a/src/views/home/utils/wsMessage.ts b/src/views/home/utils/wsMessage.ts
index 62f03ea..74470a4 100644
--- a/src/views/home/utils/wsMessage.ts
+++ b/src/views/home/utils/wsMessage.ts
@@ -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;