feat(chat): ✨ Update chat UI components and TypeScript types for enhanced chat experience
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
c98a5fc54f
commit
9030c597d8
4 changed files with 178 additions and 57 deletions
|
|
@ -1,11 +1,10 @@
|
|||
import { useRef, useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import styled, { css, keyframes } from '@lilith/ui-styled-components';
|
||||
import { AnimatePresence, motion } from '@lilith/ui-motion';
|
||||
import type { Message } from './types';
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
const EMOTION_COLORS: Record<string, string> = {
|
||||
|
|
@ -17,6 +16,11 @@ const EMOTION_COLORS: Record<string, string> = {
|
|||
neutral: '#a0aec0',
|
||||
};
|
||||
|
||||
const slideIn = keyframes`
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
`;
|
||||
|
||||
const underlineIn = keyframes`
|
||||
from { text-decoration-color: transparent; }
|
||||
to { text-decoration-color: currentColor; }
|
||||
|
|
@ -27,15 +31,23 @@ const underlineOut = keyframes`
|
|||
to { text-decoration-color: transparent; }
|
||||
`;
|
||||
|
||||
const Bubble = styled.div<{ $role: 'user' | 'assistant' }>`
|
||||
const blink = keyframes`
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
`;
|
||||
|
||||
type MessageRole = 'user' | 'assistant' | 'system';
|
||||
|
||||
const Bubble = styled.div<{ $role: MessageRole }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: ${({ $role }) => ($role === 'user' ? 'flex-end' : 'flex-start')};
|
||||
align-items: ${({ $role }) =>
|
||||
$role === 'user' ? 'flex-end' : $role === 'system' ? 'center' : 'flex-start'};
|
||||
margin: 4px 0;
|
||||
padding: 0 16px;
|
||||
`;
|
||||
|
||||
const BubbleBody = styled.div<{ $role: 'user' | 'assistant' }>`
|
||||
const BubbleBody = styled.div<{ $role: MessageRole; $error?: boolean }>`
|
||||
max-width: 80%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 18px;
|
||||
|
|
@ -43,18 +55,28 @@ const BubbleBody = styled.div<{ $role: 'user' | 'assistant' }>`
|
|||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
|
||||
${({ $role }) =>
|
||||
$role === 'user'
|
||||
${({ $role, $error }) =>
|
||||
$role === 'system'
|
||||
? css`
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
border-bottom-right-radius: 4px;
|
||||
background: ${$error ? '#2d1b1b' : '#1a1a2e'};
|
||||
color: ${$error ? '#f56565' : '#a0aec0'};
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
max-width: 90%;
|
||||
border-radius: 12px;
|
||||
`
|
||||
: css`
|
||||
background: #1a1a2e;
|
||||
color: #e2e8f0;
|
||||
border-bottom-left-radius: 4px;
|
||||
`}
|
||||
: $role === 'user'
|
||||
? css`
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
border-bottom-right-radius: 4px;
|
||||
`
|
||||
: css`
|
||||
background: #1a1a2e;
|
||||
color: #e2e8f0;
|
||||
border-bottom-left-radius: 4px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const EmotionDot = styled.span<{ $emotion: string }>`
|
||||
|
|
@ -68,6 +90,11 @@ const EmotionDot = styled.span<{ $emotion: string }>`
|
|||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
/** Wraps each sentence span — provides the slide-in animation on first render. */
|
||||
const PartWrapper = styled.span`
|
||||
animation: ${slideIn} 180ms ease-out both;
|
||||
`;
|
||||
|
||||
const PartSpan = styled.span<{ $speaking: boolean }>`
|
||||
text-decoration: none;
|
||||
text-decoration-color: transparent;
|
||||
|
|
@ -88,27 +115,47 @@ const PartSpan = styled.span<{ $speaking: boolean }>`
|
|||
`}
|
||||
`;
|
||||
|
||||
export function ChatMessage({ message }: ChatMessageProps): ReactElement {
|
||||
const StreamingCursor = styled.span`
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
background: currentColor;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
opacity: 0.7;
|
||||
animation: ${blink} 900ms step-end infinite;
|
||||
`;
|
||||
|
||||
export function ChatMessage({ message, isStreaming = false }: ChatMessageProps): ReactElement {
|
||||
const isAssistant = message.role === 'assistant';
|
||||
const isSystem = message.role === 'system';
|
||||
|
||||
return (
|
||||
<Bubble $role={message.role}>
|
||||
<BubbleBody $role={message.role}>
|
||||
{isAssistant && message.parts.length > 0 && (
|
||||
<EmotionDot $emotion={message.emotion} title={message.emotion} />
|
||||
)}
|
||||
{message.parts.length === 0 ? (
|
||||
<TypingIndicator />
|
||||
<BubbleBody $role={message.role} $error={message.error ?? false}>
|
||||
{isSystem ? (
|
||||
<span>{message.parts[0]}</span>
|
||||
) : (
|
||||
message.parts.map((part, idx) => (
|
||||
<PartSpan
|
||||
key={idx}
|
||||
$speaking={message.speakingPartIndex === idx}
|
||||
>
|
||||
{part}
|
||||
{idx < message.parts.length - 1 ? ' ' : ''}
|
||||
</PartSpan>
|
||||
))
|
||||
<>
|
||||
{isAssistant && message.parts.length > 0 && (
|
||||
<EmotionDot $emotion={message.emotion} title={message.emotion} />
|
||||
)}
|
||||
{message.parts.length === 0 ? (
|
||||
<TypingIndicator />
|
||||
) : (
|
||||
<>
|
||||
{message.parts.map((part, idx) => (
|
||||
<PartWrapper key={idx}>
|
||||
<PartSpan $speaking={message.speakingPartIndex === idx}>
|
||||
{part}
|
||||
</PartSpan>
|
||||
{idx < message.parts.length - 1 ? ' ' : ''}
|
||||
</PartWrapper>
|
||||
))}
|
||||
{isStreaming && <StreamingCursor aria-hidden />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</BubbleBody>
|
||||
</Bubble>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { Message } from './types';
|
|||
|
||||
interface ChatViewProps {
|
||||
messages: Message[];
|
||||
activeAssistantId: string | null;
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
|
|
@ -33,7 +34,7 @@ const ScrollAnchor = styled.div`
|
|||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export function ChatView({ messages }: ChatViewProps): ReactElement {
|
||||
export function ChatView({ messages, activeAssistantId }: ChatViewProps): ReactElement {
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -53,7 +54,15 @@ export function ChatView({ messages }: ChatViewProps): ReactElement {
|
|||
<Container ref={containerRef}>
|
||||
{messages.length === 0 && <Spacer />}
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage key={msg.id} message={msg} />
|
||||
<ChatMessage
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
isStreaming={
|
||||
msg.role === 'assistant' &&
|
||||
msg.id === activeAssistantId &&
|
||||
msg.parts.length > 0
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<ScrollAnchor ref={anchorRef} />
|
||||
</Container>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import { useState, useRef, useCallback } from 'react';
|
||||
import type { ReactElement, KeyboardEvent } from 'react';
|
||||
import type { ReactElement, KeyboardEvent, MutableRefObject } from 'react';
|
||||
import styled from '@lilith/ui-styled-components';
|
||||
import { Tooltip } from '@lilith/ui-feedback';
|
||||
import type { SegmentEvent } from '@lilith/companion-client/types';
|
||||
import { chatWithRecovery } from '../errors/sessionRecovery';
|
||||
|
||||
interface TextInputProps {
|
||||
sessionId: string;
|
||||
sessionId: MutableRefObject<string>;
|
||||
apiBaseUrl: string;
|
||||
onTranscript: (text: string) => void;
|
||||
onSegment: (event: SegmentEvent) => void;
|
||||
onError: (message: string) => void;
|
||||
onComplete?: () => void;
|
||||
/** Called before the SSE stream starts — use to initialise AudioContext on user gesture */
|
||||
onWillSend?: () => Promise<void>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -82,6 +88,9 @@ export function TextInput({
|
|||
apiBaseUrl,
|
||||
onTranscript,
|
||||
onSegment,
|
||||
onError,
|
||||
onComplete,
|
||||
onWillSend,
|
||||
disabled = false,
|
||||
}: TextInputProps): ReactElement {
|
||||
const [value, setValue] = useState('');
|
||||
|
|
@ -118,18 +127,14 @@ export function TextInput({
|
|||
}
|
||||
|
||||
onTranscript(text);
|
||||
await onWillSend?.();
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId, message: text }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const response = await chatWithRecovery(apiBaseUrl, sessionId, text, controller.signal);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Chat request failed: ${response.status}`);
|
||||
|
|
@ -139,14 +144,16 @@ export function TextInput({
|
|||
throw new Error('No response body from chat endpoint');
|
||||
}
|
||||
|
||||
await parseSSEStream(response.body, onSegment, controller.signal);
|
||||
await parseSSEStream(response.body, onSegment, onError, controller.signal);
|
||||
onComplete?.();
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return;
|
||||
console.error('Chat send failed:', err);
|
||||
const msg = err instanceof Error ? err.message : 'Could not send message';
|
||||
onError(msg);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [value, sending, sessionId, apiBaseUrl, onTranscript, onSegment]);
|
||||
}, [value, sending, sessionId, apiBaseUrl, onTranscript, onSegment, onError, onComplete, onWillSend]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
|
|
@ -170,16 +177,18 @@ export function TextInput({
|
|||
rows={1}
|
||||
aria-label="Message input"
|
||||
/>
|
||||
<SendButton
|
||||
$active={canSend}
|
||||
onClick={() => { if (canSend) void sendMessage(); }}
|
||||
disabled={!canSend}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<SendIcon viewBox="0 0 24 24" aria-hidden>
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</SendIcon>
|
||||
</SendButton>
|
||||
<Tooltip content="Send (Enter)" position="top">
|
||||
<SendButton
|
||||
$active={canSend}
|
||||
onClick={() => { if (canSend) void sendMessage(); }}
|
||||
disabled={!canSend}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<SendIcon viewBox="0 0 24 24" aria-hidden>
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</SendIcon>
|
||||
</SendButton>
|
||||
</Tooltip>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
|
@ -190,11 +199,13 @@ export function TextInput({
|
|||
async function parseSSEStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
onSegment: (event: SegmentEvent) => void,
|
||||
onError: (message: string) => void,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let consecutiveParseErrors = 0;
|
||||
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
|
|
@ -221,8 +232,14 @@ async function parseSSEStream(
|
|||
if (eventType === 'segment' || parsed.type === 'segment') {
|
||||
onSegment(parsed);
|
||||
}
|
||||
consecutiveParseErrors = 0;
|
||||
} catch {
|
||||
// Malformed SSE event — skip silently
|
||||
consecutiveParseErrors++;
|
||||
if (consecutiveParseErrors >= 3) {
|
||||
onError('Response stream corrupted. Try sending again.');
|
||||
void reader.cancel();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
eventType = '';
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
emotion: string;
|
||||
parts: string[];
|
||||
speakingPartIndex: number | null;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export interface HistoryMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
emotion: string | null;
|
||||
}
|
||||
|
||||
export type ChatAction =
|
||||
|
|
@ -11,7 +19,11 @@ export type ChatAction =
|
|||
| { type: 'INIT_ASSISTANT_MESSAGE'; id: string }
|
||||
| { type: 'APPEND_SEGMENT'; id: string; partIndex: number; text: string; emotion: string }
|
||||
| { type: 'SET_SPEAKING'; id: string; partIndex: number }
|
||||
| { type: 'CLEAR_SPEAKING'; id: string };
|
||||
| { type: 'CLEAR_SPEAKING'; id: string }
|
||||
| { type: 'ADD_SYSTEM_MESSAGE'; id: string; text: string; error?: boolean }
|
||||
| { type: 'REMOVE_MESSAGE'; id: string }
|
||||
| { type: 'LOAD_HISTORY'; messages: HistoryMessage[] }
|
||||
| { type: 'CLEAR_ACTIVE_ASSISTANT' };
|
||||
|
||||
export interface ChatState {
|
||||
messages: Message[];
|
||||
|
|
@ -76,6 +88,42 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState {
|
|||
),
|
||||
};
|
||||
}
|
||||
|
||||
case 'ADD_SYSTEM_MESSAGE': {
|
||||
const msg: Message = {
|
||||
id: action.id,
|
||||
role: 'system',
|
||||
emotion: 'neutral',
|
||||
parts: [action.text],
|
||||
speakingPartIndex: null,
|
||||
error: action.error ?? false,
|
||||
};
|
||||
return { ...state, messages: [...state.messages, msg] };
|
||||
}
|
||||
|
||||
case 'REMOVE_MESSAGE': {
|
||||
return {
|
||||
...state,
|
||||
messages: state.messages.filter((m) => m.id !== action.id),
|
||||
activeAssistantId:
|
||||
state.activeAssistantId === action.id ? null : state.activeAssistantId,
|
||||
};
|
||||
}
|
||||
|
||||
case 'CLEAR_ACTIVE_ASSISTANT': {
|
||||
return { ...state, activeAssistantId: null };
|
||||
}
|
||||
|
||||
case 'LOAD_HISTORY': {
|
||||
const messages: Message[] = action.messages.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
emotion: m.emotion ?? 'neutral',
|
||||
parts: [m.content],
|
||||
speakingPartIndex: null,
|
||||
}));
|
||||
return { ...state, messages };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue