feat: AI Type Agent - vision-based Open Dental automation via Windows agent

Adds a Windows-side agent (screenshot/click/type over HTTP) plus backend
services to locate UI elements via vision and drive an existing-patient
appointment flow in Open Dental, wired into the Copy/Type Agent page and
socket progress updates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 17:24:27 -04:00
parent ae961781ac
commit 59f064583a
16 changed files with 1769 additions and 101 deletions

View File

@@ -4,6 +4,7 @@ PORT=5000
CLOUDFLARE_HOST= CLOUDFLARE_HOST=
FRONTEND_URLS=http://localhost:3000,https://local-summit.mydentalofficemanagement.com FRONTEND_URLS=http://localhost:3000,https://local-summit.mydentalofficemanagement.com
SELENIUM_AGENT_BASE_URL=http://localhost:5002 SELENIUM_AGENT_BASE_URL=http://localhost:5002
WINDOWS_AGENT_TOKEN=dev-windows-agent-token
JWT_SECRET = 'dentalsecret' JWT_SECRET = 'dentalsecret'
LICENSE_SECRET=3aa4ab937e46c6863b9e3c2b591a595b31ea3af1060bf5e7961ad722a8b54f92 LICENSE_SECRET=3aa4ab937e46c6863b9e3c2b591a595b31ea3af1060bf5e7961ad722a8b54f92
DB_HOST=localhost DB_HOST=localhost

View File

@@ -4,6 +4,7 @@ PORT=5000
CLOUDFLARE_HOST= CLOUDFLARE_HOST=
FRONTEND_URLS=http://localhost:3000 FRONTEND_URLS=http://localhost:3000
SELENIUM_AGENT_BASE_URL=http://localhost:5002 SELENIUM_AGENT_BASE_URL=http://localhost:5002
WINDOWS_AGENT_TOKEN=
JWT_SECRET = 'dentalsecret' JWT_SECRET = 'dentalsecret'
DB_HOST=localhost DB_HOST=localhost
DB_USER=postgres DB_USER=postgres

View File

@@ -33,6 +33,7 @@
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
"sharp": "^0.35.3",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"twilio": "^6.0.0", "twilio": "^6.0.0",
"ws": "^8.18.0", "ws": "^8.18.0",

View File

@@ -0,0 +1,68 @@
// Simulates DentalAgent.exe connecting to the backend's /agent socket.io namespace,
// so the Type Agent page's "Windows Agent: Connected" status can be tested end-to-end
// before the real pyautogui-based Windows client exists.
//
// Usage: node scripts/fake-windows-agent.js [serverUrl]
// Requires WINDOWS_AGENT_TOKEN to match the backend's .env value.
// The server identifies agents by connection IP address, not anything sent here.
const { io } = require("socket.io-client");
const serverUrl = process.argv[2] || "http://localhost:5000";
const token = process.env.WINDOWS_AGENT_TOKEN || "dev-windows-agent-token";
const socket = io(`${serverUrl}/agent`, {
auth: { token },
reconnectionAttempts: 5,
});
socket.on("connect", () => {
console.log(`✅ Connected as fake Windows Agent, socket id: ${socket.id}`);
});
// 1x1 transparent PNG, base64 — stands in for a real screen capture.
const FAKE_SCREENSHOT =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
socket.on("cmd:screenshot", (_payload, ack) => {
console.log("📸 screenshot requested");
ack({ image: FAKE_SCREENSHOT });
});
socket.on("cmd:click", ({ x, y }, ack) => {
console.log(`🖱️ click at (${x}, ${y})`);
ack({ ok: true });
});
socket.on("cmd:double_click", ({ x, y }, ack) => {
console.log(`🖱️ double-click at (${x}, ${y})`);
ack({ ok: true });
});
socket.on("cmd:double_click_current", (_payload, ack) => {
console.log("🖱️ double-click at current cursor position");
ack({ ok: true });
});
socket.on("cmd:type", ({ text }, ack) => {
console.log(`⌨️ type "${text}"`);
ack({ ok: true });
});
socket.on("cmd:key", ({ key }, ack) => {
console.log(`⌨️ press key "${key}"`);
ack({ ok: true });
});
socket.on("connect_error", (err) => {
console.error("❌ Connection failed:", err.message);
});
socket.on("disconnect", (reason) => {
console.log("🔌 Disconnected:", reason);
});
process.on("SIGINT", () => {
socket.close();
process.exit(0);
});

View File

@@ -45,6 +45,7 @@ import licenseRoutes from "./license";
import insuranceStatusBcbsMaRoutes from "./insuranceStatusBcbsMa"; import insuranceStatusBcbsMaRoutes from "./insuranceStatusBcbsMa";
import labRxRoutes from "./lab-rx"; import labRxRoutes from "./lab-rx";
import seleniumSettingsRoutes from "./selenium-settings"; import seleniumSettingsRoutes from "./selenium-settings";
import typeAgentRoutes from "./type-agent";
const router = Router(); const router = Router();
@@ -82,6 +83,7 @@ router.use("/cloud-storage", cloudStorageRoutes);
router.use("/payments-reports", paymentsReportsRoutes); router.use("/payments-reports", paymentsReportsRoutes);
router.use("/export-payments-reports", exportPaymentsReportsRoutes); router.use("/export-payments-reports", exportPaymentsReportsRoutes);
router.use("/job-monitor", jobMonitorRoutes); router.use("/job-monitor", jobMonitorRoutes);
router.use("/type-agent", typeAgentRoutes);
router.use("/twilio", twilioRoutes); router.use("/twilio", twilioRoutes);
router.use("/ai", aiSettingsRoutes); router.use("/ai", aiSettingsRoutes);
router.use("/office-hours", officeHoursRoutes); router.use("/office-hours", officeHoursRoutes);

View File

