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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
68
apps/Backend/scripts/fake-windows-agent.js
Normal file
68
apps/Backend/scripts/fake-windows-agent.js
Normal 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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
109
apps/Backend/src/routes/type-agent.ts
Normal file
109
apps/Backend/src/routes/type-agent.ts
Normal 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;
|
||||||
138
apps/Backend/src/services/typeAgentRunner.ts
Normal file
138
apps/Backend/src/services/typeAgentRunner.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
96
apps/Backend/src/services/visionLocate.ts
Normal file
96
apps/Backend/src/services/visionLocate.ts
Normal 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");
|
||||||
|
}
|
||||||
58
apps/Backend/src/services/windowsAgentBridge.ts
Normal file
58
apps/Backend/src/services/windowsAgentBridge.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
59
apps/WindowsAgent/README.md
Normal file
59
apps/WindowsAgent/README.md
Normal 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
218
apps/WindowsAgent/agent.py
Normal 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()
|
||||||
4
apps/WindowsAgent/package.json
Normal file
4
apps/WindowsAgent/package.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "windowsagent",
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
7
apps/WindowsAgent/requirements.txt
Normal file
7
apps/WindowsAgent/requirements.txt
Normal 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
579
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user