Files
MyAiDesk/nomifun-tauri/ui/src/common/chat/chatLib.ts
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

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));
};