182 lines
6.0 KiB
TypeScript
182 lines
6.0 KiB
TypeScript
import React from 'react';
|
|
import type { ComponentType } from 'react';
|
|
import type { Part } from '@opencode-ai/sdk';
|
|
import { RiArrowDownSLine, RiArrowRightSLine, RiBrainAi3Line, RiChatAi3Line } from '@remixicon/react';
|
|
import { cn } from '@/lib/utils';
|
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|
|
|
type PartWithText = Part & { text?: string; content?: string };
|
|
|
|
export type ReasoningVariant = 'thinking' | 'justification';
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
type IconComponent = ComponentType<any>;
|
|
|
|
const variantConfig: Record<
|
|
ReasoningVariant,
|
|
{ label: string; Icon: IconComponent }
|
|
> = {
|
|
thinking: { label: 'Thinking', Icon: RiBrainAi3Line },
|
|
justification: { label: 'Justification', Icon: RiChatAi3Line },
|
|
};
|
|
|
|
const cleanReasoningText = (text: string): string => {
|
|
if (typeof text !== 'string' || text.trim().length === 0) {
|
|
return '';
|
|
}
|
|
|
|
return text
|
|
.split('\n')
|
|
.map((line: string) => line.replace(/^>\s?/, '').trimEnd())
|
|
.filter((line: string) => line.trim().length > 0)
|
|
.join('\n')
|
|
.trim();
|
|
};
|
|
|
|
const getReasoningSummary = (text: string): string => {
|
|
if (!text) {
|
|
return '';
|
|
}
|
|
|
|
const trimmed = text.trim();
|
|
const newlineIndex = trimmed.indexOf('\n');
|
|
const periodIndex = trimmed.indexOf('.');
|
|
|
|
const cutoffCandidates = [
|
|
newlineIndex >= 0 ? newlineIndex : Infinity,
|
|
periodIndex >= 0 ? periodIndex : Infinity,
|
|
];
|
|
const cutoff = Math.min(...cutoffCandidates);
|
|
|
|
if (!Number.isFinite(cutoff)) {
|
|
return trimmed;
|
|
}
|
|
|
|
return trimmed.substring(0, cutoff).trim();
|
|
};
|
|
|
|
type ReasoningTimelineBlockProps = {
|
|
text: string;
|
|
variant: ReasoningVariant;
|
|
onContentChange?: (reason?: ContentChangeReason) => void;
|
|
blockId: string;
|
|
};
|
|
|
|
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|
text,
|
|
variant,
|
|
onContentChange,
|
|
blockId,
|
|
}) => {
|
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
|
|
|
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
|
|
const { label, Icon } = variantConfig[variant];
|
|
|
|
React.useEffect(() => {
|
|
if (text.trim().length === 0) {
|
|
return;
|
|
}
|
|
onContentChange?.('structural');
|
|
}, [onContentChange, isExpanded, text]);
|
|
|
|
if (!text || text.trim().length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="my-1" data-reasoning-block-id={blockId}>
|
|
<div
|
|
className={cn(
|
|
'group/tool flex items-center gap-2 pr-2 pl-px py-1.5 rounded-xl cursor-pointer'
|
|
)}
|
|
onClick={() => setIsExpanded((prev) => !prev)}
|
|
>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<div className="relative h-3.5 w-3.5 flex-shrink-0">
|
|
<div
|
|
className={cn(
|
|
'absolute inset-0 transition-opacity',
|
|
isExpanded && 'opacity-0',
|
|
!isExpanded && 'group-hover/tool:opacity-0'
|
|
)}
|
|
>
|
|
<Icon className="h-3.5 w-3.5" />
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
'absolute inset-0 transition-opacity flex items-center justify-center',
|
|
isExpanded && 'opacity-100',
|
|
!isExpanded && 'opacity-0 group-hover/tool:opacity-100'
|
|
)}
|
|
>
|
|
{isExpanded ? <RiArrowDownSLine className="h-3.5 w-3.5" /> : <RiArrowRightSLine className="h-3.5 w-3.5" />}
|
|
</div>
|
|
</div>
|
|
<span className="typography-meta font-medium">{label}</span>
|
|
</div>
|
|
|
|
{summary && (
|
|
<div className="flex-1 min-w-0 typography-meta text-muted-foreground/70">
|
|
<span className="truncate block">{summary}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div
|
|
className={cn(
|
|
'relative pr-2 pb-2 pt-2 pl-[1.4375rem]',
|
|
'before:absolute before:left-[0.4375rem] before:w-px before:bg-border/80 before:content-[""]',
|
|
'before:top-[-0.25rem] before:bottom-0'
|
|
)}
|
|
>
|
|
<ScrollableOverlay
|
|
as="blockquote"
|
|
outerClassName="max-h-80"
|
|
className="whitespace-pre-wrap break-words typography-meta italic text-muted-foreground/70 p-0"
|
|
>
|
|
{text}
|
|
</ScrollableOverlay>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type ReasoningPartProps = {
|
|
part: Part;
|
|
onContentChange?: (reason?: ContentChangeReason) => void;
|
|
messageId: string;
|
|
};
|
|
|
|
const ReasoningPart: React.FC<ReasoningPartProps> = ({
|
|
part,
|
|
onContentChange,
|
|
messageId,
|
|
}) => {
|
|
const partWithText = part as PartWithText;
|
|
const rawText = partWithText.text || partWithText.content || '';
|
|
const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]);
|
|
|
|
const timeInfo = 'time' in part ? (part.time as { start: number; end?: number }) : null;
|
|
if (!timeInfo?.end) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<ReasoningTimelineBlock
|
|
text={textContent}
|
|
variant="thinking"
|
|
onContentChange={onContentChange}
|
|
blockId={part.id || `${messageId}-reasoning`}
|
|
/>
|
|
);
|
|
};
|
|
|
|
// eslint-disable-next-line react-refresh/only-export-components
|
|
export const formatReasoningText = (text: string): string => cleanReasoningText(text);
|
|
|
|
export default ReasoningPart;
|