123 lines
3.8 KiB
TypeScript
123 lines
3.8 KiB
TypeScript
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' },
|
||
};
|