SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
4.9 KB · 110 lines tsx
Raw Blame History
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 && !messages[id] ? (65          <div className="mx-auto max-w-[900px] px-3 sm:px-6 py-6 space-y-5" aria-busy="true">66            <div className="flex justify-end"><div className="skeleton h-12 w-[60%]" /></div>67            <div className="space-y-2"><div className="skeleton h-14 w-full" /><div className="skeleton h-28 w-[92%]" /></div>68            <div className="flex justify-end"><div className="skeleton h-10 w-[45%]" /></div>69            <div className="skeleton h-36 w-[88%]" />70          </div>71        ) : !id || list.length === 0 ? (72          <EmptyState course={courseObj} onPick={(t) => handleSend(t, [])} term={courseObj?.term || ''} />73        ) : (74          <div className="mx-auto max-w-[900px] px-3 sm:px-6 py-4 space-y-5">75            {list.map((m, i) => (76              <MessageBubble77                key={m.id}78                msg={m}79                conversationId={id}80                initials={initials}81                isLast={i === list.length - 1}82                onRegenerate={m.role === 'assistant' && lastAssistant?.id === m.id && !isStreaming && !m.id.startsWith('pending') ? () => regenerate(id, m.id, deep) : undefined}83                onFeedback={m.role === 'assistant' && !m.id.startsWith('pending') ? (fb) => feedback(id, m.id, fb) : undefined}84              />85            ))}86          </div>87        )}88      </div>89      {!atBottom && list.length > 0 && (90        <button onClick={() => { setAtBottom(true); scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); }}91          className="absolute right-4 bottom-[172px] sm:bottom-[150px] h-10 w-10 rounded-full bg-white border border-neutral-line shadow-float inline-flex items-center justify-center text-uqo-blue active:scale-95 transition" aria-label="Aller en bas">92          <ArrowDown size={18} />93        </button>94      )}95      <Composer96        conversationId={id || null}97        streaming={isStreaming}98        onSend={handleSend}99        onStop={() => id && stop(id)}100        deep={deep}101        onToggleDeep={() => setDeep((v) => !v)}102        course={activeCourse}103        onCourseChange={setCourse}104        courses={courseCodes.length ? courseCodes : ['IMM1003', 'IMM1033']}105        disabled={!user}106      />107    </div>108  );109}110