Leon4gr45's picture
Upload folder using huggingface_hub
eeb9404 verified
Raw
History Blame Contribute Delete
18.9 kB
'use client';
import React, { useState } from 'react';
import { ChevronDown, X, Crosshair, LayoutGrid, Image as ImageIcon, Info, FileText, Mic } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { PlacedBlock } from '@/lib/semantic-blocks/types';
import { getBlockById } from '@/lib/semantic-blocks/registry';
import type { PendingImage, PendingFile, PendingAudio } from '@/lib/llm/multi-agent-orchestrator';
import type { ContentBlock } from '@/lib/llm/types';
interface FocusContextData {
domPath: string;
snippet: string;
}
interface SemanticBlockData {
name: string;
domPath: string;
position: string;
description: string;
}
interface MessageContextProps {
focusContext?: FocusContextData | null;
semanticBlocks?: (PlacedBlock[] | SemanticBlockData[]);
images?: PendingImage[];
/** For readOnly mode: image content blocks from the stored message */
imageBlocks?: ContentBlock[];
files?: PendingFile[];
/** For readOnly mode: attached file names from the stored message */
fileNames?: { name: string }[];
audioClips?: PendingAudio[];
/** For readOnly mode: input_audio content blocks from the stored message */
audioBlocks?: ContentBlock[];
/** Non-dismissable system note (e.g. "user declined project creation") */
systemNote?: string | null;
onClearFocus?: () => void;
onRemoveBlock?: (placementId: string) => void;
onClearBlocks?: () => void;
onRemoveImage?: (imageId: string) => void;
onClearImages?: () => void;
onRemoveFile?: (fileId: string) => void;
onClearFiles?: () => void;
onRemoveAudio?: (audioId: string) => void;
onClearAudio?: () => void;
/** readOnly: start the context expanded (e.g. an attachment-only message) */
defaultOpen?: boolean;
readOnly?: boolean;
}
function isPlacedBlock(b: PlacedBlock | SemanticBlockData): b is PlacedBlock {
return 'blockId' in b;
}
function getBlockName(b: PlacedBlock | SemanticBlockData): string {
if (isPlacedBlock(b)) return getBlockById(b.blockId)?.name || b.blockId;
return b.name;
}
function getBlockDescription(b: PlacedBlock | SemanticBlockData): string {
if (isPlacedBlock(b)) return getBlockById(b.blockId)?.description || '';
return b.description;
}
function getBlockDomPath(b: PlacedBlock | SemanticBlockData): string {
return b.domPath;
}
function getBlockPosition(b: PlacedBlock | SemanticBlockData): string {
return b.position;
}
function getBlockKey(b: PlacedBlock | SemanticBlockData, i: number): string {
if (isPlacedBlock(b)) return b.placementId;
return `sb-${i}`;
}
/** Collapsible section within the unified context component */
function Section({
icon: Icon,
label,
summary,
onClear,
children,
defaultOpen = false,
readOnly = false,
}: {
icon: React.ElementType;
label: string;
summary?: string;
onClear?: () => void;
children: React.ReactNode;
defaultOpen?: boolean;
readOnly?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border-t border-primary/10 first:border-t-0">
<button
type="button"
className="flex items-center gap-2 w-full px-3 py-1.5 text-left hover:bg-primary/5 transition-colors"
onClick={() => setOpen(prev => !prev)}
>
<ChevronDown className={cn("h-3 w-3 text-primary/50 flex-shrink-0 transition-transform", !open && "-rotate-90")} />
<Icon className="h-3 w-3 text-primary/60 flex-shrink-0" />
<span className="text-[11px] font-medium text-foreground/90">{label}</span>
{summary && <span className="text-[10px] text-muted-foreground/60 truncate">{summary}</span>}
{!readOnly && onClear && (
<span
className="ml-auto text-[10px] text-muted-foreground/50 hover:text-foreground/70 flex-shrink-0"
onClick={(e) => { e.stopPropagation(); onClear(); }}
>
Clear
</span>
)}
</button>
{open && (
<div className="px-3 pb-2 pt-0.5">
{children}
</div>
)}
</div>
);
}
/** Code snippet box — shared between focus and block descriptions */
function SnippetBox({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<pre className={cn(
"max-h-20 overflow-auto rounded border border-border/50 bg-background/80 px-2 py-1.5 text-[10px] text-foreground leading-relaxed",
className,
)}>
<code>{children}</code>
</pre>
);
}
export function MessageContext({
focusContext,
semanticBlocks,
images,
imageBlocks,
files,
fileNames,
audioClips,
audioBlocks,
systemNote,
onClearFocus,
onRemoveBlock,
onClearBlocks,
onRemoveImage,
onClearImages,
onRemoveFile,
onClearFiles,
onRemoveAudio,
onClearAudio,
defaultOpen,
readOnly = false,
}: MessageContextProps) {
const hasBlocks = semanticBlocks && semanticBlocks.length > 0;
const hasImages = images && images.length > 0;
const hasImageBlocks = imageBlocks && imageBlocks.some(b => b.type === 'image_url');
const hasFiles = files && files.length > 0;
const hasFileNames = fileNames && fileNames.length > 0;
const hasAudio = audioClips && audioClips.length > 0;
const hasAudioBlocks = audioBlocks && audioBlocks.some(b => b.type === 'input_audio');
const hasFocus = !!focusContext;
const hasNote = !!systemNote;
if (!hasFocus && !hasBlocks && !hasImages && !hasImageBlocks && !hasFiles && !hasFileNames && !hasAudio && !hasAudioBlocks && !hasNote) return null;
// Build summary for collapsed readOnly view
const summaryParts: string[] = [];
if (hasNote) summaryParts.push('note');
if (hasFocus) summaryParts.push('focus');
if (hasBlocks) summaryParts.push(`${semanticBlocks!.length} block${semanticBlocks!.length !== 1 ? 's' : ''}`);
if (hasImages) summaryParts.push(`${images!.length} image${images!.length !== 1 ? 's' : ''}`);
if (!hasImages && hasImageBlocks) {
const count = imageBlocks!.filter(b => b.type === 'image_url').length;
summaryParts.push(`${count} image${count !== 1 ? 's' : ''}`);
}
const fileCount = (files?.length ?? 0) || (fileNames?.length ?? 0);
if (fileCount > 0) summaryParts.push(`${fileCount} file${fileCount !== 1 ? 's' : ''}`);
const audioCount = (audioClips?.length ?? 0) || (audioBlocks?.filter(b => b.type === 'input_audio').length ?? 0);
if (audioCount > 0) summaryParts.push(`${audioCount} recording${audioCount !== 1 ? 's' : ''}`);
if (readOnly) {
return <ReadOnlyContext
focusContext={focusContext}
semanticBlocks={semanticBlocks}
imageBlocks={imageBlocks}
fileNames={fileNames}
audioBlocks={audioBlocks}
summary={summaryParts.join(', ')}
defaultOpen={defaultOpen}
/>;
}
return (
<div className="rounded-md border border-dashed border-primary/30 bg-primary/5 overflow-hidden">
{/* Header */}
<div className="px-3 py-1.5 flex items-center gap-2">
<span className="font-medium text-[10px] uppercase tracking-wider text-primary/80">Included in next message</span>
</div>
{/* Sections */}
{hasNote && (
<Section
icon={Info}
label="Context"
summary={systemNote!.length > 40 ? systemNote!.slice(0, 37) + '...' : systemNote!}
readOnly={true}
>
<p className="text-[11px] text-foreground/70 leading-relaxed">{systemNote}</p>
</Section>
)}
{hasFocus && (
<Section
icon={Crosshair}
label="Focus"
summary={focusContext!.domPath.split(' > ').slice(-1)[0]}
onClear={onClearFocus}
readOnly={readOnly}
>
<div className="text-[10px] font-mono text-muted-foreground/70 break-all leading-snug mb-1">
{focusContext!.domPath}
</div>
{focusContext!.snippet && (
<SnippetBox>{focusContext!.snippet}</SnippetBox>
)}
</Section>
)}
{hasBlocks && (
<Section
icon={LayoutGrid}
label={`Blocks (${semanticBlocks!.length})`}
onClear={onClearBlocks}
readOnly={readOnly}
>
<div className="space-y-2">
{semanticBlocks!.map((block, i) => (
<div key={getBlockKey(block, i)} className="group">
<div className="flex items-center gap-1 mb-0.5">
<span className="text-[11px] font-medium text-foreground/90">{getBlockName(block)}</span>
{!readOnly && onRemoveBlock && isPlacedBlock(block) && (
<button
onClick={() => onRemoveBlock(block.placementId)}
className="h-3.5 w-3.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0 text-muted-foreground/50 hover:text-foreground/70"
title="Remove block"
>
<X className="h-2.5 w-2.5" />
</button>
)}
</div>
<div className="text-[10px] font-mono text-muted-foreground/60 mb-1">
{getBlockPosition(block)} {getBlockDomPath(block)}
</div>
{getBlockDescription(block) && (
<SnippetBox>{getBlockDescription(block)}</SnippetBox>
)}
</div>
))}
</div>
</Section>
)}
{hasImages && (
<Section
icon={ImageIcon}
label={`Images (${images!.length})`}
onClear={onClearImages}
defaultOpen={true}
readOnly={readOnly}
>
<div className="flex flex-wrap gap-1.5">
{images!.map((img) => (
<div key={img.id} className="relative group">
<img
src={img.preview}
alt="Attached"
className="h-10 w-10 object-cover rounded border border-border/50"
/>
{!readOnly && onRemoveImage && (
<button
onClick={() => onRemoveImage(img.id)}
className="absolute -top-1 -right-1 h-3.5 w-3.5 bg-destructive text-destructive-foreground rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
title="Remove image"
>
<X className="h-2 w-2" />
</button>
)}
</div>
))}
</div>
</Section>
)}
{hasFiles && (
<Section
icon={FileText}
label={`Files (${files!.length})`}
onClear={onClearFiles}
defaultOpen={true}
readOnly={readOnly}
>
<div className="flex flex-wrap gap-1.5">
{files!.map((f) => (
<div key={f.id} className="group flex items-center gap-1.5 px-2 py-1 rounded border border-border/50 bg-muted/40 text-[11px] max-w-full">
<FileText className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="truncate text-foreground/90">{f.name}</span>
{!readOnly && onRemoveFile && (
<button
onClick={() => onRemoveFile(f.id)}
className="shrink-0 text-muted-foreground/50 hover:text-foreground/70"
title="Remove file"
>
<X className="h-2.5 w-2.5" />
</button>
)}
</div>
))}
</div>
</Section>
)}
{hasAudio && (
<Section
icon={Mic}
label={`Voice (${audioClips!.length})`}
onClear={onClearAudio}
defaultOpen={true}
readOnly={readOnly}
>
<div className="flex flex-col gap-1.5">
{audioClips!.map((clip) => (
<div key={clip.id} className="group flex items-center gap-2">
{clip.data ? (
<audio
controls
src={`data:audio/${clip.format};base64,${clip.data}`}
className="h-8 flex-1 min-w-0"
/>
) : (
<span className="flex-1 min-w-0 flex items-center gap-1.5 text-[11px] text-foreground/80">
<Mic className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="truncate">{clip.transcript || 'Voice note'}</span>
</span>
)}
{!readOnly && onRemoveAudio && (
<button
onClick={() => onRemoveAudio(clip.id)}
className="shrink-0 text-muted-foreground/50 hover:text-foreground/70"
title="Remove recording"
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</Section>
)}
</div>
);
}
/** Read-only version for user messages in chat history */
function ReadOnlyContext({
focusContext,
semanticBlocks,
imageBlocks,
fileNames,
audioBlocks,
summary,
defaultOpen,
}: {
focusContext?: FocusContextData | null;
semanticBlocks?: (PlacedBlock[] | SemanticBlockData[]);
imageBlocks?: ContentBlock[];
fileNames?: { name: string }[];
audioBlocks?: ContentBlock[];
summary: string;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen ?? false);
const hasBlocks = semanticBlocks && semanticBlocks.length > 0;
const hasFocus = !!focusContext;
const actualImageBlocks = imageBlocks?.filter(b => b.type === 'image_url') || [];
const hasImages = actualImageBlocks.length > 0;
const hasFiles = fileNames && fileNames.length > 0;
const actualAudioBlocks = audioBlocks?.filter(b => b.type === 'input_audio') || [];
const hasAudio = actualAudioBlocks.length > 0;
return (
<div className="mt-2">
<button
type="button"
className="flex items-center gap-1.5 text-[11px] text-primary hover:text-primary/80 transition-colors"
onClick={() => setOpen(prev => !prev)}
>
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0 transition-transform", !open && "-rotate-90")} />
<span>Context</span>
<span className="text-[10px]">({summary})</span>
</button>
{open && (
<div className="mt-1.5 rounded border border-border/40 bg-background/60 text-[10px] overflow-hidden">
{hasFocus && (
<div className="px-2.5 py-2 border-b border-border/30 last:border-b-0">
<div className="flex items-center gap-1.5 mb-1">
<Crosshair className="h-2.5 w-2.5 text-primary/60" />
<span className="font-medium text-foreground/80">Focus</span>
<span className="font-mono text-muted-foreground/50 truncate">{focusContext!.domPath.split(' > ').slice(-1)[0]}</span>
</div>
{focusContext!.snippet && (
<SnippetBox className="max-h-16 text-[9px]">{focusContext!.snippet}</SnippetBox>
)}
</div>
)}
{hasBlocks && (
<div className="px-2.5 py-2 border-b border-border/30 last:border-b-0">
<div className="flex items-center gap-1.5 mb-1">
<LayoutGrid className="h-2.5 w-2.5 text-primary/60" />
<span className="font-medium text-foreground/80">Blocks</span>
</div>
<div className="space-y-1.5">
{semanticBlocks!.map((block, i) => (
<div key={getBlockKey(block, i)}>
<div>
<span className="text-foreground/70 font-medium">{getBlockName(block)}</span>
<span className="text-muted-foreground/50 ml-1">{getBlockPosition(block)} {getBlockDomPath(block).split(' > ').slice(-2).join(' > ')}</span>
</div>
{getBlockDescription(block) && (
<SnippetBox className="max-h-14 text-[9px] mt-0.5">{getBlockDescription(block)}</SnippetBox>
)}
</div>
))}
</div>
</div>
)}
{hasImages && (
<div className="px-2.5 py-2">
<div className="flex items-center gap-1.5 mb-1.5">
<ImageIcon className="h-2.5 w-2.5 text-primary/60" />
<span className="font-medium text-foreground/80">Images ({actualImageBlocks.length})</span>
</div>
<div className="flex flex-wrap gap-1.5">
{actualImageBlocks.map((block, i) => (
block.type === 'image_url' && (
<img
key={`img-${i}`}
src={block.image_url.url}
alt="Attached"
className="h-10 w-auto rounded border border-border/50 object-cover"
/>
)
))}
</div>
</div>
)}
{hasFiles && (
<div className="px-2.5 py-2 border-t border-border/30">
<div className="flex items-center gap-1.5 mb-1.5">
<FileText className="h-2.5 w-2.5 text-primary/60" />
<span className="font-medium text-foreground/80">Files ({fileNames!.length})</span>
</div>
<div className="flex flex-wrap gap-1.5">
{fileNames!.map((f, i) => (
<span key={`file-${i}`} className="flex items-center gap-1 px-1.5 py-0.5 rounded border border-border/50 bg-muted/40 text-foreground/80">
<FileText className="h-2.5 w-2.5 shrink-0 text-muted-foreground" />
<span className="truncate max-w-[160px]">{f.name}</span>
</span>
))}
</div>
</div>
)}
{hasAudio && (
<div className="px-2.5 py-2 border-t border-border/30">
<div className="flex items-center gap-1.5 mb-1.5">
<Mic className="h-2.5 w-2.5 text-primary/60" />
<span className="font-medium text-foreground/80">Voice ({actualAudioBlocks.length})</span>
</div>
<div className="flex flex-col gap-1.5">
{actualAudioBlocks.map((block, i) => (
block.type === 'input_audio' && (
<audio
key={`aud-${i}`}
controls
src={`data:audio/${block.input_audio.format};base64,${block.input_audio.data}`}
className="h-8 w-full max-w-[280px]"
/>
)
))}
</div>
</div>
)}
</div>
)}
</div>
);
}