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
62 changes: 46 additions & 16 deletions apps/desktop/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ const SUMMARY_PROVIDER_DEFAULTS = {
baseURL: "https://api.openai.com/v1",
apiKey: "",
},
openrouter: {
provider: "openrouter",
model: "openai/gpt-4o-mini",
baseURL: "https://openrouter.ai/api/v1",
apiKey: "",
},
ollama: {
provider: "ollama",
model: "",
Expand Down Expand Up @@ -180,7 +186,20 @@ function notesDatabasePath() {
}

function normalizeSummaryProvider(value) {
return value === "ollama" || value === "lmstudio" ? value : "openai";
return value === "openrouter" || value === "ollama" || value === "lmstudio" ? value : "openai";
}

function summaryProviderRequiresAPIKey(provider) {
return provider === "openai" || provider === "openrouter";
}

function summaryProviderDisplayName(provider) {
return {
openai: "OpenAI",
openrouter: "OpenRouter",
ollama: "Ollama",
lmstudio: "LM Studio",
}[provider] || "OpenAI";
}

function builtInSummaryTemplates() {
Expand Down Expand Up @@ -334,7 +353,7 @@ function normalizeSummaryProviderConfig(provider, value = {}, legacy = {}) {
(useLegacy ? legacy.baseURL : "") ||
defaults.baseURL,
).trim().replace(/\/+$/, "") || defaults.baseURL,
apiKey: provider === "openai"
apiKey: summaryProviderRequiresAPIKey(provider)
? String(value.apiKey || value.api_key || (useLegacy ? legacy.apiKey : "") || "").trim()
: String(value.apiKey || value.api_key || (useLegacy ? legacy.apiKey : "") || defaults.apiKey).trim(),
};
Expand Down Expand Up @@ -452,11 +471,23 @@ function ollamaTagsURL(baseURL) {
return `${parsed.origin}/api/tags`;
}

function summaryProviderRequestHeaders(settings, options = {}) {
const headers = {
Authorization: `Bearer ${settings.apiKey || "local"}`,
};
if (options.includeContentType !== false) {
headers["Content-Type"] = "application/json";
}
if (settings.provider === "openrouter") {
headers["HTTP-Referer"] = "https://github.com/qyinm/MirrorNote";
headers["X-Title"] = APP_DISPLAY_NAME;
}
return headers;
}

async function listOpenAICompatibleModels(settings) {
const data = await fetchJSON(openAICompatibleModelListURL(settings.baseURL), {
headers: {
Authorization: `Bearer ${settings.apiKey || "local"}`,
},
headers: summaryProviderRequestHeaders(settings, { includeContentType: false }),
});
const rawModels = Array.isArray(data?.data) ? data.data : [];
return sortSummaryModels(rawModels.map(normalizeModelOption));
Expand All @@ -479,10 +510,10 @@ async function listSummaryModels(providerOverride) {
? persistedSettings.summary?.model
: SUMMARY_PROVIDER_DEFAULTS[provider].model,
});
if (provider === "openai" && !summary.apiKey) {
if (summaryProviderRequiresAPIKey(provider) && !summary.apiKey) {
return {
models: summary.model ? [{ id: summary.model, name: summary.model }] : [],
message: "Add an OpenAI API key to load available models.",
message: `Add an ${summaryProviderDisplayName(provider)} API key to load available models.`,
};
}

Expand All @@ -508,8 +539,8 @@ async function listSummaryModels(providerOverride) {

let requestChatCompletion = async function requestChatCompletion(summarySettings, messages, options = {}) {
const settings = normalizeSummarySettings(summarySettings);
if (settings.provider === "openai" && !settings.apiKey) {
throw new Error("Add an OpenAI API key in Settings before generating a summary.");
if (summaryProviderRequiresAPIKey(settings.provider) && !settings.apiKey) {
throw new Error(`Add an ${summaryProviderDisplayName(settings.provider)} API key in Settings before generating a summary.`);
}

const body = {
Expand All @@ -524,10 +555,7 @@ let requestChatCompletion = async function requestChatCompletion(summarySettings

const response = await fetch(`${settings.baseURL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${settings.apiKey || "local"}`,
},
headers: summaryProviderRequestHeaders(settings),
body: JSON.stringify(body),
});
const text = await response.text();
Expand Down Expand Up @@ -928,13 +956,13 @@ function publicSummarySettings(settings) {
configs[provider] = {
model: summary.providerConfigs[provider].model,
baseURL: summary.providerConfigs[provider].baseURL,
apiKeyConfigured: provider === "openai"
apiKeyConfigured: summaryProviderRequiresAPIKey(provider)
? summary.providerConfigs[provider].apiKey.length > 0
: summary.providerConfigs[provider].baseURL.length > 0,
};
return configs;
}, {}),
apiKeyConfigured: summary.provider === "openai" ? summary.apiKey.length > 0 : true,
apiKeyConfigured: summaryProviderRequiresAPIKey(summary.provider) ? summary.apiKey.length > 0 : true,
};
}

Expand Down Expand Up @@ -3209,7 +3237,7 @@ async function generateNoteSummary(noteID, templateID) {
}

const responseFormat =
summarySettings.provider === "openai"
summarySettings.provider === "openai" || summarySettings.provider === "openrouter"
? { type: "json_schema", json_schema: summaryJSONSchemaForTemplate(template) }
: null;

Expand Down Expand Up @@ -5056,6 +5084,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") {
normalizeTranscriptSegments,
parseSTTChunkLedgerJSONL,
parseTranscriptJSONL,
publicSummarySettings,
parseSummaryResultJSON,
readNote,
replaceGeneratedSummaryBlockInMarkdown,
Expand All @@ -5071,6 +5100,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") {
saveTranscript,
sanitizeSummaryMarkdown,
summaryJSONSchemaForTemplate,
summaryProviderRequestHeaders,
summaryTemplateById,
summaryTemplatesForSettings,
serializeTranscriptJSONL,
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/main/native-launch.node-test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const {
noteHasCompletedTranscriptForSourceContext,
parseSTTChunkLedgerJSONL,
parseTranscriptJSONL,
publicSummarySettings,
normalizeSummarySettings,
summaryProviderRequestHeaders,
serializeTranscriptJSONL,
sourceContextStatusFromMetadata,
} = require("../main.cjs");
Expand Down Expand Up @@ -159,6 +162,31 @@ assert.equal(noteHasCompletedTranscriptForSourceContext({
}), false);
assert.equal(normalizeSourceContextStatus({ state: "failed", detail: "Add key.", retryable: true }).retryable, true);

const openRouterSummarySettings = normalizeSummarySettings({
provider: "openrouter",
providerConfigs: {
openrouter: {
model: "anthropic/claude-sonnet-4.5",
baseURL: "https://openrouter.ai/api/v1/",
apiKey: "sk-or-test",
},
},
});
assert.equal(openRouterSummarySettings.provider, "openrouter");
assert.equal(openRouterSummarySettings.model, "anthropic/claude-sonnet-4.5");
assert.equal(openRouterSummarySettings.baseURL, "https://openrouter.ai/api/v1");
assert.equal(openRouterSummarySettings.apiKey, "sk-or-test");
const publicOpenRouterSummarySettings = publicSummarySettings({ summary: openRouterSummarySettings });
assert.equal(publicOpenRouterSummarySettings.apiKeyConfigured, true);
assert.equal(publicOpenRouterSummarySettings.providerConfigs.openrouter.apiKeyConfigured, true);
assert.equal(publicOpenRouterSummarySettings.providerConfigs.openrouter.apiKey, undefined);
assert.deepEqual(summaryProviderRequestHeaders(openRouterSummarySettings), {
"Content-Type": "application/json",
Authorization: "Bearer sk-or-test",
"HTTP-Referer": "https://github.com/qyinm/MirrorNote",
"X-Title": "MirrorNote",
});

const contextTempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "mirrornote-source-context-"));
const generatedContextPaths = contextPacketPaths(contextTempDirectory);
fs.mkdirSync(generatedContextPaths.contextDirectoryPath, { recursive: true });
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/components/ProviderBrandIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export function ProviderBrandIcon({ provider, ...props }: ProviderBrandIconProps
<svg viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd" xmlns="http://www.w3.org/2000/svg" {...props}>
{provider === "openai" ? (
<path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z" />
) : provider === "openrouter" ? (
<path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z" />
) : provider === "ollama" ? (
<path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z" />
) : (
Expand Down
19 changes: 15 additions & 4 deletions apps/desktop/src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ import { ProviderBrandIcon } from "./ProviderBrandIcon";

const SUMMARY_PROVIDER_DEFAULTS = {
openai: { model: "gpt-4o-mini", baseURL: "https://api.openai.com/v1" },
openrouter: { model: "openai/gpt-4o-mini", baseURL: "https://openrouter.ai/api/v1" },
ollama: { model: "", baseURL: "http://localhost:11434/v1" },
lmstudio: { model: "", baseURL: "http://localhost:1234/v1" },
} as const;

const SUMMARY_PROVIDER_OPTIONS: { value: SummaryProvider; label: string; badge?: string }[] = [
{ value: "openai", label: "OpenAI API" },
{ value: "openrouter", label: "OpenRouter" },
{ value: "ollama", label: "Ollama", badge: "Local" },
{ value: "lmstudio", label: "LM Studio", badge: "Local" },
];
Expand Down Expand Up @@ -211,10 +213,10 @@ export function SettingsPanel({

function isSummaryProviderReady(provider: SummaryProvider) {
const providerConfig = summaryProviderConfig(provider);
if (provider === "openai") {
if (summaryProviderRequiresAPIKey(provider)) {
return Boolean(
providerConfig.apiKeyConfigured ||
summaryProviderConfigDrafts.openai.apiKey.trim().length > 0,
summaryProviderConfigDrafts[provider]?.apiKey.trim().length > 0,
);
}
const draftBaseURL = summaryProviderConfigDrafts[provider]?.baseURL;
Expand Down Expand Up @@ -574,13 +576,13 @@ export function SettingsPanel({

{expanded ? (
<div className="settings-provider-body">
{provider.value === "openai" ? (
{summaryProviderRequiresAPIKey(provider.value) ? (
<label className="settings-provider-field">
<span>{t("settings.intelligence.apiKey")}</span>
<input
type="password"
value={providerDraft.apiKey}
placeholder={providerConfig.apiKeyConfigured ? t("common.saved") : "sk-..."}
placeholder={providerConfig.apiKeyConfigured ? t("common.saved") : summaryAPIKeyPlaceholder(provider.value)}
onFocus={() => setFocusedSummaryField("apiKey")}
onChange={(event) => {
const nextAPIKey = event.currentTarget.value;
Expand Down Expand Up @@ -761,11 +763,20 @@ function ensureDefaultAudioDevice(
function defaultSummaryProviderConfigDrafts(): SummaryProviderConfigDraft {
return {
openai: { baseURL: SUMMARY_PROVIDER_DEFAULTS.openai.baseURL, apiKey: "" },
openrouter: { baseURL: SUMMARY_PROVIDER_DEFAULTS.openrouter.baseURL, apiKey: "" },
ollama: { baseURL: SUMMARY_PROVIDER_DEFAULTS.ollama.baseURL, apiKey: "" },
lmstudio: { baseURL: SUMMARY_PROVIDER_DEFAULTS.lmstudio.baseURL, apiKey: "" },
};
}

function summaryProviderRequiresAPIKey(provider: SummaryProvider) {
return provider === "openai" || provider === "openrouter";
}

function summaryAPIKeyPlaceholder(provider: SummaryProvider) {
return provider === "openrouter" ? "sk-or-..." : "sk-...";
}

function formatAudioDeviceLabel(device: AudioDeviceOption, defaultLabel: string) {
const suffixes = [
device.isDefault ? defaultLabel : "",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export type STTChunkStatus = "queued" | "running" | "completed" | "failed";
export type SourceContextState = "idle" | "generating" | "ready" | "failed";
export type SpeakerLabelingMode = "local" | "disabled";
export type AppLanguage = "en" | "ko";
export type SummaryProvider = "openai" | "ollama" | "lmstudio";
export type SummaryProvider = "openai" | "openrouter" | "ollama" | "lmstudio";
export type SummaryTemplateKind = "built-in" | "custom";

export interface SummaryTemplateSection {
Expand Down
1 change: 1 addition & 0 deletions docs/summary-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Use an OpenAI-compatible chat completions endpoint.
| Provider | Base URL | API key | Model |
| --- | --- | --- | --- |
| OpenAI | `https://api.openai.com/v1` | Required OpenAI API key | `gpt-4o-mini` by default |
| OpenRouter | `https://openrouter.ai/api/v1` | Required OpenRouter API key | `openai/gpt-4o-mini` by default |
| Ollama | `http://localhost:11434/v1` | Placeholder value | Pulled local model, for example `llama3.1:8b` |
| LM Studio | `http://localhost:1234/v1` | Placeholder value | Loaded model identifier |

Expand Down
Loading