diff --git a/packages/x-ui/components/bubble/src/components/bubble-thought-chain/bubble-thought-chain.component.tsx b/packages/x-ui/components/bubble/src/components/bubble-thought-chain/bubble-thought-chain.component.tsx index 0d81f50dcf758d645357f9fc394af9ca06ded967..09defa09b28acd565c530f05c94487ebfb6e9d32 100644 --- a/packages/x-ui/components/bubble/src/components/bubble-thought-chain/bubble-thought-chain.component.tsx +++ b/packages/x-ui/components/bubble/src/components/bubble-thought-chain/bubble-thought-chain.component.tsx @@ -54,6 +54,9 @@ export default defineComponent({ showLabel={false} showLoading={true} initExpanded={true} + listStatus={props.content?.listStatus} + completedCount={props.content?.completedCount} + totalCount={props.content?.totalCount} /> ); }; diff --git a/packages/x-ui/components/bubble/src/components/text-content/utils/block-parser.ts b/packages/x-ui/components/bubble/src/components/text-content/utils/block-parser.ts index 62cd9b68a0adc6ee87baebba2932cd804582220d..8ba0c795cdf6bd377c97bf7fce2fd4b2f58cb88d 100644 --- a/packages/x-ui/components/bubble/src/components/text-content/utils/block-parser.ts +++ b/packages/x-ui/components/bubble/src/components/text-content/utils/block-parser.ts @@ -1,6 +1,7 @@ import { formatDisplayText, isWidgetBlock, + isWidgetBlockComplete, parseAssistantWidget, getWidgetImplOptions, } from '../../../composition/use-widget-content'; @@ -31,7 +32,7 @@ export function parseBlocks(text: string): ParsedBlock[] { for (const part of assistantParts) { if (isWidgetBlock(part)) { - if (part.endsWith('```')) { + if (isWidgetBlockComplete(part)) { // assistant-widget 块 const widgetData = parseAssistantWidget(part); blocks.push({ diff --git a/packages/x-ui/components/bubble/src/composition/types.ts b/packages/x-ui/components/bubble/src/composition/types.ts index 1fb27f2e04816a33b5464bec9e658e94eebfd39a..aa671706c333f574e4ff8212ca23b56a5c76d2e4 100644 --- a/packages/x-ui/components/bubble/src/composition/types.ts +++ b/packages/x-ui/components/bubble/src/composition/types.ts @@ -121,6 +121,16 @@ export interface ThoughtChainContent { * 用于 `Todo` 规划标题:`message` 仅为文案,与 `taskList` 根任务状态无关,避免 `NotStart` 被画成「未完成任务」。 */ showHeaderStatusIcon?: boolean; + /** + * 待办列表整体状态:由网关 `agent/todo-list.content.listStatus` 透传。 + * `completed` 时 `FXStartedTodo` 走「整体完成」分支:header 圆形 icon 换为对勾、 + * 标题后追加「已完成 (N/N)」chip,根节点加 `is-completed` 类(不自动收起)。 + */ + listStatus?: 'active' | 'completed'; + /** 已完成项数(用于完成 chip 文案,可由 todoList 自动计算时不必显式传) */ + completedCount?: number; + /** 总项数(同上,未显式传时回退到 `todoList.length`) */ + totalCount?: number; } /** 思考过程 */ diff --git a/packages/x-ui/components/bubble/src/composition/use-widget-content.ts b/packages/x-ui/components/bubble/src/composition/use-widget-content.ts index d557332c44d66e9fca4e0eb4771c4e89f23f639d..74685a663974c6b362cd7838aecddca528a2ff90 100644 --- a/packages/x-ui/components/bubble/src/composition/use-widget-content.ts +++ b/packages/x-ui/components/bubble/src/composition/use-widget-content.ts @@ -41,17 +41,46 @@ function normalizeWidgetBlock(text: string): string { return text.replace(/```\s*\n+\s*assistant-widget/g, '``` assistant-widget'); } +/** 完整 widget 代码块:`` ``` assistant-widget ... ``` `` 且结尾闭围栏不与开围栏重叠 */ +const WIDGET_COMPLETE_REGEX = /^```\s*assistant-widget[\s\S]*?```$/; +/** 不完整 widget 代码块:以 `` ``` assistant-widget`` 开头但未闭合 */ +const WIDGET_INCOMPLETE_REGEX = /^```\s*assistant-widget/; +/** + * 「正在变成 widget 的前缀」:流式渲染过程中,当前累计文本是 `` ``` ``、`` ``` a``、`` ``` assistant`` 这类 + * `assistant-widget` 的前缀子串,后续 chunk 可能补齐成 widget。 + * 不识别这种前缀会让 block 类型经历 `TextBlock → AssistantWidgetPending` 的硬切换, + * 给 Vue 渲染层制造卸载/重挂载副作用,进而丢失 widget JS 的动态加载。 + */ +const WIDGET_PARTIAL_PREFIX_REGEX = /^```\s*([a-zA-Z0-9-]*)$/; + +function matchesWidgetPartialPrefix(text: string): boolean { + const m = text.match(WIDGET_PARTIAL_PREFIX_REGEX); + if (!m) return false; + const lang = (m[1] ?? '').toLowerCase(); + return lang === '' || 'assistant-widget'.startsWith(lang); +} + +/** + * 判断文本是否为完整的 widget 代码块 + * 用于区分流式过程中的 pending / complete 状态:单纯 `endsWith('```')` 会把 + * 仅有一个开围栏 `` ``` `` 的中间态误判为完整 widget。 + */ +export function isWidgetBlockComplete(text: string): boolean { + if (!text) return false; + return WIDGET_COMPLETE_REGEX.test(text); +} + /** * 判断文本是否为 widget 代码块 - * 支持标准格式和不完整格式(流式渲染中途被截断的情况) + * 支持标准格式、不完整格式(流式渲染中途被截断),以及流式起步时仅有围栏前缀的情况 */ export function isWidgetBlock(text: string): boolean { if (!text) return false; - // 匹配完整的 widget 代码块(assistant-widget) - const completeRegex = /^```\s*assistant-widget[\s\S]*?```$/; - // 匹配不完整的 widget 代码块(截断情况) - const incompleteRegex = /^```\s*assistant-widget/; - return completeRegex.test(text) || incompleteRegex.test(text); + return ( + WIDGET_COMPLETE_REGEX.test(text) || + WIDGET_INCOMPLETE_REGEX.test(text) || + matchesWidgetPartialPrefix(text) + ); } /** diff --git a/packages/x-ui/components/started-todo/src/started-todo.component.tsx b/packages/x-ui/components/started-todo/src/started-todo.component.tsx index d078066e659f2b3bf374fe717c522682d0b9cd25..a71c6472a2b6ae113d394c4e28dccc789d64e0fd 100644 --- a/packages/x-ui/components/started-todo/src/started-todo.component.tsx +++ b/packages/x-ui/components/started-todo/src/started-todo.component.tsx @@ -125,6 +125,21 @@ export default defineComponent({ ) ); } + const isListCompleted = computed(() => props.listStatus === 'completed'); + + /** + * 完成数/总数:优先用业务方显式传入;否则按 `todoList` 中 `status === 'Done'` 自动统计。 + * 仅在 `listStatus === 'completed'` 渲染 chip 时使用,进行中态不展示数字。 + */ + const completedTotal = computed(() => { + const list = props.todoList ?? []; + const total = props.totalCount ?? list.length; + const done = + props.completedCount ?? + list.filter((it: TodoWorkItem) => it?.status === 'Done').length; + return { done, total }; + }); + function renderLoadingIcon() { if (!props.showLoading) { return null; @@ -133,19 +148,40 @@ export default defineComponent({ if (props.loading) { return props.loading; } + // 整体已完成:header 圆形 icon 直接显示勾选(不走 haiyueLogoUrl 单项分支) + if (isListCompleted.value) { + return ; + } if (props.status === 'Done') { return } return ; } + function renderCompletedChip() { + if (!isListCompleted.value) return null; + const { done, total } = completedTotal.value; + const label = total > 0 ? `已完成 (${done}/${total})` : '已完成'; + return ( + + {label} + + ); + } + return () => { return (
} {renderLoadingIcon()} + {renderCompletedChip()} {renderExpand()}
{props.detailViewMode === "expand" && ( diff --git a/packages/x-ui/components/started-todo/src/started-todo.props.ts b/packages/x-ui/components/started-todo/src/started-todo.props.ts index a2b046b0fc11957dfa5f04cc700e5f8137a10c21..58ad40f27df680c4eb9bef7e7d73da4b23e557f8 100644 --- a/packages/x-ui/components/started-todo/src/started-todo.props.ts +++ b/packages/x-ui/components/started-todo/src/started-todo.props.ts @@ -51,8 +51,25 @@ export const startedTodoProps = { outputMode: { type: String as PropType, default: 'streaming' as OutputMode + }, + /** + * 待办列表整体状态:`completed` 时 header 圆形 icon 换为对勾、追加「已完成 (N/N)」chip, + * 根节点添加 `is-completed` 类,便于业务方在 SCSS 做轻量点缀;不影响展开/收起。 + */ + listStatus: { + type: String as PropType<'active' | 'completed'>, + default: undefined + }, + /** 已完成项数:若未显式传入,组件内部按 `todoList.filter(status='Done').length` 自动计算 */ + completedCount: { + type: Number, + default: undefined + }, + /** 总项数:若未显式传入,组件内部回退到 `todoList.length` */ + totalCount: { + type: Number, + default: undefined } - }; diff --git a/packages/x-ui/components/style.scss b/packages/x-ui/components/style.scss index 2c7d8df401e1cef23f6a11180db7b68399f0358f..6edf27fe50a7d4ddb0a41c41396a67b53da4e258 100644 --- a/packages/x-ui/components/style.scss +++ b/packages/x-ui/components/style.scss @@ -844,6 +844,27 @@ $color-accent-red: #f46160; // } } + /** + * 独立思考气泡(content.type === 'AgentThinking')上方默认留 30px 间距, + * 用于区隔"前一段助手输出"与"新一轮思考"。 + */ + &.is-thinking-message { + margin-top: 30px; + } + + /** + * 两种例外不加上方间距: + * 1. 列表首条 + * 2. 紧邻用户消息之后的第一条思考 + * 右侧用 `.f-chat-message.is-thinking-message` 而不是 `&.is-thinking-message` + * 避免 dart-sass 双 `&` 邻接组合符语义不确定;同时选择器 specificity 高于 + * 上面的 30px 规则,可稳定覆盖。 + */ + &.is-thinking-message:first-child, + &.user + .f-chat-message.is-thinking-message { + margin-top: 0; + } + .f-chat-message-bubble { padding: 10px 12px; border-radius: 12px; @@ -1913,6 +1934,11 @@ $color-accent-red: #f46160; color: $text-muted-dark; } + /* 完成项的对勾图标统一走绿色,与主流任务卡片(Linear / Cursor)一致 */ + &.is-done .f-chat-todo-status-done { + color: #16a34a; + } + .f-chat-todo-task-wrapper { flex: 1; min-width: 0; @@ -1992,9 +2018,12 @@ $color-accent-red: #f46160; color: rgba(0, 0, 0, 0.55); } +/* Todo 状态图标统一走绿色(NotStart / Working / Done),与主流任务卡片视觉一致 */ +$todo-status-color: #16a34a; + /* NotStart: 空心圆 - farrisicon 未找到时 CSS 占位 */ .f-chat-todo-status-notstart { - border: 1px solid $color-accent; + border: 1px solid $todo-status-color; border-radius: 50%; background: transparent; } @@ -2002,7 +2031,7 @@ $color-accent-red: #f46160; /* Working: 转圈 - farrisicon 未找到时 CSS 占位 */ .f-chat-todo-status-working { font-size: 16px; - color: $color-accent; + color: $todo-status-color; font-family: FarrisXIcons; &::after { content: "\e6da"; @@ -2020,7 +2049,7 @@ $color-accent-red: #f46160; /* Done: 对号圆 - farrisicon 未找到时 CSS 占位 */ .f-chat-todo-status-done { font-size: 16px; - color: $color-accent; + color: $todo-status-color; font-family: FarrisXIcons; &::after { content: "\e6d9"; @@ -4052,6 +4081,37 @@ color: $text-caption;; } } +/* 整体待办完成态(由 ThoughtChainContent.listStatus === 'completed' 触发) */ +.f-chat-message-started-todo.is-completed { + .f-chat-message-started-todo--header { + .f-chat-started-todo-message { + color: $text-primary; + } + /* header 的对勾图标走绿色,与列表项 done 状态保持一致 */ + > .f-chat-todo-status-done { + color: #16a34a; + } + } +} + +/* 整体完成 chip:紧跟标题后,浅色背景,不抢眼 */ +.f-chat-started-todo-completed-chip { + display: inline-flex; + align-items: center; + flex-shrink: 0; + margin-left: 6px; + padding: 1px 8px; + font-size: 12px; + font-weight: 500; + line-height: 18px; + color: #16a34a; /* 主流任务卡片完成色(参考 Linear / Cursor) */ + background-color: rgba(22, 163, 74, 0.1); + border: 1px solid rgba(22, 163, 74, 0.2); + border-radius: 10px; + white-space: nowrap; + user-select: none; +} + .f-chat-todo-item{ &>.f-chat-message-started-todo{ box-shadow: none; diff --git a/packages/x-ui/components/todo/src/components/todo-list-item-view.component.tsx b/packages/x-ui/components/todo/src/components/todo-list-item-view.component.tsx index 377d545e892235141406dff191423de32d47351a..f9d0cc17c428d2fc9be7df2b2bef238e2d358440 100644 --- a/packages/x-ui/components/todo/src/components/todo-list-item-view.component.tsx +++ b/packages/x-ui/components/todo/src/components/todo-list-item-view.component.tsx @@ -15,13 +15,15 @@ export default defineComponent({ registerProperty('title', props.title ?? ''); registerProperty('task', props.task ?? ''); + registerProperty('detail', props.message ?? ''); // 监听属性变化 watch( - [() => props.title, () => props.task], - ([title, task]) => { + [() => props.title, () => props.task, () => props.message], + ([title, task, message]) => { updatePropertyValue('title', title ?? ''); updatePropertyValue('task', task ?? ''); + updatePropertyValue('detail', message ?? ''); } ); @@ -94,9 +96,17 @@ export default defineComponent({
{displayTitle.value}
{displayTask.value}
+ {displayDetail.value ? ( +
{displayDetail.value}
+ ) : null}
) : ( -
{displayTask.value}
+
+ {displayTask.value} + {displayDetail.value ? ( +
{displayDetail.value}
+ ) : null} +
)} ))} diff --git a/packages/x-ui/components/todo/src/composition/type.ts b/packages/x-ui/components/todo/src/composition/type.ts index a8edddde79a47154d2c91c1c4d44985647026885..ef56974ccef946ed8016a3c20b8c5df4445fe65a 100644 --- a/packages/x-ui/components/todo/src/composition/type.ts +++ b/packages/x-ui/components/todo/src/composition/type.ts @@ -26,10 +26,19 @@ export interface TodoWorkItem { title?: string; } +/** 待办列表整体状态:`active` 进行中(含创建/更新);`completed` 整个列表全部完成 */ +export type TodoListStatus = 'active' | 'completed'; + /** 待办任务类型消息内容 */ export interface MessageContentTodo { type: 'Todo'; /** 消息标题,显示在列表外部 */ message: string; items: TodoWorkItem[]; + /** + * 整体待办状态:由网关 `agent/todo-list` content.listStatus 透传。 + * `completed` 时上层(如 BubbleThoughtChain / FXStartedTodo)应展示「全部完成」视觉 + * (header icon 换勾、追加 chip 等),不再自动收起。 + */ + listStatus?: TodoListStatus; }