- ChatInput: replace VoiceRecordButton with VoiceMicButton (VAD-powered) - ChatInput: add VoiceModeToggle above input when enableVoiceInput=true - ChatMessage: add ChatVoiceBadge render for voice_input and voice_full messageTypes - ChatMessage: auto-play reads from localStorage nexus:voice:autoplay key - ChatPanel: import and call useVoiceMode, extract mode as voiceMode - ChatPanel: pass voiceMode as third arg to all startStream calls (5 call sites)
230 lines
7.2 KiB
TypeScript
230 lines
7.2 KiB
TypeScript
import { useState } from "react";
|
|
import { ChatMarkdownMessage } from "./ChatMarkdownMessage";
|
|
import { ChatMessageIdentityBar } from "./ChatMessageIdentityBar";
|
|
import { ChatStreamingCursor } from "./ChatStreamingCursor";
|
|
import { ChatMessageActions } from "./ChatMessageActions";
|
|
import { ChatSpecCard } from "./ChatSpecCard";
|
|
import { ChatHandoffIndicator } from "./ChatHandoffIndicator";
|
|
import { ChatTaskCreatedBadge } from "./ChatTaskCreatedBadge";
|
|
import { ChatStatusUpdateBadge } from "./ChatStatusUpdateBadge";
|
|
import { ChatFilePreview } from "./ChatFilePreview";
|
|
import { ChatVoiceBadge } from "./ChatVoiceBadge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "../lib/utils";
|
|
import type { AgentRole, ChatFile } from "@paperclipai/shared";
|
|
|
|
interface ChatMessageProps {
|
|
id?: string;
|
|
role: "user" | "assistant" | "system";
|
|
content: string;
|
|
messageType?: string | null;
|
|
conversationId?: string;
|
|
agentName?: string | null;
|
|
agentIcon?: string | null;
|
|
agentRole?: AgentRole | null;
|
|
timestamp?: string;
|
|
isStreaming?: boolean;
|
|
isAnyStreaming?: boolean;
|
|
onEdit?: (messageId: string, newContent: string) => void;
|
|
onRetry?: (messageId: string) => void;
|
|
onHandoff?: (spec: { what: string; why: string; constraints: string; success: string }) => void;
|
|
onBookmark?: (messageId: string) => void;
|
|
isBookmarked?: boolean;
|
|
files?: ChatFile[];
|
|
}
|
|
|
|
export function ChatMessage({
|
|
id,
|
|
role,
|
|
content,
|
|
messageType,
|
|
conversationId,
|
|
agentName,
|
|
agentIcon,
|
|
agentRole,
|
|
timestamp,
|
|
isStreaming,
|
|
isAnyStreaming,
|
|
onEdit,
|
|
onRetry,
|
|
onHandoff,
|
|
onBookmark,
|
|
isBookmarked,
|
|
files,
|
|
}: ChatMessageProps) {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editValue, setEditValue] = useState(content);
|
|
|
|
// Dispatch to specialized system message components (Phase 23)
|
|
if (role === "system" || messageType) {
|
|
if (messageType === "spec_card") {
|
|
return (
|
|
<ChatSpecCard
|
|
content={content}
|
|
messageId={id}
|
|
conversationId={conversationId}
|
|
onHandoff={onHandoff}
|
|
/>
|
|
);
|
|
}
|
|
if (messageType === "handoff") {
|
|
return <ChatHandoffIndicator content={content} />;
|
|
}
|
|
if (messageType === "task_created") {
|
|
try {
|
|
const data = JSON.parse(content) as { taskId?: string; taskTitle?: string; taskUrl?: string };
|
|
return <ChatTaskCreatedBadge taskId={data.taskId} taskTitle={data.taskTitle} taskUrl={data.taskUrl} />;
|
|
} catch {
|
|
return <ChatTaskCreatedBadge />;
|
|
}
|
|
}
|
|
if (messageType === "status_update") {
|
|
try {
|
|
const data = JSON.parse(content) as { agentName: string; taskId: string; taskTitle?: string; taskUrl?: string };
|
|
return <ChatStatusUpdateBadge agentName={data.agentName} taskId={data.taskId} taskTitle={data.taskTitle} taskUrl={data.taskUrl} />;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
if (messageType === "voice_input" || messageType === "voice_full") {
|
|
const autoPlay = typeof window !== "undefined"
|
|
? localStorage.getItem("nexus:voice:autoplay") === "true"
|
|
: false;
|
|
return (
|
|
<div className="max-w-full group relative">
|
|
{agentName && (
|
|
<ChatMessageIdentityBar
|
|
agentName={agentName}
|
|
agentIcon={agentIcon}
|
|
agentRole={agentRole}
|
|
timestamp={timestamp}
|
|
isStreaming={isStreaming}
|
|
/>
|
|
)}
|
|
<ChatVoiceBadge
|
|
content={content}
|
|
messageType={messageType}
|
|
autoPlayVoice={autoPlay}
|
|
/>
|
|
{isStreaming && <ChatStreamingCursor />}
|
|
<ChatMessageActions
|
|
role="assistant"
|
|
isStreaming={isAnyStreaming}
|
|
onRetry={id && onRetry ? () => onRetry(id) : undefined}
|
|
onBookmark={id && onBookmark ? () => onBookmark(id) : undefined}
|
|
isBookmarked={isBookmarked}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
// Fall through to default system message rendering (plain markdown)
|
|
}
|
|
|
|
if (role === "user") {
|
|
if (isEditing) {
|
|
return (
|
|
<div className="flex justify-end">
|
|
<div className="max-w-[85%] w-full">
|
|
<textarea
|
|
value={editValue}
|
|
onChange={(e) => setEditValue(e.target.value)}
|
|
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm resize-none min-h-[40px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
aria-label="Edit your message"
|
|
rows={3}
|
|
/>
|
|
<div className="flex justify-end gap-2 mt-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setIsEditing(false);
|
|
setEditValue(content);
|
|
}}
|
|
>
|
|
Discard edit
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
disabled={!editValue.trim()}
|
|
onClick={() => {
|
|
if (id && onEdit && editValue.trim()) {
|
|
onEdit(id, editValue.trim());
|
|
setIsEditing(false);
|
|
}
|
|
}}
|
|
>
|
|
Save edit
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex justify-end">
|
|
<div
|
|
className={cn(
|
|
"relative group max-w-[85%] rounded-lg bg-secondary px-3 py-2 text-secondary-foreground text-sm",
|
|
)}
|
|
>
|
|
{content}
|
|
{files && files.length > 0 && (
|
|
<div className="mt-2 flex flex-col gap-2">
|
|
{files.map((f) => (
|
|
<ChatFilePreview
|
|
key={f.id}
|
|
file={f}
|
|
contentPath={`/api/files/${f.id}/content`}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
<ChatMessageActions
|
|
role="user"
|
|
isStreaming={isAnyStreaming}
|
|
onEdit={() => setIsEditing(true)}
|
|
onBookmark={id && onBookmark ? () => onBookmark(id) : undefined}
|
|
isBookmarked={isBookmarked}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// assistant or system
|
|
return (
|
|
<div className="max-w-full group relative">
|
|
{agentName && (
|
|
<ChatMessageIdentityBar
|
|
agentName={agentName}
|
|
agentIcon={agentIcon}
|
|
agentRole={agentRole}
|
|
timestamp={timestamp}
|
|
isStreaming={isStreaming}
|
|
/>
|
|
)}
|
|
<ChatMarkdownMessage content={content} />
|
|
{files && files.length > 0 && (
|
|
<div className="mt-2 flex flex-col gap-2">
|
|
{files.map((f) => (
|
|
<ChatFilePreview
|
|
key={f.id}
|
|
file={f}
|
|
contentPath={`/api/files/${f.id}/content`}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
{isStreaming && <ChatStreamingCursor />}
|
|
<ChatMessageActions
|
|
role="assistant"
|
|
isStreaming={isAnyStreaming}
|
|
onRetry={id && onRetry ? () => onRetry(id) : undefined}
|
|
onBookmark={id && onBookmark ? () => onBookmark(id) : undefined}
|
|
isBookmarked={isBookmarked}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|