chat-panel.tsx 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. 'use client';
  2. import { useState, useRef, useEffect } from 'react';
  3. import { ArrowUp } from 'lucide-react';
  4. import type { PBLChatMessage, PBLIssue } from '@/lib/pbl/types';
  5. import { useI18n } from '@/lib/hooks/use-i18n';
  6. import { MessageResponse } from '@/components/ai-elements/message';
  7. import { useDraftCache } from '@/lib/hooks/use-draft-cache';
  8. import { SpeechButton } from '@/components/audio/speech-button';
  9. interface ChatPanelProps {
  10. readonly messages: PBLChatMessage[];
  11. readonly currentIssue: PBLIssue | null;
  12. readonly userRole: string;
  13. readonly isLoading: boolean;
  14. readonly onSendMessage: (text: string) => void;
  15. }
  16. export function ChatPanel({
  17. messages,
  18. currentIssue,
  19. userRole,
  20. isLoading,
  21. onSendMessage,
  22. }: ChatPanelProps) {
  23. const { t } = useI18n();
  24. const [input, setInput] = useState('');
  25. const messagesEndRef = useRef<HTMLDivElement>(null);
  26. const inputRef = useRef<HTMLTextAreaElement>(null);
  27. const composingRef = useRef(false);
  28. // Draft cache
  29. const {
  30. cachedValue: cachedDraft,
  31. updateCache: updateDraftCache,
  32. clearCache: clearDraftCache,
  33. } = useDraftCache<string>({ key: 'pblChatDraft' });
  34. // Restore draft: use lazy initializer for first render, then sync via derived state
  35. const [prevCachedDraft, setPrevCachedDraft] = useState(cachedDraft);
  36. if (cachedDraft !== prevCachedDraft) {
  37. setPrevCachedDraft(cachedDraft);
  38. if (cachedDraft) {
  39. setInput(cachedDraft);
  40. }
  41. }
  42. // Auto-scroll on new messages
  43. useEffect(() => {
  44. messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  45. }, [messages.length]);
  46. const handleInputChange = (value: string) => {
  47. setInput(value);
  48. updateDraftCache(value);
  49. };
  50. const handleSubmit = () => {
  51. if (!input.trim() || isLoading) return;
  52. onSendMessage(input.trim());
  53. setInput('');
  54. clearDraftCache();
  55. };
  56. const handleKeyDown = (e: React.KeyboardEvent) => {
  57. if (e.key === 'Enter' && !e.shiftKey && !composingRef.current) {
  58. e.preventDefault();
  59. handleSubmit();
  60. }
  61. };
  62. return (
  63. <div className="flex flex-col h-full">
  64. {/* Header */}
  65. <div className="px-4 py-3 border-b">
  66. <h2 className="font-semibold text-sm">{t('pbl.chat.title')}</h2>
  67. {currentIssue && (
  68. <p className="text-xs text-muted-foreground mt-0.5">
  69. {t('pbl.chat.currentIssue')}: {currentIssue.title}
  70. </p>
  71. )}
  72. </div>
  73. {/* Messages */}
  74. <div className="flex-1 overflow-y-auto p-4 space-y-3">
  75. {messages.map((msg) => (
  76. <ChatMessage key={msg.id} message={msg} isUser={msg.agent_name === userRole} />
  77. ))}
  78. {isLoading && (
  79. <div className="flex items-center gap-2 text-muted-foreground text-sm">
  80. <div className="flex gap-1">
  81. <span className="animate-bounce" style={{ animationDelay: '0ms' }}>
  82. .
  83. </span>
  84. <span className="animate-bounce" style={{ animationDelay: '150ms' }}>
  85. .
  86. </span>
  87. <span className="animate-bounce" style={{ animationDelay: '300ms' }}>
  88. .
  89. </span>
  90. </div>
  91. </div>
  92. )}
  93. <div ref={messagesEndRef} />
  94. </div>
  95. {/* Input */}
  96. <div className="border-t p-3">
  97. <div className="flex items-center gap-2 text-[10px] text-muted-foreground mb-2">
  98. <span>{t('pbl.chat.mentionHint')}</span>
  99. </div>
  100. <div className="flex gap-2 items-center">
  101. <textarea
  102. ref={inputRef}
  103. value={input}
  104. onChange={(e) => handleInputChange(e.target.value)}
  105. onKeyDown={handleKeyDown}
  106. onCompositionStart={() => {
  107. composingRef.current = true;
  108. }}
  109. onCompositionEnd={() => {
  110. composingRef.current = false;
  111. }}
  112. placeholder={t('pbl.chat.placeholder')}
  113. disabled={isLoading}
  114. rows={1}
  115. className="flex-1 resize-none rounded-lg border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50"
  116. />
  117. <SpeechButton
  118. size="md"
  119. disabled={isLoading}
  120. onTranscription={(text) => {
  121. setInput((prev) => {
  122. const next = prev + (prev ? ' ' : '') + text;
  123. updateDraftCache(next);
  124. return next;
  125. });
  126. }}
  127. />
  128. <button
  129. onClick={handleSubmit}
  130. disabled={!input.trim() || isLoading}
  131. className="shrink-0 h-8 w-8 rounded-lg flex items-center justify-center transition-colors bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
  132. >
  133. <ArrowUp className="w-4 h-4" />
  134. </button>
  135. </div>
  136. </div>
  137. </div>
  138. );
  139. }
  140. function ChatMessage({ message, isUser }: { message: PBLChatMessage; isUser: boolean }) {
  141. const isSystem = message.agent_name === 'System';
  142. if (isSystem) {
  143. return (
  144. <div className="flex justify-center">
  145. <span className="text-xs text-muted-foreground bg-muted/50 rounded-full px-3 py-1">
  146. {message.message}
  147. </span>
  148. </div>
  149. );
  150. }
  151. return (
  152. <div className={`flex flex-col ${isUser ? 'items-end' : 'items-start'}`}>
  153. <span className="text-[10px] font-medium text-muted-foreground mb-0.5 px-1">
  154. {message.agent_name}
  155. </span>
  156. <div
  157. className={`rounded-xl px-3 py-2 text-sm max-w-[85%] ${
  158. isUser ? 'bg-primary text-primary-foreground whitespace-pre-wrap' : 'bg-muted'
  159. }`}
  160. >
  161. {isUser ? message.message : <MessageResponse>{message.message}</MessageResponse>}
  162. </div>
  163. </div>
  164. );
  165. }