@@ -0,0 +1,109 @@
import crypto from "crypto";
import { Router, Request, Response } from "express";
import { getAgentStatus, io } from "../socket";
import { captureScreenshot, click, disconnectAgent, doubleClick, doubleClickCurrent, pressKey, typeText } from "../services/windowsAgentBridge";
import { runExistingPatientOpenDental } from "../services/typeAgentRunner";
import { storage } from "../storage";
const router = Router();
// GET /api/type-agent/status
// Current Windows Agent (DentalAgent.exe) connection status, for the initial page load —
// live updates after that come over the "type-agent:status" socket.io event.
router.get("/status", (_req: Request, res: Response) => {
res.json(getAgentStatus());
});
// POST /api/type-agent/disconnect
// Forcibly disconnects a connected agent by IP — the "x" on the Type Agent page.
router.post("/disconnect", (req: Request, res: Response) => {
const { ip } = req.body ?? {};
if (!ip) {
res.status(400).json({ error: "ip is required" });
return;
}
try {
disconnectAgent(ip);
res.json({ success: true });
} catch (err) {
res.status(404).json({ error: err instanceof Error ? err.message : "Agent not found" });
}
});
// POST /api/type-agent/test-command
// Sends a single low-level command (screenshot/click/double_click/type/key) straight to a
// connected Windows Agent. Exists to verify the command bridge before the AI-driven step
// orchestration is wired in — not meant to be called from the Type Agent UI directly.
router.post("/test-command", async (req: Request, res: Response) => {
const { type, x, y, text, key, ip } = req.body ?? {};
try {
let result;
switch (type) {
case "screenshot":
result = await captureScreenshot(ip);
break;
case "click":
result = await click(Number(x), Number(y), ip);
break;
case "double_click":
result = await doubleClick(Number(x), Number(y), ip);
break;
case "double_click_current":
result = await doubleClickCurrent(ip);
break;
case "type":
result = await typeText(String(text ?? ""), ip);
break;
case "key":
result = await pressKey(String(key ?? ""), ip);
break;
default:
res.status(400).json({ error: "Unknown command type" });
return;
}
res.json({ success: true, result });
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : "Command failed" });
}
});
// POST /api/type-agent/run
// Kicks off a real step-by-step run against a connected Windows Agent. Only Open Dental /
// existing-patient is wired up so far — everything else the Type Agent page still shows as
// a preview only. Responds immediately with a runId; progress streams over the
// "type-agent:run:<runId>" socket.io event so the browser doesn't have to hold the request
// open for what can be a slow, multi-step, vision-driven sequence.
router.post("/run", async (req: Request, res: Response) => {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({ error: "Unauthorized" });
return;
}
const { software, action, ip, patientId } = req.body ?? {};
if (software !== "open-dental" || action !== "existing-patient") {
res.status(400).json({ error: "This software/action combination isn't automated yet." });
return;
}
if (!ip) {
res.status(400).json({ error: "ip is required" });
return;
}
const patient = await storage.getPatient(Number(patientId));
if (!patient?.lastName || !patient?.firstName) {
res.status(400).json({ error: "Patient not found or missing a first/last name" });
return;
}
const runId = crypto.randomUUID();
res.json({ runId });
runExistingPatientOpenDental(userId, ip, patient.lastName, patient.firstName, (index, total, label, status, error) => {
io?.emit(`type-agent:run:${runId}`, { index, total, label, status, error });
}).catch((err) => {
console.error("[type-agent/run] failed:", err);
});
});
export default router;

View File

@@ -0,0 +1,138 @@
import { captureScreenshot, click, doubleClick, doubleClickCurrent, typeText } from "./windowsAgentBridge";
import { locateOnScreenshot } from "./visionLocate";
export type StepStatus = "running" | "done" | "error";
export type ProgressCallback = (
index: number,
total: number,
label: string,
status: StepStatus,
error?: string
) => void;
interface RunContext {
ip: string;
userId: number;
patientLastName: string;
patientFirstName: string;
}
interface RunStep {
label: string;
execute: (ctx: RunContext) => Promise<void>;
// Time to let the target app's UI settle/render before the next step's screenshot.
delayAfterMs: number;
}
async function visionClick(ctx: RunContext, goal: string, opts?: { doubleClick?: boolean }) {
const { image } = await captureScreenshot(ctx.ip);
const loc = await locateOnScreenshot(ctx.userId, image, goal);
if (!loc) throw new Error(`Could not find on screen: ${goal}`);
console.log(`[type-agent] ${opts?.doubleClick ? "double-click" : "click"} at (${loc.x}, ${loc.y}) — goal: ${goal.slice(0, 80)}...`);
if (opts?.doubleClick) {
await doubleClick(loc.x, loc.y, ctx.ip);
} else {
await click(loc.x, loc.y, ctx.ip);
}
}
// Open Dental, "make an appointment for an existing patient" — the exact sequence confirmed
// against a real Open Dental instance: double-click the pre-positioned schedule slot (opens
// Select Patient) → type last name → double-click the matching patient row (opens Edit
// Appointment with the appointment already created) → click the Exam procedure → click Save.
const OPEN_DENTAL_EXISTING_PATIENT: RunStep[] = [
{
label: "Double-click the schedule at the cursor position",
execute: async (ctx) => {
await doubleClickCurrent(ctx.ip);
},
delayAfterMs: 1000, // Select Patient window opening
},
{
label: "Find and click the Last Name field",
execute: async (ctx) => {
await visionClick(
ctx,
"the Last Name text input field in the Select Patient window — it's in the \"Search by:\" " +
"panel on the RIGHT half of the window (if you split the window into a left half and a " +
"right half), not in the results grid on the left"
);
},
delayAfterMs: 300,
},
{
label: "Type the patient's last name",
execute: async (ctx) => {
await typeText(ctx.patientLastName, ctx.ip);
},
delayAfterMs: 600, // patient list filtering
},
{
label: "Find and double-click the matching patient row",
execute: async (ctx) => {
const lastPrefix = ctx.patientLastName.slice(0, 3);
const firstPrefix = ctx.patientFirstName.slice(0, 3);
await visionClick(
ctx,
`Search the LEFT half of the Select Patient window for the row of patient data whose ` +
`LastName column starts with "${lastPrefix}" AND whose First Name column starts with ` +
`"${firstPrefix}" — matching is case-insensitive and only needs to match the first 3 ` +
`letters of each name, not the full name exactly. Both the last name prefix and first name ` +
`prefix must match — there may be other rows with a matching last name (family members, ` +
`e.g. a sibling or spouse) but a different first name; do NOT click those, they are the ` +
`wrong patient. That row is likely shaded/highlighted in LIGHT BLUE as the currently-selected ` +
`row. Do NOT click the window's own title bar — a DARK navy-blue bar at the very top of the ` +
`window that just reads "Select Patient" in white text; that is window chrome, not a patient ` +
`row, and is well above the results grid. Also do not click the column header row (labels ` +
`like "PatNum", "LastName", "First Name"). Click on the actual patient text in the matching data row.`,
{ doubleClick: true }
);
},
delayAfterMs: 1200, // Edit Appointment window opening
},
{
label: "Find and click the Exam procedure",
execute: async (ctx) => {
await visionClick(
ctx,
'the "Exam" item in the procedures list of the Edit Appointment window — it\'s in the ' +
"UPPER-MIDDLE area of the window (if you split the window into an upper half and a lower " +
"half, and again into left/middle/right thirds, it's in the upper half, middle third), " +
'in a plain text list that also contains items like "Ex,Pro,Flo", "Prophy-Adult", "Pano"'
);
},
delayAfterMs: 500,
},
{
label: "Find and click Save",
execute: async (ctx) => {
await visionClick(ctx, "the Save button in the lower right corner of the Edit Appointment window");
},
delayAfterMs: 300,
},
];
export async function runExistingPatientOpenDental(
userId: number,
ip: string,
patientLastName: string,
patientFirstName: string,
onProgress: ProgressCallback
): Promise<void> {
const ctx: RunContext = { ip, userId, patientLastName, patientFirstName };
const steps = OPEN_DENTAL_EXISTING_PATIENT;
for (let i = 0; i < steps.length; i++) {
const step = steps[i]!;
onProgress(i, steps.length, step.label, "running");
try {
await step.execute(ctx);
onProgress(i, steps.length, step.label, "done");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
onProgress(i, steps.length, step.label, "error", message);
throw err;
}
await new Promise((resolve) => setTimeout(resolve, step.delayAfterMs));
}
}

