Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/desktop/src/settings/ai/shared/model-capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";

import { modelSupportsImageInput } from "./model-capabilities";

describe("modelSupportsImageInput", () => {
it("allows known multimodal hosted models", () => {
expect(modelSupportsImageInput("hyprnote", "Auto")).toBe(true);
expect(modelSupportsImageInput("openai", "gpt-4o")).toBe(true);
expect(modelSupportsImageInput("anthropic", "claude-3-5-sonnet")).toBe(
true,
);
expect(
modelSupportsImageInput("google_generative_ai", "gemini-2.5-pro"),
).toBe(true);
});

it("blocks known text-only or non-chat models", () => {
expect(modelSupportsImageInput("openai", "gpt-3.5-turbo")).toBe(false);
expect(modelSupportsImageInput("openai", "gpt-4")).toBe(false);
expect(modelSupportsImageInput("anthropic", "claude-2.1")).toBe(false);
expect(modelSupportsImageInput("anthropic", "custom-text-model")).toBe(
false,
);
expect(modelSupportsImageInput("openai", "text-embedding-3-large")).toBe(
false,
);
});

it("requires a vision-like model name for unknown local providers", () => {
expect(modelSupportsImageInput("ollama", "llava:latest")).toBe(true);
expect(modelSupportsImageInput("custom", "llama-3.1-8b")).toBe(false);
});
});
28 changes: 28 additions & 0 deletions apps/desktop/src/settings/ai/shared/model-capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const TEXT_ONLY_MODEL_RE =
/(?:^|[/:\-.])(?:gpt-3\.5|claude-2|claude-instant|davinci|babbage|curie|ada|dall-e|sora|gpt-image|image-generation|embed|embedding|whisper|tts|transcribe|moderation|realtime|computer)(?:$|[/:\-.])/i;

const IMAGE_INPUT_MODEL_RE =
/(?:gpt-4o|gpt-4\.1|gpt-5|claude-3|claude-sonnet|claude-opus|claude-haiku|gemini|pixtral|vision|vl|llava|llama-3\.2-vision|llama3\.2-vision|moondream|minicpm-v|internvl|qwen(?:2|2\.5|3)?-vl|gemma-3|gemma3)/i;

export function modelSupportsImageInput(
providerId: string | undefined,
modelId: string | undefined,
): boolean {
if (!providerId || !modelId) {
return false;
}

if (TEXT_ONLY_MODEL_RE.test(modelId)) {
return false;
}

if (providerId === "hyprnote" && modelId === "Auto") {
return true;
}

if (IMAGE_INPUT_MODEL_RE.test(modelId)) {
return true;
}

return false;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

import {
collectEnhanceImageContext,
collectImageReferences,
getBase64ByteLength,
} from "./enhance-images";

const fsSyncMocks = vi.hoisted(() => ({
attachmentList: vi.fn(),
attachmentRead: vi.fn(),
}));

vi.mock("@hypr/plugin-fs-sync", () => ({
commands: fsSyncMocks,
}));

describe("enhance image context", () => {
beforeEach(() => {
vi.clearAllMocks();
fsSyncMocks.attachmentList.mockResolvedValue({
status: "ok",
data: [
{
attachmentId: "diagram.png",
path: "/vault/sessions/session-1/attachments/diagram.png",
extension: "png",
modifiedAt: "",
},
{
attachmentId: "stale.png",
path: "/vault/sessions/session-1/attachments/stale.png",
extension: "png",
modifiedAt: "",
},
],
});
fsSyncMocks.attachmentRead.mockResolvedValue({
status: "ok",
data: [104, 101, 108, 108, 111],
});
});

it("reads only image attachments referenced by note JSON", async () => {
const rawContent = JSON.stringify({
type: "doc",
content: [
{
type: "image",
attrs: {
src: "asset://localhost/%2Fvault%2Fsessions%2Fsession-1%2Fattachments%2Fdiagram.png",
attachmentId: "diagram.png",
},
},
],
});

const images = await collectEnhanceImageContext("session-1", rawContent);

expect(images).toEqual([
{
base64: "aGVsbG8=",
mimeType: "image/png",
filename: "diagram.png",
},
]);
expect(fsSyncMocks.attachmentRead).toHaveBeenCalledWith(
"session-1",
"diagram.png",
);
});

it("extracts markdown image filenames from asset URLs", () => {
expect(
collectImageReferences(
"![diagram](asset://localhost/%2Fvault%2Fsessions%2Fsession-1%2Fattachments%2Fdiagram.png)",
),
).toEqual([{ filename: "diagram.png" }]);
});

it("does not treat remote markdown images as local attachments", () => {
expect(
collectImageReferences("![diagram](https://example.com/diagram.png)"),
).toEqual([{ filename: undefined }]);
});

it("keeps base64 data URL images without reading attachments", async () => {
const images = await collectEnhanceImageContext(
"session-1",
"![pasted](data:image/png;base64,abc123)",
);

expect(images).toEqual([{ base64: "abc123", mimeType: "image/png" }]);
expect(fsSyncMocks.attachmentList).not.toHaveBeenCalled();
});

it("does not load an attachment again for a node that already has a data URL", async () => {
const rawContent = JSON.stringify({
type: "doc",
content: [
{
type: "image",
attrs: {
src: "data:image/png;base64,abc123",
attachmentId: "diagram.png",
},
},
],
});

const images = await collectEnhanceImageContext("session-1", rawContent);

expect(images).toEqual([{ base64: "abc123", mimeType: "image/png" }]);
expect(fsSyncMocks.attachmentList).not.toHaveBeenCalled();
});

it("computes decoded base64 byte length before applying the data URL cap", () => {
expect(getBase64ByteLength("aGVsbG8=")).toBe(5);
expect(getBase64ByteLength("YW55IGNhcm5hbCBwbGVhcw==")).toBe(16);
});
});
Loading
Loading