Files
openmaic/OpenMAIC/lib/chat/pi/tools/classroom-actions.ts
2026-08-16 14:58:47 +08:00

774 lines
23 KiB
TypeScript

import type { AgentTool } from '@earendil-works/pi-agent-core';
import { Type, type Static } from 'typebox';
import { nanoid } from 'nanoid';
import type { AgentConfig } from '@/lib/orchestration/registry/types';
import { getEffectiveActions } from '@/lib/orchestration/tool-schemas';
import type { WhiteboardActionRecord } from '@/lib/orchestration/types';
import type { StatelessChatRequest } from '@/lib/types/chat';
import type { SendEvent } from '../types';
const SpotlightParams = Type.Object({
elementId: Type.String(),
dimOpacity: Type.Optional(Type.Number()),
});
type SpotlightParams = Static<typeof SpotlightParams>;
const LaserParams = Type.Object({
elementId: Type.String(),
color: Type.Optional(Type.String()),
});
type LaserParams = Static<typeof LaserParams>;
const PlayVideoParams = Type.Object({
elementId: Type.String(),
});
type PlayVideoParams = Static<typeof PlayVideoParams>;
const EmptyParams = Type.Object({});
type EmptyParams = Static<typeof EmptyParams>;
const WbDeleteParams = Type.Object({
elementId: Type.String(),
});
type WbDeleteParams = Static<typeof WbDeleteParams>;
const WbDrawTextParams = Type.Object({
content: Type.String(),
x: Type.Number(),
y: Type.Number(),
width: Type.Optional(Type.Number()),
height: Type.Optional(Type.Number()),
fontSize: Type.Optional(Type.Number()),
color: Type.Optional(Type.String()),
elementId: Type.Optional(Type.String()),
});
type WbDrawTextParams = Static<typeof WbDrawTextParams>;
const WbDrawShapeParams = Type.Object({
shape: Type.Union([Type.Literal('rectangle'), Type.Literal('circle'), Type.Literal('triangle')]),
x: Type.Number(),
y: Type.Number(),
width: Type.Number(),
height: Type.Number(),
fillColor: Type.Optional(Type.String()),
elementId: Type.Optional(Type.String()),
});
type WbDrawShapeParams = Static<typeof WbDrawShapeParams>;
const WbDrawChartParams = Type.Object({
chartType: Type.Union([
Type.Literal('bar'),
Type.Literal('column'),
Type.Literal('line'),
Type.Literal('pie'),
Type.Literal('ring'),
Type.Literal('area'),
Type.Literal('radar'),
Type.Literal('scatter'),
]),
x: Type.Number(),
y: Type.Number(),
width: Type.Number(),
height: Type.Number(),
data: Type.Object({
labels: Type.Array(Type.String()),
legends: Type.Array(Type.String()),
series: Type.Array(Type.Array(Type.Number())),
}),
themeColors: Type.Optional(Type.Array(Type.String())),
elementId: Type.Optional(Type.String()),
});
type WbDrawChartParams = Static<typeof WbDrawChartParams>;
const WbDrawLatexParams = Type.Object({
latex: Type.String(),
x: Type.Number(),
y: Type.Number(),
width: Type.Optional(Type.Number()),
height: Type.Optional(Type.Number()),
color: Type.Optional(Type.String()),
elementId: Type.Optional(Type.String()),
});
type WbDrawLatexParams = Static<typeof WbDrawLatexParams>;
const WbDrawTableParams = Type.Object({
x: Type.Number(),
y: Type.Number(),
width: Type.Number(),
height: Type.Number(),
data: Type.Array(Type.Array(Type.String(), { minItems: 1 }), { minItems: 1 }),
outline: Type.Optional(
Type.Object({
width: Type.Number(),
style: Type.String(),
color: Type.String(),
}),
),
theme: Type.Optional(
Type.Object({
color: Type.String(),
}),
),
elementId: Type.Optional(Type.String()),
});
type WbDrawTableParams = Static<typeof WbDrawTableParams>;
const WbDrawLineParams = Type.Object({
startX: Type.Number(),
startY: Type.Number(),
endX: Type.Number(),
endY: Type.Number(),
color: Type.Optional(Type.String()),
width: Type.Optional(Type.Number()),
style: Type.Optional(Type.Union([Type.Literal('solid'), Type.Literal('dashed')])),
points: Type.Optional(
Type.Union([
Type.Tuple([Type.Literal(''), Type.Literal('arrow')]),
Type.Tuple([Type.Literal('arrow'), Type.Literal('')]),
Type.Tuple([Type.Literal('arrow'), Type.Literal('arrow')]),
Type.Tuple([Type.Literal(''), Type.Literal('')]),
]),
),
elementId: Type.Optional(Type.String()),
});
type WbDrawLineParams = Static<typeof WbDrawLineParams>;
const WbDrawCodeParams = Type.Object({
language: Type.String(),
code: Type.String(),
x: Type.Number(),
y: Type.Number(),
width: Type.Optional(Type.Number()),
height: Type.Optional(Type.Number()),
fileName: Type.Optional(Type.String()),
elementId: Type.Optional(Type.String()),
});
type WbDrawCodeParams = Static<typeof WbDrawCodeParams>;
const WbEditCodeParams = Type.Union([
Type.Object({
elementId: Type.String(),
operation: Type.Literal('insert_after'),
lineId: Type.String(),
content: Type.String(),
}),
Type.Object({
elementId: Type.String(),
operation: Type.Literal('insert_before'),
lineId: Type.String(),
content: Type.String(),
}),
Type.Object({
elementId: Type.String(),
operation: Type.Literal('delete_lines'),
lineIds: Type.Array(Type.String(), { minItems: 1 }),
}),
Type.Object({
elementId: Type.String(),
operation: Type.Literal('replace_lines'),
lineIds: Type.Array(Type.String(), { minItems: 1 }),
content: Type.String(),
}),
]);
type WbEditCodeParams = Static<typeof WbEditCodeParams>;
function getInitialWhiteboardElementCount(body: StatelessChatRequest): number {
const whiteboards = body.storeState.stage?.whiteboard;
const latestWhiteboard = Array.isArray(whiteboards) ? whiteboards[whiteboards.length - 1] : null;
const elements = latestWhiteboard?.elements;
return Array.isArray(elements) ? elements.length : 0;
}
function getInitialWhiteboardElementIds(body: StatelessChatRequest): Set<string> {
const whiteboards = body.storeState.stage?.whiteboard;
const latestWhiteboard = Array.isArray(whiteboards) ? whiteboards[whiteboards.length - 1] : null;
const elements = latestWhiteboard?.elements;
if (!Array.isArray(elements)) return new Set();
return new Set(
elements
.map((element) =>
element && typeof element === 'object' && 'id' in element
? String((element as { id?: unknown }).id ?? '')
: '',
)
.filter(Boolean),
);
}
function getInitialWhiteboardCodeLineIds(body: StatelessChatRequest): Map<string, Set<string>> {
const whiteboards = body.storeState.stage?.whiteboard;
const latestWhiteboard = Array.isArray(whiteboards) ? whiteboards[whiteboards.length - 1] : null;
const elements = latestWhiteboard?.elements;
if (!Array.isArray(elements)) return new Map();
const result = new Map<string, Set<string>>();
for (const element of elements) {
if (!element || typeof element !== 'object') continue;
const candidate = element as { id?: unknown; type?: unknown; lines?: unknown };
if (candidate.type !== 'code' || typeof candidate.id !== 'string') continue;
if (!Array.isArray(candidate.lines)) continue;
const lineIds = candidate.lines
.map((line) =>
line && typeof line === 'object' && 'id' in line
? String((line as { id?: unknown }).id ?? '')
: '',
)
.filter(Boolean);
result.set(candidate.id, new Set(lineIds));
}
return result;
}
function isWhiteboardDrawAction(name: string): boolean {
return name.startsWith('wb_draw_');
}
function isNonEmptyRectangularStringMatrix(value: unknown): value is string[][] {
if (!Array.isArray(value) || value.length === 0) return false;
const columnCount = Array.isArray(value[0]) ? value[0].length : 0;
return (
columnCount > 0 &&
value.every(
(row) =>
Array.isArray(row) &&
row.length === columnCount &&
row.every((cell) => typeof cell === 'string'),
)
);
}
export interface PiWhiteboardRuntimeState {
open: boolean;
visibleElementCount: number;
knownElementIds: Set<string>;
codeLineIdsByElementId: Map<string, Set<string>>;
}
export function createPiWhiteboardRuntimeState(
body: StatelessChatRequest,
): PiWhiteboardRuntimeState {
return {
open: Boolean(body.storeState.whiteboardOpen),
visibleElementCount: getInitialWhiteboardElementCount(body),
knownElementIds: getInitialWhiteboardElementIds(body),
codeLineIdsByElementId: getInitialWhiteboardCodeLineIds(body),
};
}
function findCurrentSlideElement(
body: StatelessChatRequest,
elementId: string,
): { type?: string } | null {
const currentScene = body.storeState.currentSceneId
? body.storeState.scenes.find((scene) => scene.id === body.storeState.currentSceneId)
: undefined;
if (currentScene?.content.type !== 'slide') return null;
return currentScene.content.canvas.elements.find((element) => element.id === elementId) ?? null;
}
export function buildChildActionTools(opts: {
body: StatelessChatRequest;
agent: AgentConfig;
messageId: string;
send: SendEvent;
onActionDone: (record?: WhiteboardActionRecord) => void;
maxActionsPerAgent: number;
enableWhiteboardTools: boolean;
whiteboardState?: PiWhiteboardRuntimeState;
}): AgentTool[] {
const currentScene = opts.body.storeState.currentSceneId
? opts.body.storeState.scenes.find((scene) => scene.id === opts.body.storeState.currentSceneId)
: undefined;
const slideOnlyPiActions = currentScene?.type === 'slide' ? ['play_video'] : [];
const allowWhiteboardMutations = opts.enableWhiteboardTools;
const piActionAllowlist = allowWhiteboardMutations
? [
'spotlight',
'laser',
...slideOnlyPiActions,
'wb_open',
'wb_close',
'wb_draw_text',
'wb_draw_shape',
'wb_draw_chart',
'wb_draw_latex',
'wb_draw_table',
'wb_draw_line',
'wb_draw_code',
'wb_edit_code',
'wb_clear',
'wb_delete',
]
: ['spotlight', 'laser', ...slideOnlyPiActions];
const effectiveActions = new Set(
getEffectiveActions(opts.agent.allowedActions, currentScene?.type).filter((name) =>
piActionAllowlist.includes(name),
),
);
let emittedActionCount = 0;
const whiteboardState = opts.whiteboardState ?? createPiWhiteboardRuntimeState(opts.body);
const makeActionTool = <
TParams extends
| typeof SpotlightParams
| typeof LaserParams
| typeof PlayVideoParams
| typeof EmptyParams
| typeof WbDeleteParams
| typeof WbDrawTextParams
| typeof WbDrawShapeParams
| typeof WbDrawChartParams
| typeof WbDrawLatexParams
| typeof WbDrawTableParams
| typeof WbDrawLineParams
| typeof WbDrawCodeParams
| typeof WbEditCodeParams,
>(
name: string,
label: string,
description: string,
parameters: TParams,
): AgentTool<TParams> => ({
name,
label,
description,
parameters,
executionMode: 'sequential',
execute: async (_toolCallId, params) => {
if (name === 'wb_open' && whiteboardState.open) {
return {
content: [
{
type: 'text',
text: 'Action wb_open skipped because the whiteboard is already open.',
},
],
details: { skipped: true, reason: 'whiteboard_already_open', actionName: name },
};
}
if (
(name === 'wb_clear' || name === 'wb_delete') &&
whiteboardState.visibleElementCount === 0
) {
return {
content: [
{
type: 'text',
text: `Action ${name} skipped because the whiteboard has no visible elements.`,
},
],
details: { skipped: true, reason: 'whiteboard_empty', actionName: name },
};
}
if (emittedActionCount >= opts.maxActionsPerAgent) {
return {
content: [
{
type: 'text',
text: `Action ${name} skipped because this agent turn already used the allowed action budget.`,
},
],
details: { skipped: true, reason: 'action_budget', actionName: name },
};
}
if (name === 'play_video') {
const elementId = (params as PlayVideoParams).elementId;
const slideElement = findCurrentSlideElement(opts.body, elementId);
if (!slideElement) {
return {
content: [
{
type: 'text',
text: `Action play_video skipped because slide element "${elementId}" was not found.`,
},
],
details: {
skipped: true,
reason: 'slide_element_not_found',
actionName: name,
elementId,
},
};
}
if (slideElement.type !== 'video') {
return {
content: [
{
type: 'text',
text: `Action play_video skipped because slide element "${elementId}" is not a video.`,
},
],
details: {
skipped: true,
reason: 'slide_element_not_video',
actionName: name,
elementId,
elementType: slideElement.type,
},
};
}
}
const actionParams = params as Record<string, unknown>;
if (isWhiteboardDrawAction(name) && !actionParams.elementId) {
actionParams.elementId = nanoid();
}
if (name === 'wb_draw_table' && !isNonEmptyRectangularStringMatrix(actionParams.data)) {
return {
content: [
{
type: 'text',
text: 'Action wb_draw_table skipped because data must be a non-empty rectangular string matrix.',
},
],
details: {
skipped: true,
reason: 'whiteboard_invalid_table',
actionName: name,
},
};
}
if (isWhiteboardDrawAction(name)) {
const elementId = actionParams.elementId;
if (
typeof elementId === 'string' &&
elementId &&
whiteboardState.knownElementIds.has(elementId)
) {
return {
content: [
{
type: 'text',
text: `Action ${name} skipped because whiteboard element id "${elementId}" already exists.`,
},
],
details: {
skipped: true,
reason: 'whiteboard_element_id_conflict',
actionName: name,
elementId,
},
};
}
}
if (name === 'wb_delete') {
const elementId = String(actionParams.elementId ?? '');
if (!whiteboardState.knownElementIds.has(elementId)) {
return {
content: [
{
type: 'text',
text: `Action wb_delete skipped because whiteboard element "${elementId}" was not found.`,
},
],
details: {
skipped: true,
reason: 'whiteboard_element_not_found',
actionName: name,
elementId,
},
};
}
}
if (name === 'wb_edit_code') {
const elementId = String(actionParams.elementId ?? '');
const knownLineIds = whiteboardState.codeLineIdsByElementId.get(elementId);
if (!knownLineIds) {
return {
content: [
{
type: 'text',
text: `Action wb_edit_code skipped because code element "${elementId}" was not found.`,
},
],
details: {
skipped: true,
reason: 'whiteboard_code_element_not_found',
actionName: name,
elementId,
},
};
}
const operation = actionParams.operation;
const targetLineIds =
operation === 'insert_after' || operation === 'insert_before'
? [String(actionParams.lineId ?? '')]
: Array.isArray(actionParams.lineIds)
? actionParams.lineIds.map(String)
: [];
const missingLineId = targetLineIds.find((lineId) => !knownLineIds.has(lineId));
if (missingLineId) {
return {
content: [
{
type: 'text',
text: `Action wb_edit_code skipped because line "${missingLineId}" was not found in code element "${elementId}".`,
},
],
details: {
skipped: true,
reason: 'whiteboard_code_line_not_found',
actionName: name,
elementId,
lineId: missingLineId,
},
};
}
}
if (name === 'wb_draw_code') {
actionParams.lineIds = String(actionParams.code ?? '')
.split('\n')
.map((_line, index) => `L${index + 1}`);
}
if (name === 'wb_edit_code') {
const operation = actionParams.operation;
if (
operation === 'insert_after' ||
operation === 'insert_before' ||
operation === 'replace_lines'
) {
const contentLineCount = String(actionParams.content ?? '').split('\n').length;
const replacedLineIds =
operation === 'replace_lines' && Array.isArray(actionParams.lineIds)
? actionParams.lineIds.map(String)
: [];
actionParams.newLineIds = Array.from(
{ length: contentLineCount },
(_unused, index) => replacedLineIds[index] ?? nanoid(),
);
}
}
const actionId = nanoid();
emittedActionCount += 1;
await opts.send({
type: 'action',
data: {
actionId,
actionName: name,
params: actionParams,
agentId: opts.agent.id,
messageId: opts.messageId,
},
});
if (name === 'wb_open') whiteboardState.open = true;
if (name === 'wb_close') whiteboardState.open = false;
if (name === 'wb_clear') {
whiteboardState.open = true;
whiteboardState.visibleElementCount = 0;
whiteboardState.knownElementIds.clear();
whiteboardState.codeLineIdsByElementId.clear();
}
if (isWhiteboardDrawAction(name)) {
whiteboardState.open = true;
whiteboardState.visibleElementCount += 1;
const elementId = actionParams.elementId;
if (typeof elementId === 'string' && elementId) {
whiteboardState.knownElementIds.add(elementId);
if (name === 'wb_draw_code') {
whiteboardState.codeLineIdsByElementId.set(
elementId,
new Set((actionParams.lineIds as string[]) ?? []),
);
}
}
}
if (name === 'wb_delete') {
whiteboardState.open = true;
const elementId = String(actionParams.elementId);
whiteboardState.knownElementIds.delete(elementId);
whiteboardState.visibleElementCount = Math.max(whiteboardState.visibleElementCount - 1, 0);
whiteboardState.codeLineIdsByElementId.delete(elementId);
}
if (name === 'wb_edit_code') {
const elementId = String(actionParams.elementId);
const lineIds = whiteboardState.codeLineIdsByElementId.get(elementId);
const operation = actionParams.operation;
if (lineIds && (operation === 'delete_lines' || operation === 'replace_lines')) {
const targetLineIds = (actionParams.lineIds as string[]) ?? [];
targetLineIds.forEach((lineId) => lineIds.delete(lineId));
if (operation === 'replace_lines') {
((actionParams.newLineIds as string[]) ?? []).forEach((lineId) => lineIds.add(lineId));
}
} else if (lineIds && (operation === 'insert_after' || operation === 'insert_before')) {
((actionParams.newLineIds as string[]) ?? []).forEach((lineId) => lineIds.add(lineId));
}
}
opts.onActionDone(
name.startsWith('wb_')
? {
actionName: name as WhiteboardActionRecord['actionName'],
agentId: opts.agent.id,
agentName: opts.agent.name,
params: actionParams,
}
: undefined,
);
return {
content: [{ type: 'text', text: `Action ${name} was sent to the client.` }],
details: { actionId, actionName: name, params },
};
},
});
const tools: AgentTool[] = [];
if (effectiveActions.has('spotlight')) {
tools.push(
makeActionTool(
'spotlight',
'Spotlight slide element',
'Focus attention on one slide element by elementId.',
SpotlightParams,
),
);
}
if (effectiveActions.has('laser')) {
tools.push(
makeActionTool(
'laser',
'Laser pointer',
'Point at one slide element by elementId.',
LaserParams,
),
);
}
if (effectiveActions.has('play_video')) {
tools.push(
makeActionTool(
'play_video',
'Play slide video',
'Start playback of a video element on the current slide by elementId.',
PlayVideoParams,
),
);
}
if (effectiveActions.has('wb_open')) {
tools.push(
makeActionTool('wb_open', 'Open whiteboard', 'Open the classroom whiteboard.', EmptyParams),
);
}
if (effectiveActions.has('wb_draw_text')) {
tools.push(
makeActionTool(
'wb_draw_text',
'Draw whiteboard text',
'Draw concise text, equations, or key steps on the whiteboard.',
WbDrawTextParams,
),
);
}
if (effectiveActions.has('wb_draw_shape')) {
tools.push(
makeActionTool(
'wb_draw_shape',
'Draw whiteboard shape',
'Draw a rectangle, circle, or triangle on the whiteboard.',
WbDrawShapeParams,
),
);
}
if (effectiveActions.has('wb_draw_chart')) {
tools.push(
makeActionTool(
'wb_draw_chart',
'Draw whiteboard chart',
'Draw a chart on the whiteboard for data or comparisons.',
WbDrawChartParams,
),
);
}
if (effectiveActions.has('wb_draw_latex')) {
tools.push(
makeActionTool(
'wb_draw_latex',
'Draw whiteboard LaTeX',
'Draw a LaTeX formula on the whiteboard.',
WbDrawLatexParams,
),
);
}
if (effectiveActions.has('wb_draw_table')) {
tools.push(
makeActionTool(
'wb_draw_table',
'Draw whiteboard table',
'Draw a table on the whiteboard for structured data.',
WbDrawTableParams,
),
);
}
if (effectiveActions.has('wb_draw_line')) {
tools.push(
makeActionTool(
'wb_draw_line',
'Draw whiteboard line',
'Draw a line or arrow on the whiteboard.',
WbDrawLineParams,
),
);
}
if (effectiveActions.has('wb_draw_code')) {
tools.push(
makeActionTool(
'wb_draw_code',
'Draw whiteboard code',
'Draw a syntax-highlighted code block on the whiteboard.',
WbDrawCodeParams,
),
);
}
if (effectiveActions.has('wb_edit_code')) {
tools.push(
makeActionTool(
'wb_edit_code',
'Edit whiteboard code',
'Edit an existing code block on the whiteboard.',
WbEditCodeParams,
),
);
}
if (effectiveActions.has('wb_clear')) {
tools.push(
makeActionTool(
'wb_clear',
'Clear whiteboard',
'Clear all elements from the classroom whiteboard.',
EmptyParams,
),
);
}
if (effectiveActions.has('wb_delete')) {
tools.push(
makeActionTool(
'wb_delete',
'Delete whiteboard element',
'Delete one whiteboard element by elementId.',
WbDeleteParams,
),
);
}
if (effectiveActions.has('wb_close')) {
tools.push(
makeActionTool(
'wb_close',
'Close whiteboard',
'Close the whiteboard and return to the slide.',
EmptyParams,
),
);
}
return tools;
}