From ceb7e35dd088fc45396c63cf06babc78c86e43e3 Mon Sep 17 00:00:00 2001 From: SEN Date: Sun, 16 Aug 2026 21:21:28 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=A2=84?= =?UTF-8?q?=E7=BA=A6=E6=97=B6=E6=AE=B5=E5=92=8C=E5=85=B1=E4=BA=AB=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E9=85=8D=E7=BD=AE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/market/calendar/index.tsx | 56 ++++++++- .../market/productDetailModal/index.tsx | 16 ++- src/executor/tools/workForm/goods/index.tsx | 108 ++++++++++++------ 3 files changed, 140 insertions(+), 40 deletions(-) diff --git a/src/components/DataPreview/plaza/components/market/calendar/index.tsx b/src/components/DataPreview/plaza/components/market/calendar/index.tsx index 13729d6e7..ca057a05a 100644 --- a/src/components/DataPreview/plaza/components/market/calendar/index.tsx +++ b/src/components/DataPreview/plaza/components/market/calendar/index.tsx @@ -1,8 +1,9 @@ import React, { useMemo, useState, useRef, useEffect } from 'react'; -import { Calendar, Empty, Tag, Tooltip } from 'antd'; +import { Calendar, DatePicker, Empty, Tag, Tooltip } from 'antd'; import dayjs from 'dayjs'; import isBetween from 'dayjs/plugin/isBetween'; import { TimeSlotRule, PriceMode } from '../timeSlotConfig'; +import moment from 'moment'; dayjs.extend(isBetween); @@ -778,10 +779,61 @@ const SelectableCalendarTab: React.FC = ({ ); }; +// 共享周期 下单选择 时间段 +const CycleTimeTab: React.FC = ({ product, value, onChange }) => { + return ( +
+
+ 预约共享周期 +
+ { + return ( + current && + (current < moment(product!.useStartTime, 'YYYY-MM-DD').startOf('day') || + current > moment(product!.useEndTime, 'YYYY-MM-DD').endOf('day')) + ); + }} + onChange={(vals) => { + if (Array.isArray(vals) && vals.length > 1) { + const startTime = vals[0]!.format('YYYY-MM-DD'); + const endTime = vals[1]!.format('YYYY-MM-DD'); + const bookingPersons: BookingPerson[] = Array.from({ length: 1 }, (_, i) => ({ + name: value?.bookingPersons[i]?.name || '', + phone: value?.bookingPersons[i]?.phone || '', + email: value?.bookingPersons[i]?.email, + organization: value?.bookingPersons[i]?.organization, + purpose: value?.bookingPersons[i]?.purpose, + isContact: i === 0, + })); + onChange && + onChange({ + ruleId: product!.id, + date: '', + startTime, + endTime, + durationMinutes: 0, + slotCount: 1, + maxConcurrent: 1, + personCount: 1, + bookingPersons, + totalPrice: product!.price, + priceMode: 'package', + unitPrice: product!.price, + }); + } + }} + /> +
+ ); +}; // ===== 统一导出 ===== const CalendarTab: React.FC = (props) => { if (props.onChange) { - return ; + if (props.product?.useTimeRules?.length) { + return ; + } + return ; } return ; }; diff --git a/src/components/DataPreview/plaza/components/market/productDetailModal/index.tsx b/src/components/DataPreview/plaza/components/market/productDetailModal/index.tsx index f29e1a9f9..c7123f503 100644 --- a/src/components/DataPreview/plaza/components/market/productDetailModal/index.tsx +++ b/src/components/DataPreview/plaza/components/market/productDetailModal/index.tsx @@ -315,7 +315,13 @@ export const ProductDetailModal: React.FC = ({ ? '套餐/打包售卖' : currentProduct.sellType == 'single' ? '分规格售卖' - : '单件直售'} + : currentProduct.sellType == 'direct' + ? '单件直售' + : currentProduct.sellType == 'time' + ? '预约时段共享' + : currentProduct.sellType == 'cycle' + ? '预约周期共享' + : '暂无'}
@@ -484,12 +490,16 @@ export const ProductDetailModal: React.FC = ({ 已选择预约时段
- {selectedBookingInfo.date} + {selectedBookingInfo.date && ( + {selectedBookingInfo.date} + )} {selectedBookingInfo.startTime} - {selectedBookingInfo.endTime} {selectedBookingInfo.personCount}人 - {selectedBookingInfo.durationMinutes}分钟 + {selectedBookingInfo.durationMinutes > 0 && ( + {selectedBookingInfo.durationMinutes}分钟 + )}
{ goods.sellTarget = e.target.value; + if (e.target.value == 'use') { + goods.sellType = 'time'; + } else { + goods.sellType = 'package'; + } setRefreshFlag((prev) => prev + 1); }}> @@ -1438,43 +1444,75 @@ export const GoodsForm: React.FC<{ - {goods.sellTarget !== 'use' && ( - - { - if (goods) { - goods.sellType = e.target.value; - setRefreshFlag((prev) => prev + 1); - } - }}> - 套餐/打包售卖 - 分规格售卖 - 单件直售 - - - )} + + { + if (goods) { + goods.sellType = e.target.value; + console.log(999, goods); + + setRefreshFlag((prev) => prev + 1); + } + }}> + {goods.sellTarget !== 'use' ? ( + <> + 套餐/打包售卖 + 分规格售卖 + 单件直售 + + ) : ( + <> + 预约时段共享 + 预约周期共享 + + )} + + {goods.sellTarget === 'use' && ( - - { - (goods as any).useTimeRules = rules; - // 同时保留向后兼容的总人次:所有时段最大人次之和 - const total = rules.reduce((s, r) => s + (r.maxUsers || 0), 0); - goods.maxUsers = total > 0 ? total : undefined; - setRefreshFlag((prev) => prev + 1); - }} - /> - + {goods.sellType === 'time' ? ( + + { + (goods as any).useTimeRules = rules; + // 同时保留向后兼容的总人次:所有时段最大人次之和 + const total = rules.reduce((s, r) => s + (r.maxUsers || 0), 0); + goods.maxUsers = total > 0 ? total : undefined; + setRefreshFlag((prev) => prev + 1); + }} + /> + + ) : ( + + { + goods.useStartTime = vals?.[0] + ? vals[0].format('YYYY-MM-DD') + : undefined; + goods.useEndTime = vals?.[1] + ? vals[1].format('YYYY-MM-DD') + : undefined; + setRefreshFlag((prev) => prev + 1); + }} + /> + + )} )} {service && !readonly && ( -- Gitee From 0c30995c11d27d3136b1c3d5eee0b4c31352a02e Mon Sep 17 00:00:00 2001 From: SEN Date: Sun, 16 Aug 2026 21:26:32 +0800 Subject: [PATCH 2/2] =?UTF-8?q?!1312=20feat(chat):=20=E5=AE=9E=E7=8E=B0=20?= =?UTF-8?q?AgentMessage=20=E5=8D=8F=E8=AE=AE=E8=A7=A3=E6=9E=90=E4=B8=8E?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=E4=BA=A4=E4=BA=92=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge pull request !1312 from 杜昱兴/agentmessage --- .../parseMsg/agentReply/AgentReplyBlocks.tsx | 50 +++++ .../parseMsg/agentReply/CommandToast.tsx | 134 +++++++++++++ .../parseMsg/agentReply/ThinkingBox.tsx | 34 ++++ .../parseMsg/agentReply/ToolCallCard.tsx | 67 +++++++ .../parseMsg/agentReply/index.module.less | 137 +++++++++++++ .../chat/components/parseMsg/index.tsx | 32 ++- .../session/chat/groupContent/index.tsx | 37 +++- src/pages/Home/components/Search/index.tsx | 16 ++ src/ts/core/app/chat/agentmessage.ts | 186 ++++++++++++++++++ src/ts/core/app/chat/message.ts | 75 +++++++ src/ts/core/app/chat/session.ts | 36 ++++ src/ts/core/index.ts | 15 ++ src/utils/index.ts | 2 +- src/utils/string.ts | 16 +- 14 files changed, 827 insertions(+), 10 deletions(-) create mode 100644 src/components/DataPreview/session/chat/components/parseMsg/agentReply/AgentReplyBlocks.tsx create mode 100644 src/components/DataPreview/session/chat/components/parseMsg/agentReply/CommandToast.tsx create mode 100644 src/components/DataPreview/session/chat/components/parseMsg/agentReply/ThinkingBox.tsx create mode 100644 src/components/DataPreview/session/chat/components/parseMsg/agentReply/ToolCallCard.tsx create mode 100644 src/components/DataPreview/session/chat/components/parseMsg/agentReply/index.module.less create mode 100644 src/ts/core/app/chat/agentmessage.ts diff --git a/src/components/DataPreview/session/chat/components/parseMsg/agentReply/AgentReplyBlocks.tsx b/src/components/DataPreview/session/chat/components/parseMsg/agentReply/AgentReplyBlocks.tsx new file mode 100644 index 000000000..2dbade94f --- /dev/null +++ b/src/components/DataPreview/session/chat/components/parseMsg/agentReply/AgentReplyBlocks.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Viewer } from '@bytemd/react'; +import { plugins } from '../index'; +import type { ContentBlock } from '@/ts/core'; +import ThinkingBox from './ThinkingBox'; +import ToolCallCard from './ToolCallCard'; +import styles from './index.module.less'; + +interface IProps { + blocks: ContentBlock[]; +} + +/** + * 渲染 AgentMessage.Reply 的 blocks 数组。 + * 按 blocks 顺序渲染: + * - thinking:折叠的思考过程框 + * - toolcall:工具调用卡片 + * - text:Markdown 渲染 + */ +const AgentReplyBlocks: React.FC = ({ blocks }) => { + if (!blocks || blocks.length === 0) { + return
(空回复)
; + } + + return ( +
+ {blocks.map((block, idx) => { + switch (block.kind) { + case 'thinking': + return ; + case 'toolcall': + return ; + case 'text': + // text 块:Markdown 渲染 + return ( +
+
+ +
+
+ ); + default: + return null; + } + })} +
+ ); +}; + +export default AgentReplyBlocks; diff --git a/src/components/DataPreview/session/chat/components/parseMsg/agentReply/CommandToast.tsx b/src/components/DataPreview/session/chat/components/parseMsg/agentReply/CommandToast.tsx new file mode 100644 index 000000000..67deda01c --- /dev/null +++ b/src/components/DataPreview/session/chat/components/parseMsg/agentReply/CommandToast.tsx @@ -0,0 +1,134 @@ +import React, { useEffect } from 'react'; +import { notification } from 'antd'; +import { + CheckCircleOutlined, + CloseCircleOutlined, + ThunderboltOutlined, +} from '@ant-design/icons'; +import type { IAgentCommand, ICommandResponse, ISession } from '@/ts/core'; +import { dispatchAgentCommand, handleAgentSearchCommand } from '@/ts/core'; +import styles from './index.module.less'; + +interface ICommandProps { + command: IAgentCommand; + chat: ISession; + /** 消息创建时间,用于区分历史消息与实时新消息 */ + createTime: string; +} + +interface ICommandResponseProps { + response: ICommandResponse; +} + +/** + * 记录当前会话内已派发的命令 requestId,防止同会话内重复派发。 + * 模块级 Set 生命周期与页面一致,刷新页面后清空; + * 历史消息的拦截在 groupContent 渲染层通过 mountedTime 判断完成, + * 此处 Set 作为同会话内防重复的第二道防线。 + */ +const processedRequestIds = new Set(); + +/** + * 命令下发 Toast 通知。 + * 收到 AgentMessage.Command 时弹出通知; + * search 命令带 requestId 时走 handleAgentSearchCommand 回传结果,其余走 command 总线派发。 + */ +export const CommandToast: React.FC = ({ command, chat, createTime }) => { + useEffect(() => { + // 用 requestId 作为幂等键,无 requestId 时回退到 cmd+args 摘要 + const idempotencyKey = + command.requestId ?? `${command.cmd}-${JSON.stringify(command.args ?? {})}`; + if (processedRequestIds.has(idempotencyKey)) { + return; // 同会话内已处理过,跳过派发与通知 + } + processedRequestIds.add(idempotencyKey); + + let requestId: string | undefined; + if (command.cmd === 'search' && command.requestId) { + handleAgentSearchCommand(command, (payload) => chat.sendAgentMessage(payload)); + requestId = command.requestId; + } else { + requestId = dispatchAgentCommand(command); + } + const key = `agent-cmd-${command.cmd}-${Date.now()}`; + notification.open({ + key, + message: ( + + + 智能体指令:{command.cmd} + + ), + description: ( +
+ {command.args && ( +
+              {JSON.stringify(command.args, null, 2)}
+            
+ )} +
+ 已自动派发至订阅方{requestId ? `(请求ID: ${requestId})` : ''}。 +
+
+ ), + duration: 5, + }); + }, [command, chat, createTime]); + + return null; +}; + +/** + * 记录当前会话内已展示过的 CommandResponse requestId,防止重复弹通知。 + * 与 processedRequestIds 对称,作为 CommandResponseToast 的会话内幂等防线。 + */ +const processedResponseIds = new Set(); + +/** + * 命令回传 Toast 通知。 + * 收到 AgentMessage.CommandResponse 时弹出通知,告知用户命令执行结果。 + */ +export const CommandResponseToast: React.FC = ({ response }) => { + useEffect(() => { + // 用 requestId 作为幂等键,同会话内已展示过则跳过,避免重复弹通知 + if (processedResponseIds.has(response.requestId)) { + return; + } + processedResponseIds.add(response.requestId); + + const key = `agent-cmd-resp-${response.requestId}-${Date.now()}`; + notification.open({ + key, + message: ( + + {response.success ? ( + + ) : ( + + )} + 指令执行{response.success ? '成功' : '失败'} + + ), + description: ( +
+
+ 请求ID: {response.requestId} +
+ {response.msg &&
{response.msg}
} + {response.result !== undefined && ( +
+              {typeof response.result === 'string'
+                ? response.result
+                : JSON.stringify(response.result, null, 2)}
+            
+ )} +
+ ), + duration: 5, + }); + }, [response]); + + return null; +}; + +export default CommandToast; diff --git a/src/components/DataPreview/session/chat/components/parseMsg/agentReply/ThinkingBox.tsx b/src/components/DataPreview/session/chat/components/parseMsg/agentReply/ThinkingBox.tsx new file mode 100644 index 000000000..21764fecc --- /dev/null +++ b/src/components/DataPreview/session/chat/components/parseMsg/agentReply/ThinkingBox.tsx @@ -0,0 +1,34 @@ +import React, { useState } from 'react'; +import { CaretRightOutlined, CaretDownOutlined, BulbOutlined } from '@ant-design/icons'; +import styles from './index.module.less'; + +interface IProps { + text: string; +} + +/** + * 思考过程折叠框。 + * 默认折叠;点击标题切换展开/收起;展开后以 pre-wrap 显示思考文本。 + */ +const ThinkingBox: React.FC = ({ text }) => { + const [expanded, setExpanded] = useState(false); + + if (!text) return null; + + return ( +
+
setExpanded((v) => !v)} + role="button" + tabIndex={0}> + {expanded ? : } + + 思考过程 +
+ {expanded &&
{text}
} +
+ ); +}; + +export default ThinkingBox; diff --git a/src/components/DataPreview/session/chat/components/parseMsg/agentReply/ToolCallCard.tsx b/src/components/DataPreview/session/chat/components/parseMsg/agentReply/ToolCallCard.tsx new file mode 100644 index 000000000..8baafd132 --- /dev/null +++ b/src/components/DataPreview/session/chat/components/parseMsg/agentReply/ToolCallCard.tsx @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import { CaretRightOutlined, CaretDownOutlined, ToolOutlined } from '@ant-design/icons'; +import { Tag } from 'antd'; +import type { IToolCall } from '@/ts/core'; +import { safeStringify } from '@/utils'; +import styles from './index.module.less'; + +interface IProps { + tool: IToolCall; +} + +/** + * 工具调用卡片:展示工具名、参数、结果。 + * - result 缺省:显示"运行中"标签 + * - result 为字符串:直接展示 + * - result 为对象/数组:JSON.stringify 展示 + * - result 包含 "工具执行失败:" 前缀:显示错误标签 + */ +const ToolCallCard: React.FC = ({ tool }) => { + const [expanded, setExpanded] = useState(false); + + const isRunning = tool.result === undefined; + const isError = + typeof tool.result === 'string' && tool.result.startsWith('工具执行失败:'); + const resultText = + tool.result === undefined + ? '' + : typeof tool.result === 'string' + ? tool.result + : safeStringify(tool.result); + const argsText = tool.args ? safeStringify(tool.args) : ''; + + return ( +
+
setExpanded((v) => !v)} + role="button" + tabIndex={0}> + {expanded ? : } + + {tool.name} + {isRunning && 运行中} + {isError && 失败} + {!isRunning && !isError && 完成} +
+ {expanded && ( +
+ {argsText && ( +
+
参数
+
{argsText}
+
+ )} + {resultText && ( +
+
结果
+
{resultText}
+
+ )} +
+ )} +
+ ); +}; + +export default ToolCallCard; diff --git a/src/components/DataPreview/session/chat/components/parseMsg/agentReply/index.module.less b/src/components/DataPreview/session/chat/components/parseMsg/agentReply/index.module.less new file mode 100644 index 000000000..03791d276 --- /dev/null +++ b/src/components/DataPreview/session/chat/components/parseMsg/agentReply/index.module.less @@ -0,0 +1,137 @@ +.agentReplyBlocks { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.agentReplyText { + width: 100%; +} + +.markdownViewer { + font-size: 14px; + line-height: 1.6; + word-break: break-word; + :global { + div.bytemd { + height: auto !important; + } + div.markdown-body { + height: auto !important; + overflow-y: auto; + &:last-child p:last-child { + margin-bottom: 0; + } + } + } +} + +.thinkingBox { + border: 1px solid #e8e8e8; + border-left: 3px solid #faad14; + border-radius: 4px; + background: #fafafa; + overflow: hidden; +} + +.thinkingHeader { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + cursor: pointer; + user-select: none; + font-size: 13px; + color: #666; +} + +.thinkingIcon { + color: #faad14; +} + +.thinkingTitle { + font-weight: 500; +} + +.thinkingText { + padding: 8px 12px 10px; + font-size: 13px; + color: #555; + white-space: pre-wrap; + word-break: break-word; + border-top: 1px dashed #e8e8e8; + max-height: 240px; + overflow-y: auto; +} + +.toolCallCard { + border: 1px solid #e8e8e8; + border-left: 3px solid #1677ff; + border-radius: 4px; + background: #f6f8fa; + overflow: hidden; +} + +.toolCallHeader { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + cursor: pointer; + user-select: none; + font-size: 13px; + color: #333; +} + +.toolCallIcon { + color: #1677ff; +} + +.toolCallName { + font-weight: 500; + margin-right: 4px; +} + +.toolCallBody { + padding: 8px 12px 10px; + border-top: 1px dashed #e8e8e8; + display: flex; + flex-direction: column; + gap: 8px; +} + +.toolCallSection { + display: flex; + flex-direction: column; + gap: 4px; +} + +.toolCallSectionTitle { + font-size: 12px; + color: #999; + font-weight: 500; +} + +.toolCallPre { + margin: 0; + padding: 6px 8px; + background: #fff; + border-radius: 3px; + font-size: 12px; + color: #333; + white-space: pre-wrap; + word-break: break-word; + max-height: 180px; + overflow-y: auto; +} + +/* CommandToast / CommandResponseToast 通知内
 的统一样式 */
+.commandPre {
+  margin: 4px 0;
+  font-size: 12px;
+  max-height: 120px;
+  overflow: auto;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
diff --git a/src/components/DataPreview/session/chat/components/parseMsg/index.tsx b/src/components/DataPreview/session/chat/components/parseMsg/index.tsx
index ccb2aa061..c6daabaff 100644
--- a/src/components/DataPreview/session/chat/components/parseMsg/index.tsx
+++ b/src/components/DataPreview/session/chat/components/parseMsg/index.tsx
@@ -7,7 +7,7 @@ import {
   truncateString,
 } from '@/utils/tools';
 import { Divider, Image } from 'antd';
-import { MessageType, IMessage } from '@/ts/core';
+import { MessageType, IMessage, AgentMessageSubType } from '@/ts/core';
 import { FileItemShare } from '@/ts/base/model';
 import { command, parseAvatar } from '@/ts/base';
 import { formatSize } from '@/ts/base/common';
@@ -18,6 +18,8 @@ import LinkPreview from '@/components/TargetActivity/LinkPreview';
 import ActivityResource from '@/components/TargetActivity/ActivityResource';
 import TextParagraph from './textParagraph';
 import { LocationView } from '@/components/Common/LocationPicker';
+import AgentReplyBlocks from './agentReply/AgentReplyBlocks';
+import { CommandToast, CommandResponseToast } from './agentReply/CommandToast';
 import { Viewer } from '@bytemd/react';
 import gfm from '@bytemd/plugin-gfm';
 import breaks from '@bytemd/plugin-breaks';
@@ -31,7 +33,7 @@ import 'bytemd/dist/index.min.css';
 import 'highlight.js/styles/vs.css';
 import styles from './index.module.less';
 
-const plugins = [
+export const plugins = [
   gfm({
     locale: {
       strike: '删除线',
@@ -109,6 +111,23 @@ export const parseMsg = (item: IMessage): any => {
     );
   }
   switch (item.msgType) {
+    case MessageType.AgentMessage: {
+      const subType = item.agentSubType ?? AgentMessageSubType.Reply;
+      if (subType === AgentMessageSubType.Command && item.agentCommand) {
+        return (
+          
+        );
+      }
+      if (subType === AgentMessageSubType.CommandResponse && item.agentCommandResponse) {
+        return ;
+      }
+      // Reply:渲染 blocks 数组
+      return ;
+    }
     case MessageType.Image: {
       const img: FileItemShare = parseAvatar(item.msgBody);
       if (img && img.shareLink) {
@@ -348,6 +367,15 @@ export const parseCiteMsg = (item: IMessage): any => {
     
; } switch (item.msgType) { + case MessageType.AgentMessage: { + // 引用消息中智能体消息仅展示摘要,不渲染 blocks + return ( +
+ {item.from.name}: + {truncateString(item.msgTitle.replace(/^.*?:\s*/, ''), 80)} +
+ ); + } case MessageType.Image: { const img: FileItemShare = parseAvatar(item.msgBody); if (img && img.thumbnail) { diff --git a/src/components/DataPreview/session/chat/groupContent/index.tsx b/src/components/DataPreview/session/chat/groupContent/index.tsx index 1ada793dc..91d57c40c 100644 --- a/src/components/DataPreview/session/chat/groupContent/index.tsx +++ b/src/components/DataPreview/session/chat/groupContent/index.tsx @@ -6,7 +6,7 @@ import EntityInfo from '@/components/Common/GlobalComps/entityIcon'; import Information from './information'; import ForwardContentModal from './forwardContentModal'; import { showChatTime, downloadByUrl, shareOpenLink } from '@/utils/tools'; -import { IMessage, ISession, MessageType } from '@/ts/core'; +import { AgentMessageSubType, IMessage, ISession, MessageType } from '@/ts/core'; import { parseAvatar } from '@/ts/base'; import { parseCiteMsg, parseMsg, parseForwardMsg } from '../components/parseMsg'; import type { CheckboxChangeEvent } from 'antd/es/checkbox'; @@ -39,6 +39,8 @@ const GroupContent = (props: Iprops) => { const { handleReWrites, multiSelectShow } = props; const body = useRef(null); const [beforescrollHeight, setBeforescrollHeight] = useState(0); + // 组件挂载时间,用于区分历史消息(刷新加载)与实时新消息,固定不变 + const mountedTime = useRef(Date.now()); // 缓存过滤后的消息列表,避免每次渲染都重新 filter const filteredMessages = useMemo( () => messages.filter((i) => i.msgBody.includes(props.filter)), @@ -343,6 +345,14 @@ const GroupContent = (props: Iprops) => { ); }; + // 按 isMySend 渲染左右气泡,复用 loadMsgItem,消除 AgentMessage.Reply 与 default 的重复 + const renderBubble = (item: IMessage) => + item.isMySend ? ( +
{loadMsgItem(item)}
+ ) : ( +
{loadMsgItem(item)}
+ ); + const renderMessage = (item: IMessage) => { switch (item.msgType) { case MessageType.Recall: @@ -362,12 +372,27 @@ const GroupContent = (props: Iprops) => { ); case MessageType.Notify: return
{item.msgBody}
; - default: - if (item.isMySend) { - return
{loadMsgItem(item)}
; - } else { - return
{loadMsgItem(item)}
; + case MessageType.AgentMessage: { + const subType = item.agentSubType ?? AgentMessageSubType.Reply; + // Command / CommandResponse 仅作为指令载体,由 CommandToast 通过 notification 提示; + // 消息列表中不渲染任何外层容器,避免产生空白占位。 + if ( + subType === AgentMessageSubType.Command || + subType === AgentMessageSubType.CommandResponse + ) { + // 历史命令消息(刷新加载,createTime 早于挂载时间)不渲染 CommandToast, + // 避免 useEffect 重复触发命令派发与通知 + const msgTime = new Date(item.createTime).getTime(); + if (!isNaN(msgTime) && msgTime < mountedTime.current) { + return null; + } + return parseMsg(item); } + // Reply:与普通消息一致走左右气泡渲染 + return renderBubble(item); + } + default: + return renderBubble(item); } }; diff --git a/src/pages/Home/components/Search/index.tsx b/src/pages/Home/components/Search/index.tsx index ec47631ca..2f01e3407 100644 --- a/src/pages/Home/components/Search/index.tsx +++ b/src/pages/Home/components/Search/index.tsx @@ -680,6 +680,9 @@ const Search: React.FC = (props) => { } const results = await Promise.all(tasks); setContent(results.flat()); + if (debouncedSearchValue) { + command.emitterFlag('searchResult', results.flat()); + } }, [queryCluster, activeTab, debouncedSearchValue]); // 打开弹窗 @@ -691,6 +694,19 @@ const Search: React.FC = (props) => { setQueryCluster(false); }, []); + // 订阅智能体搜索命令,打开搜索模态并带入 query + useEffect(() => { + const subId = command.subscribeByFlag('openGlobalSearch', (query?: string) => { + if (query == null) return; // 规避订阅即空参调用 + openSearchModal(); + setText(query ?? ''); // 同步输入框显示 + setSearchValue(query ?? ''); // 同步触发检索 + }); + return () => { + command.unsubscribeByFlag(subId); + }; + }, [openSearchModal]); + // 关闭弹窗 重置状态 const closeModal = useCallback(() => { setModalVisible(false); diff --git a/src/ts/core/app/chat/agentmessage.ts b/src/ts/core/app/chat/agentmessage.ts new file mode 100644 index 000000000..fae333c1c --- /dev/null +++ b/src/ts/core/app/chat/agentmessage.ts @@ -0,0 +1,186 @@ +import { command, common } from '../../../base'; +import { MessageType } from '../../public'; +import type { IMessage } from './message'; + +/** 智能体消息子类型 */ +export enum AgentMessageSubType { + /** 智能体回复内容(blocks) */ + Reply = 'Reply', + /** 智能体下发指令操作前端 UI */ + Command = 'Command', + /** 前端回传指令执行结果 */ + CommandResponse = 'CommandResponse', +} + +/** 工具调用信息;result 缺省表示运行中,有值表示完成 */ +export interface IToolCall { + name: string; + args?: Record; + result?: unknown; +} + +/** AgentMessage.Reply 的内容块联合类型 */ +export type ContentBlock = + | { kind: 'thinking'; text: string } + | { kind: 'toolcall'; tool: IToolCall } + | { kind: 'text'; text: string }; + +/** AgentMessage.Command 的命令载荷 */ +export interface IAgentCommand { + cmd: string; + args?: Record; + requestId?: string; +} + +/** AgentMessage.CommandResponse 的回传载荷 */ +export interface ICommandResponse { + requestId: string; + success: boolean; + result?: unknown; + msg?: string; +} + +/** 解析后的智能体消息 */ +export interface IAgentMessage { + subType: AgentMessageSubType; + blocks: ContentBlock[]; + command?: IAgentCommand; + commandResponse?: ICommandResponse; +} + +/** + * 已实现的命令白名单。 + * 仅声明已实现处理的命令,未知命令一律拦截,避免声明未实现的命令。 + */ +const AGENT_COMMAND_WHITELIST = new Set(['search']); + +/** 判断消息是否为智能体消息 */ +export function isAgentMessage(msg: IMessage): boolean { + return msg.msgType === MessageType.AgentMessage; +} + +/** + * 解析智能体消息。缺省 subType 降级为 Reply。 + * 直接读取 Message 上的 getter,不使用 as any。 + */ +export function parseAgentMessage(msg: IMessage): IAgentMessage { + const subType = msg.agentSubType ?? AgentMessageSubType.Reply; + return { + subType, + blocks: msg.agentBlocks ?? [], + command: msg.agentCommand, + commandResponse: msg.agentCommandResponse, + }; +} + +/** + * 派发智能体命令。 + * - 未在白名单中的命令走 logger.warn 告警并返回,不派发。 + * - 命中白名单则经 command 总线以 'agent' 类型派发,供订阅方处理。 + * - 返回 requestId(若有)供调用方回传 CommandResponse。 + */ +export function dispatchAgentCommand(cmd: IAgentCommand): string | undefined { + if (!AGENT_COMMAND_WHITELIST.has(cmd.cmd)) { + common.logger.warn(`未知的智能体命令: ${cmd.cmd}`); + return undefined; + } + command.emitter('agent', cmd.cmd, cmd.args); + return cmd.requestId; +} + +/** AgentMessage 载荷(用于发送 CommandResponse) */ +export interface IAgentMessagePayload { + body: string; + subType?: AgentMessageSubType; + commandResponse?: ICommandResponse; + mentions?: string[]; +} + +/** + * 将搜索结果序列化为安全的 JSON 字符串。 + * 搜索结果元素为 IFile/ITarget 实例,其顶层 id/name/code/typeName/remark 是 getter, + * 读取时可能间接访问 directory/target/session 等带循环引用的运行时对象 + * (如 Agent.memberChats → Session.target → Agent),直接 JSON.stringify 会抛出 + * "Converting circular structure to JSON" 错误,导致 CommandResponse 无法回传。 + * 这里通过 _metadata 私有字段只提取纯数据字段(避开 getter 与 belong 嵌套对象)。 + * 输出格式与 Agent 端 kernelQuery 期望对齐:同时提供 data 与 targets 字段, + * 避免 Agent 端用 result.data 取值时得到 undefined 而误判为空结果。 + */ +function serializeSearchResult(result: unknown): string { + if (typeof result === 'string') return result; + if (!Array.isArray(result)) { + try { + return JSON.stringify(result); + } catch { + return JSON.stringify({ success: false, msg: '结果序列化失败' }); + } + } + const safeItems = result + .map((item: any) => { + // 优先从 _metadata(纯数据对象)取值,避免触发 getter 访问循环引用 + const meta = item?._metadata ?? item?.metadata ?? {}; + return { + id: meta.id ?? item?.id ?? '', + name: meta.name ?? item?.name ?? '', + typeName: meta.typeName ?? item?.typeName ?? '', + code: meta.code ?? item?.code ?? '', + remark: meta.remark ?? item?.remark ?? '', + }; + }) + // 过滤掉 id 与 name 均为空的无效项,避免 Agent 端误判结果为空 + .filter((it) => it.id || it.name); + return JSON.stringify({ + success: true, + total: safeItems.length, + data: safeItems, + targets: safeItems, + }); +} + +/** + * 处理智能体搜索命令。 + * - 派发 openGlobalSearch flag 让 Search 组件打开模态 + * - 订阅 searchResult flag 等待搜索完成 + * - 收到结果后通过 session.sendAgentMessage 回传 CommandResponse + * - 9s 超时回传失败(略短于 agent 端 10s) + */ +export function handleAgentSearchCommand( + cmd: IAgentCommand, + sendResponse: (payload: IAgentMessagePayload) => Promise, +): void { + const requestId = cmd.requestId; + if (!requestId) { + const query = (cmd.args as { query?: string } | undefined)?.query ?? ''; + command.emitterFlag('openGlobalSearch', query); + return; + } + + const query = (cmd.args as { query?: string } | undefined)?.query ?? ''; + command.emitterFlag('openGlobalSearch', query); + + let resolved = false; + const subId = command.subscribeByFlag('searchResult', (result: unknown) => { + if (result == null) return; + if (resolved) return; + resolved = true; + command.unsubscribeByFlag(subId); + clearTimeout(timer); + const resultStr = serializeSearchResult(result); + sendResponse({ + body: '', + subType: AgentMessageSubType.CommandResponse, + commandResponse: { requestId, success: true, result: resultStr }, + }); + }); + + const timer = setTimeout(() => { + if (resolved) return; + resolved = true; + command.unsubscribeByFlag(subId); + sendResponse({ + body: '', + subType: AgentMessageSubType.CommandResponse, + commandResponse: { requestId, success: false, msg: '搜索超时' }, + }); + }, 9000); +} diff --git a/src/ts/core/app/chat/message.ts b/src/ts/core/app/chat/message.ts index b96b573f9..94abf983a 100644 --- a/src/ts/core/app/chat/message.ts +++ b/src/ts/core/app/chat/message.ts @@ -3,6 +3,8 @@ import { MessageType, TargetType } from '../../public'; import { IBelong } from '../../target/base/belong'; import { IPerson } from '../../target/person'; import { ISession } from './session'; +import { AgentMessageSubType } from './agentmessage'; +import type { ContentBlock, IAgentCommand, ICommandResponse } from './agentmessage'; /** * 消息标签接口定义 */ @@ -104,6 +106,14 @@ export interface IMessage { comments: number; /** 会话 */ chat: ISession; + /** 智能体消息子类型(仅 AgentMessage 有值) */ + agentSubType: AgentMessageSubType | undefined; + /** 智能体消息内容块(仅 AgentMessage.Reply 有值) */ + agentBlocks: ContentBlock[] | undefined; + /** 智能体命令(仅 AgentMessage.Command 有值) */ + agentCommand: IAgentCommand | undefined; + /** 智能体命令回传(仅 AgentMessage.CommandResponse 有值) */ + agentCommandResponse: ICommandResponse | undefined; } /** @@ -119,6 +129,10 @@ export class Message implements IMessage { const content = JSON.parse(txt.substring(5)); this._msgBody = content.body; this.mentions = content.mentions; + this._agentSubType = content.subType; + this._agentBlocks = content.blocks; + this._agentCommand = content.command; + this._agentCommandResponse = content.commandResponse; if (content.cite) { this.cite = new Message(content.cite, _chat); } @@ -142,6 +156,10 @@ export class Message implements IMessage { user: IPerson; _chat: ISession; _msgBody: string; + _agentSubType?: AgentMessageSubType; + _agentBlocks?: ContentBlock[]; + _agentCommand?: IAgentCommand; + _agentCommandResponse?: ICommandResponse; labels: IMessageLabel[] = []; metadata: model.ChatMessageType; @@ -280,6 +298,43 @@ export class Message implements IMessage { } return `${this.from.name}[${this.msgType}]:解析异常`; } + case MessageType.AgentMessage: { + const subType = this.agentSubType ?? AgentMessageSubType.Reply; + if (subType === AgentMessageSubType.Command) { + return `${header}[指令]: ${this.agentCommand?.cmd ?? ''}`; + } + if (subType === AgentMessageSubType.CommandResponse) { + return `${header}[指令回传]`; + } + // Reply:单次遍历记录各类型首个块,按 text > thinking > toolcall 优先级取摘要 + const blocks = this.agentBlocks ?? []; + let firstText: string | undefined; + let hasThinking = false; + let firstToolName: string | undefined; + for (const b of blocks) { + switch (b.kind) { + case 'text': + if (firstText === undefined) firstText = b.text; + break; + case 'thinking': + hasThinking = true; + break; + case 'toolcall': + if (firstToolName === undefined) firstToolName = b.tool.name; + break; + } + } + if (firstText !== undefined) { + return `${header}${firstText.substring(0, 50)}`; + } + if (hasThinking) { + return `${header}思考过程`; + } + if (firstToolName !== undefined) { + return `${header}工具调用: ${firstToolName}`; + } + return `${header}[${this.msgType}]`; + } } const file: model.FileItemShare = parseAvatar(this.msgBody); if (file) { @@ -300,6 +355,26 @@ export class Message implements IMessage { get msgSource(): string { return this._msgBody; } + + // 获取智能体消息子类型 + get agentSubType(): AgentMessageSubType | undefined { + return this._agentSubType; + } + + // 获取智能体消息内容块 + get agentBlocks(): ContentBlock[] | undefined { + return this._agentBlocks; + } + + // 获取智能体命令 + get agentCommand(): IAgentCommand | undefined { + return this._agentCommand; + } + + // 获取智能体命令回传 + get agentCommandResponse(): ICommandResponse | undefined { + return this._agentCommandResponse; + } } /** diff --git a/src/ts/core/app/chat/session.ts b/src/ts/core/app/chat/session.ts index 4584dc20b..b974cd7ad 100644 --- a/src/ts/core/app/chat/session.ts +++ b/src/ts/core/app/chat/session.ts @@ -6,6 +6,7 @@ import { Entity, IEntity, MessageType, TargetType, orgAuth } from '../../public' import { ITarget } from '../../target/base/target'; import { XCollection } from '../../public/collection'; import { GroupMessage, IMessage, Message } from './message'; +import type { IAgentMessagePayload } from './agentmessage'; import { Activity, IActivity } from './activity'; import { logger } from '@/ts/base/common'; import { sessionOperates, teamOperates } from '../../public/operates'; @@ -69,6 +70,8 @@ export interface ISession extends IEntity { cite?: IMessage, forward?: IMessage[], ): Promise; + /** 发送智能体消息(支持 CommandResponse 回传) */ + sendAgentMessage(payload: IAgentMessagePayload): Promise; /** 撤回消息 */ recallMessage(id: string): Promise; /** 标记消息 */ @@ -454,6 +457,39 @@ export class Session extends Entity implements ISession { return data !== undefined; } + /** 发送智能体消息(支持 CommandResponse 回传) */ + async sendAgentMessage(payload: IAgentMessagePayload): Promise { + if (this.target.typeName === TargetType.Group && !this.target.hasRelationAuth()) { + return false; + } + const encodedPayload = { + body: payload.body, + mentions: payload.mentions ?? [], + cite: null, + forward: null, + ...(payload.subType !== undefined ? { subType: payload.subType } : {}), + ...(payload.commandResponse !== undefined + ? { commandResponse: payload.commandResponse } + : {}), + }; + const data = await this.coll.insert( + { + typeName: MessageType.AgentMessage, + fromId: this.userId, + toId: this.sessionId, + comments: [], + designateId: this.designateId, + content: common.StringPako.deflate('[obj]' + JSON.stringify(encodedPayload)), + } as unknown as model.ChatMessageType, + this.copyId, + ); + if (data) { + this.receiveMessage('insert', data); + await this.notify('insert', [data], false); + } + return data !== undefined; + } + // 撤回消息 async recallMessage(id: string): Promise { const data = await this.coll.update( diff --git a/src/ts/core/index.ts b/src/ts/core/index.ts index 35fa88071..1792c8ab6 100644 --- a/src/ts/core/index.ts +++ b/src/ts/core/index.ts @@ -1,5 +1,20 @@ export type { IActivity, IActivityMessage } from './app/chat/activity'; export { GroupActivity } from './app/chat/activity'; +export type { + ContentBlock, + IAgentCommand, + IAgentMessage, + IAgentMessagePayload, + ICommandResponse, + IToolCall, +} from './app/chat/agentmessage'; +export { + AgentMessageSubType, + dispatchAgentCommand, + handleAgentSearchCommand, + isAgentMessage, + parseAgentMessage, +} from './app/chat/agentmessage'; export type { IMessage, IMessageLabel } from './app/chat/message'; export type { ISession } from './app/chat/session'; export type { IPlaza } from './app/plaza'; diff --git a/src/utils/index.ts b/src/utils/index.ts index c2a5abd2a..eab417e3f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,7 +2,7 @@ export { formatDate, getWeek, formatTimeByPattern, formatTimeAgo, parseChineseTi export { formatNumber } from './number'; export { uniqueArrayBy } from './array'; export { filterEmptyPropObj, sortObjByKeys, assignment, isLoadOptions, convertToMongoSort, convertToMongoSummary } from './object'; -export { isEmoji, isSpecialChar, ellipsisText, isJSONString } from './string'; +export { isEmoji, isSpecialChar, ellipsisText, isJSONString, safeStringify } from './string'; export { vaildAccount } from './validation'; export { visitTree } from './tree'; export { getQueryString, getScrollX, getJsonText } from './other'; \ No newline at end of file diff --git a/src/utils/string.ts b/src/utils/string.ts index 807723e76..eff73458d 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -35,4 +35,18 @@ export const isJSONString = (str: string) => { } catch (e) { return false; } -}; \ No newline at end of file +}; + +/** + * 安全的 JSON 序列化:成功返回 JSON.stringify(value, null, indent), + * 失败(含循环引用等不可序列化结构)回退为 String(value),避免抛出异常中断渲染。 + * @param value 待序列化的值 + * @param indent 缩进空格数,默认 2 + */ +export const safeStringify = (value: unknown, indent = 2): string => { + try { + return JSON.stringify(value, null, indent); + } catch { + return String(value); + } +}; -- Gitee