Initial public release
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user