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
}
return