Files
beaver_project/app-instance/frontend/components/chat-workbench/MessageList.tsx
steven_li 0c180f48f2 feat(delegation): 添加直连模式下的委托公告回调机制
- 引入 DirectAnnouncementCallback 类型用于处理直连模式下的公告
- 在 DelegationManager 中添加 _direct_announcement_callback 属性和设置方法
- 实现 _notify_direct_announcement 方法用于在非总线模式下将公告回写到本地会话
- 在委托取消、完成和分组完成时添加对直连公告的通知逻辑

feat(web): 增加 WebSocket 广播器支持实时会话更新通知

- 创建 WebSocketBroadcaster 类用于跟踪认证的 WebSocket 连接并广播 JSON 事件
- 在应用启动时初始化 websocket_broadcaster 实例
- 实现连接注册、注销和消息广播功能
- 添加过期连接清理机制

feat(agent): 新增系统公告处理方法支持本地处理

- 在 AgentLoop 中添加 process_system_announcement 方法用于在无常驻 run() 场景下处理系统公告
- 创建 InboundMessage 并通过 _process_message 进行处理

feat(cron): 改进定时任务的会话路由解析和实时更新

- 添加 _resolve_cron_session_key 和 _infer_cron_route_from_session_key 辅助函数
- 在 cron 任务执行完成后通过 WebSocket 广播会话更新事件
- 在添加定时任务时自动推断目标会话的渠道和聊天 ID

refactor: 项目名称从 Boardware Genius 统一改为 Boardware Agent Sandbox

- 更新前端页面标题和描述文本中的产品名称
- 添加新的品牌 Logo 图片资源
- 在前端布局中使用新的 Logo 显示
- 更新授权门户中的品牌信息和 Logo 显示

feat(frontend): 添加会话更新事件监听实现消息自动刷新

- 定义 SessionUpdatedEvent 类型接口
- 在 ChatPage 中添加会话更新事件的处理逻辑
- 当收到会话更新事件时自动重新加载会话列表和当前会话消息

feat(api): 扩展定时任务 API 支持会话键参数

- 在 addCronJob API 参数中添加 session_key 字段
- 更新前端 Cron 页面的表单处理以传递当前会话键
2026-03-18 14:34:25 +08:00

150 lines
5.1 KiB
TypeScript

'use client';
import React from 'react';
import { Bot, Loader2, Paperclip, User } from 'lucide-react';
import type { ChatMessage } from '@/types';
import { getAccessToken, getFileUrl } from '@/lib/api';
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
import { ScrollArea } from '@/components/ui/scroll-area';
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
const [blobUrl, setBlobUrl] = React.useState<string | null>(null);
React.useEffect(() => {
const token = getAccessToken();
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
let revoke: string | null = null;
fetch(src, { headers })
.then((res) => res.blob())
.then((blob) => {
revoke = URL.createObjectURL(blob);
setBlobUrl(revoke);
})
.catch(() => {});
return () => {
if (revoke) URL.revokeObjectURL(revoke);
};
}, [src]);
if (!blobUrl) return <div className="w-32 h-32 bg-muted animate-pulse rounded" />;
return <img src={blobUrl} alt={alt} className={className} loading="lazy" decoding="async" />;
}
function MessageBubble({ message }: { message: ChatMessage }) {
const isUser = message.role === 'user';
const textContent = typeof message.content === 'string' ? message.content : String(message.content || '');
return (
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
{!isUser && (
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Bot className="w-4 h-4 text-primary" />
</div>
)}
<div
className={`rounded-xl px-4 py-3 max-w-[88%] shadow-sm ${
isUser
? 'bg-primary text-primary-foreground'
: 'bg-card border border-border/80'
}`}
>
{message.attachments && message.attachments.length > 0 && (
<div className="mb-2 space-y-2">
{message.attachments.map((att) => {
const fileUrl = getFileUrl(att.file_id);
if (att.content_type.startsWith('image/')) {
return (
<a key={att.file_id} href={fileUrl} target="_blank" rel="noopener noreferrer">
<AuthImage
src={fileUrl}
alt={att.name}
className="max-w-xs max-h-60 rounded border border-border/50 cursor-pointer hover:opacity-90"
/>
</a>
);
}
return (
<a
key={att.file_id}
href={fileUrl}
download={att.name}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${
isUser
? 'bg-primary-foreground/10 hover:bg-primary-foreground/20'
: 'bg-muted hover:bg-muted/80'
}`}
>
<Paperclip className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{att.name}</span>
{att.size && (
<span className="text-xs opacity-70 flex-shrink-0">
{att.size > 1024 * 1024
? `${(att.size / 1024 / 1024).toFixed(1)}MB`
: `${(att.size / 1024).toFixed(0)}KB`}
</span>
)}
</a>
);
})}
</div>
)}
{isUser ? (
<p className="text-sm whitespace-pre-wrap">{textContent}</p>
) : (
<MarkdownContent content={textContent} />
)}
</div>
{isUser && (
<div className="w-7 h-7 rounded-full bg-secondary flex items-center justify-center flex-shrink-0 mt-0.5">
<User className="w-4 h-4" />
</div>
)}
</div>
);
}
export function MessageList({
messages,
isThinking,
messagesEndRef,
viewportRef,
}: {
messages: ChatMessage[];
isThinking: boolean;
messagesEndRef: React.RefObject<HTMLDivElement>;
viewportRef: React.RefObject<HTMLDivElement>;
}) {
return (
<ScrollArea className="h-full px-4" viewportRef={viewportRef}>
<div className="max-w-4xl mx-auto py-4 space-y-4">
{messages.length === 0 && !isThinking && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Bot className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium">Boardware Agent Sandbox</p>
<p className="text-sm"></p>
</div>
)}
{messages.map((msg, i) => (
<MessageBubble key={`${msg.role}:${msg.timestamp || i}:${i}`} message={msg} />
))}
{isThinking && (
<div className="flex items-center gap-2 text-muted-foreground px-1">
<Bot className="w-5 h-5" />
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">...</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
);
}