View File

@@ -0,0 +1,96 @@
import { storage } from "../storage";
import { resolveAiProvider, getLlm } from "../ai/llm-factory";
interface ParsedLocation {
found: boolean;
x?: number;
y?: number;
}
// Parses the model's response into {found, x, y}, tolerating near-miss JSON rather than
// only accepting strictly valid JSON. Seen in practice: {"found": true, "x": 1023, 187} —
// a dropped "y" key with the value still present positionally. Since this response should
// only ever contain two numbers (the coordinates), falling back to "first two numbers after
// found" is safe and recovers without burning another AI call.
function parseLocationResponse(raw: string): ParsedLocation | null {
const match = raw.match(/\{[\s\S]*\}/);
if (!match) return null;
const block = match[0];
try {
return JSON.parse(block) as ParsedLocation;
} catch {
// fall through to lenient repair below
}
const foundMatch = block.match(/"found"\s*:\s*(true|false)/i);
if (!foundMatch) return null;
const found = foundMatch[1]!.toLowerCase() === "true";
if (!found) return { found: false };
const afterFound = block.slice(foundMatch.index! + foundMatch[0].length);
const numbers = afterFound.match(/-?\d+(\.\d+)?/g);
if (!numbers || numbers.length < 2) return null;
return { found: true, x: Number(numbers[0]), y: Number(numbers[1]) };
}
// Asks the configured vision AI to find a described UI element in a screenshot and return
// its pixel coordinates — the core primitive the Type Agent step runner clicks/types through.
// Reuses the same Claude-vision pattern as /api/ai/detect-eligibility-info.
export async function locateOnScreenshot(
userId: number,
imageBase64: string,
goal: string
): Promise<{ x: number; y: number } | null> {
const aiSettings = await storage.getAiSettings(userId);
const activeAi = resolveAiProvider(aiSettings ?? {});
if (!activeAi) {
throw new Error("AI is not configured. Add an API key in AI Settings.");
}
if (activeAi.provider !== "claude") {
throw new Error("Vision-guided steps require Claude to be the active AI provider.");
}
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
const content = [
{
type: "text",
text:
`This is a screenshot of a Windows desktop application. Find this UI element: "${goal}". ` +
"Respond with strict JSON only, no prose, no markdown fences: " +
'{"found": true, "x": <pixel x>, "y": <pixel y>} if you can locate it (coordinates must be ' +
'pixel positions within this exact image, at the center of the element), or {"found": false} ' +
"if it isn't visible in this screenshot.",
},
{
type: "image_url",
image_url: { url: `data:image/png;base64,${imageBase64}` },
},
];
// A response that's fully unparseable even after the lenient repair above is worth one
// retry (rare model hiccup); anything the repair can salvage is used immediately, no
// retry needed.
const MAX_ATTEMPTS = 2;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const response = await llm.invoke([{ role: "user", content }] as any);
const raw = String(response.content).trim();
const parsed = parseLocationResponse(raw);
if (!parsed) {
lastError = new Error(`AI returned an unparseable response: ${raw.slice(0, 200)}`);
continue;
}
if (!parsed.found || typeof parsed.x !== "number" || typeof parsed.y !== "number") {
return null;
}
return { x: Math.round(parsed.x), y: Math.round(parsed.y) };
}
throw lastError ?? new Error("AI returned an unparseable response");
}

View File

