feat: AI 对话支持 A2UI 表单/审查/图表与 Agent 工具

This commit is contained in:
2026-08-05 17:11:00 +08:00
parent 644c35ce53
commit 0e6e3e2d96
64 changed files with 8395 additions and 6434 deletions

View File

@@ -0,0 +1,122 @@
import React from 'react';
import type { BubbleListProps } from '@ant-design/x';
import type { Attachment } from '@ant-design/x/es/attachments';
import type { MessageInfo } from '@ant-design/x-sdk';
import { Tooltip } from 'antd';
import type { AiAttachment, AiChatMessage, AiConversation } from './types';
export interface ConversationData extends AiConversation {
key: string;
label: string;
}
export type ConversationRunStatus = 'running' | 'done' | 'error' | 'stopped';
export function conversationStatusMeta(status: ConversationRunStatus): {
label: string;
color: string;
} {
if (status === 'running') return { label: '生成中', color: 'processing' };
if (status === 'done') return { label: '已完成', color: 'success' };
if (status === 'error') return { label: '失败', color: 'error' };
return { label: '已停止', color: 'default' };
}
export function sortConversations(items: AiConversation[]): AiConversation[] {
return [...items].sort((a, b) => {
const aTime = new Date(a.lastMessageAt || a.updatedAt).getTime();
const bTime = new Date(b.lastMessageAt || b.updatedAt).getTime();
return bTime - aTime;
});
}
export function toConversationData(item: AiConversation): ConversationData {
return { ...item, key: String(item.id), label: item.title };
}
export function toUploadFile(attachment: AiAttachment): Attachment<AiAttachment> {
return {
uid: String(attachment.id),
name: attachment.name,
size: attachment.size,
status:
attachment.status === 'ready'
? 'done'
: attachment.status === 'failed'
? 'error'
: 'uploading',
url: attachment.url,
response: attachment,
description: attachment.error || undefined,
cardType: attachment.mimeType.startsWith('image/') ? 'image' : 'file',
};
}
export function emptyAssistant(): AiChatMessage {
return {
role: 'assistant',
content: '',
reasoningContent: '',
toolRuns: [],
attachments: [],
};
}
/**
* 当前会话内新发送的用户消息还没有服务端数字 ID本地为 msg_N 临时 key
* 但紧随其后的 AI 回答会携带 replyToMessageId可据此反推用户消息 ID。
*/
export function resolveUserMessageId(
info: MessageInfo<AiChatMessage>,
all: MessageInfo<AiChatMessage>[],
): number | null {
if (typeof info.message.id === 'number') return info.message.id;
const index = all.findIndex((item) => item.id === info.id);
if (index === -1) return null;
for (const item of all.slice(index + 1)) {
if (typeof item.message.replyToMessageId === 'number') {
return item.message.replyToMessageId;
}
}
return null;
}
export interface HoverActionItem {
key: string;
title: string;
icon: React.ReactNode;
danger?: boolean;
onClick: () => void;
}
/** Codex Desktop 风格hover 消息时在气泡外显示的纯图标操作,不包裹 Button */
export function MessageHoverActions({ items }: { items: HoverActionItem[] }) {
return (
<div className="ai-chat-hover-actions" role="toolbar" aria-label="消息操作">
{items.map((item) => (
<Tooltip key={item.key} title={item.title}>
<span
role="button"
tabIndex={0}
className={`ai-chat-hover-action${item.danger ? ' is-danger' : ''}`}
aria-label={item.title}
onClick={item.onClick}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
item.onClick();
}
}}
>
{item.icon}
</span>
</Tooltip>
))}
</div>
);
}
export const aiBubbleRoles: BubbleListProps['role'] = {
user: { placement: 'end', variant: 'filled', shape: 'corner' },
assistant: { placement: 'start', variant: 'borderless' },
};