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
11 changes: 10 additions & 1 deletion apps/desktop/electron/features.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,13 @@ function registerFeatures(host, features = loadFeatures()) {
for (const { module } of features) module.register(host);
}

module.exports = { FEATURES_DIR, loadManifests, loadFeatures, capabilitiesFromFeatures, registerFeatures };
// The MCP tool groups the manifests contribute (`contributes.mcpTools`, a
// path relative to the manifest): each module exports (ctx) => tools[] like
// electron/mcp/tools/*.js, and mcp/tools/index.js appends them to its own.
function loadMcpToolGroups(manifests = loadManifests()) {
return manifests
.filter((manifest) => manifest.contributes?.mcpTools)
.map((manifest) => require(path.join(FEATURES_DIR, manifest.id, manifest.contributes.mcpTools)));
}

module.exports = { FEATURES_DIR, loadManifests, loadFeatures, capabilitiesFromFeatures, registerFeatures, loadMcpToolGroups };
13 changes: 12 additions & 1 deletion apps/desktop/electron/features.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");

const { loadManifests, loadFeatures, capabilitiesFromFeatures, registerFeatures, FEATURES_DIR } = require("./features");
const { loadManifests, loadFeatures, capabilitiesFromFeatures, registerFeatures, loadMcpToolGroups, FEATURES_DIR } = require("./features");

