f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
835 lines
24 KiB
TypeScript
835 lines
24 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025-2026 NomiFun (nomifun.com)
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
|
*/
|
|
|
|
import type { AcpPermissionRequest, PlanUpdate, ToolCallUpdate } from '@/common/types/platform/acpTypes';
|
|
import type { IResponseMessage } from '../adapter/ipcBridge';
|
|
import { uuid } from '../utils';
|
|
|
|
/**
|
|
* 安全的路径拼接函数,兼容Windows和Mac
|
|
* @param basePath 基础路径
|
|
* @param relativePath 相对路径
|
|
* @returns 拼接后的绝对路径
|
|
*/
|
|
export const joinPath = (basePath: string, relativePath: string): string => {
|
|
// 标准化路径分隔符为 /
|
|
const normalizePath = (path: string) => path.replace(/\\/g, '/');
|
|
|
|
const base = normalizePath(basePath);
|
|
const relative = normalizePath(relativePath);
|
|
|
|
// 去掉base路径末尾的斜杠
|
|
const cleanBase = base.replace(/\/+$/, '');
|
|
|
|
// 处理相对路径中的 ./ 和 ../
|
|
const parts = relative.split('/');
|
|
const resultParts = [];
|
|
|
|
for (const part of parts) {
|
|
if (part === '.' || part === '') {
|
|
continue; // 跳过 . 和空字符串
|
|
} else if (part === '..') {
|
|
// 处理上级目录
|
|
if (resultParts.length > 0) {
|
|
resultParts.pop(); // 移除最后一个部分
|
|
}
|
|
} else {
|
|
resultParts.push(part);
|
|
}
|
|
}
|
|
|
|
// 拼接路径
|
|
const result = cleanBase + '/' + resultParts.join('/');
|
|
|
|
// 确保路径格式正确
|
|
return result.replace(/\/+/g, '/'); // 将多个连续的斜杠替换为单个
|
|
};
|
|
|
|
/**
|
|
* @description 跟对话相关的消息类型申明 及相关处理
|
|
*/
|
|
|
|
type TMessageType =
|
|
| 'text'
|
|
| 'tips'
|
|
| 'tool_call'
|
|
| 'tool_group'
|
|
| 'agent_status'
|
|
| 'permission'
|
|
| 'acp_permission'
|
|
| 'acp_tool_call'
|
|
| 'plan'
|
|
| 'thinking'
|
|
| 'available_commands';
|
|
|
|
interface IMessage<T extends TMessageType, Content extends Record<string, any>> {
|
|
/**
|
|
* 唯一ID — frontend-local message key (uuid), NOT a backend entity id.
|
|
*/
|
|
id: string;
|
|
/**
|
|
* 消息来源ID,— backend messages.id, stays TEXT (`msg_…`).
|
|
*/
|
|
msg_id?: string;
|
|
|
|
/** 消息会话ID — conversation primary key, INTEGER (numeric-id spec). */
|
|
conversation_id: number;
|
|
/**
|
|
* 消息类型
|
|
*/
|
|
type: T;
|
|
/**
|
|
* 消息内容
|
|
*/
|
|
content: Content;
|
|
/**
|
|
* 消息创建时间
|
|
*/
|
|
created_at?: number;
|
|
/**
|
|
* 消息位置
|
|
*/
|
|
position?: 'left' | 'right' | 'center' | 'pop';
|
|
/**
|
|
* 消息状态
|
|
*/
|
|
status?: 'finish' | 'pending' | 'error' | 'work';
|
|
/**
|
|
* Hidden from UI display but persisted to DB and sent to agent.
|
|
*/
|
|
hidden?: boolean;
|
|
}
|
|
|
|
export type CronMessageMeta = {
|
|
source: 'cron';
|
|
cron_job_id: string;
|
|
cron_job_name: string;
|
|
triggered_at: number;
|
|
};
|
|
|
|
export type IMessageText = IMessage<
|
|
'text',
|
|
{
|
|
content: string;
|
|
/** Backend explicitly replaced the accumulated text for this msg_id. */
|
|
replace?: boolean;
|
|
cronMeta?: CronMessageMeta;
|
|
teammateMessage?: boolean;
|
|
senderName?: string;
|
|
senderAgentType?: string;
|
|
/** Sender teammate's conversation id — lets the renderer resolve preset avatars via their conversation extras. */
|
|
senderConversationId?: number;
|
|
}
|
|
>;
|
|
|
|
export type AgentErrorOwnership = 'nomifun' | 'user_agent' | 'user_llm_provider' | 'unknown_upstream';
|
|
|
|
export type AgentErrorResolutionKind =
|
|
| 'retry'
|
|
| 'wait_for_current_response'
|
|
| 'start_new_session'
|
|
| 'reconnect_agent'
|
|
| 'check_agent_login'
|
|
| 'check_agent_installation'
|
|
| 'check_agent_version'
|
|
| 'check_local_command'
|
|
| 'check_provider_credentials'
|
|
| 'check_provider_billing'
|
|
| 'check_provider_base_url'
|
|
| 'change_model'
|
|
| 'reduce_context'
|
|
| 'send_feedback';
|
|
|
|
export type AgentErrorResolutionTarget = 'provider_settings' | 'agent_settings' | 'new_conversation' | 'feedback';
|
|
|
|
export type AgentErrorResolution = {
|
|
kind: AgentErrorResolutionKind;
|
|
target?: AgentErrorResolutionTarget;
|
|
};
|
|
|
|
export type AgentStreamErrorInfo = {
|
|
message: string;
|
|
code?: string;
|
|
ownership?: AgentErrorOwnership;
|
|
detail?: string;
|
|
workspacePath?: string;
|
|
retryable?: boolean;
|
|
feedback_recommended?: boolean;
|
|
resolution?: AgentErrorResolution;
|
|
};
|
|
|
|
export type IMessageTips = IMessage<
|
|
'tips',
|
|
{
|
|
content: string;
|
|
type: 'error' | 'success' | 'warning';
|
|
error?: AgentStreamErrorInfo;
|
|
}
|
|
>;
|
|
|
|
export type IMessageToolCall = IMessage<
|
|
'tool_call',
|
|
{
|
|
call_id: string;
|
|
name: string;
|
|
args: Record<string, any>;
|
|
error?: string;
|
|
status?: 'running' | 'completed' | 'error';
|
|
input?: Record<string, any>;
|
|
output?: string;
|
|
description?: string;
|
|
}
|
|
>;
|
|
|
|
type IMessageToolGroupConfirmationDetailsBase<Type, Extra extends Record<string, any>> = {
|
|
type: Type;
|
|
title: string;
|
|
} & Extra;
|
|
|
|
export type IMessageToolGroup = IMessage<
|
|
'tool_group',
|
|
Array<{
|
|
call_id: string;
|
|
description: string;
|
|
name: string;
|
|
render_output_as_markdown: boolean;
|
|
result_display?:
|
|
| string
|
|
| {
|
|
file_diff: string;
|
|
file_name: string;
|
|
}
|
|
| {
|
|
img_url: string;
|
|
relative_path: string;
|
|
};
|
|
status: 'Executing' | 'Success' | 'Error' | 'Canceled' | 'Pending' | 'Confirming';
|
|
confirmationDetails?:
|
|
| IMessageToolGroupConfirmationDetailsBase<
|
|
'edit',
|
|
{
|
|
file_name: string;
|
|
file_diff: string;
|
|
isModifying?: boolean;
|
|
}
|
|
>
|
|
| IMessageToolGroupConfirmationDetailsBase<
|
|
'exec',
|
|
{
|
|
rootCommand: string;
|
|
command: string;
|
|
}
|
|
>
|
|
| IMessageToolGroupConfirmationDetailsBase<
|
|
'info',
|
|
{
|
|
urls?: string[];
|
|
prompt: string;
|
|
}
|
|
>
|
|
| IMessageToolGroupConfirmationDetailsBase<
|
|
'mcp',
|
|
{
|
|
tool_name: string;
|
|
tool_display_name: string;
|
|
server_name: string;
|
|
}
|
|
>;
|
|
}>
|
|
>;
|
|
|
|
// Unified agent status message type for all ACP-based agents (Claude, Qwen, Codex, etc.)
|
|
export type IMessageAgentStatus = IMessage<
|
|
'agent_status',
|
|
{
|
|
backend: string; // Agent identifier: 'claude', 'qwen', 'codex', 'remote', etc.
|
|
status: 'connecting' | 'connected' | 'authenticated' | 'session_active' | 'error';
|
|
/** Display name for the agent (e.g. extension-contributed adapter name) / Agent 显示名称 */
|
|
agent_name?: string;
|
|
// Optional legacy fields for backward compatibility
|
|
session_id?: string;
|
|
is_connected?: boolean;
|
|
has_active_session?: boolean;
|
|
}
|
|
>;
|
|
|
|
export type IMessageAcpPermission = IMessage<'acp_permission', AcpPermissionRequest>;
|
|
|
|
export type IMessagePermission = IMessage<'permission', IConfirmation>;
|
|
|
|
export type IMessageAcpToolCall = IMessage<'acp_tool_call', ToolCallUpdate>;
|
|
|
|
export const mergeAcpToolCallContent = (
|
|
existing: IMessageAcpToolCall['content'],
|
|
incoming: IMessageAcpToolCall['content']
|
|
): IMessageAcpToolCall['content'] => ({
|
|
...existing,
|
|
...incoming,
|
|
update: {
|
|
...existing.update,
|
|
...incoming.update,
|
|
},
|
|
});
|
|
|
|
type ResponseTextData = {
|
|
content: string;
|
|
replace?: boolean;
|
|
cronMeta?: CronMessageMeta;
|
|
teammate_message?: boolean;
|
|
sender_name?: string;
|
|
sender_backend?: string;
|
|
sender_conversation_id?: number;
|
|
};
|
|
|
|
const isResponseTextData = (data: unknown): data is ResponseTextData =>
|
|
typeof data === 'object' &&
|
|
data !== null &&
|
|
'content' in data &&
|
|
typeof (data as { content?: unknown }).content === 'string';
|
|
|
|
export const isTextContentReplacement = (content: IMessageText['content'] | undefined): boolean =>
|
|
content?.replace === true;
|
|
|
|
export const mergeTextMessageContent = (
|
|
existing: IMessageText['content'],
|
|
incoming: IMessageText['content']
|
|
): IMessageText['content'] => {
|
|
const { replace: _existingReplace, ...existingRest } = existing;
|
|
const { replace: incomingReplace, ...incomingRest } = incoming;
|
|
|
|
return {
|
|
...existingRest,
|
|
...incomingRest,
|
|
content: incomingReplace ? incoming.content : existing.content + incoming.content,
|
|
...(incomingReplace ? { replace: true } : {}),
|
|
};
|
|
};
|
|
|
|
export const preferTextMessageVersion = (primary: IMessageText, secondary: IMessageText): IMessageText => {
|
|
const primaryIsReplace = isTextContentReplacement(primary.content);
|
|
const secondaryIsReplace = isTextContentReplacement(secondary.content);
|
|
|
|
if (primaryIsReplace !== secondaryIsReplace) {
|
|
return primaryIsReplace ? primary : secondary;
|
|
}
|
|
|
|
return secondary.content.content.length > primary.content.content.length ? secondary : primary;
|
|
};
|
|
|
|
export type IMessagePlan = IMessage<
|
|
'plan',
|
|
{
|
|
session_id: string;
|
|
entries: PlanUpdate['update']['entries'];
|
|
}
|
|
>;
|
|
|
|
export type IMessageThinking = IMessage<
|
|
'thinking',
|
|
{
|
|
content: string;
|
|
subject?: string;
|
|
duration?: number;
|
|
status: 'thinking' | 'done';
|
|
}
|
|
>;
|
|
|
|
// Available commands from ACP agents (Claude, etc.)
|
|
export type AvailableCommand = {
|
|
name: string;
|
|
description: string;
|
|
hint?: string;
|
|
};
|
|
|
|
export type IMessageAvailableCommands = IMessage<
|
|
'available_commands',
|
|
{
|
|
commands: AvailableCommand[];
|
|
}
|
|
>;
|
|
|
|
// eslint-disable-next-line max-len
|
|
export type TMessage =
|
|
| IMessageText
|
|
| IMessageTips
|
|
| IMessageToolCall
|
|
| IMessageToolGroup
|
|
| IMessageAgentStatus
|
|
| IMessagePermission
|
|
| IMessageAcpPermission
|
|
| IMessageAcpToolCall
|
|
| IMessagePlan
|
|
| IMessageThinking
|
|
| IMessageAvailableCommands;
|
|
|
|
// 统一所有需要用户交互的用户类型
|
|
export interface IConfirmation<Option extends any = any> {
|
|
title?: string;
|
|
id: string;
|
|
action?: string;
|
|
description: string;
|
|
call_id: string;
|
|
options: Array<{
|
|
label: string;
|
|
value: Option;
|
|
params?: Record<string, string>; // Translation interpolation parameters
|
|
}>;
|
|
/**
|
|
* Command type for exec confirmations (e.g., 'curl', 'npm', 'git')
|
|
* Used for "always allow" permission memory
|
|
*/
|
|
command_type?: string;
|
|
}
|
|
|
|
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
|
|
const AGENT_ERROR_OWNERSHIPS = new Set<AgentErrorOwnership>([
|
|
'nomifun',
|
|
'user_agent',
|
|
'user_llm_provider',
|
|
'unknown_upstream',
|
|
]);
|
|
|
|
const AGENT_ERROR_RESOLUTION_KINDS = new Set<AgentErrorResolutionKind>([
|
|
'retry',
|
|
'wait_for_current_response',
|
|
'start_new_session',
|
|
'reconnect_agent',
|
|
'check_agent_login',
|
|
'check_agent_installation',
|
|
'check_agent_version',
|
|
'check_local_command',
|
|
'check_provider_credentials',
|
|
'check_provider_billing',
|
|
'check_provider_base_url',
|
|
'change_model',
|
|
'reduce_context',
|
|
'send_feedback',
|
|
]);
|
|
|
|
const AGENT_ERROR_RESOLUTION_TARGETS = new Set<AgentErrorResolutionTarget>([
|
|
'provider_settings',
|
|
'agent_settings',
|
|
'new_conversation',
|
|
'feedback',
|
|
]);
|
|
|
|
export const normalizeAgentErrorResolution = (value: unknown): AgentErrorResolution | undefined => {
|
|
if (!isObject(value) || typeof value.kind !== 'string') {
|
|
return undefined;
|
|
}
|
|
|
|
if (!AGENT_ERROR_RESOLUTION_KINDS.has(value.kind as AgentErrorResolutionKind)) {
|
|
return undefined;
|
|
}
|
|
|
|
const target =
|
|
typeof value.target === 'string' && AGENT_ERROR_RESOLUTION_TARGETS.has(value.target as AgentErrorResolutionTarget)
|
|
? (value.target as AgentErrorResolutionTarget)
|
|
: undefined;
|
|
|
|
return {
|
|
kind: value.kind as AgentErrorResolutionKind,
|
|
...(target ? { target } : {}),
|
|
};
|
|
};
|
|
|
|
export const normalizeAgentStreamError = (value: unknown): AgentStreamErrorInfo | undefined => {
|
|
if (!isObject(value) || typeof value.message !== 'string') {
|
|
return undefined;
|
|
}
|
|
|
|
const code = typeof value.code === 'string' ? value.code : undefined;
|
|
const ownership =
|
|
typeof value.ownership === 'string' && AGENT_ERROR_OWNERSHIPS.has(value.ownership as AgentErrorOwnership)
|
|
? (value.ownership as AgentErrorOwnership)
|
|
: undefined;
|
|
const detail = typeof value.detail === 'string' ? value.detail : undefined;
|
|
const workspacePath = typeof value.workspacePath === 'string' ? value.workspacePath : undefined;
|
|
const retryable = typeof value.retryable === 'boolean' ? value.retryable : undefined;
|
|
const feedback_recommended = typeof value.feedback_recommended === 'boolean' ? value.feedback_recommended : undefined;
|
|
const resolution = normalizeAgentErrorResolution(value.resolution);
|
|
|
|
if (
|
|
!code &&
|
|
!ownership &&
|
|
!detail &&
|
|
!workspacePath &&
|
|
retryable === undefined &&
|
|
feedback_recommended === undefined &&
|
|
!resolution
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
message: value.message,
|
|
...(code ? { code } : {}),
|
|
...(ownership ? { ownership } : {}),
|
|
...(detail ? { detail } : {}),
|
|
...(workspacePath ? { workspacePath } : {}),
|
|
...(retryable !== undefined ? { retryable } : {}),
|
|
...(feedback_recommended !== undefined ? { feedback_recommended } : {}),
|
|
...(resolution ? { resolution } : {}),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* @description 将后端返回的消息转换为前端消息
|
|
* */
|
|
export const transformMessage = (message: IResponseMessage): TMessage | undefined => {
|
|
const created_at = message.created_at ?? Date.now();
|
|
switch (message.type) {
|
|
case 'error': {
|
|
const errorData = message.data;
|
|
const structuredError = normalizeAgentStreamError(errorData);
|
|
const errorText =
|
|
typeof errorData === 'string'
|
|
? errorData
|
|
: ((errorData as { message?: string })?.message ?? JSON.stringify(errorData));
|
|
return {
|
|
id: uuid(),
|
|
type: 'tips',
|
|
msg_id: message.msg_id,
|
|
position: 'center',
|
|
conversation_id: message.conversation_id,
|
|
created_at,
|
|
content: {
|
|
content: errorText,
|
|
type: 'error',
|
|
...(structuredError ? { error: structuredError } : {}),
|
|
},
|
|
};
|
|
}
|
|
case 'tips': {
|
|
const data = message.data as {
|
|
content: string;
|
|
type?: 'error' | 'success' | 'warning';
|
|
error?: unknown;
|
|
};
|
|
const tipType = data.type ?? 'warning';
|
|
const structuredError =
|
|
tipType === 'error'
|
|
? (normalizeAgentStreamError(data.error) ?? normalizeAgentStreamError({ ...data, message: data.content }))
|
|
: undefined;
|
|
return {
|
|
id: uuid(),
|
|
type: 'tips',
|
|
msg_id: message.msg_id,
|
|
position: 'center',
|
|
conversation_id: message.conversation_id,
|
|
created_at,
|
|
content: {
|
|
content: data.content,
|
|
type: tipType,
|
|
...(structuredError ? { error: structuredError } : {}),
|
|
},
|
|
};
|
|
}
|
|
case 'text':
|
|
case 'content':
|
|
case 'user_content': {
|
|
const data = message.data;
|
|
const isRichData = isResponseTextData(data);
|
|
const shouldReplace = message.replace === true || (isRichData && data.replace === true);
|
|
return {
|
|
id: uuid(),
|
|
type: 'text',
|
|
msg_id: message.msg_id,
|
|
position: message.type === 'user_content' ? 'right' : 'left',
|
|
conversation_id: message.conversation_id,
|
|
created_at,
|
|
content: isRichData
|
|
? {
|
|
content: data.content,
|
|
cronMeta: data.cronMeta,
|
|
...(shouldReplace ? { replace: true } : {}),
|
|
...(data.teammate_message ? { teammateMessage: true } : {}),
|
|
...(data.sender_name ? { senderName: data.sender_name } : {}),
|
|
...(data.sender_backend ? { senderAgentType: data.sender_backend } : {}),
|
|
...(data.sender_conversation_id != null ? { senderConversationId: data.sender_conversation_id } : {}),
|
|
}
|
|
: {
|
|
content: data as string,
|
|
...(shouldReplace ? { replace: true } : {}),
|
|
},
|
|
...(message.hidden && { hidden: true }),
|
|
};
|
|
}
|
|
case 'tool_call': {
|
|
return {
|
|
id: uuid(),
|
|
type: 'tool_call',
|
|
msg_id: message.msg_id,
|
|
conversation_id: message.conversation_id,
|
|
position: 'left',
|
|
created_at,
|
|
content: message.data as any,
|
|
};
|
|
}
|
|
case 'tool_group': {
|
|
return {
|
|
type: 'tool_group',
|
|
id: uuid(),
|
|
msg_id: message.msg_id,
|
|
conversation_id: message.conversation_id,
|
|
created_at,
|
|
content: message.data as any,
|
|
};
|
|
}
|
|
case 'agent_status': {
|
|
return {
|
|
id: uuid(),
|
|
type: 'agent_status',
|
|
msg_id: message.msg_id,
|
|
position: 'center',
|
|
conversation_id: message.conversation_id,
|
|
created_at,
|
|
content: message.data as any,
|
|
};
|
|
}
|
|
case 'permission': {
|
|
return {
|
|
id: uuid(),
|
|
type: 'permission',
|
|
msg_id: message.msg_id,
|
|
position: 'left',
|
|
conversation_id: message.conversation_id,
|
|
created_at,
|
|
content: message.data as any,
|
|
};
|
|
}
|
|
case 'acp_permission': {
|
|
return {
|
|
id: uuid(),
|
|
type: 'acp_permission',
|
|
msg_id: message.msg_id,
|
|
position: 'left',
|
|
conversation_id: message.conversation_id,
|
|
created_at,
|
|
content: message.data as any,
|
|
};
|
|
}
|
|
case 'acp_tool_call': {
|
|
return {
|
|
id: uuid(),
|
|
type: 'acp_tool_call',
|
|
msg_id: message.msg_id,
|
|
position: 'left',
|
|
conversation_id: message.conversation_id,
|
|
created_at,
|
|
content: message.data as any,
|
|
};
|
|
}
|
|
case 'plan': {
|
|
return {
|
|
id: uuid(),
|
|
type: 'plan',
|
|
msg_id: message.msg_id,
|
|
position: 'left',
|
|
conversation_id: message.conversation_id,
|
|
created_at,
|
|
content: message.data as any,
|
|
};
|
|
}
|
|
case 'thinking': {
|
|
const data = message.data as {
|
|
content: string;
|
|
subject?: string;
|
|
duration?: number;
|
|
duration_ms?: number;
|
|
status: 'thinking' | 'done';
|
|
};
|
|
return {
|
|
id: uuid(),
|
|
type: 'thinking',
|
|
msg_id: message.msg_id,
|
|
position: 'left',
|
|
conversation_id: message.conversation_id,
|
|
created_at,
|
|
content: {
|
|
content: data.content,
|
|
subject: data.subject,
|
|
duration: data.duration ?? data.duration_ms,
|
|
status: data.status,
|
|
},
|
|
};
|
|
}
|
|
// Disabled: available_commands messages are too noisy and distracting in the chat UI
|
|
case 'available_commands':
|
|
return undefined;
|
|
case 'start':
|
|
case 'finish':
|
|
case 'thought':
|
|
case 'skill_suggest':
|
|
case 'cron_trigger':
|
|
case 'info': // Stream retry notifications and similar transient agent updates
|
|
case 'system': // Cron system responses, ignored
|
|
case 'acp_model_info': // Model info updates, handled by AcpModelSelector
|
|
case 'codex_model_info': // Legacy Codex model info updates
|
|
case 'acp_context_usage': // Context usage updates, handled by AcpSendBox
|
|
case 'request_trace': // Request trace events, logged to F12 console (not persisted)
|
|
return undefined;
|
|
default: {
|
|
console.warn(
|
|
`[transformMessage] Unsupported message type '${message.type}'. All non-standard message types should be pre-processed by respective AgentManagers.`
|
|
);
|
|
return undefined;
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @description 将消息合并到消息列表中
|
|
* */
|
|
export const composeMessage = (
|
|
message: TMessage | undefined,
|
|
list: TMessage[] | undefined,
|
|
messageHandler: (type: 'update' | 'insert', message: TMessage) => void = () => {}
|
|
): TMessage[] => {
|
|
if (!message) return list || [];
|
|
if (!list?.length) {
|
|
messageHandler('insert', message);
|
|
return [message];
|
|
}
|
|
const last = list[list.length - 1];
|
|
|
|
const updateMessage = (index: number, message: TMessage, change = true) => {
|
|
message.id = list[index].id;
|
|
list[index] = message;
|
|
if (change) messageHandler('update', message);
|
|
return list.slice();
|
|
};
|
|
const pushMessage = (message: TMessage) => {
|
|
list.push(message);
|
|
messageHandler('insert', message);
|
|
return list.slice();
|
|
};
|
|
|
|
if (message.type === 'tool_group') {
|
|
const remainingToolsMap = new Map(message.content.map((t) => [t.call_id, t] as const));
|
|
if (remainingToolsMap.size === 0) return list;
|
|
|
|
const updatesToReport: TMessage[] = [];
|
|
|
|
const updatedList = list.map((existingMessage) => {
|
|
if (existingMessage.type !== 'tool_group') return existingMessage;
|
|
if (!existingMessage.content.length) return existingMessage;
|
|
|
|
let didMergeIntoThisMessage = false;
|
|
const new_content = existingMessage.content.map((tool) => {
|
|
const newToolData = remainingToolsMap.get(tool.call_id);
|
|
if (!newToolData) return tool;
|
|
didMergeIntoThisMessage = true;
|
|
remainingToolsMap.delete(tool.call_id);
|
|
// Create new object instead of mutating original
|
|
return { ...tool, ...newToolData };
|
|
});
|
|
|
|
if (!didMergeIntoThisMessage) return existingMessage;
|
|
const updatedMessage = { ...existingMessage, content: new_content } as TMessage;
|
|
updatesToReport.push(updatedMessage);
|
|
return updatedMessage;
|
|
});
|
|
|
|
const didUpdateExisting = updatesToReport.length > 0;
|
|
for (const updatedMessage of updatesToReport) {
|
|
messageHandler('update', updatedMessage);
|
|
}
|
|
|
|
const baseList = didUpdateExisting ? updatedList : list;
|
|
|
|
// If there are new tool calls, append them as a new tool_group message (without mutating inputs)
|
|
if (remainingToolsMap.size > 0) {
|
|
const newTools = Array.from(remainingToolsMap.values());
|
|
const insertMessage = { ...message, content: newTools } as TMessage;
|
|
messageHandler('insert', insertMessage);
|
|
return baseList.concat(insertMessage);
|
|
}
|
|
// No new tools appended; return a new list only if something was updated
|
|
return didUpdateExisting ? baseList : list;
|
|
}
|
|
|
|
// Handle Gemini tool_call message merging
|
|
if (message.type === 'tool_call') {
|
|
for (let i = 0, len = list.length; i < len; i++) {
|
|
const msg = list[i];
|
|
if (msg.type === 'tool_call' && msg.content.call_id === message.content.call_id) {
|
|
// Create new object instead of mutating original
|
|
return updateMessage(i, { ...msg, content: { ...msg.content, ...message.content } });
|
|
}
|
|
}
|
|
// If no existing tool call found, add new one
|
|
return pushMessage(message);
|
|
}
|
|
|
|
// Handle acp_tool_call message merging
|
|
if (message.type === 'acp_tool_call') {
|
|
for (let i = 0, len = list.length; i < len; i++) {
|
|
const msg = list[i];
|
|
if (msg.type === 'acp_tool_call' && msg.content.update?.tool_call_id === message.content.update?.tool_call_id) {
|
|
// Create new object instead of mutating original
|
|
const merged = mergeAcpToolCallContent(msg.content, message.content);
|
|
return updateMessage(i, { ...msg, content: merged });
|
|
}
|
|
}
|
|
// If no existing tool call found, add new one
|
|
return pushMessage(message);
|
|
}
|
|
|
|
if (message.type === 'plan') {
|
|
for (let i = 0, len = list.length; i < len; i++) {
|
|
const msg = list[i];
|
|
if (msg.type === 'plan' && msg.content.session_id === message.content.session_id) {
|
|
// Create new object instead of mutating original
|
|
const merged = { ...msg.content, ...message.content };
|
|
return updateMessage(i, { ...msg, content: merged });
|
|
}
|
|
}
|
|
return pushMessage(message);
|
|
// If no existing plan found, add new one
|
|
}
|
|
|
|
// Handle thinking message merging — only merge contiguous streaming chunks
|
|
if (message.type === 'thinking') {
|
|
if (message.content.status === 'done') {
|
|
for (let i = list.length - 1; i >= 0; i--) {
|
|
const msg = list[i];
|
|
if (msg.type !== 'thinking' || msg.msg_id !== message.msg_id) continue;
|
|
|
|
const merged = {
|
|
...msg.content,
|
|
status: 'done' as const,
|
|
duration: message.content.duration,
|
|
subject: message.content.subject || msg.content.subject,
|
|
};
|
|
return updateMessage(i, { ...msg, content: merged });
|
|
}
|
|
}
|
|
|
|
if (last.type === 'thinking' && last.msg_id === message.msg_id) {
|
|
// Otherwise append content
|
|
const merged = {
|
|
...last.content,
|
|
content: last.content.content + message.content.content,
|
|
subject: message.content.subject || last.content.subject,
|
|
};
|
|
return updateMessage(list.length - 1, { ...last, content: merged });
|
|
}
|
|
return pushMessage(message);
|
|
}
|
|
|
|
if (last.msg_id !== message.msg_id || last.type !== message.type) {
|
|
return pushMessage(message);
|
|
}
|
|
if (message.type === 'text' && last.type === 'text') {
|
|
message.content = mergeTextMessageContent(last.content, message.content);
|
|
}
|
|
return updateMessage(list.length - 1, Object.assign({}, last, message));
|
|
};
|