InspectorRAGet / src /components /chat /ChatLine.tsx
kpfadnis's picture
refactor: type consolidation, chat component promotion, generation UX fixes, converter cleanup
753fcc5
Raw
History Blame
11.1 kB
/**
*
* Copyright 2023-present InspectorRAGet Team
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
**/
import cx from 'classnames';
import Balancer from 'react-wrap-balancer';
import { useState, useEffect, useRef } from 'react';
import { Modal, Button, DefinitionTooltip } from '@carbon/react';
import {
CheckmarkFilled,
WarningFilled,
ErrorFilled,
Restart,
} from '@carbon/icons-react';
import {
ToolCallCard,
ToolResponseCard,
} from '@/src/components/tools/ToolCards';
import {
Message,
MessageRetry,
ToolMessage,
AssistantMessage,
} from '@/src/types';
import Avatar from '@/src/components/chat/Avatar';
import DocumentsViewer from '@/src/components/chat/DocumentsViewer';
import classes from './ChatLine.module.scss';
// ===================================================================================
// TYPES
// ===================================================================================
interface ChatLineProps {
messageId: string;
message: Message;
latestResponse?: boolean;
onSelection?: Function;
focused?: boolean;
selected?: boolean;
}
// ===================================================================================
// RENDER FUNCTIONS
// ===================================================================================
const STATUS_CONFIG = {
pass: {
icon: CheckmarkFilled,
label: 'Pass',
badgeClass: classes.statusBadgePass,
},
warn: {
icon: WarningFilled,
label: 'Warn',
badgeClass: classes.statusBadgeWarn,
},
fail: {
icon: ErrorFilled,
label: 'Fail',
badgeClass: classes.statusBadgeFail,
},
} as const;
function RetriesModal({
retries,
open,
onClose,
}: {
retries: MessageRetry[];
open: boolean;
onClose: () => void;
}) {
return (
<Modal
open={open}
onRequestClose={onClose}
modalHeading={`Retry Attempts (${retries.length})`}
passiveModal
size="md"
>
<div className={classes.retriesModalContent}>
{retries.map((retry, idx) => (
<div key={idx} className={classes.retryAttempt}>
<span className={classes.retryAttemptHeader}>
Attempt {idx + 1}
</span>
{retry.error && (
<div className={classes.retryError}>
<span className={classes.retryErrorLabel}>Error</span>
<span className={classes.retryErrorText}>{retry.error}</span>
</div>
)}
{retry.content && <p>{retry.content}</p>}
{retry.tool_calls &&
retry.tool_calls.map((call, callIdx) => (
<ToolCallCard
key={callIdx}
call={call}
defaultExpanded={
retry.tool_calls!.length <= 2 &&
JSON.stringify(call.arguments).length <= 120
}
/>
))}
</div>
))}
</div>
</Modal>
);
}
function BalloonFooter({
status,
statusDefinition,
retries,
}: {
status?: string;
statusDefinition?: string;
retries?: MessageRetry[];
}) {
const [retriesOpen, setRetriesOpen] = useState(false);
const hasStatus = status && status in STATUS_CONFIG;
const hasRetries = retries && retries.length > 0;
if (!hasStatus && !hasRetries) return null;
const config = hasStatus
? STATUS_CONFIG[status as keyof typeof STATUS_CONFIG]
: null;
return (
<>
<div className={classes.balloonFooter}>
<div className={classes.balloonFooterRight}>
{config &&
(statusDefinition ? (
<DefinitionTooltip
definition={statusDefinition}
align="top-right"
openOnHover
className={cx(classes.statusBadge, config.badgeClass)}
>
<config.icon size={14} />
{config.label}
</DefinitionTooltip>
) : (
<span className={cx(classes.statusBadge, config.badgeClass)}>
<config.icon size={14} />
{config.label}
</span>
))}
{hasRetries && (
<Button
kind="ghost"
size="sm"
renderIcon={Restart}
onClick={() => setRetriesOpen(true)}
>
{retries.length} {retries.length === 1 ? 'retry' : 'retries'}
</Button>
)}
</div>
</div>
{hasRetries && (
<RetriesModal
retries={retries}
open={retriesOpen}
onClose={() => setRetriesOpen(false)}
/>
)}
</>
);
}
function ToolResponseContent({
messageId,
message,
onSelection,
}: {
messageId: string;
message: ToolMessage;
onSelection?: Function;
}) {
const [documentIndex, setDocumentIndex] = useState<number>(0);
if (message.type === 'documents' && Array.isArray(message.content)) {
return (
<div className={classes.toolResponse}>
<DocumentsViewer
key={`${messageId}__documents--${message.content.length}`}
id={`${messageId}__documents`}
documents={message.content}
documentIndex={documentIndex}
setDocumentIndex={setDocumentIndex}
onSelection={onSelection}
/>
</div>
);
}
const contentStr =
message.type === 'json' || typeof message.content !== 'string'
? JSON.stringify(message.content, null, 2)
: (message.content as string);
return (
<div className={classes.toolResponse}>
<ToolResponseCard
toolCallId={message.tool_call_id}
name={message.name}
content={contentStr}
/>
</div>
);
}
function AssistantResponse({
messageId,
message,
onSelection,
}: {
messageId: string;
message: AssistantMessage;
onSelection?: Function;
}) {
return (
<div className={classes.assistantResponse}>
{message.content ? (
<Balancer
className={cx(classes.message, classes.assistantMessage)}
ratio={0.2}
onMouseDown={() => {
if (onSelection) {
onSelection(
`messages[${messageId.split('--').slice(-1)[0]}].content`,
);
}
}}
onMouseUp={() => {
if (onSelection) {
onSelection(
`messages[${messageId.split('--').slice(-1)[0]}].content`,
);
}
}}
>
{message.content.split('\n').map((line, i) => (
<span key={i}>
{line}
<br />
</span>
))}
</Balancer>
) : null}
{message.tool_calls
? message.tool_calls.map((tool, toolIdx) => {
// Collapse if there are more than 2 calls, or if the single call's args are long
const defaultExpanded =
message.tool_calls!.length <= 2 &&
JSON.stringify(tool.arguments).length <= 120;
return (
<div
key={`message-${messageId}__tool-${toolIdx}`}
className={classes.toolCallItem}
>
<ToolCallCard call={tool} defaultExpanded={defaultExpanded} />
</div>
);
})
: null}
</div>
);
}
// ===================================================================================
// MAIN FUNCTIONS
// ===================================================================================
export default function ChatLine({
messageId,
message,
latestResponse,
onSelection,
focused,
selected,
}: ChatLineProps) {
const anchorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (anchorRef.current && focused) {
anchorRef.current.scrollIntoView({
behavior: 'smooth',
block: message.role === 'user' ? 'start' : 'center',
inline: 'center',
});
}
}, [focused, message.role]);
if (!message) {
return null;
}
const status = message.metadata?.status as string | undefined;
const statusDefinition = message.metadata?.statusDefinition as
| string
| undefined;
const retries = message.retries;
return (
<div
ref={anchorRef}
className={cx(classes.line, {
[classes.assistantLine]: message.role === 'assistant',
[classes.latestResponse]: latestResponse,
})}
>
<Avatar role={message.role} selected={selected} status={status} />
<div
className={cx(
classes.baloon,
message.role === 'assistant'
? //@ts-ignore
message.tool_calls
? classes.toolCallBaloon
: classes.assistantBaloon
: message.role === 'tool'
? classes.toolResponseBaloon
: null,
{ [classes.baloonSelected]: selected },
)}
>
{message.role === 'system' ||
message.role === 'developer' ||
message.role === 'user' ? (
<Balancer
className={cx(classes.message)}
ratio={0.2}
onMouseDown={() => {
if (onSelection) {
onSelection(
`messages[${messageId.split('--').slice(-1)[0]}].text`,
);
}
}}
onMouseUp={() => {
if (onSelection) {
onSelection(
`messages[${messageId.split('--').slice(-1)[0]}].text`,
);
}
}}
>
{message.content.split('\n').map((line, i) => (
<span key={i}>
{line}
<br />
</span>
))}
</Balancer>
) : message.role === 'tool' ? (
<ToolResponseContent
messageId={messageId}
// @ts-ignoremessage is narrowed to role==='tool' by the condition above,
// but the base Message type lacks ToolMessage fields (tool_call_id, type).
message={message}
onSelection={onSelection}
/>
) : (
<AssistantResponse
messageId={messageId}
//@ts-ignore
message={message}
onSelection={onSelection}
/>
)}
<BalloonFooter
status={status}
statusDefinition={statusDefinition}
retries={retries}
/>
</div>
</div>
);
}