| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848 |
- /**
- * Prompt Builder for Stateless Generation
- *
- * Builds system prompts and converts messages for the LLM.
- */
- import type { StatelessChatRequest } from '@/lib/types/chat';
- import type { AgentConfig } from '@/lib/orchestration/registry/types';
- import type { WhiteboardActionRecord, AgentTurnSummary } from './director-prompt';
- import { getActionDescriptions, getEffectiveActions } from './tool-schemas';
- // ==================== Role Guidelines ====================
- const ROLE_GUIDELINES: Record<string, string> = {
- teacher: `Your role in this classroom: LEAD TEACHER.
- You are responsible for:
- - Controlling the lesson flow, slides, and pacing
- - Explaining concepts clearly with examples and analogies
- - Asking questions to check understanding
- - Using spotlight/laser to direct attention to slide elements
- - Using the whiteboard for diagrams and formulas
- You can use all available actions. Never announce your actions — just teach naturally.`,
- assistant: `Your role in this classroom: TEACHING ASSISTANT.
- You are responsible for:
- - Supporting the lead teacher by filling gaps and answering side questions
- - Rephrasing explanations in simpler terms when students are confused
- - Providing concrete examples and background context
- - Using the whiteboard sparingly to supplement (not duplicate) the teacher's content
- You play a supporting role — don't take over the lesson.`,
- student: `Your role in this classroom: STUDENT.
- You are responsible for:
- - Participating actively in discussions
- - Asking questions, sharing observations, reacting to the lesson
- - Keeping responses SHORT (1-2 sentences max)
- - Only using the whiteboard when explicitly invited by the teacher
- You are NOT a teacher — your responses should be much shorter than the teacher's.`,
- };
- // ==================== Types ====================
- /**
- * Discussion context for agent-initiated discussions
- */
- interface DiscussionContext {
- topic: string;
- prompt?: string;
- }
- // ==================== Peer Context ====================
- /**
- * Build a context section summarizing what other agents said this round.
- * Returns empty string if no agents have spoken yet.
- */
- function buildPeerContextSection(
- agentResponses: AgentTurnSummary[] | undefined,
- currentAgentName: string,
- ): string {
- if (!agentResponses || agentResponses.length === 0) return '';
- // Filter out self (defensive — director shouldn't dispatch same agent twice)
- const peers = agentResponses.filter((r) => r.agentName !== currentAgentName);
- if (peers.length === 0) return '';
- const peerLines = peers.map((r) => `- ${r.agentName}: "${r.contentPreview}"`).join('\n');
- return `
- # This Round's Context (CRITICAL — READ BEFORE RESPONDING)
- The following agents have already spoken in this discussion round:
- ${peerLines}
- You are ${currentAgentName}, responding AFTER the agents above. You MUST:
- 1. NOT repeat greetings or introductions — they have already been made
- 2. NOT restate what previous speakers already explained
- 3. Add NEW value from YOUR unique perspective as ${currentAgentName}
- 4. Build on, question, or extend what was said — do not echo it
- 5. If you agree with a previous point, say so briefly and then ADD something new
- `;
- }
- // ==================== System Prompt ====================
- /**
- * Build system prompt for structured output generation
- *
- * @param agentConfig - The agent configuration
- * @param storeState - Current application state
- * @param discussionContext - Optional discussion context for agent-initiated discussions
- * @returns System prompt string
- */
- export function buildStructuredPrompt(
- agentConfig: AgentConfig,
- storeState: StatelessChatRequest['storeState'],
- discussionContext?: DiscussionContext,
- whiteboardLedger?: WhiteboardActionRecord[],
- userProfile?: { nickname?: string; bio?: string },
- agentResponses?: AgentTurnSummary[],
- ): string {
- // Determine current scene type for action filtering
- const currentScene = storeState.currentSceneId
- ? storeState.scenes.find((s) => s.id === storeState.currentSceneId)
- : undefined;
- const sceneType = currentScene?.type;
- // Filter actions by scene type (spotlight/laser only available on slides)
- const effectiveActions = getEffectiveActions(agentConfig.allowedActions, sceneType);
- const actionDescriptions = getActionDescriptions(effectiveActions);
- // Build context about current state
- const stateContext = buildStateContext(storeState);
- // Build virtual whiteboard context from ledger (shows changes by other agents this round)
- const virtualWbContext = buildVirtualWhiteboardContext(storeState, whiteboardLedger);
- // Build student profile section (only when nickname or bio is present)
- const studentProfileSection =
- userProfile?.nickname || userProfile?.bio
- ? `\n# Student Profile
- You are teaching ${userProfile.nickname || 'a student'}.${userProfile.bio ? `\nTheir background: ${userProfile.bio}` : ''}
- Personalize your teaching based on their background when relevant. Address them by name naturally.\n`
- : '';
- // Build peer context section (what agents already said this round)
- const peerContext = buildPeerContextSection(agentResponses, agentConfig.name);
- // Whether spotlight/laser are available (only on slide scenes)
- const hasSlideActions =
- effectiveActions.includes('spotlight') || effectiveActions.includes('laser');
- // Build format example based on available actions
- const formatExample = hasSlideActions
- ? `[{"type":"action","name":"spotlight","params":{"elementId":"img_1"}},{"type":"text","content":"Your natural speech to students"}]`
- : `[{"type":"action","name":"wb_open","params":{}},{"type":"text","content":"Your natural speech to students"}]`;
- // Ordering principles
- const orderingPrinciples = hasSlideActions
- ? `- spotlight/laser actions should appear BEFORE the corresponding text object (point first, then speak)
- - whiteboard actions can interleave WITH text objects (draw while speaking)`
- : `- whiteboard actions can interleave WITH text objects (draw while speaking)`;
- // Good examples — include spotlight/laser examples only for slide scenes
- const spotlightExamples = hasSlideActions
- ? `[{"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."}]
- [{"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."}]
- `
- : '';
- // Action usage guidelines — conditional spotlight/laser lines
- const slideActionGuidelines = hasSlideActions
- ? `- spotlight: Use to focus attention on ONE key element. Don't overuse — max 1-2 per response.
- - laser: Use to point at elements. Good for directing attention during explanations.
- `
- : '';
- const mutualExclusionNote = hasSlideActions
- ? `- 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.
- - Prefer variety: mix spotlights, laser, and whiteboard for engaging teaching. Don't use the same action type repeatedly.`
- : '';
- const roleGuideline = ROLE_GUIDELINES[agentConfig.role] || ROLE_GUIDELINES.student;
- // Build language constraint from stage language
- const courseLanguage = storeState.stage?.language;
- const languageConstraint = courseLanguage
- ? `\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`
- : '';
- return `# Role
- You are ${agentConfig.name}.
- ## Your Personality
- ${agentConfig.persona}
- ## Your Classroom Role
- ${roleGuideline}
- ${studentProfileSection}${peerContext}${languageConstraint}
- # Output Format
- You MUST output a JSON array for ALL responses. Each element is an object with a \`type\` field:
- ${formatExample}
- ## Format Rules
- 1. Output a single JSON array — no explanation, no code fences
- 2. \`type:"action"\` objects contain \`name\` and \`params\`
- 3. \`type:"text"\` objects contain \`content\` (speech text)
- 4. Action and text objects can freely interleave in any order
- 5. The \`]\` closing bracket marks the end of your response
- 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.
- ## Ordering Principles
- ${orderingPrinciples}
- ## Speech Guidelines (CRITICAL)
- - Effects fire concurrently with your speech — students see results as you speak
- - Text content is what you SAY OUT LOUD to students - natural teaching speech
- - Do NOT say "let me add...", "I'll create...", "now I'm going to..."
- - Do NOT describe your actions - just speak naturally as a teacher
- - Students see action results appear on screen - you don't need to announce them
- - Your speech should flow naturally regardless of whether actions succeed or fail
- - NEVER use markdown formatting (blockquotes >, headings #, bold **, lists -, code blocks) in text content — it is spoken aloud, not rendered
- ## Length & Style (CRITICAL)
- ${buildLengthGuidelines(agentConfig.role)}
- ### Good Examples
- ${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."}]
- [{"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."}]
- ### Bad Examples (DO NOT do this)
- [{"type":"text","content":"Let me open the whiteboard"},{"type":"action",...}] (Don't announce actions!)
- [{"type":"text","content":"I'm going to draw a diagram for you..."}] (Don't describe what you're doing!)
- [{"type":"text","content":"Action complete, shape has been added"}] (Don't report action results!)
- ## Whiteboard Guidelines
- ${buildWhiteboardGuidelines(agentConfig.role)}
- # Available Actions
- ${actionDescriptions}
- ## Action Usage Guidelines
- ${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.
- - 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.
- - 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.
- ${mutualExclusionNote}
- # Current State
- ${stateContext}
- ${virtualWbContext}
- Remember: Speak naturally as a teacher. Effects fire concurrently with your speech.${
- discussionContext
- ? agentResponses && agentResponses.length > 0
- ? `
- # Discussion Context
- Topic: "${discussionContext.topic}"
- ${discussionContext.prompt ? `Guiding prompt: ${discussionContext.prompt}` : ''}
- 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.`
- : `
- # Discussion Context
- You are initiating a discussion on the following topic: "${discussionContext.topic}"
- ${discussionContext.prompt ? `Guiding prompt: ${discussionContext.prompt}` : ''}
- 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.`
- : ''
- }`;
- }
- // ==================== Length Guidelines ====================
- /**
- * Build role-aware length and style guidelines.
- *
- * All agents should be concise and conversational. Student agents must be
- * significantly shorter than teacher to avoid overshadowing the teacher's role.
- */
- function buildLengthGuidelines(role: string): string {
- 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."
- - Speak conversationally and naturally — this is a live classroom, not a textbook. Use oral language, not written prose.`;
- if (role === 'teacher') {
- return `- Keep your TOTAL speech text around 100 characters (across all text objects combined). Prefer 2-3 short sentences over one long paragraph.
- ${common}
- - Prioritize inspiring students to THINK over explaining everything yourself. Ask questions, pose challenges, give hints — don't just lecture.
- - When explaining, give the key insight in one crisp sentence, then pause or ask a question. Avoid exhaustive explanations.`;
- }
- if (role === 'assistant') {
- return `- Keep your TOTAL speech text around 80 characters. You are a supporting role — be brief.
- ${common}
- - One key point per response. Don't repeat the teacher's full explanation — add a quick angle, example, or summary.`;
- }
- // Student roles — must be noticeably shorter than teacher
- return `- Keep your TOTAL speech text around 50 characters. 1-2 sentences max.
- ${common}
- - 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.
- - Speak in quick, natural reactions: a question, a joke, a brief insight, a short observation. Not paragraphs.
- - Inspire and provoke thought with punchy comments, not lengthy analysis.`;
- }
- // ==================== Whiteboard Guidelines ====================
- /**
- * Build role-aware whiteboard guidelines.
- *
- * - Teacher / Assistant: full whiteboard freedom with dedup & coordination rules.
- * - Student: whiteboard is opt-in — only use it when explicitly invited by the
- * teacher (e.g., "come solve this on the board"), never proactively.
- */
- function buildWhiteboardGuidelines(role: string): string {
- const common = `- Before drawing on the whiteboard, check the "Current State" section below for existing whiteboard elements.
- - 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.
- - 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.
- - If another agent has already drawn related content, build upon or extend it rather than starting from scratch.`;
- const latexGuidelines = `
- ### LaTeX Element Sizing (CRITICAL)
- 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.
- **Height guide by formula category:**
- | Category | Examples | Recommended height |
- |----------|---------|-------------------|
- | Inline equations | E=mc^2, a+b=c | 50-80 |
- | Equations with fractions | \\frac{-b±√(b²-4ac)}{2a} | 60-100 |
- | Integrals / limits | \\int_0^1 f(x)dx, \\lim_{x→0} | 60-100 |
- | Summations with limits | \\sum_{i=1}^{n} i^2 | 80-120 |
- | Matrices | \\begin{pmatrix}...\\end{pmatrix} | 100-180 |
- | Standalone fractions | \\frac{a}{b}, \\frac{1}{2} | 50-80 |
- | Nested fractions | \\frac{\\frac{a}{b}}{\\frac{c}{d}} | 80-120 |
- **Key rules:**
- - ALWAYS specify height. The height you set is the actual rendered height.
- - When placing elements below each other, add height + 20-40px gap.
- - Width is auto-computed — long formulas expand horizontally, short ones stay narrow.
- - If a formula's auto-computed width exceeds the whiteboard, reduce height.
- **Multi-step derivations:**
- Give each step the **same height** (e.g., 70-80px). The system auto-computes width proportionally — all steps render at the same vertical size.
- ### LaTeX Support
- This project uses KaTeX for formula rendering, which supports virtually all standard LaTeX math commands. You may use any standard LaTeX math command freely.
- - \\text{} can render English text. For non-Latin labels, use a separate TextElement.`;
- if (role === 'teacher') {
- return `- Use text elements for notes, steps, and explanations.
- - Use chart elements for data visualization (bar charts, line graphs, pie charts, etc.).
- - Use latex elements for mathematical formulas and scientific equations.
- - Use table elements for structured data, comparisons, and organized information.
- - Use shape elements sparingly — only for simple diagrams. Do not add large numbers of meaningless shapes.
- - Use line elements to connect related elements, draw arrows showing relationships, or annotate diagrams. Specify arrow markers via the points parameter.
- - If the whiteboard is too crowded, call wb_clear to wipe it clean before adding new elements.
- ### Deleting Elements
- - Use wb_delete to remove a specific element by its ID (shown as [id:xxx] in whiteboard state).
- - Prefer wb_delete over wb_clear when only 1-2 elements need removal.
- - 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.
- ### Animation-Like Effects with Delete + Draw
- 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.
- - To use: add elementId (e.g. "step1", "box_a") when drawing, then wb_delete with that elementId to remove it later.
- - Step-by-step reveal: Draw step 1 (elementId:"step1") → speak → delete "step1" → draw step 2 (elementId:"step2") → speak → ...
- - State transitions: Draw initial state (elementId:"state") → explain → delete "state" → draw final state
- - Progressive diagrams: Draw base diagram → add elements one by one with speech between each
- - 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.
- - Combine wb_delete (by element ID) with wb_draw_* actions to update specific parts without clearing everything.
- ### Layout Constraints (IMPORTANT)
- The whiteboard canvas is 1000 × 562 pixels. Follow these rules to prevent element overlap:
- **Coordinate system:**
- - X range: 0 (left) to 1000 (right), Y range: 0 (top) to 562 (bottom)
- - Leave 20px margin from edges (safe area: x 20-980, y 20-542)
- **Spacing rules:**
- - Maintain at least 20px gap between adjacent elements
- - Vertical stacking: next_y = previous_y + previous_height + 30
- - Side by side: next_x = previous_x + previous_width + 30
- **Layout patterns:**
- - Top-down flow: Start from y=30, stack downward with gaps
- - Two-column: Left column x=20-480, right column x=520-980
- - Center single element: x = (1000 - element_width) / 2
- **Before adding a new element:**
- - Check existing elements' positions in the whiteboard state
- - Ensure your new element's bounding box does not overlap with any existing element
- - If space is insufficient, use wb_delete to remove unneeded elements or wb_clear to start fresh
- ${latexGuidelines}
- ${common}`;
- }
- if (role === 'assistant') {
- return `- The whiteboard is primarily the teacher's space. As an assistant, use it sparingly to supplement.
- - 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.
- - Only draw on the whiteboard to clarify something the teacher missed, or to add a brief supplementary note that won't clutter the board.
- - Limit yourself to at most 1-2 small elements per response. Prefer speech over drawing.
- ${latexGuidelines}
- ${common}`;
- }
- // Student role: suppress proactive whiteboard usage
- return `- The whiteboard is primarily the teacher's space. Do NOT draw on it proactively.
- - 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").
- - If no one asked you to use the whiteboard, express your ideas through speech only.
- - When you ARE invited to use the whiteboard, keep it minimal and tidy — add only what was asked for.
- ${common}`;
- }
- // ==================== Element Summarization ====================
- /**
- * Strip HTML tags to extract plain text
- */
- function stripHtml(html: string): string {
- return html.replace(/<[^>]*>/g, '').trim();
- }
- /**
- * Summarize a single PPT element into a one-line description
- */
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTElement variants have heterogeneous shapes
- function summarizeElement(el: any): string {
- const id = el.id ? `[id:${el.id}]` : '';
- const pos = `at (${Math.round(el.left)},${Math.round(el.top)})`;
- const size =
- el.width != null && el.height != null
- ? ` size ${Math.round(el.width)}×${Math.round(el.height)}`
- : el.width != null
- ? ` w=${Math.round(el.width)}`
- : '';
- switch (el.type) {
- case 'text': {
- const text = stripHtml(el.content || '').slice(0, 60);
- const suffix = text.length >= 60 ? '...' : '';
- return `${id} text${el.textType ? `[${el.textType}]` : ''}: "${text}${suffix}" ${pos}${size}`;
- }
- case 'image': {
- const src = el.src?.startsWith('data:') ? '[embedded]' : el.src?.slice(0, 50) || 'unknown';
- return `${id} image: ${src} ${pos}${size}`;
- }
- case 'shape': {
- const shapeText = el.text?.content ? stripHtml(el.text.content).slice(0, 40) : '';
- return `${id} shape${shapeText ? `: "${shapeText}"` : ''} ${pos}${size}`;
- }
- case 'chart':
- return `${id} chart[${el.chartType}]: labels=[${(el.data?.labels || []).slice(0, 4).join(',')}] ${pos}${size}`;
- case 'table': {
- const rows = el.data?.length || 0;
- const cols = el.data?.[0]?.length || 0;
- return `${id} table: ${rows}x${cols} ${pos}${size}`;
- }
- case 'latex':
- return `${id} latex: "${(el.latex || '').slice(0, 40)}" ${pos}${size}`;
- case 'line': {
- const lx = Math.round(el.left ?? 0);
- const ly = Math.round(el.top ?? 0);
- const sx = el.start?.[0] ?? 0;
- const sy = el.start?.[1] ?? 0;
- const ex = el.end?.[0] ?? 0;
- const ey = el.end?.[1] ?? 0;
- return `${id} line: (${lx + sx},${ly + sy}) → (${lx + ex},${ly + ey})`;
- }
- case 'video':
- return `${id} video ${pos}${size}`;
- case 'audio':
- return `${id} audio ${pos}${size}`;
- default:
- return `${id} ${el.type || 'unknown'} ${pos}${size}`;
- }
- }
- /**
- * Summarize an array of elements into line descriptions
- */
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTElement variants have heterogeneous shapes
- function summarizeElements(elements: any[]): string {
- if (elements.length === 0) return ' (empty)';
- const lines = elements.map((el, i) => ` ${i + 1}. ${summarizeElement(el)}`);
- return lines.join('\n');
- }
- // ==================== Virtual Whiteboard Context ====================
- /**
- * Tracked element from replaying the whiteboard ledger
- */
- interface VirtualWhiteboardElement {
- agentName: string;
- summary: string;
- elementId?: string; // Present for elements from initial whiteboard state
- }
- /**
- * Replay the whiteboard ledger to build an attributed element list.
- *
- * - wb_clear resets the accumulated elements
- * - wb_draw_* appends a new element with the agent's name
- * - wb_open / wb_close are ignored (structural, not content)
- *
- * Returns empty string when the ledger is empty (zero extra token overhead).
- */
- function buildVirtualWhiteboardContext(
- storeState: StatelessChatRequest['storeState'],
- ledger?: WhiteboardActionRecord[],
- ): string {
- if (!ledger || ledger.length === 0) return '';
- // Replay ledger to build current element list
- const elements: VirtualWhiteboardElement[] = [];
- for (const record of ledger) {
- switch (record.actionName) {
- case 'wb_clear':
- elements.length = 0;
- break;
- case 'wb_delete': {
- // Remove element by matching elementId from initial whiteboard state
- // (elements drawn this round don't have tracked IDs)
- const deleteId = String(record.params.elementId || '');
- const idx = elements.findIndex((el) => el.elementId === deleteId);
- if (idx >= 0) elements.splice(idx, 1);
- break;
- }
- case 'wb_draw_text': {
- const content = String(record.params.content || '').slice(0, 40);
- const x = record.params.x ?? '?';
- const y = record.params.y ?? '?';
- const w = record.params.width ?? 400;
- const h = record.params.height ?? 100;
- elements.push({
- agentName: record.agentName,
- summary: `text: "${content}${content.length >= 40 ? '...' : ''}" at (${x},${y}), size ~${w}x${h}`,
- });
- break;
- }
- case 'wb_draw_shape': {
- const shapeType = record.params.type || record.params.shape || 'rectangle';
- const x = record.params.x ?? '?';
- const y = record.params.y ?? '?';
- const w = record.params.width ?? 100;
- const h = record.params.height ?? 100;
- elements.push({
- agentName: record.agentName,
- summary: `shape(${shapeType}) at (${x},${y}), size ${w}x${h}`,
- });
- break;
- }
- case 'wb_draw_chart': {
- const chartType = record.params.chartType || record.params.type || 'bar';
- const labels = Array.isArray(record.params.labels)
- ? record.params.labels
- : (record.params.data as Record<string, unknown>)?.labels;
- const x = record.params.x ?? '?';
- const y = record.params.y ?? '?';
- const w = record.params.width ?? 350;
- const h = record.params.height ?? 250;
- elements.push({
- agentName: record.agentName,
- summary: `chart(${chartType})${labels ? `: labels=[${(labels as string[]).slice(0, 4).join(',')}]` : ''} at (${x},${y}), size ${w}x${h}`,
- });
- break;
- }
- case 'wb_draw_latex': {
- const latex = String(record.params.latex || '').slice(0, 40);
- const x = record.params.x ?? '?';
- const y = record.params.y ?? '?';
- const w = record.params.width ?? 400;
- // Estimate latex height: ~80px default for single-line, more for complex formulas
- const h = record.params.height ?? 80;
- elements.push({
- agentName: record.agentName,
- summary: `latex: "${latex}${latex.length >= 40 ? '...' : ''}" at (${x},${y}), size ~${w}x${h}`,
- });
- break;
- }
- case 'wb_draw_table': {
- const data = record.params.data as unknown[][] | undefined;
- const rows = data?.length || 0;
- const cols = (data?.[0] as unknown[])?.length || 0;
- const x = record.params.x ?? '?';
- const y = record.params.y ?? '?';
- const w = record.params.width ?? 400;
- const h = record.params.height ?? rows * 40 + 20;
- elements.push({
- agentName: record.agentName,
- summary: `table(${rows}×${cols}) at (${x},${y}), size ${w}x${h}`,
- });
- break;
- }
- case 'wb_draw_line': {
- const sx = record.params.startX ?? '?';
- const sy = record.params.startY ?? '?';
- const ex = record.params.endX ?? '?';
- const ey = record.params.endY ?? '?';
- const pts = record.params.points as string[] | undefined;
- const hasArrow = pts?.includes('arrow') ? ' (arrow)' : '';
- elements.push({
- agentName: record.agentName,
- summary: `line${hasArrow}: (${sx},${sy}) → (${ex},${ey})`,
- });
- break;
- }
- // wb_open, wb_close — skip
- }
- }
- if (elements.length === 0) return '';
- const elementLines = elements
- .map((el, i) => ` ${i + 1}. [by ${el.agentName}] ${el.summary}`)
- .join('\n');
- return `
- ## Whiteboard Changes This Round (IMPORTANT)
- Other agents have modified the whiteboard during this discussion round.
- Current whiteboard elements (${elements.length}):
- ${elementLines}
- DO NOT redraw content that already exists. Check positions above before adding new elements.
- `;
- }
- // ==================== State Context ====================
- /**
- * Build context string from store state
- */
- function buildStateContext(storeState: StatelessChatRequest['storeState']): string {
- const { stage, scenes, currentSceneId, mode, whiteboardOpen } = storeState;
- const lines: string[] = [];
- // Mode
- lines.push(`Mode: ${mode}`);
- // Whiteboard status
- lines.push(
- `Whiteboard: ${whiteboardOpen ? 'OPEN (slide canvas is hidden)' : 'closed (slide canvas is visible)'}`,
- );
- // Stage info
- if (stage) {
- lines.push(
- `Course: ${stage.name || 'Untitled'}${stage.description ? ` - ${stage.description}` : ''}`,
- );
- }
- // Scenes summary
- lines.push(`Total scenes: ${scenes.length}`);
- if (currentSceneId) {
- const currentScene = scenes.find((s) => s.id === currentSceneId);
- if (currentScene) {
- lines.push(
- `Current scene: "${currentScene.title}" (${currentScene.type}, id: ${currentSceneId})`,
- );
- // Slide scene: include element details
- if (currentScene.content.type === 'slide') {
- const elements = currentScene.content.canvas.elements;
- lines.push(`Current slide elements (${elements.length}):\n${summarizeElements(elements)}`);
- }
- // Quiz scene: include question summary
- if (currentScene.content.type === 'quiz') {
- const questions = currentScene.content.questions;
- const qSummary = questions
- .slice(0, 5)
- .map((q, i) => ` ${i + 1}. [${q.type}] ${q.question.slice(0, 80)}`)
- .join('\n');
- lines.push(
- `Quiz questions (${questions.length}):\n${qSummary}${questions.length > 5 ? `\n ... and ${questions.length - 5} more` : ''}`,
- );
- }
- }
- } else if (scenes.length > 0) {
- lines.push('No scene currently selected');
- }
- // List first few scenes
- if (scenes.length > 0) {
- const sceneSummary = scenes
- .slice(0, 5)
- .map((s, i) => ` ${i + 1}. ${s.title} (${s.type}, id: ${s.id})`)
- .join('\n');
- lines.push(
- `Scenes:\n${sceneSummary}${scenes.length > 5 ? `\n ... and ${scenes.length - 5} more` : ''}`,
- );
- }
- // Whiteboard content (last whiteboard in the stage)
- if (stage?.whiteboard && stage.whiteboard.length > 0) {
- const lastWb = stage.whiteboard[stage.whiteboard.length - 1];
- const wbElements = lastWb.elements || [];
- lines.push(
- `Whiteboard (last of ${stage.whiteboard.length}, ${wbElements.length} elements):\n${summarizeElements(wbElements)}`,
- );
- }
- return lines.join('\n');
- }
- // ==================== Conversation Summary ====================
- /**
- * OpenAI message format (used by director)
- */
- interface OpenAIMessage {
- role: 'system' | 'user' | 'assistant';
- content: string;
- }
- /**
- * Summarize conversation history for the director agent
- *
- * Produces a condensed text summary of the last N messages,
- * truncating long messages and including role labels.
- *
- * @param messages - OpenAI-format messages to summarize
- * @param maxMessages - Maximum number of recent messages to include (default 10)
- * @param maxContentLength - Maximum content length per message (default 200)
- */
- export function summarizeConversation(
- messages: OpenAIMessage[],
- maxMessages = 10,
- maxContentLength = 200,
- ): string {
- if (messages.length === 0) {
- return 'No conversation history yet.';
- }
- const recent = messages.slice(-maxMessages);
- const lines = recent.map((msg) => {
- const roleLabel =
- msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
- const content =
- msg.content.length > maxContentLength
- ? msg.content.slice(0, maxContentLength) + '...'
- : msg.content;
- return `[${roleLabel}] ${content}`;
- });
- return lines.join('\n');
- }
- // ==================== Message Conversion ====================
- /**
- * Convert UI messages to OpenAI format
- * Includes tool call information so the model knows what actions were taken
- */
- export function convertMessagesToOpenAI(
- messages: StatelessChatRequest['messages'],
- currentAgentId?: string,
- ): Array<{ role: 'system' | 'user' | 'assistant'; content: string }> {
- return messages
- .filter((msg) => msg.role === 'user' || msg.role === 'assistant')
- .map((msg) => {
- if (msg.role === 'assistant') {
- // Assistant messages use JSON array format to serve as few-shot examples
- // that match the expected output format from the system prompt
- const items: Array<{ type: string; [key: string]: string }> = [];
- if (msg.parts) {
- for (const part of msg.parts) {
- const p = part as Record<string, unknown>;
- if (p.type === 'text' && p.text) {
- items.push({ type: 'text', content: p.text as string });
- } else if ((p.type as string)?.startsWith('action-') && p.state === 'result') {
- const actionName = (p.actionName ||
- (p.type as string).replace('action-', '')) as string;
- const output = p.output as Record<string, unknown> | undefined;
- const isSuccess = output?.success === true;
- const resultSummary = isSuccess
- ? output?.data
- ? `result: ${JSON.stringify(output.data).slice(0, 100)}`
- : 'success'
- : (output?.error as string) || 'failed';
- items.push({
- type: 'action',
- name: actionName,
- result: resultSummary,
- });
- }
- }
- }
- const content = items.length > 0 ? JSON.stringify(items) : '';
- const msgAgentId = msg.metadata?.agentId;
- // When currentAgentId is provided and this message is from a DIFFERENT agent,
- // convert to user role with agent name attribution
- if (currentAgentId && msgAgentId && msgAgentId !== currentAgentId) {
- const agentName = msg.metadata?.senderName || msgAgentId;
- return {
- role: 'user' as const,
- content: content ? `[${agentName}]: ${content}` : '',
- };
- }
- return {
- role: 'assistant' as const,
- content,
- };
- }
- // User messages: keep plain text concatenation
- const contentParts: string[] = [];
- if (msg.parts) {
- for (const part of msg.parts) {
- const p = part as Record<string, unknown>;
- if (p.type === 'text' && p.text) {
- contentParts.push(p.text as string);
- } else if ((p.type as string)?.startsWith('action-') && p.state === 'result') {
- const actionName = (p.actionName ||
- (p.type as string).replace('action-', '')) as string;
- const output = p.output as Record<string, unknown> | undefined;
- const isSuccess = output?.success === true;
- const resultSummary = isSuccess
- ? output?.data
- ? `result: ${JSON.stringify(output.data).slice(0, 100)}`
- : 'success'
- : (output?.error as string) || 'failed';
- contentParts.push(`[Action ${actionName}: ${resultSummary}]`);
- }
- }
- }
- // Extract speaker name from metadata (e.g. other agents' messages in discussion)
- const senderName = msg.metadata?.senderName;
- let content = contentParts.join('\n');
- if (senderName) {
- content = `[${senderName}]: ${content}`;
- }
- // Annotate interrupted messages so the LLM knows context was cut short
- const isInterrupted =
- (msg as unknown as Record<string, unknown>).metadata &&
- ((msg as unknown as Record<string, unknown>).metadata as Record<string, unknown>)
- ?.interrupted;
- return {
- role: 'user' as const,
- content: isInterrupted
- ? `${content}\n[This response was interrupted — do NOT continue it. Start a new JSON array response.]`
- : content,
- };
- })
- .filter((msg) => {
- // Drop empty messages and messages with only dots/ellipsis/whitespace
- // (produced by failed agent streams)
- const stripped = msg.content.replace(/[.\s…]+/g, '');
- return stripped.length > 0;
- });
- }
|