92 lines
3.6 KiB
TypeScript
92 lines
3.6 KiB
TypeScript
import { LaunchType, closeMainWindow, launchCommand, showHUD, type LaunchProps } from "@vicinae/api";
|
|
import { captureRegion } from "./lib/capture";
|
|
import { getLamPreferences } from "./lib/preferences";
|
|
import { describeCaptureFailure, readSnipContext, snipDirectory } from "./lib/snip";
|
|
import { visionSupport } from "./lib/vision";
|
|
|
|
/**
|
|
* Vicinae's `closeMainWindow` defers the actual hide to a 50ms timer
|
|
* (`closeWindow(settings, 50ms)` in `ui-service.hpp`), so a capture started the
|
|
* instant the call resolves can still find the window on screen and select a
|
|
* region around it. Waiting a little past that timer is the difference between
|
|
* "the window is closed" and "the window is usually closed".
|
|
*/
|
|
const WINDOW_SETTLE_MS = 100;
|
|
|
|
function settleWindow(): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, WINDOW_SETTLE_MS));
|
|
}
|
|
|
|
/**
|
|
* Entrypoint for the "snip" command.
|
|
*
|
|
* `no-view`, and it has to be. Vicinae calls `showWindow()` when it activates a
|
|
* command from a closed launcher, so the window is up by the time any extension
|
|
* code runs; capturing from a view means selecting a region around a panel
|
|
* sitting in the middle of the screen. This command closes the window first,
|
|
* captures with nothing in the way, and only then hands the image to the chat,
|
|
* which is the command that brings the window back.
|
|
*
|
|
* It is also the *only* capture path. The chat's own action launches this command
|
|
* rather than capturing inline, so there is one implementation, one capability
|
|
* check, and one place where the window is dealt with.
|
|
*
|
|
* Bind it to a global shortcut for one-key "snip and ask".
|
|
*/
|
|
export default async function SnipCommand(props: LaunchProps) {
|
|
const preferences = getLamPreferences();
|
|
const context = readSnipContext(props.launchContext);
|
|
|
|
// Before anything else. Everything below is pointless if the selector is
|
|
// competing with a window for the middle of the screen.
|
|
await closeMainWindow();
|
|
await settleWindow();
|
|
|
|
// Settled before the capture, because taking a screenshot for a model that
|
|
// cannot read it throws away the selection gesture the user just performed.
|
|
// "unknown" says so in those words: a dropped check is not a verdict on the
|
|
// model, and reporting it as one would send the user shopping for another.
|
|
const vision = await visionSupport({
|
|
host: preferences.host,
|
|
apiKey: preferences.apiKey,
|
|
model: preferences.model,
|
|
});
|
|
|
|
if (vision !== "yes") {
|
|
await showHUD(
|
|
vision === "no"
|
|
? `${preferences.model} cannot see images. Pick a vision model in preferences.`
|
|
: `Could not check whether ${preferences.model} can see images. Run Set Up Lam Agent.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
let snipPath: string | undefined;
|
|
let snipError: string | undefined;
|
|
|
|
try {
|
|
const capture = await captureRegion({ outputDir: snipDirectory() });
|
|
snipPath = capture?.path;
|
|
} catch (thrown) {
|
|
snipError = describeCaptureFailure(thrown);
|
|
}
|
|
|
|
// An abandoned selection says nothing at all: the user pressed Escape. But a
|
|
// snip started from an open conversation has to come back, because the window
|
|
// is now closed and the hotkey is otherwise the only way back.
|
|
if (!snipPath && !snipError && !context.sessionId) return;
|
|
|
|
// Only the fields that exist are sent. `launchCommand` types its context as a
|
|
// string map, and a key left undefined carries no information anyway.
|
|
const payload: Record<string, string> = {};
|
|
if (context.sessionId) payload.sessionId = context.sessionId;
|
|
if (snipPath) payload.snipPath = snipPath;
|
|
if (snipError) payload.snipError = snipError;
|
|
|
|
await launchCommand({
|
|
name: "chat",
|
|
type: LaunchType.UserInitiated,
|
|
context: payload,
|
|
});
|
|
}
|