@@ -0,0 +1,58 @@
import { io } from "../socket";
const COMMAND_TIMEOUT_MS = 10000;
// Picks a connected Windows Agent socket by IP address — falls back to the first connected
// agent if none is given (fine while offices only ever have one PC connected).
function getAgentSocket(ip?: string) {
if (!io) throw new Error("Socket.io not initialized");
const namespaceSockets = Array.from(io.of("/agent").sockets.values());
const target = ip
? namespaceSockets.find((s) => {
const socketIp = (s.handshake.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
|| s.handshake.address;
return socketIp === ip;
})
: namespaceSockets[0];
if (!target) throw new Error("No Windows Agent connected");
return target;
}
async function sendCommand<T>(event: string, payload: unknown, ip?: string): Promise<T> {
const socket = getAgentSocket(ip);
return socket.timeout(COMMAND_TIMEOUT_MS).emitWithAck(event, payload) as Promise<T>;
}
// Captures the Windows PC's screen. Returns a base64-encoded PNG for the app's AI to read.
export function captureScreenshot(ip?: string) {
return sendCommand<{ image: string }>("cmd:screenshot", {}, ip);
}
export function click(x: number, y: number, ip?: string) {
return sendCommand<{ ok: boolean }>("cmd:click", { x, y }, ip);
}
export function doubleClick(x: number, y: number, ip?: string) {
return sendCommand<{ ok: boolean }>("cmd:double_click", { x, y }, ip);
}
// Double-clicks wherever the mouse already is — no coordinates needed. Used for the first
// step of a run, where staff pre-position the cursor before triggering it from the browser.
export function doubleClickCurrent(ip?: string) {
return sendCommand<{ ok: boolean }>("cmd:double_click_current", {}, ip);
}
export function typeText(text: string, ip?: string) {
return sendCommand<{ ok: boolean }>("cmd:type", { text }, ip);
}
export function pressKey(key: string, ip?: string) {
return sendCommand<{ ok: boolean }>("cmd:key", { key }, ip);
}
// Forcibly closes a connected agent's socket — used by the "x" on the Type Agent page to
// let staff stop controlling a PC without touching the exe on that machine.
export function disconnectAgent(ip: string) {
const socket = getAgentSocket(ip);
socket.disconnect(true);
}

View File

@@ -3,6 +3,22 @@ import { Server, Socket } from "socket.io";
let io: Server | null = null; let io: Server | null = null;
interface ConnectedAgent {
socketId: string;
ip: string;
connectedAt: string;
}
// Windows Agents (DentalAgent.exe) connected via the /agent namespace, keyed by socket id.
const connectedAgents = new Map<string, ConnectedAgent>();
export function getAgentStatus() {
return {
connected: connectedAgents.size > 0,
agents: Array.from(connectedAgents.values()),
};
}
export function initSocket(server: HttpServer) { export function initSocket(server: HttpServer) {
const NODE_ENV = ( const NODE_ENV = (
process.env.NODE_ENV || process.env.NODE_ENV ||
@@ -47,7 +63,49 @@ export function initSocket(server: HttpServer) {
console.error("Socket engine connection_error:", err); console.error("Socket engine connection_error:", err);
}); });
initAgentNamespace(io);
return io; return io;
} }
// Windows Agent (DentalAgent.exe) bridge. Separate namespace so browser clients on the
// default namespace never see agent traffic, and so we can gate it with a shared secret —
// this connection will eventually be able to click/type on a real front-desk PC.
function initAgentNamespace(server: Server) {
const agentNamespace = server.of("/agent");
const AGENT_TOKEN = process.env.WINDOWS_AGENT_TOKEN;
agentNamespace.use((socket, next) => {
if (!AGENT_TOKEN) {
// No token configured — refuse rather than silently accepting any connection.
return next(new Error("Windows Agent bridge is not configured (WINDOWS_AGENT_TOKEN unset)"));
}
if (socket.handshake.auth?.token !== AGENT_TOKEN) {
return next(new Error("Invalid agent token"));
}
next();
});
agentNamespace.on("connection", (socket: Socket) => {
// x-forwarded-for is only trustworthy behind a proxy we control (nginx in prod);
// falls back to the raw socket address for direct LAN connections in dev.
const ip = (socket.handshake.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
|| socket.handshake.address;
connectedAgents.set(socket.id, {
socketId: socket.id,
ip,
connectedAt: new Date().toISOString(),
});
console.log("🖥️ Windows Agent connected:", socket.id, ip);
io?.emit("type-agent:status", getAgentStatus());
socket.on("disconnect", () => {
connectedAgents.delete(socket.id);
console.log("🖥️ Windows Agent disconnected:", socket.id);
io?.emit("type-agent:status", getAgentStatus());
});
});
}
export { io }; export { io };

View File

@@ -1,37 +1,208 @@
import { Keyboard, FileCheck, CreditCard, Shield, Zap, ArrowRight } from "lucide-react"; import { ReactNode, useEffect, useState } from "react";
import { Card, CardContent } from "@/components/ui/card"; import { Keyboard, CalendarPlus, UserPlus, CreditCard, Circle, CheckCircle2, Monitor, X, Loader2, XCircle, Maximize2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { PatientTable } from "@/components/patients/patient-table";
import { Patient } from "@repo/db/types";
import { apiRequest } from "@/lib/queryClient";
import { socket } from "@/lib/socket";
const SOFTWARE_TARGETS = [ type SoftwareId = "open-dental" | "dentrix" | "eaglesoft";
{ name: "Open Dental", color: "bg-blue-100 text-blue-700 border-blue-200" }, type ActionId = "existing-patient" | "new-patient" | "payment";
{ name: "Eaglesoft", color: "bg-emerald-100 text-emerald-700 border-emerald-200" },
{ name: "Dentrix", color: "bg-violet-100 text-violet-700 border-violet-200" }, interface ConnectedAgent {
{ name: "Curve Dental", color: "bg-orange-100 text-orange-700 border-orange-200" }, socketId: string;
ip: string;
connectedAt: string;
}
interface LiveStep {
index: number;
total: number;
label: string;
status: "running" | "done" | "error";
error?: string;
}
const SOFTWARE_OPTIONS: { id: SoftwareId; name: string }[] = [
{ id: "open-dental", name: "Open Dental" },
{ id: "dentrix", name: "Dentrix" },
{ id: "eaglesoft", name: "Eaglesoft" },
]; ];
const CAPABILITIES = [ const ACTIONS: { id: ActionId; icon: ReactNode; label: string }[] = [
{ { id: "existing-patient", icon: <CalendarPlus className="h-4 w-4" />, label: "Make an appointment for an existing patient" },
icon: <Shield className="h-5 w-5 text-teal-600" />, { id: "new-patient", icon: <UserPlus className="h-4 w-4" />, label: "Make an appointment for a new patient" },
title: "Eligibility Results", { id: "payment", icon: <CreditCard className="h-4 w-4" />, label: "Type payments" },
description:
"Automatically type insurance eligibility information — coverage status, plan details, deductibles, and co-pays — directly into the patient chart in your dental software.",
},
{
icon: <FileCheck className="h-5 w-5 text-blue-600" />,
title: "Claim Information",
description:
"Transfer claim numbers, claim status updates, and denial reasons from the insurance portal results into your claims module without manual re-entry.",
},
{
icon: <CreditCard className="h-5 w-5 text-indigo-600" />,
title: "Insurance Payments",
description:
"Post ERA / EOB payment details — amounts, adjustments, patient responsibility — directly into your payment ledger by typing them into the active software window.",
},
]; ];
// Preview-only step sequences. Same shape across the three software targets for now —
// each software's real step semantics (field positions, screen titles) will differ once
// the Windows Agent drives them via vision, but the flow order is the same.
const STEP_TEMPLATES: Record<ActionId, (softwareName: string, patientName: string) => string[]> = {
"existing-patient": (sw, patient) => [
`Double-click to open the patient selection window in ${sw}`,
`Screenshot the window and locate the Last Name field`,
`Type "${patient}"'s last name into the field`,
`Locate and click the Search button`,
`Locate and click the matching patient row`,
`Screenshot the Add Procedure window and locate the default procedure (Exam)`,
`Click the procedure, then click Create Appointment`,
],
"new-patient": (sw, patient) => [
`Open the New Patient entry screen in ${sw}`,
`Screenshot the form and locate the First/Last Name fields`,
`Type "${patient}"'s name, date of birth, and insurance into the form`,
`Locate and click Save / Create Patient`,
`Screenshot the Add Procedure window and locate the default procedure (Exam)`,
`Click the procedure, then click Create Appointment`,
],
payment: (sw, patient) => [
`Locate "${patient}" in ${sw} and open their ledger / payment screen`,
`Screenshot the screen and locate the payment amount field`,
`Type the payment amount, date, and type`,
`Locate and click Save / Post Payment`,
],
};
export default function AiTypeAgentPage() { export default function AiTypeAgentPage() {
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
const [software, setSoftware] = useState<SoftwareId | "">("open-dental");
const [runAction, setRunAction] = useState<ActionId | null>(null);
const [agents, setAgents] = useState<ConnectedAgent[]>([]);
const [selectedAgentIp, setSelectedAgentIp] = useState<string | null>(null);
useEffect(() => {
apiRequest("GET", "/api/type-agent/status")
.then((res) => res.json())
.then((data) => setAgents(Array.isArray(data?.agents) ? data.agents : []))
.catch(() => {});
const handleStatus = (data: { agents: ConnectedAgent[] }) =>
setAgents(Array.isArray(data?.agents) ? data.agents : []);
socket.on("type-agent:status", handleStatus);
return () => {
socket.off("type-agent:status", handleStatus);
};
}, []);
// Keep the selected PC valid as the connected list changes — auto-pick when there's
// exactly one, and drop the selection if its PC disconnects.
useEffect(() => {
if (selectedAgentIp && !agents.some((a) => a.ip === selectedAgentIp)) {
setSelectedAgentIp(null);
} else if (!selectedAgentIp && agents.length === 1) {
setSelectedAgentIp(agents[0]!.ip);
}
}, [agents, selectedAgentIp]);
const [liveSteps, setLiveSteps] = useState<LiveStep[]>([]);
const [runStartError, setRunStartError] = useState<string | null>(null);
const [startCountdown, setStartCountdown] = useState<number | null>(null);
// Open Dental / existing-patient is the only combination actually wired to the real
// Windows Agent so far — everything else still shows the static preview below.
const isAutomated = software === "open-dental" && runAction === "existing-patient";
const START_DELAY_SECONDS = 5;
useEffect(() => {
if (!isAutomated || !runAction || !selectedAgentIp || !selectedPatient) return;
let cancelled = false;
let liveRunId: string | null = null;
const handleProgress = (data: LiveStep) => {
setLiveSteps((prev) => {
const next = [...prev];
next[data.index] = data;
return next;
});
};
setLiveSteps([]);
setRunStartError(null);
setStartCountdown(START_DELAY_SECONDS);
// Gives time to minimize/switch away from Chrome and let the target software have
// focus before the first (mouse-position-dependent) step fires.
const countdownInterval = setInterval(() => {
setStartCountdown((c) => (c && c > 1 ? c - 1 : 0));
}, 1000);
const startTimeout = setTimeout(() => {
clearInterval(countdownInterval);
setStartCountdown(null);
apiRequest("POST", "/api/type-agent/run", {
software,
action: runAction,
ip: selectedAgentIp,
patientId: selectedPatient.id,
})
.then((res) => res.json())
.then((data) => {
if (cancelled) return;
if (data?.error) {
setRunStartError(data.error);
return;
}
liveRunId = data.runId;
socket.on(`type-agent:run:${data.runId}`, handleProgress);
})
.catch((err) => {
if (!cancelled) setRunStartError(err instanceof Error ? err.message : "Failed to start run");
});
}, START_DELAY_SECONDS * 1000);
return () => {
cancelled = true;
clearInterval(countdownInterval);
clearTimeout(startTimeout);
if (liveRunId) socket.off(`type-agent:run:${liveRunId}`, handleProgress);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAutomated, runAction, selectedAgentIp, selectedPatient?.id]);
const getPatientName = (patient: Patient) =>
patient.firstName && patient.lastName
? `${patient.firstName} ${patient.lastName}`
: patient.firstName ?? `patient-${patient.id}`;
const softwareName = SOFTWARE_OPTIONS.find((s) => s.id === software)?.name ?? "";
const handleDisconnect = async (ip: string) => {
try {
await apiRequest("POST", "/api/type-agent/disconnect", { ip });
} catch (error) {
console.error("Error disconnecting agent:", error);
}
};
const agentConnected = agents.length > 0;
const canRun = Boolean(software && selectedPatient && selectedAgentIp);
const activeAction = ACTIONS.find((a) => a.id === runAction);
const steps =
runAction && selectedPatient
? STEP_TEMPLATES[runAction](softwareName, getPatientName(selectedPatient))
: [];
return ( return (
<div className="max-w-3xl mx-auto px-4 py-10 space-y-10"> <div className="container mx-auto space-y-6">
{/* Hero */} {/* Hero */}
<div className="text-center space-y-3"> <div className="text-center space-y-3">
@@ -48,90 +219,188 @@ export default function AiTypeAgentPage() {
</span> </span>
</div> </div>
{/* How it works */} {/* Run setup */}
<Card> <Card>
<CardContent className="py-6 space-y-4"> <CardHeader>
<div className="flex items-center gap-2"> <CardTitle>Run Setup</CardTitle>
<Keyboard className="h-5 w-5 text-violet-500" /> <CardDescription>
<h2 className="text-base font-semibold">How it works</h2> Choose your dental software, the PC to control, and a patient below, then run one
</div> of the actions.
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 text-sm"> </CardDescription>
{[ </CardHeader>
"Retrieve data in this app\n(eligibility, claim, payment)", <CardContent className="space-y-4">
"Agent identifies the active\nfield in your dental software", <div className="flex flex-wrap items-center gap-3">
"Types the value directly\nvia keyboard automation", <div className="w-56">
].map((step, i) => ( <Select value={software} onValueChange={(v) => setSoftware(v as SoftwareId)}>
<div key={i} className="flex items-center gap-3 flex-1"> <SelectTrigger>
<div className="flex-shrink-0 w-7 h-7 rounded-full bg-violet-100 text-violet-700 text-xs font-bold flex items-center justify-center"> <SelectValue placeholder="Choose software..." />
{i + 1} </SelectTrigger>
</div> <SelectContent>
<p className="whitespace-pre-line text-muted-foreground leading-snug">{step}</p> {SOFTWARE_OPTIONS.map((s) => (
{i < 2 && <ArrowRight className="h-4 w-4 text-muted-foreground/40 hidden sm:block flex-shrink-0" />} <SelectItem key={s.id} value={s.id}>
</div>
))}
</div>
<p className="text-xs text-muted-foreground pt-1 border-t">
The agent runs as a lightweight local process on your workstation. It receives
instructions from this app and uses keyboard automation to type data into whatever
window is currently focused in your dental software no clipboard involved.
</p>
</CardContent>
</Card>
{/* What it will type */}
<div className="space-y-3">
<h2 className="text-base font-semibold">What it will type</h2>
<div className="grid gap-4 sm:grid-cols-3">
{CAPABILITIES.map((cap) => (
<Card key={cap.title}>
<CardContent className="py-5 space-y-2">
<div className="flex items-center gap-2">
{cap.icon}
<span className="text-sm font-medium">{cap.title}</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{cap.description}
</p>
</CardContent>
</Card>
))}
</div>
</div>
{/* Supported software */}
<div className="space-y-3">
<h2 className="text-base font-semibold">Planned software support</h2>
<div className="flex flex-wrap gap-2">
{SOFTWARE_TARGETS.map((s) => (
<span
key={s.name}
className={`text-sm font-medium px-3 py-1.5 rounded-lg border ${s.color}`}
>
{s.name} {s.name}
</span> </SelectItem>
))} ))}
</div> </SelectContent>
<p className="text-xs text-muted-foreground"> </Select>
Each software has its own field layout. The agent will include pre-built templates
for common workflows in each platform.
</p>
</div> </div>
{/* Why */} {agentConnected ? (
<Card className="border-violet-200 bg-violet-50/50"> <Badge variant="outline" className="gap-1.5 text-muted-foreground border-emerald-200 bg-emerald-50">
<CardContent className="py-5 flex items-start gap-3"> <Circle className="h-2 w-2 fill-emerald-500 text-emerald-500" />
<Zap className="h-5 w-5 text-violet-500 mt-0.5 flex-shrink-0" /> {agents.length} PC{agents.length > 1 ? "s" : ""} Connected
<div className="space-y-1"> </Badge>
<p className="text-sm font-medium text-violet-900">Why this matters</p> ) : (
<p className="text-sm text-violet-700 leading-relaxed"> <Badge variant="outline" className="gap-1.5 text-muted-foreground border-red-200 bg-red-50">
Staff currently check eligibility or look up a claim here, then manually retype <Circle className="h-2 w-2 fill-red-500 text-red-500" />
every value into Open Dental or Eaglesoft. This agent eliminates that step entirely No PC Connected
one click sends the data straight into the right field. </Badge>
)}
{selectedPatient ? (
<Badge variant="secondary">Patient: {getPatientName(selectedPatient)}</Badge>
) : (
<span className="text-xs text-muted-foreground">Select a patient below</span>
)}
</div>
{/* Connected PCs */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Windows PC to control</p>
{agents.length === 0 ? (
<p className="text-xs text-muted-foreground">
No DentalAgent.exe is connected yet. Run it on the front-desk PC you want to
control it will show up here.
</p> </p>
) : (
<div className="flex flex-wrap gap-2">
{agents.map((agent) => (
<div
key={agent.socketId}
className={`flex items-center gap-1.5 rounded-lg border pl-3 pr-1.5 py-1.5 text-sm transition-colors ${
selectedAgentIp === agent.ip
? "border-violet-300 bg-violet-50 text-violet-700"
: "border-input hover:bg-accent"
}`}
>
<button
type="button"
onClick={() => setSelectedAgentIp(agent.ip)}
className="flex items-center gap-1.5"
>
<Monitor className="h-3.5 w-3.5" />
{agent.ip}
</button>
<button
type="button"
onClick={() => handleDisconnect(agent.ip)}
title="Disconnect this PC"
className="rounded-full p-0.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</div>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Maximize2 className="h-3.5 w-3.5 flex-shrink-0" />
Maximize {softwareName || "your dental software"} to full screen on the target PC before running an action the agent reads the whole screen, so a small or floating window makes it harder to find things reliably.
</p>
<div className="grid gap-3 sm:grid-cols-3">
{ACTIONS.map((action) => (
<Button
key={action.id}
variant="outline"
disabled={!canRun}
title={!canRun ? "Choose software, a PC, and a patient first" : undefined}
onClick={() => setRunAction(action.id)}
className="h-auto py-4 justify-start gap-2 whitespace-normal text-left"
>
{action.icon}
{action.label}
</Button>
))}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Patient search */}
<Card>
<CardHeader>
<CardTitle>Patient Records</CardTitle>
<CardDescription>
Select the patient to use for the action above.
</CardDescription>
</CardHeader>
<CardContent>
<PatientTable allowCheckbox={true} onSelectPatient={setSelectedPatient} />
</CardContent>
</Card>
{/* Run panel */}
<Dialog open={runAction !== null} onOpenChange={(open) => !open && setRunAction(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{activeAction?.label} {softwareName}
</DialogTitle>
<DialogDescription>
{isAutomated
? `Running live on the connected PC at ${selectedAgentIp}.`
: selectedAgentIp
? `Running on the connected PC at ${selectedAgentIp}.`
: "Preview only — no PC selected, so these steps won't run automatically."}
</DialogDescription>
</DialogHeader>
{isAutomated ? (
<div className="space-y-2">
{runStartError && (
<p className="text-sm text-destructive">{runStartError}</p>
)}
{startCountdown !== null && (
<p className="text-sm font-medium text-violet-700">
Starting in {startCountdown}s minimize or switch away from this window now.
</p>
)}
{!runStartError && startCountdown === null && liveSteps.length === 0 && (
<p className="text-sm text-muted-foreground">Starting run...</p>
)}
{liveSteps.map((step, i) => (
<div key={i} className="flex items-start gap-3 text-sm">
<div className="flex-shrink-0 mt-0.5">
{step.status === "running" && <Loader2 className="h-4 w-4 animate-spin text-violet-500" />}
{step.status === "done" && <CheckCircle2 className="h-4 w-4 text-emerald-500" />}
{step.status === "error" && <XCircle className="h-4 w-4 text-destructive" />}
</div>
<div>
<p className="leading-snug pt-0.5">{step.label}</p>
{step.error && <p className="text-xs text-destructive mt-0.5">{step.error}</p>}
</div>
</div>
))}
</div>
) : (
<div className="space-y-2">
{steps.map((step, i) => (
<div key={i} className="flex items-start gap-3 text-sm">
<div className="flex-shrink-0 mt-0.5">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
{i + 1}
</span>
</div>
<p className="text-muted-foreground leading-snug pt-0.5">{step}</p>
</div>
))}
</div>
)}
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@@ -0,0 +1,59 @@
# Dental Agent (Windows)
`agent.py` runs on a staff front-desk Windows PC. It connects to the app's server over the
`/agent` socket.io namespace and executes low-level commands — screenshot, click,
double-click, type, key press — sent by the server. It has no knowledge of dental software
or workflows; the app's AI reads the screenshots this agent sends back and decides what to
click or type next.
Not wired into the repo's root `npm install` on purpose — it targets Windows (`pyautogui`,
`tkinter`) and doesn't need to install on every contributor's machine.
## Command protocol
Sent by the server, over the `/agent` namespace, as socket.io events with an ack callback:
| Event | Payload | Ack response |
|---------------------|-----------------------|--------------------|
| `cmd:screenshot` | `{}` | `{ image: <base64 PNG> }` |
| `cmd:click` | `{ x, y }` | `{ ok: true }` |
| `cmd:double_click` | `{ x, y }` | `{ ok: true }` |
| `cmd:double_click_current` | `{}` | `{ ok: true }` |
| `cmd:type` | `{ text }` | `{ ok: true }` |
| `cmd:key` | `{ key }` | `{ ok: true }` |
Auth on connect: `{ auth: { token } }`, where `token` must match the server's
`WINDOWS_AGENT_TOKEN`. The token is a constant baked into `agent.py` (`AGENT_TOKEN` near the
top) — set it before building for a real office, staff never see or type it. The server tells
front-desk PCs apart by connection IP address (shown on the Type Agent page), not anything
the agent sends.
## Local dev / testing
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python agent.py
```
On first run it asks only for the Server URL, then saves it to `agent_config.json` next to
the script so it reconnects automatically after that. After that it has no window — just a
system tray icon (green = Connected, gray = Connecting, red = Disconnected). Click it (or
right-click for the same menu) for status, the server URL, "Settings..." (reopens the setup
window pre-filled with the current URL — close without submitting to leave it unchanged), and
Quit.
`apps/Backend/scripts/fake-windows-agent.js` is a Node stand-in for this agent — useful for
testing the server-side bridge without a Windows PC or a real screen to click on.
## Building DentalAgent.exe
Must be run **on Windows** (PyInstaller doesn't cross-compile):
```bat
py -m venv .venv
.venv\Scripts\pip install -r requirements.txt
.venv\Scripts\pyinstaller --onefile --windowed --name DentalAgent agent.py
```
Output: `dist\DentalAgent.exe`. Staff download and run it once — no other setup.

218
apps/WindowsAgent/agent.py Normal file
View File

@@ -0,0 +1,218 @@
"""
DentalAgent — runs on a staff front-desk Windows PC. Connects to the app's server over
the /agent socket.io namespace and executes low-level mouse/keyboard/screenshot commands
sent by the server. It has no knowledge of dental software, patients, or workflows — the
app's AI decides what to click/type by reading the screenshots this agent sends back.
Configuration (via a small popup on first run, saved to agent_config.json next to the exe):
- Server URL (e.g. http://192.168.0.240:5000) — the only thing staff need to enter.
The server identifies which PC is which by connection IP address (shown on the Type Agent
page), so there's no Office ID to type. The AGENT_TOKEN below authenticates the exe itself
to the server — set it to match the server's WINDOWS_AGENT_TOKEN before distributing this
to real front-desk PCs; it's baked into the build, not something staff ever see or type.
After connecting it has no visible window — just a system tray icon showing
Connected/Connecting/Disconnected. Clicking the icon reopens the same setup window
(pre-filled with the current URL) without restarting the process — a self-relaunch turned
out to be unreliable with PyInstaller --onefile builds on Windows (the freshly spawned copy
can crash trying to reuse the outgoing process's temp extraction folder), so instead a single
hidden Tk window is kept alive for the whole run and just shown/hidden as needed.
"""
AGENT_TOKEN = "dev-windows-agent-token"
import base64
import io
import json
import os
import sys
import threading
import tkinter as tk
from tkinter import messagebox
import pyautogui
import pystray
import socketio
from PIL import Image, ImageDraw, ImageGrab
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "agent_config.json")
pyautogui.FAILSAFE = True # moving mouse to a screen corner aborts an in-progress action
def load_config():
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, "r") as f:
return json.load(f)
return {}
def save_config(config):
with open(CONFIG_PATH, "w") as f:
json.dump(config, f)
def make_status_icon(color):
image = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
draw.ellipse((8, 8, 56, 56), fill=color)
return image
ICON_CONNECTED = make_status_icon("#22c55e")
ICON_DISCONNECTED = make_status_icon("#ef4444")
ICON_CONNECTING = make_status_icon("#9ca3af")
ICON_NOT_CONFIGURED = make_status_icon("#9ca3af")
class DentalAgent:
def __init__(self):
self.server_url = None
self.status = "Not configured"
self.tray_icon = None
self.sio = socketio.Client(reconnection=True, reconnection_attempts=0)
self._register_handlers()
def _set_status(self, status, icon_image):
self.status = status
if self.tray_icon:
self.tray_icon.icon = icon_image
self.tray_icon.title = f"Dental Agent - {status}"
self.tray_icon.update_menu()
def _register_handlers(self):
sio = self.sio
@sio.event(namespace="/agent")
def connect():
print(f"Connected to {self.server_url}")
self._set_status("Connected", ICON_CONNECTED)
@sio.event(namespace="/agent")
def connect_error(data):
print(f"Connection failed: {data}")
self._set_status("Connection failed", ICON_DISCONNECTED)
@sio.event(namespace="/agent")
def disconnect():
print("Disconnected")
self._set_status("Disconnected", ICON_DISCONNECTED)
@sio.on("cmd:screenshot", namespace="/agent")
def on_screenshot(_data=None):
image = ImageGrab.grab()
buf = io.BytesIO()
image.save(buf, format="PNG")
encoded = base64.b64encode(buf.getvalue()).decode("ascii")
return {"image": encoded}
@sio.on("cmd:click", namespace="/agent")
def on_click(data):
pyautogui.click(data["x"], data["y"])
return {"ok": True}
@sio.on("cmd:double_click", namespace="/agent")
def on_double_click(data):
pyautogui.doubleClick(data["x"], data["y"])
return {"ok": True}
@sio.on("cmd:double_click_current", namespace="/agent")
def on_double_click_current(_data=None):
# No x/y — clicks wherever the mouse already is. Lets staff pre-position the
# cursor over the target (e.g. an open time slot) before minimizing the browser,
# so this step doesn't need a screenshot/vision lookup at all.
pyautogui.doubleClick()
return {"ok": True}
@sio.on("cmd:type", namespace="/agent")
def on_type(data):
pyautogui.write(data["text"], interval=0.02)
return {"ok": True}
@sio.on("cmd:key", namespace="/agent")
def on_key(data):
pyautogui.press(data["key"])
return {"ok": True}
def connect_to(self, server_url):
"""(Re)connects to server_url, reusing the same socketio.Client for the process's
whole lifetime — safe to call again later with a new URL to switch servers."""
self.server_url = server_url
self._set_status("Connecting...", ICON_CONNECTING)
def _run():
try:
if self.sio.connected:
self.sio.disconnect()
self.sio.connect(
server_url,
namespaces=["/agent"],
auth={"token": AGENT_TOKEN},
wait_timeout=10,
)
self.sio.wait()
except Exception as exc:
print(f"Agent stopped: {exc}")
self._set_status("Connection failed", ICON_DISCONNECTED)
threading.Thread(target=_run, daemon=True).start()
def main():
config = load_config()
agent = DentalAgent()
# --- Settings window: built once, shown/hidden for the process's whole lifetime ---
root = tk.Tk()
root.title("Dental Agent Setup")
root.resizable(False, False)
root.withdraw()
root.protocol("WM_DELETE_WINDOW", root.withdraw) # closing the window just hides it
tk.Label(root, text="Server URL").pack(pady=(15, 0))
server_entry = tk.Entry(root, width=40)
server_entry.pack(padx=15)
def on_connect():
server_url = server_entry.get().strip()
if not server_url:
messagebox.showerror("Dental Agent", "Server URL is required.")
return
save_config({"server_url": server_url})
root.withdraw()
agent.connect_to(server_url)
tk.Button(root, text="Connect", command=on_connect).pack(pady=15)
root.bind("<Return>", lambda _event: on_connect())
def show_settings():
server_entry.delete(0, tk.END)
server_entry.insert(0, agent.server_url or config.get("server_url", "http://"))
root.deiconify()
root.eval("tk::PlaceWindow . center")
root.lift()
root.focus_force()
# --- Tray icon: runs on its own thread so Tk can own the main thread/event loop ---
menu = pystray.Menu(
pystray.MenuItem(lambda _item: f"Status: {agent.status}", None, enabled=False),
pystray.MenuItem(lambda _item: f"Server: {agent.server_url or '(not set)'}", None, enabled=False),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Settings...", lambda: root.after(0, show_settings), default=True),
pystray.MenuItem("Quit", lambda icon: (icon.stop(), root.after(0, root.quit))),
)
icon = pystray.Icon("DentalAgent", ICON_NOT_CONFIGURED, "Dental Agent - Not configured", menu)
agent.tray_icon = icon
threading.Thread(target=icon.run, daemon=True).start()
if "server_url" in config:
agent.connect_to(config["server_url"])
else:
show_settings() # first run — nothing saved yet, ask right away
root.mainloop()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,4 @@
{
"name": "windowsagent",
"private": true
}

View File

@@ -0,0 +1,7 @@
python-socketio[client]==5.13.0
python-engineio==4.11.2
websocket-client==1.8.0
pyautogui==0.9.54
Pillow==11.1.0
pystray==0.19.5
pyinstaller==6.11.1

579
package-lock.json generated
View File

@@ -57,6 +57,7 @@
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
"sharp": "^0.35.3",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"twilio": "^6.0.0", "twilio": "^6.0.0",
"ws": "^8.18.0", "ws": "^8.18.0",
@@ -212,6 +213,9 @@
"extraneous": true, "extraneous": true,
"hasInstallScript": true "hasInstallScript": true
}, },
"apps/WindowsAgent": {
"name": "windowsagent"
},
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -653,6 +657,16 @@
"@electric-sql/pglite": "0.3.15" "@electric-sql/pglite": "0.3.15"
} }
}, },
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3", "version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@@ -1346,6 +1360,506 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.2"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.1"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@ioredis/commands": { "node_modules/@ioredis/commands": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
@@ -12663,6 +13177,67 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/sharp": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/sharp/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -14913,6 +15488,10 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/windowsagent": {
"resolved": "apps/WindowsAgent",
"link": true
},
"node_modules/wmf": { "node_modules/wmf": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",