use-pbl-chat.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. 'use client';
  2. /**
  3. * PBL Chat Hook - Manages chat state, @mention parsing, and API calls
  4. */
  5. import { useState, useCallback } from 'react';
  6. import type { PBLProjectConfig, PBLChatMessage, PBLAgent, PBLIssue } from '@/lib/pbl/types';
  7. import { getCurrentModelConfig } from '@/lib/utils/model-config';
  8. import { useI18n } from '@/lib/hooks/use-i18n';
  9. import { createLogger } from '@/lib/logger';
  10. const log = createLogger('PBLChat');
  11. interface UsePBLChatOptions {
  12. projectConfig: PBLProjectConfig;
  13. userRole: string;
  14. onConfigUpdate: (config: PBLProjectConfig) => void;
  15. }
  16. export function usePBLChat({ projectConfig, userRole, onConfigUpdate }: UsePBLChatOptions) {
  17. const { t } = useI18n();
  18. const [isLoading, setIsLoading] = useState(false);
  19. const messages = projectConfig.chat.messages;
  20. const currentIssue = projectConfig.issueboard.issues.find((i) => i.is_active) || null;
  21. const sendMessage = useCallback(
  22. async (text: string) => {
  23. if (!text.trim() || isLoading) return;
  24. const updatedConfig = {
  25. ...projectConfig,
  26. chat: {
  27. ...projectConfig.chat,
  28. messages: [...projectConfig.chat.messages],
  29. },
  30. };
  31. // Add user message
  32. const userMsg: PBLChatMessage = {
  33. id: `msg_${Date.now()}_user`,
  34. agent_name: userRole,
  35. message: text,
  36. timestamp: Date.now(),
  37. read_by: [userRole],
  38. };
  39. updatedConfig.chat.messages.push(userMsg);
  40. onConfigUpdate(updatedConfig);
  41. // Parse @mention to determine target agent, fallback to question agent
  42. const targetAgent = resolveTargetAgent(text, currentIssue, projectConfig.agents);
  43. if (!targetAgent) return;
  44. setIsLoading(true);
  45. try {
  46. const modelConfig = getCurrentModelConfig();
  47. const headers: Record<string, string> = {
  48. 'Content-Type': 'application/json',
  49. 'x-model': modelConfig.modelString,
  50. 'x-api-key': modelConfig.apiKey,
  51. };
  52. if (modelConfig.baseUrl) headers['x-base-url'] = modelConfig.baseUrl;
  53. if (modelConfig.providerType) headers['x-provider-type'] = modelConfig.providerType;
  54. // Strip @mention prefix from message text if present
  55. const cleanMessage = text.replace(/^@\w+\s*/i, '').trim() || text;
  56. const isJudgeAgent = currentIssue && targetAgent.name === currentIssue.judge_agent_name;
  57. const response = await fetch('/api/pbl/chat', {
  58. method: 'POST',
  59. headers,
  60. body: JSON.stringify({
  61. message: cleanMessage,
  62. agent: targetAgent,
  63. currentIssue,
  64. recentMessages: updatedConfig.chat.messages.slice(-10).map((m) => ({
  65. agent_name: m.agent_name,
  66. message: m.message,
  67. })),
  68. userRole,
  69. agentType: isJudgeAgent ? 'judge' : 'question',
  70. }),
  71. });
  72. const data = await response.json();
  73. if (data.success) {
  74. const agentMsg: PBLChatMessage = {
  75. id: `msg_${Date.now()}_agent`,
  76. agent_name: targetAgent.name,
  77. message: data.message,
  78. timestamp: Date.now(),
  79. read_by: [],
  80. };
  81. const afterConfig = {
  82. ...updatedConfig,
  83. chat: { messages: [...updatedConfig.chat.messages, agentMsg] },
  84. };
  85. // Check for COMPLETE from judge agent (excluding NEEDS_REVISION)
  86. const msgUpper = data.message.toUpperCase();
  87. if (
  88. currentIssue &&
  89. isJudgeAgent &&
  90. msgUpper.includes('COMPLETE') &&
  91. !msgUpper.includes('NEEDS_REVISION')
  92. ) {
  93. await handleIssueComplete(afterConfig, currentIssue, headers, t);
  94. }
  95. onConfigUpdate(afterConfig);
  96. }
  97. } catch (error) {
  98. log.error('[usePBLChat] Error:', error);
  99. } finally {
  100. setIsLoading(false);
  101. }
  102. },
  103. [projectConfig, userRole, currentIssue, isLoading, onConfigUpdate, t],
  104. );
  105. return { messages, isLoading, sendMessage, currentIssue };
  106. }
  107. /**
  108. * Resolve target agent from @mention, or fallback to question agent for plain messages
  109. */
  110. function resolveTargetAgent(
  111. text: string,
  112. currentIssue: PBLIssue | null,
  113. agents: PBLAgent[],
  114. ): PBLAgent | null {
  115. if (!currentIssue) return null;
  116. const mentionMatch = text.match(/^@(\w+)/i);
  117. if (mentionMatch) {
  118. const mentionType = mentionMatch[1].toLowerCase();
  119. if (mentionType === 'question') {
  120. return agents.find((a) => a.name === currentIssue.question_agent_name) || null;
  121. }
  122. if (mentionType === 'judge') {
  123. return agents.find((a) => a.name === currentIssue.judge_agent_name) || null;
  124. }
  125. // Direct agent name mention
  126. const matched = agents.find((a) => a.name.toLowerCase().includes(mentionType));
  127. if (matched) return matched;
  128. }
  129. // No @mention or unrecognized mention → route to question agent by default
  130. return agents.find((a) => a.name === currentIssue.question_agent_name) || null;
  131. }
  132. /**
  133. * Handle issue completion: mark done, activate next, generate questions for next issue
  134. */
  135. async function handleIssueComplete(
  136. config: PBLProjectConfig,
  137. completedIssue: PBLIssue,
  138. headers: Record<string, string>,
  139. t: (key: string, options?: Record<string, unknown>) => string,
  140. ) {
  141. // Mark current issue as done
  142. const issue = config.issueboard.issues.find((i) => i.id === completedIssue.id);
  143. if (issue) {
  144. issue.is_done = true;
  145. issue.is_active = false;
  146. }
  147. config.issueboard.current_issue_id = null;
  148. // Activate next incomplete issue
  149. const nextIssue = config.issueboard.issues
  150. .filter((i) => !i.is_done)
  151. .sort((a, b) => a.index - b.index)[0];
  152. if (nextIssue) {
  153. nextIssue.is_active = true;
  154. config.issueboard.current_issue_id = nextIssue.id;
  155. // Generate questions for the new issue if not already generated
  156. const questionAgent = config.agents.find((a) => a.name === nextIssue.question_agent_name);
  157. if (questionAgent && !nextIssue.generated_questions) {
  158. try {
  159. const questionPrompt = [
  160. `## Issue Information`,
  161. ``,
  162. `**Title**: ${nextIssue.title}`,
  163. `**Description**: ${nextIssue.description}`,
  164. `**Person in Charge**: ${nextIssue.person_in_charge}`,
  165. nextIssue.participants.length > 0
  166. ? `**Participants**: ${nextIssue.participants.join(', ')}`
  167. : '',
  168. nextIssue.notes ? `**Notes**: ${nextIssue.notes}` : '',
  169. ``,
  170. `## Your Task`,
  171. ``,
  172. `Based on the issue information above, generate 1-3 specific, actionable questions that will help students understand and complete this issue. Format your response as a numbered list.`,
  173. ]
  174. .filter(Boolean)
  175. .join('\n');
  176. const resp = await fetch('/api/pbl/chat', {
  177. method: 'POST',
  178. headers,
  179. body: JSON.stringify({
  180. message: questionPrompt,
  181. agent: questionAgent,
  182. currentIssue: nextIssue,
  183. recentMessages: [],
  184. userRole: '',
  185. }),
  186. });
  187. const data = await resp.json();
  188. if (data.success && data.message) {
  189. nextIssue.generated_questions = data.message;
  190. // Add Question Agent welcome message
  191. config.chat.messages.push({
  192. id: `msg_${Date.now()}_welcome`,
  193. agent_name: nextIssue.question_agent_name,
  194. message: t('pbl.chat.welcomeMessage', {
  195. title: nextIssue.title,
  196. questions: data.message,
  197. }),
  198. timestamp: Date.now(),
  199. read_by: [],
  200. });
  201. }
  202. } catch (error) {
  203. log.error('[usePBLChat] Failed to generate questions for next issue:', error);
  204. }
  205. } else if (questionAgent && nextIssue.generated_questions) {
  206. // Questions already exist, just add welcome message
  207. config.chat.messages.push({
  208. id: `msg_${Date.now()}_welcome`,
  209. agent_name: nextIssue.question_agent_name,
  210. message: t('pbl.chat.welcomeMessage', {
  211. title: nextIssue.title,
  212. questions: nextIssue.generated_questions,
  213. }),
  214. timestamp: Date.now(),
  215. read_by: [],
  216. });
  217. }
  218. // System message about progression
  219. config.chat.messages.push({
  220. id: `msg_${Date.now()}_system`,
  221. agent_name: 'System',
  222. message: t('pbl.chat.issueCompleteMessage', {
  223. completed: completedIssue.title,
  224. next: nextIssue.title,
  225. }),
  226. timestamp: Date.now(),
  227. read_by: [],
  228. });
  229. } else {
  230. // All issues complete
  231. config.chat.messages.push({
  232. id: `msg_${Date.now()}_system`,
  233. agent_name: 'System',
  234. message: t('pbl.chat.allCompleteMessage'),
  235. timestamp: Date.now(),
  236. read_by: [],
  237. });
  238. }
  239. }