Files
LangBot/web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx
T
Junyan Chin a1e6eccdeb feat(box): bidirectional attachment transfer for sandbox (#2257)
* feat(box): bidirectional attachment transfer for sandbox

Materialize inbound attachments into the sandbox workspace so agents can
process user-sent files, and collect agent-produced files from the outbox
to attach them back to the reply.

- box(service): add materialize_inbound_attachments / collect_outbound
  attachments. Prefer direct host-filesystem read/write on the bind-mounted
  workspace (no size limit), falling back to chunked exec only for
  non-shared backends (e2b/remote). Clear per-query inbox/outbox dirs at
  turn start to avoid query_id-reuse collisions.
- provider(localagent): inject inbound attachment descriptors into the
  sandbox and append a system note telling the agent the inbox/outbox paths.
- pipeline(wrapper): collect outbox files on the final stream chunk and
  append them as attachment components to the response chain.
- web(debug-dialog): render File components with a download link when
  base64/url is present; add base64/path fields to the File entity.
- tests: cover inbound/outbound, large-file transfer without truncation,
  and stale-dir clearing (86 passing).

* feat(box): support voice/file attachment round-trip end-to-end

Extends the bidirectional attachment transfer to audio and arbitrary files
through the real webchat UI, and fixes the model-payload errors that
non-image attachments triggered.

- platform(websocket_adapter): resolve Voice/File component storage keys to
  base64 (previously only Image), so audio/documents reach the sandbox inbox.
- web(debug-dialog): accept audio/* and any file in the uploader (was
  image-only), classify by mimetype, upload Voice/File via the documents
  endpoint, and render non-image staged attachments as a chip.
- provider(litellmchat): drop non-image file parts (file_base64 / file_url)
  when building the OpenAI/LiteLLM payload. These come from Voice/File
  attachments — including ones replayed from conversation history — and the
  agent reads their bytes from the sandbox, not the model. Without this the
  provider rejects the request: 'invalid content type=file_base64'.
- provider(localagent): also strip those parts from the current user message
  alongside the sandbox-path note (model-facing clarity; the requester is the
  real safety net for history).
- tests: cover the requester strip/keep behavior (file dropped, image kept and
  reshaped to image_url, mixed history, plain-string content).

* test(box): cover inbound/outbound attachment helpers; fix ruff format

- ruff format localagent.py (CI ruff format --check was failing)
- add unit tests for ResponseWrapper outbound-attachment helpers (wrapper.py 78%->98%)
- add unit tests for LocalAgentRunner._inject_inbound_attachments
- add unit tests for WebSocketAdapter._process_image_components (0%->covered)

Lifts PR patch coverage from 68.97% to ~88% (>75% target).
2026-06-18 21:40:31 +08:00

1065 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { DialogContent } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import {
Message,
MessageChainComponent,
Image,
Plain,
At,
Quote,
Voice,
File as FileComponent,
Source,
} from '@/app/infra/entities/message';
import { toast } from 'sonner';
import AtBadge from './AtBadge';
import { WebSocketClient } from '@/app/infra/websocket/WebSocketClient';
import ImagePreviewDialog from './ImagePreviewDialog';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import '@/styles/github-markdown.css';
import {
User,
Users,
ImageIcon,
Paperclip,
Send,
Reply,
Music,
Code,
AlignLeft,
} from 'lucide-react';
interface DebugDialogProps {
open: boolean;
pipelineId: string;
isEmbedded?: boolean;
onConnectionStatusChange?: (isConnected: boolean) => void;
}
export default function DebugDialog({
open,
pipelineId,
isEmbedded = false,
onConnectionStatusChange,
}: DebugDialogProps) {
const { t } = useTranslation();
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
const [sessionType, setSessionType] = useState<'person' | 'group'>('person');
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState('');
const [showAtPopover, setShowAtPopover] = useState(false);
const [hasAt, setHasAt] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [selectedImages, setSelectedImages] = useState<
Array<{
file: File;
preview: string;
fileKey?: string;
kind: 'image' | 'voice' | 'file';
}>
>([]);
const [isUploading, setIsUploading] = useState(false);
const [previewImageUrl, setPreviewImageUrl] = useState<string>('');
const [showImagePreview, setShowImagePreview] = useState(false);
const [quotedMessage, setQuotedMessage] = useState<Message | null>(null);
const [rawModeMessages, setRawModeMessages] = useState<Set<string>>(
new Set(),
);
const [streamOutput, setStreamOutput] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const wsClientRef = useRef<WebSocketClient | null>(null);
const isInitializingRef = useRef<boolean>(false);
const scrollToBottom = useCallback(() => {
// Use setTimeout to ensure scroll happens after DOM update
setTimeout(() => {
const scrollArea = document.querySelector('.scroll-area') as HTMLElement;
if (scrollArea) {
scrollArea.scrollTo({
top: scrollArea.scrollHeight,
behavior: 'smooth',
});
}
// Also ensure messagesEndRef scrolls into view
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 0);
}, []);
const loadMessages = useCallback(
async (pipelineId: string) => {
try {
const response = await httpClient.getWebSocketHistoryMessages(
pipelineId,
sessionType,
);
setMessages(response.messages);
} catch (error) {
console.error('Failed to load messages:', error);
}
},
[sessionType],
);
// Initialize WebSocket connection
const initWebSocket = useCallback(
async (pipelineId: string) => {
// Prevent duplicate initialization
if (isInitializingRef.current) {
return;
}
try {
isInitializingRef.current = true;
// Disconnect old connection
if (wsClientRef.current) {
wsClientRef.current.disconnect();
wsClientRef.current = null;
}
// Create new connection
const wsClient = new WebSocketClient(pipelineId, sessionType);
wsClient
.onConnected(() => {
setIsConnected(true);
isInitializingRef.current = false;
})
.onMessage((wsMessage) => {
// Convert WebSocketMessage to Message type
const message: Message = {
...wsMessage,
message_chain: wsMessage.message_chain as MessageChainComponent[],
};
setMessages((prevMessages) => {
// Check if message with same ID already exists
const existingIndex = prevMessages.findIndex(
(m) => m.id === message.id,
);
if (existingIndex >= 0) {
// Update existing message (streaming output)
const newMessages = [...prevMessages];
newMessages[existingIndex] = message;
return newMessages;
} else {
// Add new message
return [...prevMessages, message];
}
});
})
.onError((error) => {
console.error('WebSocket error:', error);
setIsConnected(false);
isInitializingRef.current = false;
toast.error(t('pipelines.debugDialog.connectionError'));
})
.onClose(() => {
setIsConnected(false);
isInitializingRef.current = false;
})
.onBroadcast((message) => {
toast.info(message);
});
await wsClient.connect();
wsClientRef.current = wsClient;
} catch (error) {
console.error('WebSocket connection failed:', error);
setIsConnected(false);
isInitializingRef.current = false;
toast.error(t('pipelines.debugDialog.connectionFailed'));
}
},
[sessionType, t],
);
// Scroll when messages change
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
// Watch open and pipelineId changes: connect on open, disconnect on close
useEffect(() => {
if (open) {
setSelectedPipelineId(pipelineId);
} else {
// Disconnect WebSocket immediately when dialog closes
if (wsClientRef.current) {
wsClientRef.current.disconnect();
wsClientRef.current = null;
setIsConnected(false);
isInitializingRef.current = false;
}
}
return () => {
// Disconnect WebSocket on component unmount
if (wsClientRef.current) {
wsClientRef.current.disconnect();
wsClientRef.current = null;
isInitializingRef.current = false;
}
};
}, [open, pipelineId]);
// Reload messages and reconnect when sessionType or selectedPipelineId changes
useEffect(() => {
if (open) {
// Clear current messages to avoid showing stale messages
setMessages([]);
loadMessages(selectedPipelineId);
initWebSocket(selectedPipelineId);
}
}, [sessionType, selectedPipelineId, open, loadMessages, initWebSocket]);
// Notify parent of connection status changes
useEffect(() => {
onConnectionStatusChange?.(isConnected);
}, [isConnected, onConnectionStatusChange]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node) &&
!inputRef.current?.contains(event.target as Node)
) {
setShowAtPopover(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
useEffect(() => {
if (showAtPopover) {
setIsHovering(true);
}
}, [showAtPopover]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (sessionType === 'group') {
if (value.endsWith('@')) {
setShowAtPopover(true);
} else if (showAtPopover && (!value.includes('@') || value.length > 1)) {
setShowAtPopover(false);
}
}
setInputValue(value);
};
const handleAtSelect = () => {
setHasAt(true);
setShowAtPopover(false);
setInputValue(inputValue.slice(0, -1));
};
const handleAtRemove = () => {
setHasAt(false);
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (showAtPopover) {
handleAtSelect();
} else {
sendMessage();
}
} else if (e.key === 'Backspace' && hasAt && inputValue === '') {
handleAtRemove();
}
};
const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const newImages: Array<{
file: File;
preview: string;
kind: 'image' | 'voice' | 'file';
}> = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith('image/')) {
newImages.push({
file,
preview: URL.createObjectURL(file),
kind: 'image',
});
} else if (file.type.startsWith('audio/')) {
newImages.push({ file, preview: '', kind: 'voice' });
} else {
newImages.push({ file, preview: '', kind: 'file' });
}
}
setSelectedImages((prev) => [...prev, ...newImages]);
// reset the input so selecting the same file again re-triggers onChange
e.target.value = '';
};
const handleRemoveImage = (index: number) => {
setSelectedImages((prev) => {
const newImages = [...prev];
if (newImages[index].preview) {
URL.revokeObjectURL(newImages[index].preview);
}
newImages.splice(index, 1);
return newImages;
});
};
const sendMessage = async () => {
if (
!inputValue.trim() &&
!hasAt &&
selectedImages.length === 0 &&
!quotedMessage
)
return;
if (!isConnected || !wsClientRef.current) {
toast.error(t('pipelines.debugDialog.notConnected'));
return;
}
try {
setIsUploading(true);
const messageChain = [];
// Add quoted message if present
if (quotedMessage) {
// Get message_id from the quoted message Source component
const sourceComponent = quotedMessage.message_chain.find(
(c) => c.type === 'Source',
) as Source | undefined;
const messageId = sourceComponent
? sourceComponent.id
: quotedMessage.id;
messageChain.push({
type: 'Quote',
id: messageId,
origin: quotedMessage.message_chain.filter(
(c) => c.type !== 'Source',
),
});
}
let text_content = inputValue.trim();
if (hasAt) {
text_content = ' ' + text_content;
}
if (hasAt) {
messageChain.push({
type: 'At',
target: 'websocketbot',
display: 'websocketbot',
});
}
// Add text content
if (text_content) {
messageChain.push({
type: 'Plain',
text: text_content,
});
}
// Upload attachments and add to message chain
for (const attachment of selectedImages) {
try {
if (attachment.kind === 'image') {
const result = await httpClient.uploadWebSocketImage(
selectedPipelineId,
attachment.file,
);
messageChain.push({
type: 'Image',
path: result.file_key,
});
} else {
// Voice / File go through the generic document upload endpoint,
// which returns a storage key the backend resolves into the
// sandbox inbox just like images.
const result = await httpClient.uploadDocumentFile(attachment.file);
messageChain.push({
type: attachment.kind === 'voice' ? 'Voice' : 'File',
path: result.file_id,
...(attachment.kind === 'file'
? { name: attachment.file.name }
: {}),
});
}
} catch (error) {
console.error('Attachment upload failed:', error);
toast.error(t('pipelines.debugDialog.imageUploadFailed'));
}
}
// Clear input, images, and quoted message
setInputValue('');
setHasAt(false);
setQuotedMessage(null);
selectedImages.forEach((img) => {
if (img.preview) URL.revokeObjectURL(img.preview);
});
setSelectedImages([]);
// Send message via WebSocket
// Do not add locally; wait for backend broadcast with correct ID
wsClientRef.current.sendMessage(messageChain, streamOutput);
} catch (error) {
console.error('Failed to send message:', error);
toast.error(t('pipelines.debugDialog.sendFailed'));
} finally {
setIsUploading(false);
inputRef.current?.focus();
}
};
const renderMessageComponent = (
component: MessageChainComponent,
index: number,
) => {
switch (component.type) {
case 'Plain':
return <span key={index}>{(component as Plain).text}</span>;
case 'At': {
const atComponent = component as At;
// Prefer display name, fall back to target
const displayName =
atComponent.display || atComponent.target?.toString() || '';
return (
<span key={index} className="inline-flex align-middle mx-1">
<AtBadge targetName={displayName} readonly={true} />
</span>
);
}
case 'AtAll':
return (
<span key={index} className="inline-flex align-middle mx-1">
<AtBadge
targetName={t('pipelines.debugDialog.allMembers')}
readonly={true}
/>
</span>
);
case 'Image': {
const img = component as Image;
const imageUrl = img.url || (img.base64 ? img.base64 : '');
if (!imageUrl) return null;
return (
<div key={index} className="my-2">
<img
src={imageUrl}
alt="Image"
className="max-w-full max-h-96 rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
setPreviewImageUrl(imageUrl);
setShowImagePreview(true);
}}
/>
</div>
);
}
case 'File': {
const file = component as FileComponent;
const downloadHref = file.base64
? file.base64.startsWith('data:')
? file.base64
: `data:application/octet-stream;base64,${file.base64}`
: file.url || '';
const fileName = file.name || 'Unknown';
return (
<div key={index} className="my-2 flex items-center gap-2 text-sm">
<Paperclip className="size-4" />
{downloadHref ? (
<a
href={downloadHref}
download={fileName}
className="text-primary underline hover:opacity-80"
>
[{t('pipelines.debugDialog.file')}] {fileName}
</a>
) : (
<span>
[{t('pipelines.debugDialog.file')}] {fileName}
</span>
)}
</div>
);
}
case 'Voice': {
const voice = component as Voice;
const voiceUrl = voice.url || (voice.base64 ? voice.base64 : '');
if (!voiceUrl) {
return <span key={index}>[{t('pipelines.debugDialog.voice')}]</span>;
}
return (
<div key={index} className="my-2 flex items-center gap-2">
<div className="flex items-center gap-2 px-3 py-2 bg-muted rounded-lg">
<Music className="size-5" />
<audio
controls
src={voiceUrl}
className="h-8"
style={{ maxWidth: '200px' }}
>
Your browser does not support the audio element.
</audio>
{voice.length && voice.length > 0 && (
<span className="text-xs text-muted-foreground">
{voice.length}s
</span>
)}
</div>
</div>
);
}
case 'Quote': {
const quote = component as Quote;
return (
<div
key={index}
className="mb-2 pl-3 border-l-2 border-muted-foreground/50"
>
<div className="text-sm opacity-75">
{quote.origin?.map((comp, idx) =>
renderMessageComponent(comp as MessageChainComponent, idx),
)}
</div>
</div>
);
}
case 'Source':
// Source is not rendered
return null;
default:
return <span key={index}>[{component.type}]</span>;
}
};
const getMessageTimestamp = (message: Message): number => {
// Try to get timestamp from Source component in message_chain
const sourceComponent = message.message_chain.find(
(c) => c.type === 'Source',
) as Source | undefined;
if (sourceComponent && sourceComponent.timestamp) {
return sourceComponent.timestamp;
}
// Fall back to message.timestamp if no Source component
// Assume ISO string, convert to Unix timestamp (seconds)
if (message.timestamp) {
return Math.floor(new Date(message.timestamp).getTime() / 1000);
}
return 0;
};
const formatTimestamp = (timestamp: number): string => {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
const now = new Date();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
// Check if today
const isToday = now.toDateString() === date.toDateString();
if (isToday) {
return `${hours}:${minutes}`;
}
// Check if yesterday
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = yesterday.toDateString() === date.toDateString();
if (isYesterday) {
return `${t('bots.yesterday')} ${hours}:${minutes}`;
}
// Check if this year
const isThisYear = now.getFullYear() === date.getFullYear();
if (isThisYear) {
const month = date.getMonth() + 1;
const day = date.getDate();
return t('bots.dateFormat', { month, day });
}
// Earlier dates
return t('bots.earlier');
};
// Generate a unique key for a message
const getMessageKey = (message: Message): string => {
return `${message.id}-${message.timestamp}`;
};
// Toggle raw mode for a message (by default, messages are in markdown mode)
const toggleRawMode = (message: Message) => {
const key = getMessageKey(message);
setRawModeMessages((prev) => {
const newSet = new Set(prev);
if (newSet.has(key)) {
newSet.delete(key);
} else {
newSet.add(key);
}
return newSet;
});
};
// Check if message has any Plain text content
const hasPlainText = (message: Message): boolean => {
return message.message_chain.some((c) => c.type === 'Plain');
};
// Extract plain text from message chain
const getPlainText = (message: Message): string => {
return message.message_chain
.filter((c) => c.type === 'Plain')
.map((c) => (c as Plain).text)
.join('');
};
const renderMessageContent = (message: Message) => {
const key = getMessageKey(message);
const isRawMode = rawModeMessages.has(key);
// By default, render with markdown if there's plain text (unless raw mode is enabled)
if (!isRawMode && hasPlainText(message)) {
const plainText = getPlainText(message);
const nonPlainComponents = message.message_chain.filter(
(c) => c.type !== 'Plain' && c.type !== 'Source',
);
return (
<div className="text-base leading-relaxed align-middle">
{/* Render non-Plain components first */}
{nonPlainComponents.map((component, index) =>
renderMessageComponent(component, index),
)}
{/* Render Plain text as markdown */}
<div className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[
rehypeRaw,
rehypeSanitize,
rehypeHighlight,
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: 'wrap',
properties: {
className: ['anchor'],
},
},
],
]}
components={{
ul: ({ children }) => <ul className="list-disc">{children}</ul>,
ol: ({ children }) => (
<ol className="list-decimal">{children}</ol>
),
li: ({ children }) => <li className="ml-4">{children}</li>,
img: ({ src, alt, ...props }) => {
const imageSrc = src || '';
if (typeof imageSrc !== 'string') {
return (
<img
src={src}
alt={alt || ''}
className="max-w-full h-auto rounded-lg my-4"
{...props}
/>
);
}
return (
<img
src={imageSrc}
alt={alt || ''}
className="max-w-lg h-auto my-4"
{...props}
/>
);
},
}}
>
{plainText}
</ReactMarkdown>
</div>
</div>
);
}
return (
<div className="text-base leading-relaxed align-middle whitespace-pre-wrap">
{message.message_chain.map((component, index) =>
renderMessageComponent(component, index),
)}
</div>
);
};
const renderContent = () => (
<div className="flex flex-1 h-full min-h-0">
<div className="w-14 p-2 pl-0 shrink-0 flex flex-col justify-start gap-2">
<Button
variant="ghost"
size="icon"
className={cn(
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
sessionType === 'person'
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
onClick={() => setSessionType('person')}
>
<User className="size-5" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
sessionType === 'group'
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
onClick={() => setSessionType('group')}
>
<Users className="size-5" />
</Button>
</div>
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 scroll-area">
<div className="space-y-6">
{messages.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-lg">
{t('pipelines.debugDialog.noMessages')}
</div>
) : (
messages.map((message) => (
<div
key={message.id + message.timestamp}
className={cn(
'flex',
message.role === 'user' ? 'justify-end' : 'justify-start',
)}
>
<div
className={cn(
'max-w-3xl px-5 py-3 rounded-2xl',
message.role === 'user'
? 'user-message-bubble bg-primary/10 text-foreground rounded-br-none'
: 'bg-muted text-foreground rounded-bl-none',
)}
>
{renderMessageContent(message)}
<div
className={cn(
'text-xs mt-2 flex items-center justify-between gap-2',
'text-muted-foreground',
)}
>
<div className="flex items-center gap-2">
<span>
{message.role === 'user'
? t('pipelines.debugDialog.userMessage')
: t('pipelines.debugDialog.botMessage')}
</span>
{hasPlainText(message) && (
<button
type="button"
onClick={() => toggleRawMode(message)}
className={cn(
'px-1.5 py-0.5 rounded text-[10px] transition-colors',
'hover:bg-accent',
)}
title={
rawModeMessages.has(getMessageKey(message))
? t('pipelines.debugDialog.showMarkdown')
: t('pipelines.debugDialog.showRaw')
}
>
{rawModeMessages.has(getMessageKey(message)) ? (
<span className="flex items-center gap-0.5">
<Code className="size-3" />
MD
</span>
) : (
<span className="flex items-center gap-0.5">
<AlignLeft className="size-3" />
{t('pipelines.debugDialog.showRaw')}
</span>
)}
</button>
)}
<button
type="button"
onClick={() => setQuotedMessage(message)}
className={cn(
'px-1.5 py-0.5 rounded text-[10px] transition-colors flex items-center gap-0.5',
'hover:bg-accent',
)}
title={t('pipelines.debugDialog.reply')}
>
<Reply className="size-3" />
{t('pipelines.debugDialog.reply')}
</button>
</div>
<span className="text-[10px]">
{formatTimestamp(getMessageTimestamp(message))}
</span>
</div>
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
{/* Quoted message preview */}
{quotedMessage && (
<div className="px-4 py-2 bg-muted/50 border-t">
<div className="flex items-start gap-2">
<div className="flex-1 pl-3 border-l-2 border-primary">
<div className="text-xs text-muted-foreground mb-1">
{t('pipelines.debugDialog.replyTo')}{' '}
{quotedMessage.role === 'user'
? t('pipelines.debugDialog.userMessage')
: t('pipelines.debugDialog.botMessage')}
</div>
<div className="text-sm text-foreground/70 line-clamp-2">
{quotedMessage.message_chain
.filter((c) => c.type === 'Plain')
.map((c) => (c as Plain).text)
.join('')}
</div>
</div>
<button
type="button"
onClick={() => setQuotedMessage(null)}
className="w-5 h-5 text-muted-foreground hover:text-foreground"
>
×
</button>
</div>
</div>
)}
{/* Attachment preview area */}
{selectedImages.length > 0 && (
<div className="px-4 pb-2">
<div className="flex gap-2 flex-wrap">
{selectedImages.map((image, index) => (
<div key={index} className="relative group">
{image.kind === 'image' ? (
<img
src={image.preview}
alt={`preview-${index}`}
className="w-20 h-20 object-cover rounded-lg border"
/>
) : (
<div className="w-36 h-20 px-2 rounded-lg border bg-muted/40 flex items-center gap-2 overflow-hidden">
{image.kind === 'voice' ? (
<Music className="size-5 shrink-0 text-muted-foreground" />
) : (
<Paperclip className="size-5 shrink-0 text-muted-foreground" />
)}
<span className="text-xs text-muted-foreground truncate">
{image.file.name}
</span>
</div>
)}
<button
type="button"
onClick={() => handleRemoveImage(index)}
className="absolute -top-2 -right-2 w-5 h-5 bg-destructive text-destructive-foreground rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</div>
))}
</div>
</div>
)}
<div className="p-4 pb-0 flex gap-2">
<div className="flex gap-2 items-center">
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">
{t('pipelines.debugDialog.streamOutput')}
</span>
<Switch
checked={streamOutput}
onCheckedChange={setStreamOutput}
disabled={!isConnected}
/>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*,audio/*,*/*"
multiple
onChange={handleImageSelect}
className="hidden"
/>
<Button
variant="ghost"
size="icon"
onClick={() => fileInputRef.current?.click()}
disabled={!isConnected || isUploading}
className="w-10 h-10 rounded-md hover:bg-accent"
title={t('pipelines.debugDialog.uploadImage')}
>
<ImageIcon className="size-5" />
</Button>
</div>
<div className="flex-1 flex items-center gap-2">
{hasAt && (
<AtBadge targetName="websocketbot" onRemove={handleAtRemove} />
)}
<div className="relative flex-1">
<Input
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
placeholder={t('pipelines.debugDialog.inputPlaceholder', {
type:
sessionType === 'person'
? t('pipelines.debugDialog.privateChat')
: t('pipelines.debugDialog.groupChat'),
})}
disabled={!isConnected || isUploading}
className="flex-1 rounded-md px-3 py-2 transition-none text-base disabled:opacity-50"
/>
{showAtPopover && (
<div
ref={popoverRef}
className="absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-popover text-popover-foreground shadow-lg"
>
<div
className={cn(
'flex items-center gap-2 px-4 py-1.5 rounded cursor-pointer',
isHovering ? 'bg-accent' : '',
)}
onClick={handleAtSelect}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<span>
@websocketbot - {t('pipelines.debugDialog.atTips')}
</span>
</div>
</div>
)}
</div>
</div>
<Button
onClick={sendMessage}
disabled={
(!inputValue.trim() &&
!hasAt &&
selectedImages.length === 0 &&
!quotedMessage) ||
!isConnected ||
isUploading
}
className="rounded-md w-20 px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none disabled:opacity-50"
>
{isUploading ? (
t('pipelines.debugDialog.uploading')
) : (
<>
<Send className="size-4" />
{t('pipelines.debugDialog.send')}
</>
)}
</Button>
</div>
</div>
</div>
);
// Embedded mode: return content directly
if (isEmbedded) {
return (
<>
<div className="flex flex-col h-full min-h-0">
<div className="flex-1 min-h-0 flex flex-col">{renderContent()}</div>
</div>
<ImagePreviewDialog
open={showImagePreview}
imageUrl={previewImageUrl}
onClose={() => setShowImagePreview(false)}
/>
</>
);
}
// Dialog wrapper mode
return (
<>
<DialogContent className="!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl">
{renderContent()}
</DialogContent>
<ImagePreviewDialog
open={showImagePreview}
imageUrl={previewImageUrl}
onClose={() => setShowImagePreview(false)}
/>
</>
);
}