From e73112623326cb3378c4c32df17bb9d0f102f1c7 Mon Sep 17 00:00:00 2001 From: zhubby Date: Thu, 16 Apr 2026 19:58:08 +0800 Subject: [PATCH 1/3] fix(ui): dedupe streaming placeholder messages Use stable per-turn placeholder IDs for reasoning and MCP tool progress so incremental SSE updates replace the same chat card instead of accumulating. Also remove temporary streaming cards when a turn completes or fails. Made-with: Cursor --- kagent-ui/CHANGELOG.md | 7 + kagent-ui/hooks/use-codex-session.ts | 198 +++++++++++------------ kagent-ui/lib/assistant/messages.test.js | 83 ++++++++++ kagent-ui/lib/assistant/messages.ts | 9 ++ 4 files changed, 191 insertions(+), 106 deletions(-) create mode 100644 kagent-ui/lib/assistant/messages.test.js diff --git a/kagent-ui/CHANGELOG.md b/kagent-ui/CHANGELOG.md index 7267008..9c24462 100644 --- a/kagent-ui/CHANGELOG.md +++ b/kagent-ui/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## 2026-04-16 + +- 修复聊天界面 SSE 增量消息累积问题: + - `use-codex-session` 改为为 `reasoning` 与 `mcpToolCall/progress` 使用按 turn 稳定的临时消息 ID,流式增量改为原地更新,不再为每个增量块堆出新的消息条目。 + - `turn/completed` 与 `error` 事件现在会清理当前 turn 的临时思考/工具进度卡片,避免对话结束后残留重复占位消息。 + - 新增 `messages.test.ts`,覆盖临时消息清理与流式占位原地更新的纯函数行为。 + ## 2026-02-24 - 修复“查看沙箱日志”鉴权失败(401): diff --git a/kagent-ui/hooks/use-codex-session.ts b/kagent-ui/hooks/use-codex-session.ts index 3659855..e5dd252 100644 --- a/kagent-ui/hooks/use-codex-session.ts +++ b/kagent-ui/hooks/use-codex-session.ts @@ -40,6 +40,7 @@ import { appendReasoningContentDelta, appendReasoningSummaryDelta, extractMessagesFromThread, + removeMessagesByIds, threadItemToUiMessage, upsertMessage, } from "@/lib/assistant/messages"; @@ -140,6 +141,36 @@ function mergeSessions(incoming: CodexThread[], local: CodexThread[]): CodexThre return normalizeAndSortSessions([...map.values()]); } +const TEMPORARY_STREAMING_ITEM_TYPES = ["reasoning", "mcpToolCall"] as const; + +type TemporaryStreamingItemType = + (typeof TEMPORARY_STREAMING_ITEM_TYPES)[number]; + +function isTemporaryStreamingItemType( + type: string, +): type is TemporaryStreamingItemType { + return TEMPORARY_STREAMING_ITEM_TYPES.includes( + type as TemporaryStreamingItemType, + ); +} + +function buildTemporaryMessageId( + threadId: string, + turnId: string, + type: TemporaryStreamingItemType, +): string { + return `temp:${threadId}:${turnId}:${type}`; +} + +function getTemporaryMessageIdsForTurn( + threadId: string, + turnId: string, +): string[] { + return TEMPORARY_STREAMING_ITEM_TYPES.map((type) => + buildTemporaryMessageId(threadId, turnId, type), + ); +} + /** * Codex 会话管理 Hook * 提供会话列表、创建、恢复、消息发送、事件流处理等功能 @@ -170,8 +201,6 @@ export function useCodexSession({ const [createSessionSubmitting, setCreateSessionSubmitting] = useState(false); const eventsAbortRef = useRef(null); - // 映射流式 item ID:key = `${threadId}:${turnId}:${type}`,value = 按顺序存储的 itemId 数组 - const streamingItemRef = useRef>({}); const activeWorkspaceId = activeWorkspace?.id ?? null; @@ -441,11 +470,6 @@ export function useCodexSession({ const p = msg.params as ErrorNotification | undefined; if (!p?.threadId || !p?.turnId || !p?.error?.message) return; - const prefix = `${p.threadId}:${p.turnId}:`; - for (const key of Object.keys(streamingItemRef.current)) { - if (key.startsWith(prefix)) delete streamingItemRef.current[key]; - } - setRunning((cur) => { if (!cur) return cur; if (cur.sessionId !== p.threadId) return cur; @@ -463,11 +487,17 @@ export function useCodexSession({ setMessagesBySession((prev) => ({ ...prev, - [p.threadId]: upsertMessage(prev[p.threadId] ?? [], { - id: `error_${p.threadId}_${p.turnId}`, - kind: "assistant", - text: `**请求失败**\n\n${errorHint}`, - }), + [p.threadId]: upsertMessage( + removeMessagesByIds( + prev[p.threadId] ?? [], + getTemporaryMessageIdsForTurn(p.threadId, p.turnId), + ), + { + id: `error_${p.threadId}_${p.turnId}`, + kind: "assistant", + text: `**请求失败**\n\n${errorHint}`, + }, + ), })); if (shouldGuideNewThread) { @@ -482,17 +512,14 @@ export function useCodexSession({ const p = msg.params as ItemStartedNotification | undefined; if (!p?.threadId || !p?.turnId || !p?.item) return; const scopedId = buildScopedItemId(p.turnId, p.item.id); + const messageId = isTemporaryStreamingItemType(p.item.type) + ? buildTemporaryMessageId(p.threadId, p.turnId, p.item.type) + : scopedId; const ui = threadItemToUiMessage( - overrideThreadItemId(p.item, scopedId), + overrideThreadItemId(p.item, messageId), ); if (!ui) return; if (ui.kind === "user") return; - const key = `${p.threadId}:${p.turnId}:${p.item.type}`; - const arr = streamingItemRef.current[key] ?? []; - if (!arr.includes(scopedId)) { - arr.push(scopedId); - } - streamingItemRef.current[key] = arr; setMessagesBySession((prev) => ({ ...prev, [p.threadId]: upsertMessage(prev[p.threadId] ?? [], ui), @@ -509,19 +536,12 @@ export function useCodexSession({ typeof p.delta !== "string" ) return; - const key = `${p.threadId}:${p.turnId}:agentMessage`; - const arr = streamingItemRef.current[key] ?? []; - const fallbackId = buildScopedItemId(p.turnId, p.itemId); - const mappedId = arr.length > 0 ? arr[0] : fallbackId; - if (arr.length === 0) { - arr.push(fallbackId); - streamingItemRef.current[key] = arr; - } + const itemId = buildScopedItemId(p.turnId, p.itemId); setMessagesBySession((prev) => ({ ...prev, [p.threadId]: appendAgentDelta( prev[p.threadId] ?? [], - mappedId, + itemId, p.delta, ), })); @@ -533,19 +553,16 @@ export function useCodexSession({ | ReasoningSummaryPartAddedNotification | undefined; if (!p?.threadId || !p?.turnId || !p?.itemId) return; - const key = `${p.threadId}:${p.turnId}:reasoning`; - const arr = streamingItemRef.current[key] ?? []; - const fallbackId = buildScopedItemId(p.turnId, p.itemId); - const mappedId = arr.length > 0 ? arr[0] : fallbackId; - if (arr.length === 0) { - arr.push(fallbackId); - streamingItemRef.current[key] = arr; - } + const itemId = buildTemporaryMessageId( + p.threadId, + p.turnId, + "reasoning", + ); setMessagesBySession((prev) => ({ ...prev, [p.threadId]: appendReasoningSummaryDelta( prev[p.threadId] ?? [], - mappedId, + itemId, p.summaryIndex, "", ), @@ -564,19 +581,16 @@ export function useCodexSession({ typeof p.delta !== "string" ) return; - const key = `${p.threadId}:${p.turnId}:reasoning`; - const arr = streamingItemRef.current[key] ?? []; - const fallbackId = buildScopedItemId(p.turnId, p.itemId); - const mappedId = arr.length > 0 ? arr[0] : fallbackId; - if (arr.length === 0) { - arr.push(fallbackId); - streamingItemRef.current[key] = arr; - } + const itemId = buildTemporaryMessageId( + p.threadId, + p.turnId, + "reasoning", + ); setMessagesBySession((prev) => ({ ...prev, [p.threadId]: appendReasoningSummaryDelta( prev[p.threadId] ?? [], - mappedId, + itemId, p.summaryIndex, p.delta, ), @@ -593,19 +607,16 @@ export function useCodexSession({ typeof p.delta !== "string" ) return; - const key = `${p.threadId}:${p.turnId}:reasoning`; - const arr = streamingItemRef.current[key] ?? []; - const fallbackId = buildScopedItemId(p.turnId, p.itemId); - const mappedId = arr.length > 0 ? arr[0] : fallbackId; - if (arr.length === 0) { - arr.push(fallbackId); - streamingItemRef.current[key] = arr; - } + const itemId = buildTemporaryMessageId( + p.threadId, + p.turnId, + "reasoning", + ); setMessagesBySession((prev) => ({ ...prev, [p.threadId]: appendReasoningContentDelta( prev[p.threadId] ?? [], - mappedId, + itemId, p.contentIndex, p.delta, ), @@ -624,19 +635,12 @@ export function useCodexSession({ typeof p.delta !== "string" ) return; - const key = `${p.threadId}:${p.turnId}:commandExecution`; - const arr = streamingItemRef.current[key] ?? []; - const fallbackId = buildScopedItemId(p.turnId, p.itemId); - const mappedId = arr.length > 0 ? arr[0] : fallbackId; - if (arr.length === 0) { - arr.push(fallbackId); - streamingItemRef.current[key] = arr; - } + const itemId = buildScopedItemId(p.turnId, p.itemId); setMessagesBySession((prev) => ({ ...prev, [p.threadId]: appendCommandOutputDelta( prev[p.threadId] ?? [], - mappedId, + itemId, p.delta, ), })); @@ -646,21 +650,14 @@ export function useCodexSession({ if (method === "item/commandExecution/terminalInteraction") { const p = msg.params as TerminalInteractionNotification | undefined; if (!p?.threadId || !p?.turnId || !p?.itemId) return; - const key = `${p.threadId}:${p.turnId}:commandExecution`; - const arr = streamingItemRef.current[key] ?? []; - const fallbackId = buildScopedItemId(p.turnId, p.itemId); - const mappedId = arr.length > 0 ? arr[0] : fallbackId; - if (arr.length === 0) { - arr.push(fallbackId); - streamingItemRef.current[key] = arr; - } + const itemId = buildScopedItemId(p.turnId, p.itemId); const delta = p.stdin ? `\n> ${p.stdin}` : ""; if (!delta) return; setMessagesBySession((prev) => ({ ...prev, [p.threadId]: appendCommandOutputDelta( prev[p.threadId] ?? [], - mappedId, + itemId, delta, ), })); @@ -678,19 +675,12 @@ export function useCodexSession({ typeof p.delta !== "string" ) return; - const key = `${p.threadId}:${p.turnId}:fileChange`; - const arr = streamingItemRef.current[key] ?? []; - const fallbackId = buildScopedItemId(p.turnId, p.itemId); - const mappedId = arr.length > 0 ? arr[0] : fallbackId; - if (arr.length === 0) { - arr.push(fallbackId); - streamingItemRef.current[key] = arr; - } + const itemId = buildScopedItemId(p.turnId, p.itemId); setMessagesBySession((prev) => ({ ...prev, [p.threadId]: appendFileChangeOutputDelta( prev[p.threadId] ?? [], - mappedId, + itemId, p.delta, ), })); @@ -706,19 +696,16 @@ export function useCodexSession({ typeof p.message !== "string" ) return; - const key = `${p.threadId}:${p.turnId}:mcpToolCall`; - const arr = streamingItemRef.current[key] ?? []; - const fallbackId = buildScopedItemId(p.turnId, p.itemId); - const mappedId = arr.length > 0 ? arr[0] : fallbackId; - if (arr.length === 0) { - arr.push(fallbackId); - streamingItemRef.current[key] = arr; - } + const itemId = buildTemporaryMessageId( + p.threadId, + p.turnId, + "mcpToolCall", + ); setMessagesBySession((prev) => ({ ...prev, [p.threadId]: appendMcpToolProgress( prev[p.threadId] ?? [], - mappedId, + itemId, p.message, ), })); @@ -728,15 +715,12 @@ export function useCodexSession({ if (method === "item/completed") { const p = msg.params as ItemCompletedNotification | undefined; if (!p?.threadId || !p?.turnId || !p?.item) return; - const key = `${p.threadId}:${p.turnId}:${p.item.type}`; - const arr = streamingItemRef.current[key] ?? []; - const mappedId = arr.shift(); - streamingItemRef.current[key] = arr; - const scopedId = mappedId ?? buildScopedItemId(p.turnId, p.item.id); + const scopedId = buildScopedItemId(p.turnId, p.item.id); const item = overrideThreadItemId(p.item, scopedId); const ui = threadItemToUiMessage(item); if (!ui) return; if (ui.kind === "user") return; + if (p.item.type === "reasoning") return; setMessagesBySession((prev) => ({ ...prev, [p.threadId]: upsertMessage(prev[p.threadId] ?? [], ui), @@ -750,17 +734,19 @@ export function useCodexSession({ const dividerId = `turn_completed_${p.threadId}_${p.turn.id}`; setMessagesBySession((prev) => ({ ...prev, - [p.threadId]: upsertMessage(prev[p.threadId] ?? [], { - id: dividerId, - kind: "turnCompleted", - turnId: p.turn.id, - text: "This round of conversation has ended", - }), + [p.threadId]: upsertMessage( + removeMessagesByIds( + prev[p.threadId] ?? [], + getTemporaryMessageIdsForTurn(p.threadId, p.turn.id), + ), + { + id: dividerId, + kind: "turnCompleted", + turnId: p.turn.id, + text: "This round of conversation has ended", + }, + ), })); - const prefix = `${p.threadId}:${p.turn.id}:`; - for (const key of Object.keys(streamingItemRef.current)) { - if (key.startsWith(prefix)) delete streamingItemRef.current[key]; - } setRunning((cur) => { if (!cur) return cur; if (cur.sessionId !== p.threadId) return cur; diff --git a/kagent-ui/lib/assistant/messages.test.js b/kagent-ui/lib/assistant/messages.test.js new file mode 100644 index 0000000..968b19b --- /dev/null +++ b/kagent-ui/lib/assistant/messages.test.js @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { + appendMcpToolProgress, + appendReasoningContentDelta, + removeMessagesByIds, +} from "./messages"; + +describe("assistant message helpers", () => { + test("removeMessagesByIds only removes matching temporary messages", () => { + const messages = [ + { id: "user-1", kind: "user", text: "hello", attachments: [] }, + { + id: "temp:thread:turn:reasoning", + kind: "reasoning", + summary: [], + content: ["thinking"], + }, + { + id: "tool-1", + kind: "mcpToolCall", + server: "demo", + tool: "search", + status: "completed", + args: null, + result: { ok: true }, + error: null, + durationMs: 12, + progress: [], + }, + { id: "assistant-1", kind: "assistant", text: "done" }, + ]; + + const filtered = removeMessagesByIds(messages, [ + "temp:thread:turn:reasoning", + "temp:thread:turn:mcpToolCall", + ]); + + expect(filtered).toHaveLength(3); + expect(filtered.map((msg) => msg.id)).toEqual([ + "user-1", + "tool-1", + "assistant-1", + ]); + }); + + test("streaming deltas update the same temporary placeholder in place", () => { + const first = appendReasoningContentDelta( + [], + "temp:thread:turn:reasoning", + 0, + "step 1", + ); + const second = appendReasoningContentDelta( + first, + "temp:thread:turn:reasoning", + 0, + " + step 2", + ); + const third = appendMcpToolProgress( + second, + "temp:thread:turn:mcpToolCall", + "fetching", + ); + const fourth = appendMcpToolProgress( + third, + "temp:thread:turn:mcpToolCall", + "done", + ); + + expect(second).toHaveLength(1); + expect(second[0]).toMatchObject({ + id: "temp:thread:turn:reasoning", + kind: "reasoning", + content: ["step 1 + step 2"], + }); + expect(fourth).toHaveLength(2); + expect(fourth[1]).toMatchObject({ + id: "temp:thread:turn:mcpToolCall", + kind: "mcpToolCall", + progress: ["fetching", "done"], + }); + }); +}); diff --git a/kagent-ui/lib/assistant/messages.ts b/kagent-ui/lib/assistant/messages.ts index 637fd85..45c509a 100644 --- a/kagent-ui/lib/assistant/messages.ts +++ b/kagent-ui/lib/assistant/messages.ts @@ -84,6 +84,15 @@ export function upsertMessage(list: UiMessage[], msg: UiMessage): UiMessage[] { return next; } +export function removeMessagesByIds( + list: UiMessage[], + ids: Iterable, +): UiMessage[] { + const idSet = new Set(ids); + if (idSet.size === 0) return list; + return list.filter((msg) => !idSet.has(msg.id)); +} + export function appendAgentDelta(list: UiMessage[], itemId: string, delta: string): UiMessage[] { return updateMessage( list, From 2c29fcb1214e6701a2db88e7ae0b0a82ead04ca7 Mon Sep 17 00:00:00 2001 From: zhubby Date: Thu, 16 Apr 2026 22:11:54 +0800 Subject: [PATCH 2/3] fix(ui): merge chunked assistant chat messages Coalesce adjacent assistant chunks within the same turn so streaming updates and resumed history render as a single reply instead of duplicated or vertically fragmented chat bubbles. Made-with: Cursor --- kagent-ui/CHANGELOG.md | 4 + kagent-ui/components/assistant-ui/thread.tsx | 25 ++- kagent-ui/hooks/use-codex-session.ts | 9 +- kagent-ui/lib/assistant/messages.test.ts | 209 +++++++++++++++++++ kagent-ui/lib/assistant/messages.ts | 151 +++++++++++--- 5 files changed, 367 insertions(+), 31 deletions(-) create mode 100644 kagent-ui/lib/assistant/messages.test.ts diff --git a/kagent-ui/CHANGELOG.md b/kagent-ui/CHANGELOG.md index 9c24462..933dc29 100644 --- a/kagent-ui/CHANGELOG.md +++ b/kagent-ui/CHANGELOG.md @@ -2,6 +2,10 @@ ## 2026-04-16 +- 修复聊天界面 assistant 回复重复显示: + - 为 assistant UI 消息补充 `turnId` 元信息,并在前端状态层归并同一 turn 中相邻的 assistant 气泡,避免 `item/started`、`item/agentMessage/delta`、`item/completed` 之间的消息 ID 不一致时留下重复回复。 + - `thread/resume` 历史恢复沿用同样的相邻 assistant 归并规则,避免切换或重开线程后再次出现重复欢迎语。 + - 新增 `messages.test.ts` 回归测试,覆盖相邻 assistant 合并、chunked assistant 片段重组、live delta 归并与历史恢复路径,并确保中间夹有其他 item 时不会误合并。 - 修复聊天界面 SSE 增量消息累积问题: - `use-codex-session` 改为为 `reasoning` 与 `mcpToolCall/progress` 使用按 turn 稳定的临时消息 ID,流式增量改为原地更新,不再为每个增量块堆出新的消息条目。 - `turn/completed` 与 `error` 事件现在会清理当前 turn 的临时思考/工具进度卡片,避免对话结束后残留重复占位消息。 diff --git a/kagent-ui/components/assistant-ui/thread.tsx b/kagent-ui/components/assistant-ui/thread.tsx index cfc1fd9..9304297 100644 --- a/kagent-ui/components/assistant-ui/thread.tsx +++ b/kagent-ui/components/assistant-ui/thread.tsx @@ -14,7 +14,11 @@ import { import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { Button } from "@/components/ui/button"; -import { getProviderTypeMeta, PROVIDER_TYPE_ICON_MAP } from "@/lib/model-provider-type"; +import type { FileUpdateChange } from "@/lib/codex-app-server/v2/FileUpdateChange"; +import { + getProviderTypeMeta, + PROVIDER_TYPE_ICON_MAP, +} from "@/lib/model-provider-type"; import { Select, SelectContent, @@ -28,7 +32,7 @@ export type UserAttachment = { type: "image" | "localImage"; src: string }; export type UiMessage = | { id: string; kind: "user"; text: string; attachments: UserAttachment[] } - | { id: string; kind: "assistant"; text: string } + | { id: string; kind: "assistant"; text: string; turnId?: string } | { id: string; kind: "turnCompleted"; turnId: string; text: string } | { id: string; kind: "reasoning"; summary: string[]; content: string[] } | { @@ -45,7 +49,7 @@ export type UiMessage = id: string; kind: "fileChange"; status: string; - changes: Array<{ path: string; kind: string; diff: string }>; + changes: Array; output: string | null; } | { @@ -469,7 +473,7 @@ function renderAssistantPayload(msg: Exclude) { className="rounded-md border bg-background p-2" >
- {change.path} · {change.kind} + {change.path} · {formatFileChangeKind(change.kind)}
{change.diff ? (
@@ -646,6 +650,19 @@ function formatDuration(ms: number) {
   return `${minutes}m ${rest.toFixed(0)}s`;
 }
 
+function formatFileChangeKind(kind: FileUpdateChange["kind"]) {
+  switch (kind.type) {
+    case "add":
+      return "add";
+    case "delete":
+      return "delete";
+    case "update":
+      return kind.move_path ? `update -> ${kind.move_path}` : "update";
+  }
+  const exhaustiveCheck: never = kind;
+  return exhaustiveCheck;
+}
+
 const markdownComponents: Components = {
   h1: ({ children }) => (
     

{children}

diff --git a/kagent-ui/hooks/use-codex-session.ts b/kagent-ui/hooks/use-codex-session.ts index e5dd252..348a313 100644 --- a/kagent-ui/hooks/use-codex-session.ts +++ b/kagent-ui/hooks/use-codex-session.ts @@ -129,7 +129,10 @@ function normalizeAndSortSessions(list: CodexThread[]): CodexThread[] { /** * 合并会话列表(以 incoming 为主,补充 local 中暂未同步到服务端的会话) */ -function mergeSessions(incoming: CodexThread[], local: CodexThread[]): CodexThread[] { +function mergeSessions( + incoming: CodexThread[], + local: CodexThread[], +): CodexThread[] { const map = new Map(); for (const item of incoming) { map.set(item.id, item); @@ -517,6 +520,7 @@ export function useCodexSession({ : scopedId; const ui = threadItemToUiMessage( overrideThreadItemId(p.item, messageId), + p.turnId, ); if (!ui) return; if (ui.kind === "user") return; @@ -543,6 +547,7 @@ export function useCodexSession({ prev[p.threadId] ?? [], itemId, p.delta, + p.turnId, ), })); return; @@ -717,7 +722,7 @@ export function useCodexSession({ if (!p?.threadId || !p?.turnId || !p?.item) return; const scopedId = buildScopedItemId(p.turnId, p.item.id); const item = overrideThreadItemId(p.item, scopedId); - const ui = threadItemToUiMessage(item); + const ui = threadItemToUiMessage(item, p.turnId); if (!ui) return; if (ui.kind === "user") return; if (p.item.type === "reasoning") return; diff --git a/kagent-ui/lib/assistant/messages.test.ts b/kagent-ui/lib/assistant/messages.test.ts new file mode 100644 index 0000000..72f6bd0 --- /dev/null +++ b/kagent-ui/lib/assistant/messages.test.ts @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import type { Thread } from "@/lib/codex-app-server/v2/Thread"; +import type { UiMessage } from "@/components/assistant-ui/thread"; +import { + appendAgentDelta, + extractMessagesFromThread, + upsertMessage, +} from "@/lib/assistant/messages"; + +describe("assistant message merging", () => { + test("merges adjacent assistant messages from the same turn", () => { + const started = upsertMessage([], { + id: "turn-1:item-1", + kind: "assistant", + text: "你好!很高兴见到你。", + turnId: "turn-1", + } as UiMessage); + + const completed = upsertMessage(started, { + id: "turn-1:item-2", + kind: "assistant", + text: "你好!很高兴见到你。", + turnId: "turn-1", + } as UiMessage); + + assert.deepStrictEqual(completed, [ + { + id: "turn-1:item-1", + kind: "assistant", + text: "你好!很高兴见到你。", + turnId: "turn-1", + }, + ]); + }); + + test("merges a live assistant delta into the adjacent assistant bubble", () => { + const started = upsertMessage([], { + id: "turn-1:item-1", + kind: "assistant", + text: "", + turnId: "turn-1", + } as UiMessage); + + const streamed = appendAgentDelta( + started, + "turn-1:item-2", + "你好!很高兴见到你。", + "turn-1", + ); + + assert.deepStrictEqual(streamed, [ + { + id: "turn-1:item-1", + kind: "assistant", + text: "你好!很高兴见到你。", + turnId: "turn-1", + }, + ]); + }); + + test("rebuilds history without reintroducing adjacent assistant duplicates", () => { + const thread = { + id: "thread-1", + preview: "", + modelProvider: "", + createdAt: Date.now(), + path: "", + cwd: "", + cliVersion: "", + source: "unknown", + gitInfo: null, + turns: [ + { + id: "turn-1", + status: "completed", + error: null, + items: [ + { + type: "agentMessage", + id: "item-1", + text: "你好!很高兴见到你。", + }, + { + type: "agentMessage", + id: "item-2", + text: "你好!很高兴见到你。", + }, + ], + }, + { + id: "turn-2", + status: "completed", + error: null, + items: [ + { + type: "agentMessage", + id: "item-1", + text: "第二轮回复", + }, + ], + }, + ], + } as Thread; + + assert.deepStrictEqual(extractMessagesFromThread(thread), [ + { + id: "turn-1:item-1", + kind: "assistant", + text: "你好!很高兴见到你。", + turnId: "turn-1", + }, + { + id: "turn-2:item-1", + kind: "assistant", + text: "第二轮回复", + turnId: "turn-2", + }, + ]); + }); + + test("merges chunked adjacent assistant segments from the same turn", () => { + const messages = upsertMessage( + [ + { + id: "turn-1:item-1", + kind: "assistant", + text: "你", + turnId: "turn-1", + } as UiMessage, + ], + { + id: "turn-1:item-2", + kind: "assistant", + text: "好!很高兴见到你。", + turnId: "turn-1", + } as UiMessage, + ); + + assert.deepStrictEqual(messages, [ + { + id: "turn-1:item-1", + kind: "assistant", + text: "你好!很高兴见到你。", + turnId: "turn-1", + }, + ]); + }); + + test("keeps assistant messages separated when another item is between them", () => { + const thread = { + id: "thread-2", + preview: "", + modelProvider: "", + createdAt: Date.now(), + path: "", + cwd: "", + cliVersion: "", + source: "unknown", + gitInfo: null, + turns: [ + { + id: "turn-1", + status: "completed", + error: null, + items: [ + { + type: "agentMessage", + id: "item-1", + text: "先说明一下接下来要做什么。", + }, + { + type: "reasoning", + id: "item-2", + summary: ["思考过程"], + content: [], + }, + { + type: "agentMessage", + id: "item-3", + text: "下面是最终答复。", + }, + ], + }, + ], + } as Thread; + + assert.deepStrictEqual(extractMessagesFromThread(thread), [ + { + id: "turn-1:item-1", + kind: "assistant", + text: "先说明一下接下来要做什么。", + turnId: "turn-1", + }, + { + id: "turn-1:item-2", + kind: "reasoning", + summary: ["思考过程"], + content: [], + }, + { + id: "turn-1:item-3", + kind: "assistant", + text: "下面是最终答复。", + turnId: "turn-1", + }, + ]); + }); +}); diff --git a/kagent-ui/lib/assistant/messages.ts b/kagent-ui/lib/assistant/messages.ts index 45c509a..8e3745d 100644 --- a/kagent-ui/lib/assistant/messages.ts +++ b/kagent-ui/lib/assistant/messages.ts @@ -3,7 +3,10 @@ import type { Thread as CodexThread } from "@/lib/codex-app-server/v2/Thread"; import type { ThreadItem } from "@/lib/codex-app-server/v2/ThreadItem"; import type { UserInput } from "@/lib/codex-app-server/v2/UserInput"; -import type { UiMessage, UserAttachment } from "@/components/assistant-ui/thread"; +import type { + UiMessage, + UserAttachment, +} from "@/components/assistant-ui/thread"; import { buildScopedItemId, overrideThreadItemId } from "@/lib/assistant/utils"; export function extractMessagesFromThread(thread: CodexThread): UiMessage[] { @@ -11,8 +14,11 @@ export function extractMessagesFromThread(thread: CodexThread): UiMessage[] { for (const turn of thread.turns ?? []) { const items = sortThreadItemsById(turn.items ?? []); for (const item of items) { - const scoped = overrideThreadItemId(item, buildScopedItemId(turn.id, item.id)); - const msg = threadItemToUiMessage(scoped); + const scoped = overrideThreadItemId( + item, + buildScopedItemId(turn.id, item.id), + ); + const msg = threadItemToUiMessage(scoped, turn.id); if (!msg) continue; appendMergedAssistantMessage(msgs, msg); } @@ -20,7 +26,10 @@ export function extractMessagesFromThread(thread: CodexThread): UiMessage[] { return msgs; } -export function threadItemToUiMessage(item: ThreadItem): UiMessage | null { +export function threadItemToUiMessage( + item: ThreadItem, + turnId?: string, +): UiMessage | null { switch (item.type) { case "userMessage": { const { text, attachments } = userInputToText(item.content); @@ -28,9 +37,14 @@ export function threadItemToUiMessage(item: ThreadItem): UiMessage | null { return { id: item.id, kind: "user", text, attachments }; } case "agentMessage": - return { id: item.id, kind: "assistant", text: item.text ?? "" }; + return { id: item.id, kind: "assistant", text: item.text ?? "", turnId }; case "reasoning": - return { id: item.id, kind: "reasoning", summary: item.summary ?? [], content: item.content ?? [] }; + return { + id: item.id, + kind: "reasoning", + summary: item.summary ?? [], + content: item.content ?? [], + }; case "commandExecution": return { id: item.id, @@ -78,10 +92,11 @@ export function threadItemToUiMessage(item: ThreadItem): UiMessage | null { export function upsertMessage(list: UiMessage[], msg: UiMessage): UiMessage[] { const idx = list.findIndex((m) => m.id === msg.id); - if (idx === -1) return [...list, msg]; - const next = list.slice(); - next[idx] = msg; - return next; + const next = idx === -1 ? [...list, msg] : list.slice(); + if (idx !== -1) { + next[idx] = msg; + } + return normalizeAdjacentAssistantMessages(next); } export function removeMessagesByIds( @@ -90,15 +105,31 @@ export function removeMessagesByIds( ): UiMessage[] { const idSet = new Set(ids); if (idSet.size === 0) return list; - return list.filter((msg) => !idSet.has(msg.id)); + return normalizeAdjacentAssistantMessages( + list.filter((msg) => !idSet.has(msg.id)), + ); } -export function appendAgentDelta(list: UiMessage[], itemId: string, delta: string): UiMessage[] { - return updateMessage( - list, - itemId, - () => ({ id: itemId, kind: "assistant", text: delta }), - (msg) => (msg.kind === "assistant" ? { ...msg, text: `${msg.text}${delta}` } : msg), +export function appendAgentDelta( + list: UiMessage[], + itemId: string, + delta: string, + turnId?: string, +): UiMessage[] { + return normalizeAdjacentAssistantMessages( + updateMessage( + list, + itemId, + () => ({ id: itemId, kind: "assistant", text: delta, turnId }), + (msg) => + msg.kind === "assistant" + ? { + ...msg, + text: `${msg.text}${delta}`, + turnId: msg.turnId ?? turnId, + } + : msg, + ), ); } @@ -142,7 +173,11 @@ export function appendReasoningContentDelta( ); } -export function appendCommandOutputDelta(list: UiMessage[], itemId: string, delta: string): UiMessage[] { +export function appendCommandOutputDelta( + list: UiMessage[], + itemId: string, + delta: string, +): UiMessage[] { return updateMessage( list, itemId, @@ -164,11 +199,21 @@ export function appendCommandOutputDelta(list: UiMessage[], itemId: string, delt ); } -export function appendFileChangeOutputDelta(list: UiMessage[], itemId: string, delta: string): UiMessage[] { +export function appendFileChangeOutputDelta( + list: UiMessage[], + itemId: string, + delta: string, +): UiMessage[] { return updateMessage( list, itemId, - () => ({ id: itemId, kind: "fileChange", status: "inProgress", changes: [], output: delta }), + () => ({ + id: itemId, + kind: "fileChange", + status: "inProgress", + changes: [], + output: delta, + }), (msg) => { if (msg.kind !== "fileChange") return msg; const output = `${msg.output ?? ""}${delta}`; @@ -177,7 +222,11 @@ export function appendFileChangeOutputDelta(list: UiMessage[], itemId: string, d ); } -export function appendMcpToolProgress(list: UiMessage[], itemId: string, message: string): UiMessage[] { +export function appendMcpToolProgress( + list: UiMessage[], + itemId: string, + message: string, +): UiMessage[] { return updateMessage( list, itemId, @@ -214,7 +263,10 @@ function updateMessage( return next; } -function userInputToText(content: Array): { text: string; attachments: UserAttachment[] } { +function userInputToText(content: Array): { + text: string; + attachments: UserAttachment[]; +} { const attachments: UserAttachment[] = []; const text = content .map((c) => { @@ -240,14 +292,63 @@ function appendMergedAssistantMessage(list: UiMessage[], msg: UiMessage): void { return; } const last = list[list.length - 1]; - if (last && last.kind === "assistant") { - // 恢复历史记录时将拆分的 assistant 消息按顺序拼接 - last.text = `${last.text}${msg.text}`; + if ( + last && + last.kind === "assistant" && + shouldCoalesceAssistantMessages(last, msg) + ) { + // 恢复历史记录时将同一 turn 中相邻的 assistant 消息归并成一个气泡 + list[list.length - 1] = mergeAssistantMessages(last, msg); return; } list.push(msg); } +function normalizeAdjacentAssistantMessages(list: UiMessage[]): UiMessage[] { + const normalized: UiMessage[] = []; + for (const msg of list) { + const last = normalized[normalized.length - 1]; + if ( + last && + last.kind === "assistant" && + msg.kind === "assistant" && + shouldCoalesceAssistantMessages(last, msg) + ) { + normalized[normalized.length - 1] = mergeAssistantMessages(last, msg); + continue; + } + normalized.push(msg); + } + return normalized; +} + +function mergeAssistantMessages( + current: Extract, + incoming: Extract, +): Extract { + return { + ...current, + text: mergeAssistantText(current.text, incoming.text), + turnId: current.turnId ?? incoming.turnId, + }; +} + +function shouldCoalesceAssistantMessages( + current: Extract, + incoming: Extract, +): boolean { + return !!current.turnId && current.turnId === incoming.turnId; +} + +function mergeAssistantText(current: string, incoming: string): string { + if (!incoming) return current; + if (!current) return incoming; + if (current === incoming) return current; + if (incoming.startsWith(current)) return incoming; + if (current.startsWith(incoming)) return current; + return `${current}${incoming}`; +} + function sortThreadItemsById(items: Array): Array { if (items.length <= 1) return items; const parsed = items.map((item, idx) => { From 6a549bea7a24d3671bdc37d959a50bc51d1a20da Mon Sep 17 00:00:00 2001 From: zhubby Date: Thu, 16 Apr 2026 22:24:43 +0800 Subject: [PATCH 3/3] chore(ui): update bun lockfile Record the remaining frontend lockfile changes so the branch is fully synchronized before review and merge. Made-with: Cursor --- kagent-ui/bun.lockb | Bin 480473 -> 489465 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/kagent-ui/bun.lockb b/kagent-ui/bun.lockb index 2814e3be440f38a30c69daba9eda9adb7f99edd1..4fa654cd4dc34127fbc12a187bbd8813a972ec62 100755 GIT binary patch delta 16098 zcmZuY33ye-)w#({NJ!WO5fLN^;zUt-ElN;OY&3*@mmQ;^PEdhowW1&dTZ^sM0=F<$ zTa8QW#8%}gm{MECwbos${%UEBB08}yJe4Z0|5;}4o%<@E_vM|LGiT0z&dhzj*4lsn zNBuV(8I){1YEW_0B^##>DPFoVw^G(eTkFfC_WC|SQIxN*3ya`iw4$Lll4Gk%5h#u_ z<3>b!Y!y#!eP(3TF}5-qK5;0nRMiuLim0X`9spA1k!JZYyUa>=P_f_f?u zoSJ4rOk-uK>H%P@>E^h{#OCHF<(F)7_|^PLv4F(`RNzJU4^p2+qf&awdx! zM)~QrQR~b~@|=J?$IP^zvv*f*BxZTGle0(~Rb2tBwa&68Z`=-EF>6F@9C5m@MtQnN zK>L7kKw~pFQ+DOm$)nVq70q2jkPQ8HX6i2z$V21^0ZFD}fz-p~}VV_i>$b}@r!nA*z zg`SedB99vsVG&QN#vh>B7CCiY0%lue867qeOnnBD&YZ#830Pnn%h|CKl{q1RkZfdk z0p|f$Wh2X1sxqTtvbB+9Xk-C5oyqc9)H4~ioMhA%>cSFR2=>J=KC8+ltO%{xbXH2Nq1-0MW5Cu>okq#&c;~G(`V)!{GO%H z9DU~LGjlGDpUb*Gj1+6mwHuIeqz6bJ&^};%Ks5Pld_env@d2?kUNbUkTFTmEe)Lk- zyX7v1a$kc>oabZ8^L$L5f=mh$=lhs41?d!IQjoYHwJrte6l5&KJ|~v(I)oR+P!MI;fyXcJu7=JoDUVj}$V9EnE6JxQ;K8Q`BtEsA_hLU4 zD;Q$`mKG|BvM)heS%Kb@Tfx?cKpGf82xQ5nvOL%)%I^ZiEE8eK#8BpJz`xMVAxU{* zRkY&5VUU!=sPRn*`wNo*G8VIvL+ytt#u5ngT?|pGw2)1e7sZ|ey~jnC2=lHj2%-ZO z<0g5(i>>xr=2XNjg1p!!SO z)gEEJI$cDJKPfJUEIW?!oNDiZHTd#wsVb!t1WvD)Bb)il>ypk$u5o*3{sC2EOlE4+=b;EkXo0HWf@P#Rn#jK%iH{;W=*eYH`?kJ+kr z5rLjd{@m@2`C3~j;N)a{OmTHp1x5{pj>?=K97PT*zk66;?U=(fWv;ecQloqgPjUE5 z3xR-`->>l^%Un|xU9`?N345SH{e{ygCA42KnS-PJ?O;zkcQE;yv2&&DdDl7_Nm18= zx7rTuS})ZnND$%Tm#HNwNdK}R;8U|=gB5iv9yB*F8T0|*aYH)x%?3<4`3;nERP}L? z9%?pXbZ0hFC~fp2dt)E&=QkxsJGm)2G3iab5mak%{EkgXCBG>NMH%rt>s(uiUDHGu zzWInV02d0kL4`Bf~sY8i(7Zf29%oWD617j&pMGk?`DL6czdU`N#M7RKvJ zJIF2G4th(x1Lwmn#M~BUt_#JBRQ7DqtP*V)MTm+KIdRDAl9bc96iWMbY;Wv$JM4E# zV82fA=!_4r&Dz9-{&k{h^K0gvB^KBF63gq;nO0x#33GixMY(}j_T(gU19G3efzm5| z=5O$<_Z#lF=oHub8y5u>+Q0F(z~uhhgjM<5*oeR7&3cf+jfW#{Os|(WI)O5efXCkG zoi$A|1&K-A#E9j5w6Rb~_SF{Rl5Q*noxm56lv^rOoxf~J7}hPsa0}NRd;fB?FRr{f zZ7F@TV;9qxu}~R%*)7(u=vZ_MhD7!jQt&NxNnSyn-O5BDTVQd&HPJ}Rtz+WDy1BI~ z#kjaFVFmlfrrSJS>DwG%WTe|HCwKu+^E-Fsg&Zz_$9SDci~4tv@YW`$A_K5rGR5!H zekp(N>F)Ol+vvjvaO1Vt!?w4x9=XSsw>$nai@?lwj-%7uo=~dRy;7=@+M^1)Xr18F zt_Aa=)w!T-1=(IoWYYlKi#87QR?k{BcTgIO|c+x`Ulqa-> zI2o8fAS?Mlq|)3Uy$XZk0PF|8!TE-SX>n&P&Yiq3yPCe!OJRnP@^|{((_KE7`Yt|P zSK21DCpDps6Cf$w#YP&$4t%#qRo=}xfX-a4cUMMR@8&ZLsr~M>9AYb0mdsYFEL^6y zdbIRbW(V5qH=tT<AO?|*s0a`7OV!?&Tp=UYSVge!TNiddKZcwv5lcpYL(o%{@Z%Yd# zw#SIQ=%%VScOu)9*fQJ6b+)tXbmyvdpRdsSc2yT^-Ku6wn`@g+o3I12o~2i z+brP4g|SnI5Pk6{M(oG=)k09ep<&b(Dpt8txJc^w6C04PZp4m+ak&E-&+hPC(dw!K@!B$VCq@ZKzn- zo4{GSp1pu6sTur_tR6FYH}%uaiY*}T*1Sav!kd<=f_P`Wpi54lN9OAj$W zoV(3Ki45XlBL6TP$oQ1W{mR1$hH>AqfqsP2>LYc?k|&RT#FwxU?vgtmsVu+|k9s)v zEc>YQJ95fL(~e~xjdLlZ*F5GGNck9xR7K9NAM=QFIe07)K|Jp1LO#yNknZR$QSN>| zh3LoQ&7$_lIgYziXC?lY5R%?^?R_`yJG`~2d4h#1j`APDAufjKF!qG=3##f*q`gM` zop+*kURyUyP=SkA%-Fv)sZ~}gTGl#p) zp<8N26|(6C>i1r7*l_$ZFE~|+7x7!>MN;XDvDoZ*FBWk055oNi;nF9WhDP%QXI`Q? z`lPv+5(SHwy#y~`_Jq?fGgixU5N$8BPPnp&S6J+!eAsyf%9YoGXgeG78z>9egYN%1 zn;;+3LKT$xw1v1x7z^24`f7R)39#!!TVCxIeYz{D=mzK9p&TpzYrJO}pJQGlsZ`5I zEZ48GSawUEA|;F0Jx$2hy%V#3oyplXucPwwuT#SMr2r)2W#+OjK`4qSKOPhNdNY!$bTc1?7xvp zjz07BnR$bJV~=!ic@{Hv$~GLkk*CaVl5BVSy^h)KEfa4O;@e5~(lkOr^EU4W z4*5%HLibRi+T*I`o?6u7p5%Qc=2Gzvk|R%lCyrS@G`7A&IrJUgspS`7|96SvyJQgT zyxw)@!o+Ug_54AYw34JDof{&?sj}M=g#dqHCx!toE zsPEAR@3FISMKm;)e=kW+E$|ky=|60tfhyl0+q3_qn@jT_rxEeKr+4{2(SJX!KJz~F zz==eB;1*1QZ9bsn`2i`5KJy_wqy39ZL@oc7JcUFGn2=WEPdwaGf$tHk3HYlA3Lr;P7dnglO%hgpU6)<{a)C#x?6`vxKC@QZXFLf^b3zF>WNVD zU$ECy@Z0dFFa3I;zhs&=(_5%mWru-h*L=mOfyy5TVAEG=HS4e95LGRGq11F({0d5X z;_G-Cax?hz*G!G?HucwJm9OJ)151f-NThG-x?)>N_uTNeYcV9=A(Y}frMB(EynBPIZE29;r)Jm3)73atA|9$=9Ib@belW62M+8>0%KOah|eMNi(Vr=@cQ zdst6P2gss#FjC$jK}r7{ys}q;i|Af<75KzL@Clf=K7k^O108QfQvw)k7X4Kx^XTLT zz(hl$cfh|@P_?~-F@7VUbZ<+yEXrIAyY{A#&iBSVqcR8ixlieXQal&{y`bw;kg{YS z&x^GW7#|P^duZ}t-|9LA8Hd=o1p=%u@=2LR_O)Em+cTvd2uEL3eqXXszAp=ONB}Pv zlJlC-VV|4098kSHbMVb_b7)v)KaaYTB8Ase}kY5K-Llskp2evkOatvPHqpBjR961 zYAlr*fa>84U2PHJW(EayU{9LW047@)fG}5jDP$l=!oYMS42(C=z7O6tFb;pzr8vya zaxRmH5sB9I}n<5+-;EEjovUQG1#tU$t8d&`Kn|2 zCnXp||2t$-Lu|jA!vnm2b1UIjpQi?UBM90H-{Jaju>KDDyBF3N)BNS3;{3o zZWWZ_@%)e=bx2V12ww)(K01JXkMOxu?MDQ?JZ7o134ax3Iax5Y3+M=P!<+zK+?yac zCC;aA23Z>d!^M_NnJ{j-usT4Y<&97r!Dl{qr#gMG-Bqk{N0 zR>`B{C3!Hsb|C#)QW+1N0NV|<+rh2*^^j>j5Z4a1r1%zHb-3w%3ep~|KH9EkcI44X z{BlJ&nv9e?I^b&p3NEN!l~D`-2vdn;><-ZH?glS)pzRA|kdBF};~2Z&;Z)niv3@>0 z)>7)jK?q>#2U7fQ3XN(UCRa!?%!MobqF%{i>{r9A&A81lFq*0bB4c->n{_2Kyei`t zQUf?i;t}7AS5Q2rK@DJnYuqFpi`Iaezi-91!T) z@VViMjF@7ME09sb0R2`&HRI96I}ObNGIFQ zk->0Rbhw>8fn+{{*&WB`4eK5!7rSYJTc~c52S$;ea)K_J?9jo7@$iX)if&R zje5>emN#35M-|vXjAn*LC+cm4>NGmw{|!Oygx_GJF-ChS5B@&|)pQc2pX^Bi-%zNX zCk6J$%#BY$b~+imDz6&LlWCvq$q60Ii505j+4PK>i>GsnrB9acJ!(p@YSx4um*eJyIQ67j(AHgSk)E zrIrF@C#l_WAU8weR69#o5lT9c-h?WBDl!hyI8wcLYS0zceX31r?rk66ZMN(H#h+&F z%gIBZ#wt0@PY$BqZb!e!RkGf-oh$2uQAw+401Y>*54iSL{|pQ_7W~Y=*->(Ayn1XP z)&`wpGDQGEz(g521rhs2guzF^~Vq%K0Oh7y& zv`$PRn`9OpQ%p*)O@lfC;g_qbV^WHgn4Dgl26Y-VX;4f_uT6tG4VnbZ-Tq<}c@{fTz|9@{#VwyKAr^W6wEf|B*JIz{*yACm({3J6yHL7_u=SI0Wi5ZML z!|P1-4ChhsQ$l3`vSCO~|4a+gk5rX(AjCIzATv33WfG0L~Cw}7B{Z(80#no6Vc6gjEQ;QLCTFCKnF(+psOTY z;X3BIE2kANK@OR3d$B|5=ED1*aFm28a)v|sIv@?2G$VZiaZx-8+ zh3U0vP^UqY2E`(`R%M~ysisACC%UIr8m3duX&gTMfM*>!+xo$01n?BM4L+mf`2Tj+ zD=EVlZC%^8WLDT*vi5k=K~d3S7{_Mnj!C=rSQrao>;U+?)Zz_;@2vZxwMFOI!3+%cw2RNTW8Z_ZA$IHnOYr+J_LQxB z%Prn;7<<}|t*~Rq!PwvISk{gm4`Ywmu?uOeXHn4z7<}0d{tyS%zPaINYp=EYu7e@? zujn**CfKnnX>CzaJ&bLDdr4H#Pwm)P_#SqG5BRsSliPJ!aoKTp98D=;rT+XTNKuHxd(*a2G4eQ?rVEtQ8*wd`v%q> zUefmA8R6qW-xEPx^bhQ#M!F}Ut8{k20=761h;JFK)yW7T{8`cHEF!eJT4r<#e zLmiaO1*8R~ZRacvzZa;M^TOuh#!_|Y`Qf!{>-pi}uIIqCgr^CfrKRe{^TU%=^#x(C zzURTfGI%b4=Mo_PEGVZhJco2@n|wjoHK>>Y6DPqlMxD7V98h))d_A^QtyvcKRxQiI z;Rl=!1M}cn2+tyTCIR(j;bE$Lc{r+b^Cpl9_4_dD_nWF$z@j*EFoaOsoE71mic+-k zcJ=0EVNdnMrD1m;-g{{%%V{2PRO4mgoCMl=SvWX>U~Z`Dv&tVjbyYYtL0GxUTio{Q zs<3-m`AVSLf{g547d~AgkgrMbeCz$gXhdFd?k)Sb?d>bWh9DdSLu%OiuuoVG-`Xaw z4_5{u;((D|N4B{w&a!4+k%4{zT|;v wmvBsV9(8f;N!1zEcvIMC{fJjf+eW-vcEIZL#goq&`=jYUoUBfnTXNn11IIXo3IG5A delta 15796 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�~9c>Xb|K#)X;eY&;THC0qKP01040C z6*u{!L=dhK_D4r_|eHHug?1(G4YL}`k5#L+3>m%JhtB6EbGaxzr~ zkfiooIK0E5S?EKqL!dOdy1G*gSRjo)(iLi`y$fj`4@8$~LPU2}Q)KZ&6n2FM1M;?- zdL)npY5&s91yR~3cDCDVHzlXC7lEX8HgIHjD8<=+iVVPJIEXR;xyZ>3D8{^PiB70X zrAy|t&w$3E*2qmA2-9?Nsei~jk2lq2Lmr(1f?exni# zn%ujoTwW#phl89n=(l&hDdyirm&M!!alzsGY?*FYLv@hpx|p@D<8V5M>1@sR*=ZPr zT!3vpJPlrtPgAxY0qLg#?HDczj2x8-i$@?b2agz+MeC?{^EOl^v;AzTIzu!2wD&J zMUDE9Y|@W*(GJnI(-8r5N9BCJAuw;wmB;9wa7A&{sR9${V(b~H(tEG39Kz21; z$%wta0R2$9lArX5^cP@~+!s)ExpiZMaQ@j?=qYORvs;E6X$7s5TUz^C!BQ(YdNP@< z7uDn`O`hmu7kOYMD!_R{%6}?!M>pb*xEbJA=(9x)ze)CKHd0^k1oqZuH1b+GaOOBc>MbZzwR@H;vMJEhtV*Z11jI8bSf)9u z)Zo^6-a)0YMDILMwXj~>fr`7^R=0lYDe8d3OTv49iGAX`e5Ig5%v7-1$EC6NS z0u?P)f<_6Vx--2HP;TM4+P$$*@#m;6ewNBW`Ln#N%ltb6l*^}e_BevRvz1*-Xp=_d zozN(OBr-2rv2$9aAfa=FYEhl2042xZ1mW)kW3=Z>^(j}Sn?iACAHiVdL>4wq(N0ntHTklWK$PF95<>4 znji~X2k6>4dZE$;cg+HH+?NjFxg?U|x%DD%$0Q(WKwjF51iA}ijX?4V4(!Oq7}mKV zBUr6ogdK6DlgKg?eDpLS263 zF0z;EC$|=G4N&V5XL*k=1Lz*K)3aO)Mo&^qp5L=q^1I8PRdz?p}(E}brDFEz)@KGH%g)nTpl_Lr!>@u(6|(nC0kMpvp#6Kq{DH1M6Lly1RV95YcM7iVrAgb2fK!AkWk#EBC!SocDC?e z0Lpd8>KceUx&}$!a>6#9gKBs`E44b<1~)t~c4A`2T0PjL$8ONf>hd!Lnaky_SdKEX zwslnN+E%!)4KB68qit~PI=4R#sKnaqTrOfgfzVaD)~o!^F$7l-fpuO=>=M^GBhe|wkhu&59jZpYjy2B zrM@Pq95uYb!;HkF2}8#LGDbvcgYH~8i3Zx_x-SoIZ=JqT$1`DQ`o@l9|1o*sEOHS| zD=isXoip+s>kp+00e=?(Pd%M>mK-0qB9uU)Q?T|mA@VUNEn6z(Ye{(-`MQQ?jT?iGc*QsMArk@jtc zTcL2r0QaWCWfcye&1uIfW>!iZcF*{anA-6QxeCY$!gsy#&-F#DD5Q)Hs_!ZIEl-*7 z-l%Y=;`c<_!r<>l>WX&{o{wP~QpU{+m&EVW$z1rmRrN)nXrpS{g2J5!-0h5;qHO{a z|7ee^oh~-rU@Xv27Vq9*Ow~^jzV*h`J!tl=swckk*H(d(Q^lZxe z+HV{<^v-(YQ9bZ4h%|<0r`Wu~n7zx>(9RA#PvUuM=)Dcb0s4^tM&mpBL~@GX7~=h# zjMMZ_#Su3fYXS!WbugYo#A7!b-xSrGj82ih#mMW24841cac;+FV`w}cvz~xwCZ6q> z=t(?}<9PzlSBI8=$(XH+@@+dOcj5-!{`>jy~D6XtY~!Zy#~X+4x#tPvk#uj(d|k+U-S(vEgDmF z-;n@5FRtEf9I*c*VCexoFXH(Qo`dl$K=Vm>5}0bUajY2I4A#yDVlkfc@GQkM*C+Pu zH%^+ICC4I_-b*U|)ci002t)6ojBWJc#%zZX7PYT|J^HovZX@HTR{9g!yNx~_TXpCZhLvQ48-ernv-#dpwqoix%hjNb_pG@EZa@&n}IwI6E z7gVH&ec?r@Vzh~vn3$lpq18_tb#rp=URK@Ww{BhfvN5#uWuJN6SZKL#ucC t?$