/** * @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> { /** * 唯一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; error?: string; status?: 'running' | 'completed' | 'error'; input?: Record; output?: string; description?: string; } >; type IMessageToolGroupConfirmationDetailsBase> = { 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