test("every manifest's electron entry resolves to a module with register()", () => {
const manifests = loadManifests();
Expand Down Expand Up @@ -48,3 +48,14 @@ test("registerFeatures calls each entry's register(host) in manifest order", ()
registerFeatures(host, features);
assert.deepEqual(calls, [["one", host], ["two", host]]);
});

test("the manifests' MCP tool groups load by path and export (ctx) => tools[]", () => {
const groups = loadMcpToolGroups();
assert.equal(groups.length, loadManifests().filter((m) => m.contributes?.mcpTools).length);
assert.ok(groups.length >= 1, "the repaint feature contributes repaint_asset");
for (const group of groups) assert.equal(typeof group, "function");
assert.deepEqual(loadMcpToolGroups([{ id: "lut", contributes: {} }]), []);
const [repaint] = loadMcpToolGroups([{ id: "repaint", contributes: { mcpTools: "./electron/mcpTools.js" } }]);
const tools = repaint({ deps: {}, commands: {}, requireCatalog: () => {} });
assert.deepEqual(tools.map((t) => t.name), ["repaint_asset"]);
});
42 changes: 7 additions & 35 deletions apps/desktop/electron/ipc/ai.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// AI Repaint IPC — provider token storage, model listing, style library,
// repaint job kickoff + status, repaint history.
// Provider tokens use safeStorage via the deps passed in; styles live in a
// plain JSON file under userData/afterframe/.
// AI providers IPC — the credentials (safeStorage, through host.tokens),
// preferences and model listing of the image-generation providers, shared by
// the editor's text tool (handwriting, text-image jobs below) and the AI
// Repaint feature module (features/repaint/electron/repaint.js, which owns
// the repaint job, its history and the style library). The provider registry
// itself (the five provider types) is the repaint feature's for now.

const path = require("path");
const fs = require("fs");
const { writeJsonAtomic } = require("../settingsStore");
const { jobStatusHandler } = require("./guard");

function register({
Expand All @@ -16,26 +17,10 @@ function register({
readAppSettings,
updateAppSettings,
tokens: { getStoredProviderConfigWithMigration, providerCredentials, setStoredProviderConfig, deleteStoredProviderConfig },
tasks: { startAiRepaintTask, startTextImageTask, latestJobStatus, formatJobStatus },
tasks: { startTextImageTask, latestJobStatus, formatJobStatus },
}) {
function aiStylesPath() {
return path.join(app.getPath("userData"), "afterframe", "ai-styles.json");
}
function readAiStyles() {
try {
return JSON.parse(fs.readFileSync(aiStylesPath(), "utf-8"));
} catch {
return null;
}
}
const writeAiStyles = (styles) => writeJsonAtomic(aiStylesPath(), styles);

const status = (jobType) => jobStatusHandler({ getCatalogState, latestJobStatus, formatJobStatus }, jobType);

ipcMain.handle("workspace:ai-repaint-status", status("ai_repaint"));

ipcMain.handle("workspace:ai-repaint-start", (_event, options) => startAiRepaintTask(options));

ipcMain.handle("workspace:text-image-status", status("text_image"));

ipcMain.handle("workspace:text-image-start", (_event, options) => startTextImageTask(options));
Expand Down Expand Up @@ -75,19 +60,6 @@ function register({
await updateAppSettings((settings) => ({ ...settings, aiPreferences: prefs }));
});

ipcMain.handle("workspace:get-ai-styles", () => {
return readAiStyles();
});

ipcMain.handle("workspace:save-ai-styles", async (_event, styles) => {
await writeAiStyles(styles);
});

ipcMain.handle("workspace:list-repaint-history", async (_event, assetPath) => {
if (!assetPath) return [];
return await commands.listRepaintHistory(assetPath);
});

ipcMain.handle("workspace:get-ai-provider-token", async (_event, provider) => {
return await getStoredProviderConfigWithMigration(String(provider || "")) || {};
});
Expand Down
65 changes: 26 additions & 39 deletions apps/desktop/electron/ipc/dialogs.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ const path = require("node:path");
const { importDialogOptions } = require("../importDialog");
const { catalogDialogOptions } = require("../catalogDialog");

function createDialogs({ dialog, BrowserWindow, platform = process.platform }) {
// readAppSettings / updateAppSettings: the app settings file, where the
// image pickers keep the folder they were last used in (settings.pickerDirs[key]).
// The result is host.dialogs: every dialog-showing handler, in electron/ipc/
// or a feature module, goes through it.
function createDialogs({
dialog, BrowserWindow, platform = process.platform,
readAppSettings = () => ({}), updateAppSettings = async () => {},
}) {
// dialogParent(event): the window a dialog is modal to, or null (macOS).
const dialogParent = (event) => (platform === "darwin" ? null : BrowserWindow.fromWebContents(event.sender));
function showOpenDialogFor(event, options) {
Expand All @@ -22,13 +29,26 @@ function createDialogs({ dialog, BrowserWindow, platform = process.platform }) {
const parent = dialogParent(event);
return parent ? dialog.showSaveDialog(parent, options) : dialog.showSaveDialog(options);
}
return { dialogParent, showOpenDialogFor, showSaveDialogFor };

// The reference-image pickers open where they were last used: macOS only
// remembers a panel's folder on its own when nothing else is asked of it,
// and a reference is usually one of several in the same folder.
function pickerStart(key) {
const dir = readAppSettings()?.pickerDirs?.[key];
return typeof dir === "string" && dir && fs.existsSync(dir) ? { defaultPath: dir } : {};
}
function rememberPicker(key, filePath) {
const dir = path.dirname(filePath);
void updateAppSettings((settings) => ({
...settings,
pickerDirs: { ...(settings?.pickerDirs || {}), [key]: dir },
}));
}
return { dialogParent, showOpenDialogFor, showSaveDialogFor, pickerStart, rememberPicker };
}

// t(): a translator bound to the current locale. defaultCatalogDir(action):
// where the open / create catalog dialogs start. readAppSettings /
// updateAppSettings: the app settings file, where the image pickers keep the
// folder they were last used in (settings.pickerDirs[key]).
// where the open / create catalog dialogs start.
function register({
ipcMain,
dialogs,
Expand All @@ -45,25 +65,8 @@ function register({
},
catalog: { createAt: createCatalogAt },
media: { addAllowedMediaDir },
readAppSettings = () => ({}),
updateAppSettings = async () => {},
}) {
const { showOpenDialogFor, showSaveDialogFor } = dialogs;

// The reference-image pickers open where they were last used: macOS only
// remembers a panel's folder on its own when nothing else is asked of it,
// and a reference is usually one of several in the same folder.
function pickerStart(key) {
const dir = readAppSettings()?.pickerDirs?.[key];
return typeof dir === "string" && dir && fs.existsSync(dir) ? { defaultPath: dir } : {};
}
function rememberPicker(key, filePath) {
const dir = path.dirname(filePath);
void updateAppSettings((settings) => ({
...settings,
pickerDirs: { ...(settings?.pickerDirs || {}), [key]: dir },
}));
}
const { showOpenDialogFor, showSaveDialogFor, pickerStart, rememberPicker } = dialogs;

// pick: "files" | "folders" from Import Files… / Import Folder… on Windows and
// Linux, whose dialogs can't take both (../importDialog.js).
Expand All @@ -90,22 +93,6 @@ function register({
return filePath;
});

// AI repaint style references: photos whose colours and mood the repaint
// should take on. Several at once; each goes to the job as --reference, and
// their folders are allowed through media:// for the panel's thumbnails.
ipcMain.handle("workspace:pick-repaint-refs", async (event) => {
const result = await showOpenDialogFor(event, {
title: "Choose reference images",
properties: ["openFile", "multiSelections"],
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "heic", "tif", "tiff"] }],
...pickerStart("repaintReference"),
});
if (result.canceled || !result.filePaths.length) return [];
for (const dir of new Set(result.filePaths.map((p) => path.dirname(p)))) addAllowedMediaDir(dir);
rememberPicker("repaintReference", result.filePaths[0]);
return result.filePaths;
});

ipcMain.handle("workspace:pick-catalog", async (event) => {
const options = catalogDialogOptions({ action: "open", defaultDir: defaultCatalogDir("open"), t: t() });
const result = await showOpenDialogFor(event, options);
Expand Down
38 changes: 16 additions & 22 deletions apps/desktop/electron/ipc/dialogs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,16 @@ function registered(result, { platform = "darwin", settings = {} } = {}) {
let stored = settings;
register({
ipcMain: { handle: (c, fn) => handlers.set(c, fn) },
dialogs: createDialogs({ dialog, BrowserWindow, platform }),
dialogs: createDialogs({
dialog, BrowserWindow, platform,
readAppSettings: () => stored,
updateAppSettings: async (mutate) => { stored = await mutate(stored); return stored; },
}),
platform,
t: () => (key) => key,
defaultCatalogDir: () => DEFAULT_DIR,
catalog: { createAt: (p) => { created.push(p); return `${p}.afcatalog`; } },
media: { addAllowedMediaDir: (dir) => allowed.push(dir) },
readAppSettings: () => stored,
updateAppSettings: async (mutate) => { stored = await mutate(stored); return stored; },
});
return { handlers, calls, allowed, created, settings: () => stored };
}
Expand All @@ -70,34 +72,26 @@ test("pick-handwriting-ref: the file's folder is allowed through media://", asyn
assert.equal(await registered({ canceled: true }).handlers.get("workspace:pick-handwriting-ref")(event), null);
});

test("pick-repaint-refs: several images at once, each folder allowed through media://, [] when cancelled", async () => {
const picked = registered({ canceled: false, filePaths: ["/refs/a.jpg", "/refs/b.png", "/other/c.jpg"] });
assert.deepEqual(await picked.handlers.get("workspace:pick-repaint-refs")(event), ["/refs/a.jpg", "/refs/b.png", "/other/c.jpg"]);
assert.deepEqual(picked.allowed, ["/refs", "/other"]);
assert.ok(picked.calls[0][1].properties.includes("multiSelections"));
assert.deepEqual(await registered({ canceled: true }).handlers.get("workspace:pick-repaint-refs")(event), []);
});

test("the reference pickers open in the folder they were last used in, and remember the new one", async () => {
// The repaint feature's picker (features/repaint/electron/repaint.test.js)
// shares this memory through host.dialogs.
test("the handwriting picker opens in the folder it was last used in, and remembers the new one", async () => {
// Nothing remembered yet: no defaultPath, the panel decides.
const first = registered({ canceled: false, filePaths: [path.join(DEFAULT_DIR, "a.jpg"), path.join(DEFAULT_DIR, "b.jpg")] });
await first.handlers.get("workspace:pick-repaint-refs")(event);
const first = registered({ canceled: false, filePaths: [path.join(DEFAULT_DIR, "hand.png")] });
await first.handlers.get("workspace:pick-handwriting-ref")(event);
assert.equal(first.calls[0][1].defaultPath, undefined);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(first.settings().pickerDirs.repaintReference, DEFAULT_DIR);
assert.equal(first.settings().pickerDirs.handwritingReference, DEFAULT_DIR);

// Remembered and still there: the picker starts in it. A cancelled pick
// changes nothing.
const again = registered({ canceled: true }, { settings: { pickerDirs: { repaintReference: DEFAULT_DIR, handwritingReference: DEFAULT_DIR } } });
await again.handlers.get("workspace:pick-repaint-refs")(event);
assert.equal(again.calls[0][1].defaultPath, DEFAULT_DIR);
const again = registered({ canceled: true }, { settings: { pickerDirs: { handwritingReference: DEFAULT_DIR } } });
await again.handlers.get("workspace:pick-handwriting-ref")(event);
assert.equal(again.calls[1][1].defaultPath, DEFAULT_DIR);
assert.equal(again.settings().pickerDirs.repaintReference, DEFAULT_DIR);
assert.equal(again.calls[0][1].defaultPath, DEFAULT_DIR);
assert.equal(again.settings().pickerDirs.handwritingReference, DEFAULT_DIR);

// A remembered folder that is gone is not offered (the panel would show an error).
const gone = registered({ canceled: true }, { settings: { pickerDirs: { repaintReference: path.join(DEFAULT_DIR, "no-such-folder-af") } } });
await gone.handlers.get("workspace:pick-repaint-refs")(event);
const gone = registered({ canceled: true }, { settings: { pickerDirs: { handwritingReference: path.join(DEFAULT_DIR, "no-such-folder-af") } } });
await gone.handlers.get("workspace:pick-handwriting-ref")(event);
assert.equal(gone.calls[0][1].defaultPath, undefined);
});

Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ ipcMain.handle("workspace:open-sample-catalog", () => sample.openSampleCatalog()
ipcMain.handle("workspace:reset-sample-catalog", () => sample.openSampleCatalog({ reset: true }));
ipcMain.handle("workspace:register-roots", (_event, rootType, paths) => registerRoots(rootType, paths));

const dialogs = dialogsIpc.createDialogs({ dialog, BrowserWindow });
const dialogs = dialogsIpc.createDialogs({ dialog, BrowserWindow, readAppSettings, updateAppSettings });

// Everything a register(host) can reach — ./host.js lists it and says why it
// is frozen. Built once, here, after every part above exists; the modules
Expand Down Expand Up @@ -353,8 +353,8 @@ ipcMain.handle("app:mcp-status", () => services.mcp?.getStatus() ?? { status: "s
services.set("saveFile", saveFileIpc.register(host));
frameLogosIpc.register(host);
frameTemplatesIpc.register(host);
// The feature modules (features/<id>/manifest.mjs `electron`): today the LUT
// library — see electron/features.js.
// The feature modules (features/<id>/manifest.mjs `electron`): the LUT
// library and AI Repaint — see electron/features.js.
registerFeatures(host);

// vibepin (dev only): pixel-perfect element/region screenshots for annotations.
Expand Down
18 changes: 15 additions & 3 deletions apps/desktop/electron/mcp/toolSchemas.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,22 @@ const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");

const { loadManifests, FEATURES_DIR } = require("../features");

// The host's tool files, and the groups the feature modules contribute
// (manifest `contributes.mcpTools`, the same shape).
const TOOLS_DIR = path.join(__dirname, "tools");
const toolFiles = fs.readdirSync(TOOLS_DIR)
.filter((name) => name.endsWith(".js") && !["index.js", "shared.js"].includes(name))
.map((name) => ({ name, source: fs.readFileSync(path.join(TOOLS_DIR, name), "utf8") }));
const toolFiles = [
...fs.readdirSync(TOOLS_DIR)
.filter((name) => name.endsWith(".js") && !["index.js", "shared.js"].includes(name))
.map((name) => ({ name, source: fs.readFileSync(path.join(TOOLS_DIR, name), "utf8") })),
...loadManifests()
.filter((manifest) => manifest.contributes?.mcpTools)
.map((manifest) => {
const file = path.join(FEATURES_DIR, manifest.id, manifest.contributes.mcpTools);
return { name: path.relative(path.dirname(FEATURES_DIR), file), source: fs.readFileSync(file, "utf8") };
}),
];

// Walk a balanced { ... } body starting just after its opening brace.
function braceBody(text, from) {
Expand Down
65 changes: 0 additions & 65 deletions apps/desktop/electron/mcp/tools/assets.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,71 +226,6 @@ module.exports = (ctx) => {
};
},
},
{
name: "repaint_asset",
description:
"AI image generation on a photo (image-to-image) using the provider configured in the app's Settings → AI: " +
"restyle, repaint, enhance, upscale per the prompt. The result is registered as a new version in the " +
"original's version stack. Style references (colour matching): pass other catalog photos as " +
"reference_asset_ids and/or image files as reference_paths — the result takes on their colours and mood " +
"while keeping the photo's content. Long-running: returns a job_id — poll get_job_status, then show_in_app the result.",
inputSchema: {
type: "object",
properties: {
asset_id: { type: "string" },
prompt: { type: "string", description: "What to generate/change, in natural language" },
model: { type: "string", description: "Provider-specific model id; default uses the provider's default" },
aspect_ratio: { type: "string", description: "e.g. '4:3', '1:1'; default keeps the source ratio" },
reference_asset_ids: {
type: "array", items: { type: "string" },
description: "Catalog photos to use as style references (their colour palette and mood are matched)",
},
reference_paths: {
type: "array", items: { type: "string" },
description: "Absolute paths of image files on this machine to use as style references",
},
},
required: ["asset_id", "prompt"],
},
async handler(args) {
requireCatalog();
if (!deps.startAiRepaintTask || !deps.activeRepaintProvider) throw new Error("Repaint bridge unavailable.");
const detail = await commands.assetDetail({ assetId: String(args.asset_id) });
if (!detail?.image_path) throw new Error(`No file path for asset ${args.asset_id}.`);
const configured = deps.activeRepaintProvider();
if (!configured) throw new Error("No AI repaint provider configured. Open Settings → AI to add one.");
// Style references: catalog photos resolve to their files; paths pass
// through as given. The sidecar rejects a file that is not there.
const referencePaths = [];
for (const refId of Array.isArray(args.reference_asset_ids) ? args.reference_asset_ids : []) {
const ref = await commands.assetDetail({ assetId: String(refId) });
if (!ref?.image_path) throw new Error(`No file path for reference asset ${refId}.`);
referencePaths.push(ref.image_path);
}
for (const refPath of Array.isArray(args.reference_paths) ? args.reference_paths : []) {
if (refPath) referencePaths.push(String(refPath));
}
// Model precedence: explicit arg > the user's pick in the app UI
// (selectedModels) > sidecar's per-provider default.
const { provider: active, model: uiModel } = configured;
const status = await deps.startAiRepaintTask({
sourcePath: detail.image_path,
prompt: String(args.prompt),
provider: active.id,
providerType: active.type,
model: args.model ? String(args.model) : uiModel,
aspectRatio: args.aspect_ratio || null,
referencePaths,
});
deps.broadcastCatalogChanged?.("jobs", { jobId: status.jobId, jobType: "ai_repaint" });
return {
job_id: status.jobId,
status: status.status,
provider: active.name || active.type,
note: "Generation runs in the background — poll get_job_status. The result lands in the original's version stack.",
};
},
},
{
name: "delete_assets",
description:
Expand Down
Loading
Loading