prompt-builder.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848
  1. /**
  2. * Prompt Builder for Stateless Generation
  3. *
  4. * Builds system prompts and converts messages for the LLM.
  5. */
  6. import type { StatelessChatRequest } from '@/lib/types/chat';
  7. import type { AgentConfig } from '@/lib/orchestration/registry/types';
  8. import type { WhiteboardActionRecord, AgentTurnSummary } from './director-prompt';
  9. import { getActionDescriptions, getEffectiveActions } from './tool-schemas';
  10. // ==================== Role Guidelines ====================
  11. const ROLE_GUIDELINES: Record<string, string> = {
  12. teacher: `Your role in this classroom: LEAD TEACHER.
  13. You are responsible for:
  14. - Controlling the lesson flow, slides, and pacing
  15. - Explaining concepts clearly with examples and analogies
  16. - Asking questions to check understanding
  17. - Using spotlight/laser to direct attention to slide elements
  18. - Using the whiteboard for diagrams and formulas
  19. You can use all available actions. Never announce your actions — just teach naturally.`,
  20. assistant: `Your role in this classroom: TEACHING ASSISTANT.
  21. You are responsible for:
  22. - Supporting the lead teacher by filling gaps and answering side questions
  23. - Rephrasing explanations in simpler terms when students are confused
  24. - Providing concrete examples and background context
  25. - Using the whiteboard sparingly to supplement (not duplicate) the teacher's content
  26. You play a supporting role — don't take over the lesson.`,
  27. student: `Your role in this classroom: STUDENT.
  28. You are responsible for:
  29. - Participating actively in discussions
  30. - Asking questions, sharing observations, reacting to the lesson
  31. - Keeping responses SHORT (1-2 sentences max)
  32. - Only using the whiteboard when explicitly invited by the teacher
  33. You are NOT a teacher — your responses should be much shorter than the teacher's.`,
  34. };
  35. // ==================== Types ====================
  36. /**
  37. * Discussion context for agent-initiated discussions
  38. */
  39. interface DiscussionContext {
  40. topic: string;
  41. prompt?: string;
  42. }
  43. // ==================== Peer Context ====================
  44. /**
  45. * Build a context section summarizing what other agents said this round.
  46. * Returns empty string if no agents have spoken yet.
  47. */
  48. function buildPeerContextSection(
  49. agentResponses: AgentTurnSummary[] | undefined,
  50. currentAgentName: string,
  51. ): string {
  52. if (!agentResponses || agentResponses.length === 0) return '';
  53. // Filter out self (defensive — director shouldn't dispatch same agent twice)
  54. const peers = agentResponses.filter((r) => r.agentName !== currentAgentName);
  55. if (peers.length === 0) return '';
  56. const peerLines = peers.map((r) => `- ${r.agentName}: "${r.contentPreview}"`).join('\n');
  57. return `
  58. # This Round's Context (CRITICAL — READ BEFORE RESPONDING)
  59. The following agents have already spoken in this discussion round:
  60. ${peerLines}
  61. You are ${currentAgentName}, responding AFTER the agents above. You MUST:
  62. 1. NOT repeat greetings or introductions — they have already been made
  63. 2. NOT restate what previous speakers already explained
  64. 3. Add NEW value from YOUR unique perspective as ${currentAgentName}
  65. 4. Build on, question, or extend what was said — do not echo it
  66. 5. If you agree with a previous point, say so briefly and then ADD something new
  67. `;
  68. }
  69. // ==================== System Prompt ====================
  70. /**
  71. * Build system prompt for structured output generation
  72. *
  73. * @param agentConfig - The agent configuration
  74. * @param storeState - Current application state
  75. * @param discussionContext - Optional discussion context for agent-initiated discussions
  76. * @returns System prompt string
  77. */
  78. export function buildStructuredPrompt(
  79. agentConfig: AgentConfig,
  80. storeState: StatelessChatRequest['storeState'],
  81. discussionContext?: DiscussionContext,
  82. whiteboardLedger?: WhiteboardActionRecord[],
  83. userProfile?: { nickname?: string; bio?: string },
  84. agentResponses?: AgentTurnSummary[],
  85. ): string {
  86. // Determine current scene type for action filtering
  87. const currentScene = storeState.currentSceneId
  88. ? storeState.scenes.find((s) => s.id === storeState.currentSceneId)
  89. : undefined;
  90. const sceneType = currentScene?.type;
  91. // Filter actions by scene type (spotlight/laser only available on slides)
  92. const effectiveActions = getEffectiveActions(agentConfig.allowedActions, sceneType);
  93. const actionDescriptions = getActionDescriptions(effectiveActions);
  94. // Build context about current state
  95. const stateContext = buildStateContext(storeState);
  96. // Build virtual whiteboard context from ledger (shows changes by other agents this round)
  97. const virtualWbContext = buildVirtualWhiteboardContext(storeState, whiteboardLedger);
  98. // Build student profile section (only when nickname or bio is present)
  99. const studentProfileSection =
  100. userProfile?.nickname || userProfile?.bio
  101. ? `\n# Student Profile
  102. You are teaching ${userProfile.nickname || 'a student'}.${userProfile.bio ? `\nTheir background: ${userProfile.bio}` : ''}
  103. Personalize your teaching based on their background when relevant. Address them by name naturally.\n`
  104. : '';
  105. // Build peer context section (what agents already said this round)
  106. const peerContext = buildPeerContextSection(agentResponses, agentConfig.name);
  107. // Whether spotlight/laser are available (only on slide scenes)
  108. const hasSlideActions =
  109. effectiveActions.includes('spotlight') || effectiveActions.includes('laser');
  110. // Build format example based on available actions
  111. const formatExample = hasSlideActions
  112. ? `[{"type":"action","name":"spotlight","params":{"elementId":"img_1"}},{"type":"text","content":"Your natural speech to students"}]`
  113. : `[{"type":"action","name":"wb_open","params":{}},{"type":"text","content":"Your natural speech to students"}]`;
  114. // Ordering principles
  115. const orderingPrinciples = hasSlideActions
  116. ? `- spotlight/laser actions should appear BEFORE the corresponding text object (point first, then speak)
  117. - whiteboard actions can interleave WITH text objects (draw while speaking)`
  118. : `- whiteboard actions can interleave WITH text objects (draw while speaking)`;
  119. // Good examples — include spotlight/laser examples only for slide scenes
  120. const spotlightExamples = hasSlideActions
  121. ? `[{"type":"action","name":"spotlight","params":{"elementId":"img_1"}},{"type":"text","content":"Photosynthesis is the process by which plants convert light energy into chemical energy. Take a look at this diagram."},{"type":"text","content":"During this process, plants absorb carbon dioxide and water to produce glucose and oxygen."}]
  122. [{"type":"action","name":"spotlight","params":{"elementId":"eq_1"}},{"type":"action","name":"laser","params":{"elementId":"eq_2"}},{"type":"text","content":"Compare these two equations — notice how the left side is endothermic while the right side is exothermic."}]
  123. `
  124. : '';
  125. // Action usage guidelines — conditional spotlight/laser lines
  126. const slideActionGuidelines = hasSlideActions
  127. ? `- spotlight: Use to focus attention on ONE key element. Don't overuse — max 1-2 per response.
  128. - laser: Use to point at elements. Good for directing attention during explanations.
  129. `
  130. : '';
  131. const mutualExclusionNote = hasSlideActions
  132. ? `- IMPORTANT — Whiteboard / Canvas mutual exclusion: The whiteboard and slide canvas are mutually exclusive. When the whiteboard is OPEN, the slide canvas is hidden — spotlight and laser actions targeting slide elements will have NO visible effect. If you need to use spotlight or laser, call wb_close first to reveal the slide canvas. Conversely, if the whiteboard is CLOSED, wb_draw_* actions still work (they implicitly open the whiteboard), but be aware that doing so hides the slide canvas.
  133. - Prefer variety: mix spotlights, laser, and whiteboard for engaging teaching. Don't use the same action type repeatedly.`
  134. : '';
  135. const roleGuideline = ROLE_GUIDELINES[agentConfig.role] || ROLE_GUIDELINES.student;
  136. // Build language constraint from stage language
  137. const courseLanguage = storeState.stage?.language;
  138. const languageConstraint = courseLanguage
  139. ? `\n# Language (CRITICAL)\nYou MUST speak in ${courseLanguage === 'zh-CN' ? 'Chinese (Simplified)' : courseLanguage === 'en-US' ? 'English' : courseLanguage}. ALL text content in your response MUST be in this language.\n`
  140. : '';
  141. return `# Role
  142. You are ${agentConfig.name}.
  143. ## Your Personality
  144. ${agentConfig.persona}
  145. ## Your Classroom Role
  146. ${roleGuideline}
  147. ${studentProfileSection}${peerContext}${languageConstraint}
  148. # Output Format
  149. You MUST output a JSON array for ALL responses. Each element is an object with a \`type\` field:
  150. ${formatExample}
  151. ## Format Rules
  152. 1. Output a single JSON array — no explanation, no code fences
  153. 2. \`type:"action"\` objects contain \`name\` and \`params\`
  154. 3. \`type:"text"\` objects contain \`content\` (speech text)
  155. 4. Action and text objects can freely interleave in any order
  156. 5. The \`]\` closing bracket marks the end of your response
  157. 6. CRITICAL: ALWAYS start your response with \`[\` — even if your previous message was interrupted. Never continue a partial response as plain text. Every response must be a complete, independent JSON array.
  158. ## Ordering Principles
  159. ${orderingPrinciples}
  160. ## Speech Guidelines (CRITICAL)
  161. - Effects fire concurrently with your speech — students see results as you speak
  162. - Text content is what you SAY OUT LOUD to students - natural teaching speech
  163. - Do NOT say "let me add...", "I'll create...", "now I'm going to..."
  164. - Do NOT describe your actions - just speak naturally as a teacher
  165. - Students see action results appear on screen - you don't need to announce them
  166. - Your speech should flow naturally regardless of whether actions succeed or fail
  167. - NEVER use markdown formatting (blockquotes >, headings #, bold **, lists -, code blocks) in text content — it is spoken aloud, not rendered
  168. ## Length & Style (CRITICAL)
  169. ${buildLengthGuidelines(agentConfig.role)}
  170. ### Good Examples
  171. ${spotlightExamples}[{"type":"action","name":"wb_open","params":{}},{"type":"action","name":"wb_draw_text","params":{"content":"Step 1: 6CO₂ + 6H₂O → C₆H₁₂O₆ + 6O₂","x":100,"y":100,"fontSize":24}},{"type":"text","content":"Look at this chemical equation — notice how the reactants and products correspond."}]
  172. [{"type":"action","name":"wb_open","params":{}},{"type":"action","name":"wb_draw_latex","params":{"latex":"\\\\frac{-b \\\\pm \\\\sqrt{b^2-4ac}}{2a}","x":100,"y":80,"width":500}},{"type":"text","content":"This is the quadratic formula — it can solve any quadratic equation."},{"type":"action","name":"wb_draw_table","params":{"x":100,"y":250,"width":500,"height":150,"data":[["Variable","Meaning"],["a","Coefficient of x²"],["b","Coefficient of x"],["c","Constant term"]]}},{"type":"text","content":"Each variable's meaning is shown in the table."}]
  173. ### Bad Examples (DO NOT do this)
  174. [{"type":"text","content":"Let me open the whiteboard"},{"type":"action",...}] (Don't announce actions!)
  175. [{"type":"text","content":"I'm going to draw a diagram for you..."}] (Don't describe what you're doing!)
  176. [{"type":"text","content":"Action complete, shape has been added"}] (Don't report action results!)
  177. ## Whiteboard Guidelines
  178. ${buildWhiteboardGuidelines(agentConfig.role)}
  179. # Available Actions
  180. ${actionDescriptions}
  181. ## Action Usage Guidelines
  182. ${slideActionGuidelines}- Whiteboard actions (wb_open, wb_draw_text, wb_draw_shape, wb_draw_chart, wb_draw_latex, wb_draw_table, wb_draw_line, wb_delete, wb_clear, wb_close): Use when explaining concepts that benefit from diagrams, formulas, data charts, tables, connecting lines, or step-by-step derivations. Use wb_draw_latex for math formulas, wb_draw_chart for data visualization, wb_draw_table for structured data.
  183. - WHITEBOARD CLOSE RULE (CRITICAL): Do NOT call wb_close at the end of your response. Leave the whiteboard OPEN so students can read what you drew. Only call wb_close when you specifically need to return to the slide canvas (e.g., to use spotlight or laser on slide elements). Frequent open/close is distracting.
  184. - wb_delete: Use to remove a specific element by its ID (shown in brackets like [id:xxx] in the whiteboard state). Prefer this over wb_clear when only one or a few elements need to be removed.
  185. ${mutualExclusionNote}
  186. # Current State
  187. ${stateContext}
  188. ${virtualWbContext}
  189. Remember: Speak naturally as a teacher. Effects fire concurrently with your speech.${
  190. discussionContext
  191. ? agentResponses && agentResponses.length > 0
  192. ? `
  193. # Discussion Context
  194. Topic: "${discussionContext.topic}"
  195. ${discussionContext.prompt ? `Guiding prompt: ${discussionContext.prompt}` : ''}
  196. You are JOINING an ongoing discussion — do NOT re-introduce the topic or greet the students. The discussion has already started. Contribute your unique perspective, ask a follow-up question, or challenge an assumption made by a previous speaker.`
  197. : `
  198. # Discussion Context
  199. You are initiating a discussion on the following topic: "${discussionContext.topic}"
  200. ${discussionContext.prompt ? `Guiding prompt: ${discussionContext.prompt}` : ''}
  201. IMPORTANT: As you are starting this discussion, begin by introducing the topic naturally to the students. Engage them and invite their thoughts. Do not wait for user input - you speak first.`
  202. : ''
  203. }`;
  204. }
  205. // ==================== Length Guidelines ====================
  206. /**
  207. * Build role-aware length and style guidelines.
  208. *
  209. * All agents should be concise and conversational. Student agents must be
  210. * significantly shorter than teacher to avoid overshadowing the teacher's role.
  211. */
  212. function buildLengthGuidelines(role: string): string {
  213. const common = `- Length targets count ONLY your speech text (type:"text" content). Actions (spotlight, whiteboard, etc.) do NOT count toward length. Use as many actions as needed — they don't make your speech "too long."
  214. - Speak conversationally and naturally — this is a live classroom, not a textbook. Use oral language, not written prose.`;
  215. if (role === 'teacher') {
  216. return `- Keep your TOTAL speech text around 100 characters (across all text objects combined). Prefer 2-3 short sentences over one long paragraph.
  217. ${common}
  218. - Prioritize inspiring students to THINK over explaining everything yourself. Ask questions, pose challenges, give hints — don't just lecture.
  219. - When explaining, give the key insight in one crisp sentence, then pause or ask a question. Avoid exhaustive explanations.`;
  220. }
  221. if (role === 'assistant') {
  222. return `- Keep your TOTAL speech text around 80 characters. You are a supporting role — be brief.
  223. ${common}
  224. - One key point per response. Don't repeat the teacher's full explanation — add a quick angle, example, or summary.`;
  225. }
  226. // Student roles — must be noticeably shorter than teacher
  227. return `- Keep your TOTAL speech text around 50 characters. 1-2 sentences max.
  228. ${common}
  229. - You are a STUDENT, not a teacher. Your responses should be much shorter than the teacher's. If your response is as long as the teacher's, you are doing it wrong.
  230. - Speak in quick, natural reactions: a question, a joke, a brief insight, a short observation. Not paragraphs.
  231. - Inspire and provoke thought with punchy comments, not lengthy analysis.`;
  232. }
  233. // ==================== Whiteboard Guidelines ====================
  234. /**
  235. * Build role-aware whiteboard guidelines.
  236. *
  237. * - Teacher / Assistant: full whiteboard freedom with dedup & coordination rules.
  238. * - Student: whiteboard is opt-in — only use it when explicitly invited by the
  239. * teacher (e.g., "come solve this on the board"), never proactively.
  240. */
  241. function buildWhiteboardGuidelines(role: string): string {
  242. const common = `- Before drawing on the whiteboard, check the "Current State" section below for existing whiteboard elements.
  243. - Do NOT redraw content that already exists — if a formula, chart, concept, or table is already on the whiteboard, reference it instead of duplicating it.
  244. - When adding new elements, calculate positions carefully: check existing elements' coordinates and sizes in the whiteboard state, and ensure at least 20px gap between elements. Canvas size is 1000×562. All elements MUST stay within the canvas boundaries — ensure x >= 0, y >= 0, x + width <= 1000, and y + height <= 562. Never place elements that extend beyond the edges.
  245. - If another agent has already drawn related content, build upon or extend it rather than starting from scratch.`;
  246. const latexGuidelines = `
  247. ### LaTeX Element Sizing (CRITICAL)
  248. LaTeX elements have **auto-calculated width** (width = height × aspectRatio). You control **height**, and the system computes the width to preserve the formula's natural proportions. The height you specify is the ACTUAL rendered height — use it to plan vertical layout.
  249. **Height guide by formula category:**
  250. | Category | Examples | Recommended height |
  251. |----------|---------|-------------------|
  252. | Inline equations | E=mc^2, a+b=c | 50-80 |
  253. | Equations with fractions | \\frac{-b±√(b²-4ac)}{2a} | 60-100 |
  254. | Integrals / limits | \\int_0^1 f(x)dx, \\lim_{x→0} | 60-100 |
  255. | Summations with limits | \\sum_{i=1}^{n} i^2 | 80-120 |
  256. | Matrices | \\begin{pmatrix}...\\end{pmatrix} | 100-180 |
  257. | Standalone fractions | \\frac{a}{b}, \\frac{1}{2} | 50-80 |
  258. | Nested fractions | \\frac{\\frac{a}{b}}{\\frac{c}{d}} | 80-120 |
  259. **Key rules:**
  260. - ALWAYS specify height. The height you set is the actual rendered height.
  261. - When placing elements below each other, add height + 20-40px gap.
  262. - Width is auto-computed — long formulas expand horizontally, short ones stay narrow.
  263. - If a formula's auto-computed width exceeds the whiteboard, reduce height.
  264. **Multi-step derivations:**
  265. Give each step the **same height** (e.g., 70-80px). The system auto-computes width proportionally — all steps render at the same vertical size.
  266. ### LaTeX Support
  267. This project uses KaTeX for formula rendering, which supports virtually all standard LaTeX math commands. You may use any standard LaTeX math command freely.
  268. - \\text{} can render English text. For non-Latin labels, use a separate TextElement.`;
  269. if (role === 'teacher') {
  270. return `- Use text elements for notes, steps, and explanations.
  271. - Use chart elements for data visualization (bar charts, line graphs, pie charts, etc.).
  272. - Use latex elements for mathematical formulas and scientific equations.
  273. - Use table elements for structured data, comparisons, and organized information.
  274. - Use shape elements sparingly — only for simple diagrams. Do not add large numbers of meaningless shapes.
  275. - Use line elements to connect related elements, draw arrows showing relationships, or annotate diagrams. Specify arrow markers via the points parameter.
  276. - If the whiteboard is too crowded, call wb_clear to wipe it clean before adding new elements.
  277. ### Deleting Elements
  278. - Use wb_delete to remove a specific element by its ID (shown as [id:xxx] in whiteboard state).
  279. - Prefer wb_delete over wb_clear when only 1-2 elements need removal.
  280. - Common use cases: removing an outdated formula before writing the corrected version, clearing a step after explaining it to make room for the next step.
  281. ### Animation-Like Effects with Delete + Draw
  282. All wb_draw_* actions accept an optional **elementId** parameter. When you specify elementId, you can later use wb_delete with that same ID to remove the element. This is essential for creating animation effects.
  283. - To use: add elementId (e.g. "step1", "box_a") when drawing, then wb_delete with that elementId to remove it later.
  284. - Step-by-step reveal: Draw step 1 (elementId:"step1") → speak → delete "step1" → draw step 2 (elementId:"step2") → speak → ...
  285. - State transitions: Draw initial state (elementId:"state") → explain → delete "state" → draw final state
  286. - Progressive diagrams: Draw base diagram → add elements one by one with speech between each
  287. - Example: draw a shape at position A with elementId "obj", explain it, delete "obj", draw the same shape at position B — this creates the illusion of movement.
  288. - Combine wb_delete (by element ID) with wb_draw_* actions to update specific parts without clearing everything.
  289. ### Layout Constraints (IMPORTANT)
  290. The whiteboard canvas is 1000 × 562 pixels. Follow these rules to prevent element overlap:
  291. **Coordinate system:**
  292. - X range: 0 (left) to 1000 (right), Y range: 0 (top) to 562 (bottom)
  293. - Leave 20px margin from edges (safe area: x 20-980, y 20-542)
  294. **Spacing rules:**
  295. - Maintain at least 20px gap between adjacent elements
  296. - Vertical stacking: next_y = previous_y + previous_height + 30
  297. - Side by side: next_x = previous_x + previous_width + 30
  298. **Layout patterns:**
  299. - Top-down flow: Start from y=30, stack downward with gaps
  300. - Two-column: Left column x=20-480, right column x=520-980
  301. - Center single element: x = (1000 - element_width) / 2
  302. **Before adding a new element:**
  303. - Check existing elements' positions in the whiteboard state
  304. - Ensure your new element's bounding box does not overlap with any existing element
  305. - If space is insufficient, use wb_delete to remove unneeded elements or wb_clear to start fresh
  306. ${latexGuidelines}
  307. ${common}`;
  308. }
  309. if (role === 'assistant') {
  310. return `- The whiteboard is primarily the teacher's space. As an assistant, use it sparingly to supplement.
  311. - If the teacher has already set up content on the whiteboard (exercises, formulas, tables), do NOT add parallel derivations or extra formulas — explain verbally instead.
  312. - Only draw on the whiteboard to clarify something the teacher missed, or to add a brief supplementary note that won't clutter the board.
  313. - Limit yourself to at most 1-2 small elements per response. Prefer speech over drawing.
  314. ${latexGuidelines}
  315. ${common}`;
  316. }
  317. // Student role: suppress proactive whiteboard usage
  318. return `- The whiteboard is primarily the teacher's space. Do NOT draw on it proactively.
  319. - Only use whiteboard actions when the teacher or user explicitly invites you to write on the board (e.g., "come solve this", "show your work on the whiteboard").
  320. - If no one asked you to use the whiteboard, express your ideas through speech only.
  321. - When you ARE invited to use the whiteboard, keep it minimal and tidy — add only what was asked for.
  322. ${common}`;
  323. }
  324. // ==================== Element Summarization ====================
  325. /**
  326. * Strip HTML tags to extract plain text
  327. */
  328. function stripHtml(html: string): string {
  329. return html.replace(/<[^>]*>/g, '').trim();
  330. }
  331. /**
  332. * Summarize a single PPT element into a one-line description
  333. */
  334. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTElement variants have heterogeneous shapes
  335. function summarizeElement(el: any): string {
  336. const id = el.id ? `[id:${el.id}]` : '';
  337. const pos = `at (${Math.round(el.left)},${Math.round(el.top)})`;
  338. const size =
  339. el.width != null && el.height != null
  340. ? ` size ${Math.round(el.width)}×${Math.round(el.height)}`
  341. : el.width != null
  342. ? ` w=${Math.round(el.width)}`
  343. : '';
  344. switch (el.type) {
  345. case 'text': {
  346. const text = stripHtml(el.content || '').slice(0, 60);
  347. const suffix = text.length >= 60 ? '...' : '';
  348. return `${id} text${el.textType ? `[${el.textType}]` : ''}: "${text}${suffix}" ${pos}${size}`;
  349. }
  350. case 'image': {
  351. const src = el.src?.startsWith('data:') ? '[embedded]' : el.src?.slice(0, 50) || 'unknown';
  352. return `${id} image: ${src} ${pos}${size}`;
  353. }
  354. case 'shape': {
  355. const shapeText = el.text?.content ? stripHtml(el.text.content).slice(0, 40) : '';
  356. return `${id} shape${shapeText ? `: "${shapeText}"` : ''} ${pos}${size}`;
  357. }
  358. case 'chart':
  359. return `${id} chart[${el.chartType}]: labels=[${(el.data?.labels || []).slice(0, 4).join(',')}] ${pos}${size}`;
  360. case 'table': {
  361. const rows = el.data?.length || 0;
  362. const cols = el.data?.[0]?.length || 0;
  363. return `${id} table: ${rows}x${cols} ${pos}${size}`;
  364. }
  365. case 'latex':
  366. return `${id} latex: "${(el.latex || '').slice(0, 40)}" ${pos}${size}`;
  367. case 'line': {
  368. const lx = Math.round(el.left ?? 0);
  369. const ly = Math.round(el.top ?? 0);
  370. const sx = el.start?.[0] ?? 0;
  371. const sy = el.start?.[1] ?? 0;
  372. const ex = el.end?.[0] ?? 0;
  373. const ey = el.end?.[1] ?? 0;
  374. return `${id} line: (${lx + sx},${ly + sy}) → (${lx + ex},${ly + ey})`;
  375. }
  376. case 'video':
  377. return `${id} video ${pos}${size}`;
  378. case 'audio':
  379. return `${id} audio ${pos}${size}`;
  380. default:
  381. return `${id} ${el.type || 'unknown'} ${pos}${size}`;
  382. }
  383. }
  384. /**
  385. * Summarize an array of elements into line descriptions
  386. */
  387. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTElement variants have heterogeneous shapes
  388. function summarizeElements(elements: any[]): string {
  389. if (elements.length === 0) return ' (empty)';
  390. const lines = elements.map((el, i) => ` ${i + 1}. ${summarizeElement(el)}`);
  391. return lines.join('\n');
  392. }
  393. // ==================== Virtual Whiteboard Context ====================
  394. /**
  395. * Tracked element from replaying the whiteboard ledger
  396. */
  397. interface VirtualWhiteboardElement {
  398. agentName: string;
  399. summary: string;
  400. elementId?: string; // Present for elements from initial whiteboard state
  401. }
  402. /**
  403. * Replay the whiteboard ledger to build an attributed element list.
  404. *
  405. * - wb_clear resets the accumulated elements
  406. * - wb_draw_* appends a new element with the agent's name
  407. * - wb_open / wb_close are ignored (structural, not content)
  408. *
  409. * Returns empty string when the ledger is empty (zero extra token overhead).
  410. */
  411. function buildVirtualWhiteboardContext(
  412. storeState: StatelessChatRequest['storeState'],
  413. ledger?: WhiteboardActionRecord[],
  414. ): string {
  415. if (!ledger || ledger.length === 0) return '';
  416. // Replay ledger to build current element list
  417. const elements: VirtualWhiteboardElement[] = [];
  418. for (const record of ledger) {
  419. switch (record.actionName) {
  420. case 'wb_clear':
  421. elements.length = 0;
  422. break;
  423. case 'wb_delete': {
  424. // Remove element by matching elementId from initial whiteboard state
  425. // (elements drawn this round don't have tracked IDs)
  426. const deleteId = String(record.params.elementId || '');
  427. const idx = elements.findIndex((el) => el.elementId === deleteId);
  428. if (idx >= 0) elements.splice(idx, 1);
  429. break;
  430. }
  431. case 'wb_draw_text': {
  432. const content = String(record.params.content || '').slice(0, 40);
  433. const x = record.params.x ?? '?';
  434. const y = record.params.y ?? '?';
  435. const w = record.params.width ?? 400;
  436. const h = record.params.height ?? 100;
  437. elements.push({
  438. agentName: record.agentName,
  439. summary: `text: "${content}${content.length >= 40 ? '...' : ''}" at (${x},${y}), size ~${w}x${h}`,
  440. });
  441. break;
  442. }
  443. case 'wb_draw_shape': {
  444. const shapeType = record.params.type || record.params.shape || 'rectangle';
  445. const x = record.params.x ?? '?';
  446. const y = record.params.y ?? '?';
  447. const w = record.params.width ?? 100;
  448. const h = record.params.height ?? 100;
  449. elements.push({
  450. agentName: record.agentName,
  451. summary: `shape(${shapeType}) at (${x},${y}), size ${w}x${h}`,
  452. });
  453. break;
  454. }
  455. case 'wb_draw_chart': {
  456. const chartType = record.params.chartType || record.params.type || 'bar';
  457. const labels = Array.isArray(record.params.labels)
  458. ? record.params.labels
  459. : (record.params.data as Record<string, unknown>)?.labels;
  460. const x = record.params.x ?? '?';
  461. const y = record.params.y ?? '?';
  462. const w = record.params.width ?? 350;
  463. const h = record.params.height ?? 250;
  464. elements.push({
  465. agentName: record.agentName,
  466. summary: `chart(${chartType})${labels ? `: labels=[${(labels as string[]).slice(0, 4).join(',')}]` : ''} at (${x},${y}), size ${w}x${h}`,
  467. });
  468. break;
  469. }
  470. case 'wb_draw_latex': {
  471. const latex = String(record.params.latex || '').slice(0, 40);
  472. const x = record.params.x ?? '?';
  473. const y = record.params.y ?? '?';
  474. const w = record.params.width ?? 400;
  475. // Estimate latex height: ~80px default for single-line, more for complex formulas
  476. const h = record.params.height ?? 80;
  477. elements.push({
  478. agentName: record.agentName,
  479. summary: `latex: "${latex}${latex.length >= 40 ? '...' : ''}" at (${x},${y}), size ~${w}x${h}`,
  480. });
  481. break;
  482. }
  483. case 'wb_draw_table': {
  484. const data = record.params.data as unknown[][] | undefined;
  485. const rows = data?.length || 0;
  486. const cols = (data?.[0] as unknown[])?.length || 0;
  487. const x = record.params.x ?? '?';
  488. const y = record.params.y ?? '?';
  489. const w = record.params.width ?? 400;
  490. const h = record.params.height ?? rows * 40 + 20;
  491. elements.push({
  492. agentName: record.agentName,
  493. summary: `table(${rows}×${cols}) at (${x},${y}), size ${w}x${h}`,
  494. });
  495. break;
  496. }
  497. case 'wb_draw_line': {
  498. const sx = record.params.startX ?? '?';
  499. const sy = record.params.startY ?? '?';
  500. const ex = record.params.endX ?? '?';
  501. const ey = record.params.endY ?? '?';
  502. const pts = record.params.points as string[] | undefined;
  503. const hasArrow = pts?.includes('arrow') ? ' (arrow)' : '';
  504. elements.push({
  505. agentName: record.agentName,
  506. summary: `line${hasArrow}: (${sx},${sy}) → (${ex},${ey})`,
  507. });
  508. break;
  509. }
  510. // wb_open, wb_close — skip
  511. }
  512. }
  513. if (elements.length === 0) return '';
  514. const elementLines = elements
  515. .map((el, i) => ` ${i + 1}. [by ${el.agentName}] ${el.summary}`)
  516. .join('\n');
  517. return `
  518. ## Whiteboard Changes This Round (IMPORTANT)
  519. Other agents have modified the whiteboard during this discussion round.
  520. Current whiteboard elements (${elements.length}):
  521. ${elementLines}
  522. DO NOT redraw content that already exists. Check positions above before adding new elements.
  523. `;
  524. }
  525. // ==================== State Context ====================
  526. /**
  527. * Build context string from store state
  528. */
  529. function buildStateContext(storeState: StatelessChatRequest['storeState']): string {
  530. const { stage, scenes, currentSceneId, mode, whiteboardOpen } = storeState;
  531. const lines: string[] = [];
  532. // Mode
  533. lines.push(`Mode: ${mode}`);
  534. // Whiteboard status
  535. lines.push(
  536. `Whiteboard: ${whiteboardOpen ? 'OPEN (slide canvas is hidden)' : 'closed (slide canvas is visible)'}`,
  537. );
  538. // Stage info
  539. if (stage) {
  540. lines.push(
  541. `Course: ${stage.name || 'Untitled'}${stage.description ? ` - ${stage.description}` : ''}`,
  542. );
  543. }
  544. // Scenes summary
  545. lines.push(`Total scenes: ${scenes.length}`);
  546. if (currentSceneId) {
  547. const currentScene = scenes.find((s) => s.id === currentSceneId);
  548. if (currentScene) {
  549. lines.push(
  550. `Current scene: "${currentScene.title}" (${currentScene.type}, id: ${currentSceneId})`,
  551. );
  552. // Slide scene: include element details
  553. if (currentScene.content.type === 'slide') {
  554. const elements = currentScene.content.canvas.elements;
  555. lines.push(`Current slide elements (${elements.length}):\n${summarizeElements(elements)}`);
  556. }
  557. // Quiz scene: include question summary
  558. if (currentScene.content.type === 'quiz') {
  559. const questions = currentScene.content.questions;
  560. const qSummary = questions
  561. .slice(0, 5)
  562. .map((q, i) => ` ${i + 1}. [${q.type}] ${q.question.slice(0, 80)}`)
  563. .join('\n');
  564. lines.push(
  565. `Quiz questions (${questions.length}):\n${qSummary}${questions.length > 5 ? `\n ... and ${questions.length - 5} more` : ''}`,
  566. );
  567. }
  568. }
  569. } else if (scenes.length > 0) {
  570. lines.push('No scene currently selected');
  571. }
  572. // List first few scenes
  573. if (scenes.length > 0) {
  574. const sceneSummary = scenes
  575. .slice(0, 5)
  576. .map((s, i) => ` ${i + 1}. ${s.title} (${s.type}, id: ${s.id})`)
  577. .join('\n');
  578. lines.push(
  579. `Scenes:\n${sceneSummary}${scenes.length > 5 ? `\n ... and ${scenes.length - 5} more` : ''}`,
  580. );
  581. }
  582. // Whiteboard content (last whiteboard in the stage)
  583. if (stage?.whiteboard && stage.whiteboard.length > 0) {
  584. const lastWb = stage.whiteboard[stage.whiteboard.length - 1];
  585. const wbElements = lastWb.elements || [];
  586. lines.push(
  587. `Whiteboard (last of ${stage.whiteboard.length}, ${wbElements.length} elements):\n${summarizeElements(wbElements)}`,
  588. );
  589. }
  590. return lines.join('\n');
  591. }
  592. // ==================== Conversation Summary ====================
  593. /**
  594. * OpenAI message format (used by director)
  595. */
  596. interface OpenAIMessage {
  597. role: 'system' | 'user' | 'assistant';
  598. content: string;
  599. }
  600. /**
  601. * Summarize conversation history for the director agent
  602. *
  603. * Produces a condensed text summary of the last N messages,
  604. * truncating long messages and including role labels.
  605. *
  606. * @param messages - OpenAI-format messages to summarize
  607. * @param maxMessages - Maximum number of recent messages to include (default 10)
  608. * @param maxContentLength - Maximum content length per message (default 200)
  609. */
  610. export function summarizeConversation(
  611. messages: OpenAIMessage[],
  612. maxMessages = 10,
  613. maxContentLength = 200,
  614. ): string {
  615. if (messages.length === 0) {
  616. return 'No conversation history yet.';
  617. }
  618. const recent = messages.slice(-maxMessages);
  619. const lines = recent.map((msg) => {
  620. const roleLabel =
  621. msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
  622. const content =
  623. msg.content.length > maxContentLength
  624. ? msg.content.slice(0, maxContentLength) + '...'
  625. : msg.content;
  626. return `[${roleLabel}] ${content}`;
  627. });
  628. return lines.join('\n');
  629. }
  630. // ==================== Message Conversion ====================
  631. /**
  632. * Convert UI messages to OpenAI format
  633. * Includes tool call information so the model knows what actions were taken
  634. */
  635. export function convertMessagesToOpenAI(
  636. messages: StatelessChatRequest['messages'],
  637. currentAgentId?: string,
  638. ): Array<{ role: 'system' | 'user' | 'assistant'; content: string }> {
  639. return messages
  640. .filter((msg) => msg.role === 'user' || msg.role === 'assistant')
  641. .map((msg) => {
  642. if (msg.role === 'assistant') {
  643. // Assistant messages use JSON array format to serve as few-shot examples
  644. // that match the expected output format from the system prompt
  645. const items: Array<{ type: string; [key: string]: string }> = [];
  646. if (msg.parts) {
  647. for (const part of msg.parts) {
  648. const p = part as Record<string, unknown>;
  649. if (p.type === 'text' && p.text) {
  650. items.push({ type: 'text', content: p.text as string });
  651. } else if ((p.type as string)?.startsWith('action-') && p.state === 'result') {
  652. const actionName = (p.actionName ||
  653. (p.type as string).replace('action-', '')) as string;
  654. const output = p.output as Record<string, unknown> | undefined;
  655. const isSuccess = output?.success === true;
  656. const resultSummary = isSuccess
  657. ? output?.data
  658. ? `result: ${JSON.stringify(output.data).slice(0, 100)}`
  659. : 'success'
  660. : (output?.error as string) || 'failed';
  661. items.push({
  662. type: 'action',
  663. name: actionName,
  664. result: resultSummary,
  665. });
  666. }
  667. }
  668. }
  669. const content = items.length > 0 ? JSON.stringify(items) : '';
  670. const msgAgentId = msg.metadata?.agentId;
  671. // When currentAgentId is provided and this message is from a DIFFERENT agent,
  672. // convert to user role with agent name attribution
  673. if (currentAgentId && msgAgentId && msgAgentId !== currentAgentId) {
  674. const agentName = msg.metadata?.senderName || msgAgentId;
  675. return {
  676. role: 'user' as const,
  677. content: content ? `[${agentName}]: ${content}` : '',
  678. };
  679. }
  680. return {
  681. role: 'assistant' as const,
  682. content,
  683. };
  684. }
  685. // User messages: keep plain text concatenation
  686. const contentParts: string[] = [];
  687. if (msg.parts) {
  688. for (const part of msg.parts) {
  689. const p = part as Record<string, unknown>;
  690. if (p.type === 'text' && p.text) {
  691. contentParts.push(p.text as string);
  692. } else if ((p.type as string)?.startsWith('action-') && p.state === 'result') {
  693. const actionName = (p.actionName ||
  694. (p.type as string).replace('action-', '')) as string;
  695. const output = p.output as Record<string, unknown> | undefined;
  696. const isSuccess = output?.success === true;
  697. const resultSummary = isSuccess
  698. ? output?.data
  699. ? `result: ${JSON.stringify(output.data).slice(0, 100)}`
  700. : 'success'
  701. : (output?.error as string) || 'failed';
  702. contentParts.push(`[Action ${actionName}: ${resultSummary}]`);
  703. }
  704. }
  705. }
  706. // Extract speaker name from metadata (e.g. other agents' messages in discussion)
  707. const senderName = msg.metadata?.senderName;
  708. let content = contentParts.join('\n');
  709. if (senderName) {
  710. content = `[${senderName}]: ${content}`;
  711. }
  712. // Annotate interrupted messages so the LLM knows context was cut short
  713. const isInterrupted =
  714. (msg as unknown as Record<string, unknown>).metadata &&
  715. ((msg as unknown as Record<string, unknown>).metadata as Record<string, unknown>)
  716. ?.interrupted;
  717. return {
  718. role: 'user' as const,
  719. content: isInterrupted
  720. ? `${content}\n[This response was interrupted — do NOT continue it. Start a new JSON array response.]`
  721. : content,
  722. };
  723. })
  724. .filter((msg) => {
  725. // Drop empty messages and messages with only dots/ellipsis/whitespace
  726. // (produced by failed agent streams)
  727. const stripped = msg.content.replace(/[.\s…]+/g, '');
  728. return stripped.length > 0;
  729. });
  730. }