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:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user