SPB Git

spb/uqo-chat

Public
4.4 KB · 103 lines tsx
1import { useEffect, useMemo, useRef, useState } from 'react';2import { useNavigate, useParams } from 'react-router-dom';3import { useQuery } from '@tanstack/react-query';4import { ArrowDown } from 'lucide-react';5import { api } from '@/lib/api';6import type { Course } from '@/lib/types';7import { useAuth } from '@/stores/auth';8import { useChat } from '@/stores/chat';9import { useUI } from '@/stores/ui';10import { MessageBubble } from './message-bubble';11import { Composer } from './composer';12import { EmptyState } from './empty-state';1314export function ChatView() {15  const { id } = useParams();16  const nav = useNavigate();17  const user = useAuth((s) => s.user);18  const { course, setCourse } = useUI();19  const { messages, streaming, warnings, loadMessages, createConversation, send, stop, regenerate, feedback } = useChat();20  const [deep, setDeep] = useState(!!user?.preferences.deep);21  const [atBottom, setAtBottom] = useState(true);22  const scrollRef = useRef<HTMLDivElement>(null);23  const { data: courses } = useQuery({ queryKey: ['courses'], queryFn: () => api<Course[]>('/courses'), staleTime: 300_000 });24  const conv = useChat((s) => s.conversations.find((c) => c.id === id));25  const list = id ? messages[id] || [] : [];26  const isStreaming = id ? !!streaming[id] : false;27  const activeCourse = conv?.course || course;28  const courseObj = useMemo(() => courses?.find((c) => c.code === activeCourse), [courses, activeCourse]);29  const courseCodes = (courses || []).map((c) => c.code);3031  useEffect(() => {32    if (id && !messages[id]) loadMessages(id).catch(() => nav('/'));33  }, [id, messages, loadMessages, nav]);3435  useEffect(() => {36    if (atBottom && list.length > 0) scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });37  }, [list, atBottom]);3839  const onScroll = () => {40    const el = scrollRef.current;41    if (!el) return;42    setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 80);43  };4445  const handleSend = async (text: string, attachments: string[]) => {46    let convId = id;47    if (!convId) {48      const c = await createConversation(activeCourse);49      convId = c.id;50      nav(`/c/${c.id}`, { replace: true });51    }52    setAtBottom(true);53    await send(convId, text, attachments, deep);54  };5556  const initials = (user?.display_name || user?.email || 'É').slice(0, 2).toUpperCase();57  const lastAssistant = [...list].reverse().find((m) => m.role === 'assistant');5859  return (60    <div className="flex h-full flex-col">61      {isStreaming && <div className="progress-bar" aria-hidden="true" />}62      {id && warnings[id] && <div className="bg-[#fff8dc] text-[#7a6300] text-sm px-4 py-2 text-center">{warnings[id]}</div>}63      <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto scroll-thin">64        {!id || list.length === 0 ? (65          <EmptyState course={courseObj} onPick={(t) => handleSend(t, [])} term={courseObj?.term || ''} />66        ) : (67          <div className="mx-auto max-w-[900px] px-3 sm:px-6 py-4 space-y-5">68            {list.map((m, i) => (69              <MessageBubble70                key={m.id}71                msg={m}72                conversationId={id}73                initials={initials}74                isLast={i === list.length - 1}75                onRegenerate={m.role === 'assistant' && lastAssistant?.id === m.id && !isStreaming && !m.id.startsWith('pending') ? () => regenerate(id, m.id, deep) : undefined}76                onFeedback={m.role === 'assistant' && !m.id.startsWith('pending') ? (fb) => feedback(id, m.id, fb) : undefined}77              />78            ))}79          </div>80        )}81      </div>82      {!atBottom && list.length > 0 && (83        <button onClick={() => { setAtBottom(true); scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); }}84          className="absolute right-4 bottom-[132px] h-10 w-10 rounded-full bg-white border border-neutral-line shadow-card inline-flex items-center justify-center text-uqo-blue" aria-label="Aller en bas">85          <ArrowDown size={18} />86        </button>87      )}88      <Composer89        conversationId={id || null}90        streaming={isStreaming}91        onSend={handleSend}92        onStop={() => id && stop(id)}93        deep={deep}94        onToggleDeep={() => setDeep((v) => !v)}95        course={activeCourse}96        onCourseChange={setCourse}97        courses={courseCodes.length ? courseCodes : ['IMM1003', 'IMM1033']}98        disabled={!user}99      />100    </div>101  );102}103
Files · cd08d441f7c277e8fedd6f3d3be82f02b44553ba
Menu

Theme