SidePanel.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654
  1. /* eslint-disable @typescript-eslint/no-explicit-any */
  2. import { useState, useEffect, useCallback, useRef } from 'react';
  3. import { RxDiscordLogo } from 'react-icons/rx';
  4. import { FiSettings } from 'react-icons/fi';
  5. import { PiPlusBold } from 'react-icons/pi';
  6. import { GrHistory } from 'react-icons/gr';
  7. import { type Message, Actors, chatHistoryStore } from '@extension/storage';
  8. import MessageList from './components/MessageList';
  9. import ChatInput from './components/ChatInput';
  10. import ChatHistoryList from './components/ChatHistoryList';
  11. import TemplateList from './components/TemplateList';
  12. import { EventType, type AgentEvent, ExecutionState } from './types/event';
  13. import { defaultTemplates } from './templates';
  14. import './SidePanel.css';
  15. const SidePanel = () => {
  16. const [messages, setMessages] = useState<Message[]>([]);
  17. const [inputEnabled, setInputEnabled] = useState(true);
  18. const [showStopButton, setShowStopButton] = useState(false);
  19. const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
  20. const [showHistory, setShowHistory] = useState(false);
  21. const [chatSessions, setChatSessions] = useState<Array<{ id: string; title: string; createdAt: number }>>([]);
  22. const [isFollowUpMode, setIsFollowUpMode] = useState(false);
  23. const [isHistoricalSession, setIsHistoricalSession] = useState(false);
  24. const [isDarkMode, setIsDarkMode] = useState(false);
  25. const sessionIdRef = useRef<string | null>(null);
  26. const portRef = useRef<chrome.runtime.Port | null>(null);
  27. const heartbeatIntervalRef = useRef<number | null>(null);
  28. const messagesEndRef = useRef<HTMLDivElement>(null);
  29. const setInputTextRef = useRef<((text: string) => void) | null>(null);
  30. // Check for dark mode preference
  31. useEffect(() => {
  32. const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
  33. setIsDarkMode(darkModeMediaQuery.matches);
  34. const handleChange = (e: MediaQueryListEvent) => {
  35. setIsDarkMode(e.matches);
  36. };
  37. darkModeMediaQuery.addEventListener('change', handleChange);
  38. return () => darkModeMediaQuery.removeEventListener('change', handleChange);
  39. }, []);
  40. useEffect(() => {
  41. sessionIdRef.current = currentSessionId;
  42. }, [currentSessionId]);
  43. const appendMessage = useCallback((newMessage: Message, sessionId?: string | null) => {
  44. // Don't save progress messages
  45. const isProgressMessage = newMessage.content === 'Showing progress...';
  46. setMessages(prev => {
  47. const filteredMessages = prev.filter(
  48. (msg, idx) => !(msg.content === 'Showing progress...' && idx === prev.length - 1),
  49. );
  50. return [...filteredMessages, newMessage];
  51. });
  52. // Use provided sessionId if available, otherwise fall back to sessionIdRef.current
  53. const effectiveSessionId = sessionId !== undefined ? sessionId : sessionIdRef.current;
  54. console.log('sessionId', effectiveSessionId);
  55. // Save message to storage if we have a session and it's not a progress message
  56. if (effectiveSessionId && !isProgressMessage) {
  57. chatHistoryStore
  58. .addMessage(effectiveSessionId, newMessage)
  59. .catch(err => console.error('Failed to save message to history:', err));
  60. }
  61. }, []);
  62. const handleTaskState = useCallback(
  63. (event: AgentEvent) => {
  64. const { actor, state, timestamp, data } = event;
  65. const content = data?.details;
  66. let skip = true;
  67. let displayProgress = false;
  68. switch (actor) {
  69. case Actors.SYSTEM:
  70. switch (state) {
  71. case ExecutionState.TASK_START:
  72. // Reset historical session flag when a new task starts
  73. setIsHistoricalSession(false);
  74. break;
  75. case ExecutionState.TASK_OK:
  76. setIsFollowUpMode(true);
  77. setInputEnabled(true);
  78. setShowStopButton(false);
  79. break;
  80. case ExecutionState.TASK_FAIL:
  81. setIsFollowUpMode(true);
  82. setInputEnabled(true);
  83. setShowStopButton(false);
  84. skip = false;
  85. break;
  86. case ExecutionState.TASK_CANCEL:
  87. setIsFollowUpMode(false);
  88. setInputEnabled(true);
  89. setShowStopButton(false);
  90. skip = false;
  91. break;
  92. case ExecutionState.TASK_PAUSE:
  93. break;
  94. case ExecutionState.TASK_RESUME:
  95. break;
  96. default:
  97. console.error('Invalid task state', state);
  98. return;
  99. }
  100. break;
  101. case Actors.USER:
  102. break;
  103. case Actors.PLANNER:
  104. switch (state) {
  105. case ExecutionState.STEP_START:
  106. displayProgress = true;
  107. break;
  108. case ExecutionState.STEP_OK:
  109. skip = false;
  110. break;
  111. case ExecutionState.STEP_FAIL:
  112. skip = false;
  113. break;
  114. case ExecutionState.STEP_CANCEL:
  115. break;
  116. default:
  117. console.error('Invalid step state', state);
  118. return;
  119. }
  120. break;
  121. case Actors.NAVIGATOR:
  122. switch (state) {
  123. case ExecutionState.STEP_START:
  124. displayProgress = true;
  125. break;
  126. case ExecutionState.STEP_OK:
  127. displayProgress = false;
  128. break;
  129. case ExecutionState.STEP_FAIL:
  130. skip = false;
  131. displayProgress = false;
  132. break;
  133. case ExecutionState.STEP_CANCEL:
  134. displayProgress = false;
  135. break;
  136. case ExecutionState.ACT_START:
  137. if (content !== 'cache_content') {
  138. // skip to display caching content
  139. skip = false;
  140. }
  141. break;
  142. case ExecutionState.ACT_OK:
  143. skip = true;
  144. break;
  145. case ExecutionState.ACT_FAIL:
  146. skip = false;
  147. break;
  148. default:
  149. console.error('Invalid action', state);
  150. return;
  151. }
  152. break;
  153. case Actors.VALIDATOR:
  154. switch (state) {
  155. case ExecutionState.STEP_START:
  156. displayProgress = true;
  157. break;
  158. case ExecutionState.STEP_OK:
  159. skip = false;
  160. break;
  161. case ExecutionState.STEP_FAIL:
  162. skip = false;
  163. break;
  164. default:
  165. console.error('Invalid validation', state);
  166. return;
  167. }
  168. break;
  169. default:
  170. console.error('Unknown actor', actor);
  171. return;
  172. }
  173. if (!skip) {
  174. appendMessage({
  175. actor,
  176. content: content || '',
  177. timestamp: timestamp,
  178. });
  179. }
  180. if (displayProgress) {
  181. appendMessage({
  182. actor,
  183. content: 'Showing progress...',
  184. timestamp: timestamp,
  185. });
  186. }
  187. },
  188. [appendMessage],
  189. );
  190. // Stop heartbeat and close connection
  191. const stopConnection = useCallback(() => {
  192. if (heartbeatIntervalRef.current) {
  193. clearInterval(heartbeatIntervalRef.current);
  194. heartbeatIntervalRef.current = null;
  195. }
  196. if (portRef.current) {
  197. portRef.current.disconnect();
  198. portRef.current = null;
  199. }
  200. }, []);
  201. // Setup connection management
  202. const setupConnection = useCallback(() => {
  203. // Only setup if no existing connection
  204. if (portRef.current) {
  205. return;
  206. }
  207. try {
  208. portRef.current = chrome.runtime.connect({ name: 'side-panel-connection' });
  209. // biome-ignore lint/suspicious/noExplicitAny: <explanation>
  210. portRef.current.onMessage.addListener((message: any) => {
  211. // Add type checking for message
  212. if (message && message.type === EventType.EXECUTION) {
  213. handleTaskState(message);
  214. } else if (message && message.type === 'error') {
  215. // Handle error messages from service worker
  216. appendMessage({
  217. actor: Actors.SYSTEM,
  218. content: message.error || 'Unknown error occurred',
  219. timestamp: Date.now(),
  220. });
  221. setInputEnabled(true);
  222. setShowStopButton(false);
  223. } else if (message && message.type === 'heartbeat_ack') {
  224. console.log('Heartbeat acknowledged');
  225. }
  226. });
  227. portRef.current.onDisconnect.addListener(() => {
  228. const error = chrome.runtime.lastError;
  229. console.log('Connection disconnected', error ? `Error: ${error.message}` : '');
  230. portRef.current = null;
  231. if (heartbeatIntervalRef.current) {
  232. clearInterval(heartbeatIntervalRef.current);
  233. heartbeatIntervalRef.current = null;
  234. }
  235. setInputEnabled(true);
  236. setShowStopButton(false);
  237. });
  238. // Setup heartbeat interval
  239. if (heartbeatIntervalRef.current) {
  240. clearInterval(heartbeatIntervalRef.current);
  241. }
  242. heartbeatIntervalRef.current = window.setInterval(() => {
  243. if (portRef.current?.name === 'side-panel-connection') {
  244. try {
  245. portRef.current.postMessage({ type: 'heartbeat' });
  246. } catch (error) {
  247. console.error('Heartbeat failed:', error);
  248. stopConnection(); // Stop connection if heartbeat fails
  249. }
  250. } else {
  251. stopConnection(); // Stop if port is invalid
  252. }
  253. }, 25000);
  254. } catch (error) {
  255. console.error('Failed to establish connection:', error);
  256. appendMessage({
  257. actor: Actors.SYSTEM,
  258. content: 'Failed to connect to service worker',
  259. timestamp: Date.now(),
  260. });
  261. // Clear any references since connection failed
  262. portRef.current = null;
  263. }
  264. }, [handleTaskState, appendMessage, stopConnection]);
  265. // Add safety check for message sending
  266. const sendMessage = useCallback(
  267. // biome-ignore lint/suspicious/noExplicitAny: <explanation>
  268. (message: any) => {
  269. if (portRef.current?.name !== 'side-panel-connection') {
  270. throw new Error('No valid connection available');
  271. }
  272. try {
  273. portRef.current.postMessage(message);
  274. } catch (error) {
  275. console.error('Failed to send message:', error);
  276. stopConnection(); // Stop connection when message sending fails
  277. throw error;
  278. }
  279. },
  280. [stopConnection],
  281. );
  282. // Handle chat commands that start with /
  283. const handleCommand = async (command: string): Promise<boolean> => {
  284. try {
  285. // Setup connection if not exists
  286. if (!portRef.current) {
  287. setupConnection();
  288. }
  289. // Handle different commands
  290. if (command === '/state') {
  291. // Send state command to background
  292. portRef.current?.postMessage({
  293. type: 'state',
  294. });
  295. return true;
  296. }
  297. if (command === '/nohighlight') {
  298. // Send remove_highlight command to background
  299. portRef.current?.postMessage({
  300. type: 'nohighlight',
  301. });
  302. return true;
  303. }
  304. // Unsupported command
  305. appendMessage({
  306. actor: Actors.SYSTEM,
  307. content: `Unsupported command: ${command}. Available commands: /state, /nohighlight`,
  308. timestamp: Date.now(),
  309. });
  310. return true;
  311. } catch (err) {
  312. const errorMessage = err instanceof Error ? err.message : String(err);
  313. console.error('Command error', errorMessage);
  314. appendMessage({
  315. actor: Actors.SYSTEM,
  316. content: errorMessage,
  317. timestamp: Date.now(),
  318. });
  319. return true;
  320. }
  321. };
  322. const handleSendMessage = async (text: string) => {
  323. console.log('handleSendMessage', text);
  324. // Trim the input text first
  325. const trimmedText = text.trim();
  326. if (!trimmedText) return;
  327. // Check if the input is a command (starts with /)
  328. if (trimmedText.startsWith('/')) {
  329. // Process command and return if it was handled
  330. const wasHandled = await handleCommand(trimmedText);
  331. if (wasHandled) return;
  332. }
  333. // Block sending messages in historical sessions
  334. if (isHistoricalSession) {
  335. console.log('Cannot send messages in historical sessions');
  336. return;
  337. }
  338. try {
  339. const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
  340. const tabId = tabs[0]?.id;
  341. if (!tabId) {
  342. throw new Error('No active tab found');
  343. }
  344. setInputEnabled(false);
  345. setShowStopButton(true);
  346. // Create a new chat session for this task if not in follow-up mode
  347. if (!isFollowUpMode) {
  348. const newSession = await chatHistoryStore.createSession(
  349. text.substring(0, 50) + (text.length > 50 ? '...' : ''),
  350. );
  351. console.log('newSession', newSession);
  352. // Store the session ID in both state and ref
  353. const sessionId = newSession.id;
  354. setCurrentSessionId(sessionId);
  355. sessionIdRef.current = sessionId;
  356. }
  357. const userMessage = {
  358. actor: Actors.USER,
  359. content: text,
  360. timestamp: Date.now(),
  361. };
  362. // Pass the sessionId directly to appendMessage
  363. appendMessage(userMessage, sessionIdRef.current);
  364. // Setup connection if not exists
  365. if (!portRef.current) {
  366. setupConnection();
  367. }
  368. // Send message using the utility function
  369. if (isFollowUpMode) {
  370. // Send as follow-up task
  371. await sendMessage({
  372. type: 'follow_up_task',
  373. task: text,
  374. taskId: sessionIdRef.current,
  375. tabId,
  376. });
  377. console.log('follow_up_task sent', text, tabId, sessionIdRef.current);
  378. } else {
  379. // Send as new task
  380. await sendMessage({
  381. type: 'new_task',
  382. task: text,
  383. taskId: sessionIdRef.current,
  384. tabId,
  385. });
  386. console.log('new_task sent', text, tabId, sessionIdRef.current);
  387. }
  388. } catch (err) {
  389. const errorMessage = err instanceof Error ? err.message : String(err);
  390. console.error('Task error', errorMessage);
  391. appendMessage({
  392. actor: Actors.SYSTEM,
  393. content: errorMessage,
  394. timestamp: Date.now(),
  395. });
  396. setInputEnabled(true);
  397. setShowStopButton(false);
  398. stopConnection();
  399. }
  400. };
  401. const handleStopTask = async () => {
  402. try {
  403. portRef.current?.postMessage({
  404. type: 'cancel_task',
  405. });
  406. } catch (err) {
  407. const errorMessage = err instanceof Error ? err.message : String(err);
  408. console.error('cancel_task error', errorMessage);
  409. appendMessage({
  410. actor: Actors.SYSTEM,
  411. content: errorMessage,
  412. timestamp: Date.now(),
  413. });
  414. }
  415. setInputEnabled(true);
  416. setShowStopButton(false);
  417. };
  418. const handleNewChat = () => {
  419. // Clear messages and start a new chat
  420. setMessages([]);
  421. setCurrentSessionId(null);
  422. sessionIdRef.current = null;
  423. setInputEnabled(true);
  424. setShowStopButton(false);
  425. setIsFollowUpMode(false);
  426. setIsHistoricalSession(false);
  427. // Disconnect any existing connection
  428. stopConnection();
  429. };
  430. const loadChatSessions = useCallback(async () => {
  431. try {
  432. const sessions = await chatHistoryStore.getSessionsMetadata();
  433. setChatSessions(sessions.sort((a, b) => b.createdAt - a.createdAt));
  434. } catch (error) {
  435. console.error('Failed to load chat sessions:', error);
  436. }
  437. }, []);
  438. const handleLoadHistory = async () => {
  439. await loadChatSessions();
  440. setShowHistory(true);
  441. };
  442. const handleBackToChat = () => {
  443. setShowHistory(false);
  444. };
  445. const handleSessionSelect = async (sessionId: string) => {
  446. try {
  447. const fullSession = await chatHistoryStore.getSession(sessionId);
  448. if (fullSession && fullSession.messages.length > 0) {
  449. setCurrentSessionId(fullSession.id);
  450. setMessages(fullSession.messages);
  451. setIsFollowUpMode(false);
  452. setIsHistoricalSession(true); // Mark this as a historical session
  453. }
  454. setShowHistory(false);
  455. } catch (error) {
  456. console.error('Failed to load session:', error);
  457. }
  458. };
  459. const handleSessionDelete = async (sessionId: string) => {
  460. try {
  461. await chatHistoryStore.deleteSession(sessionId);
  462. await loadChatSessions();
  463. if (sessionId === currentSessionId) {
  464. setMessages([]);
  465. setCurrentSessionId(null);
  466. }
  467. } catch (error) {
  468. console.error('Failed to delete session:', error);
  469. }
  470. };
  471. const handleTemplateSelect = (content: string) => {
  472. console.log('handleTemplateSelect', content);
  473. if (setInputTextRef.current) {
  474. setInputTextRef.current(content);
  475. }
  476. };
  477. // Cleanup on unmount
  478. useEffect(() => {
  479. return () => {
  480. stopConnection();
  481. };
  482. }, [stopConnection]);
  483. // Scroll to bottom when new messages arrive
  484. // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
  485. useEffect(() => {
  486. messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  487. }, [messages]);
  488. return (
  489. <div>
  490. <div
  491. className={`flex h-screen flex-col ${isDarkMode ? 'bg-slate-900' : ''} overflow-hidden border ${isDarkMode ? 'border-sky-800' : 'border-[rgb(186,230,253)]'} rounded-2xl`}>
  492. <header className="header relative">
  493. <div className="header-logo">
  494. {showHistory ? (
  495. <button
  496. type="button"
  497. onClick={handleBackToChat}
  498. className={`${isDarkMode ? 'text-sky-400 hover:text-sky-300' : 'text-sky-400 hover:text-sky-500'} cursor-pointer`}
  499. aria-label="返回聊天">
  500. ← 返回
  501. </button>
  502. ) : (
  503. <img src="/icon.png" alt="Extension Logo" className="size-6" />
  504. )}
  505. </div>
  506. <div className="header-icons">
  507. {!showHistory && (
  508. <>
  509. <button
  510. type="button"
  511. onClick={handleNewChat}
  512. onKeyDown={e => e.key === 'Enter' && handleNewChat()}
  513. className={`header-icon ${isDarkMode ? 'text-sky-400 hover:text-sky-300' : 'text-sky-400 hover:text-sky-500'} cursor-pointer`}
  514. aria-label="New Chat"
  515. tabIndex={0}>
  516. <PiPlusBold size={20} />
  517. </button>
  518. <button
  519. type="button"
  520. onClick={handleLoadHistory}
  521. onKeyDown={e => e.key === 'Enter' && handleLoadHistory()}
  522. className={`header-icon ${isDarkMode ? 'text-sky-400 hover:text-sky-300' : 'text-sky-400 hover:text-sky-500'} cursor-pointer`}
  523. aria-label="Load History"
  524. tabIndex={0}>
  525. <GrHistory size={20} />
  526. </button>
  527. </>
  528. )}
  529. <button
  530. type="button"
  531. onClick={() => chrome.runtime.openOptionsPage()}
  532. onKeyDown={e => e.key === 'Enter' && chrome.runtime.openOptionsPage()}
  533. className={`header-icon ${isDarkMode ? 'text-sky-400 hover:text-sky-300' : 'text-sky-400 hover:text-sky-500'} cursor-pointer`}
  534. aria-label="Settings"
  535. tabIndex={0}>
  536. <FiSettings size={20} />
  537. </button>
  538. </div>
  539. </header>
  540. {showHistory ? (
  541. <div className="flex-1 overflow-hidden">
  542. <ChatHistoryList
  543. sessions={chatSessions}
  544. onSessionSelect={handleSessionSelect}
  545. onSessionDelete={handleSessionDelete}
  546. visible={true}
  547. isDarkMode={isDarkMode}
  548. />
  549. </div>
  550. ) : (
  551. <>
  552. {messages.length === 0 && (
  553. <>
  554. <div
  555. className={`border-t ${isDarkMode ? 'border-sky-900' : 'border-sky-100'} mb-2 p-2 shadow-sm backdrop-blur-sm`}>
  556. <ChatInput
  557. onSendMessage={handleSendMessage}
  558. onStopTask={handleStopTask}
  559. disabled={!inputEnabled || isHistoricalSession}
  560. showStopButton={showStopButton}
  561. setContent={setter => {
  562. setInputTextRef.current = setter;
  563. }}
  564. isDarkMode={isDarkMode}
  565. />
  566. </div>
  567. {/*<div>*/}
  568. {/* <TemplateList*/}
  569. {/* templates={defaultTemplates}*/}
  570. {/* onTemplateSelect={handleTemplateSelect}*/}
  571. {/* isDarkMode={isDarkMode}*/}
  572. {/* />*/}
  573. {/*</div>*/}
  574. </>
  575. )}
  576. <div
  577. className={`scrollbar-gutter-stable flex-1 overflow-x-hidden overflow-y-scroll scroll-smooth p-2 ${isDarkMode ? 'bg-slate-900/80' : ''}`}>
  578. <MessageList messages={messages} isDarkMode={isDarkMode} />
  579. <div ref={messagesEndRef} />
  580. </div>
  581. {messages.length > 0 && (
  582. <div
  583. className={`border-t ${isDarkMode ? 'border-sky-900' : 'border-sky-100'} p-2 shadow-sm backdrop-blur-sm`}>
  584. <ChatInput
  585. onSendMessage={handleSendMessage}
  586. onStopTask={handleStopTask}
  587. disabled={!inputEnabled || isHistoricalSession}
  588. showStopButton={showStopButton}
  589. setContent={setter => {
  590. setInputTextRef.current = setter;
  591. }}
  592. isDarkMode={isDarkMode}
  593. />
  594. </div>
  595. )}
  596. </>
  597. )}
  598. </div>
  599. </div>
  600. );
  601. };
  602. export default SidePanel;