mirror of
https://github.com/fbarrca/obsidian-inlineAI.git
synced 2026-07-22 11:50:24 +00:00
Compare commits
16 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b995101048 | ||
|
|
86601be0f0 | ||
|
|
d900ef1ca6 | ||
|
|
1c61d23cc6 | ||
|
|
69817bffc2 | ||
|
|
b1933fb823 | ||
|
|
053119ed15 | ||
|
|
05dc259155 | ||
|
|
71a6447f98 | ||
|
|
15fe7f02c5 | ||
|
|
3abc20fe6e | ||
|
|
d51f10b33e | ||
|
|
1024c37991 | ||
|
|
b389c5991d | ||
|
|
5f21d0bdd5 | ||
|
|
6880043c8b |
14 changed files with 1255 additions and 51 deletions
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"id": "inlineai",
|
||||
"name": "InlineAI",
|
||||
"version": "1.2.4",
|
||||
"minAppVersion": "0.15.0",
|
||||
"version": "1.2.7",
|
||||
"minAppVersion": "1.11.4",
|
||||
"description": "AI-powered suggestions, contextual edits, and advanced text transformations directly into your editor.",
|
||||
"author": "FBarrca",
|
||||
"authorUrl": "https://github.com/FBarrca/",
|
||||
"fundingUrl": "https://www.buymeacoffee.com/FBarrCa",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
}
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "obsidian-inlineai",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-inlineai",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.7",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.4",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-inlineai",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.7",
|
||||
"description": "Cursor or Copilot like Inline AI interface for Obsidian",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
153
src/api.ts
153
src/api.ts
|
|
@ -10,6 +10,9 @@ import {
|
|||
import { InlineAISettings } from "./settings";
|
||||
import { App, MarkdownView, Notice } from "obsidian";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { callCodexApi } from "./codex-client";
|
||||
import { getValidCodexToken } from "./codex-auth";
|
||||
import { getApiKey, getCodexTokens, setCodexTokens } from "./credentials";
|
||||
import { setGeneratedResponseEffect } from "./modules/AIExtension";
|
||||
import { parseCommand } from "./modules/commands/parser";
|
||||
import { MessageQueue } from "./modules/messageHistory/queue";
|
||||
|
|
@ -97,8 +100,9 @@ export class ChatApiManager {
|
|||
}
|
||||
|
||||
switch (settings.provider) {
|
||||
case "openai":
|
||||
if (!settings.apiKey) {
|
||||
case "openai": {
|
||||
const apiKey = getApiKey(this.app);
|
||||
if (!apiKey) {
|
||||
new Notice(
|
||||
"⚠️ OpenAI API key is required. Please check your settings.",
|
||||
);
|
||||
|
|
@ -107,20 +111,24 @@ export class ChatApiManager {
|
|||
return new ChatOpenAI({
|
||||
modelName: settings.model,
|
||||
temperature: 0,
|
||||
apiKey: settings.apiKey,
|
||||
apiKey,
|
||||
});
|
||||
}
|
||||
|
||||
case "ollama":
|
||||
return new ChatOllama({
|
||||
model: settings.model,
|
||||
});
|
||||
case "gemini":
|
||||
case "gemini": {
|
||||
const apiKey = getApiKey(this.app);
|
||||
return new ChatGoogleGenerativeAI({
|
||||
model: settings.model,
|
||||
apiKey: settings.apiKey,
|
||||
apiKey: apiKey ?? undefined,
|
||||
});
|
||||
case "azure":
|
||||
if (!settings.apiKey || !settings.azureEndpoint) {
|
||||
}
|
||||
case "azure": {
|
||||
const apiKey = getApiKey(this.app);
|
||||
if (!apiKey || !settings.azureEndpoint) {
|
||||
new Notice(
|
||||
"⚠️ API key and Azure endpoint are required for Azure provider.",
|
||||
);
|
||||
|
|
@ -139,15 +147,17 @@ export class ChatApiManager {
|
|||
}
|
||||
|
||||
return new AzureChatOpenAI({
|
||||
azureOpenAIApiKey: settings.apiKey,
|
||||
azureOpenAIApiKey: apiKey,
|
||||
azureOpenAIApiInstanceName: instanceName,
|
||||
azureOpenAIApiDeploymentName: settings.model,
|
||||
azureOpenAIApiVersion:
|
||||
settings.azureApiVersion || "2024-02-15-preview",
|
||||
temperature: 0,
|
||||
});
|
||||
case "custom":
|
||||
if (!settings.apiKey || !settings.customURL) {
|
||||
}
|
||||
case "custom": {
|
||||
const apiKey = getApiKey(this.app);
|
||||
if (!apiKey || !settings.customURL) {
|
||||
new Notice(
|
||||
"⚠️ API key and custom base URL are required for custom providers.",
|
||||
);
|
||||
|
|
@ -156,12 +166,17 @@ export class ChatApiManager {
|
|||
return new ChatOpenAI({
|
||||
modelName: settings.model,
|
||||
temperature: 0,
|
||||
openAIApiKey: settings.apiKey,
|
||||
openAIApiKey: apiKey,
|
||||
// 'configuration.basePath' is the recognized property
|
||||
configuration: {
|
||||
baseURL: settings.customURL.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "codex":
|
||||
// Handled directly in callApi — no LangChain client needed
|
||||
return null;
|
||||
|
||||
default:
|
||||
new Notice(`⚠️ Unsupported provider: ${settings.provider}`);
|
||||
|
|
@ -184,6 +199,10 @@ export class ChatApiManager {
|
|||
systemMessage: string,
|
||||
message: string,
|
||||
): Promise<string> {
|
||||
if (this.settings.provider === "codex") {
|
||||
return this.callCodexProvider(systemMessage, message);
|
||||
}
|
||||
|
||||
if (!this.chatClient) {
|
||||
new Notice(
|
||||
"⚠️ Chat client is not initialized. Please check your settings.",
|
||||
|
|
@ -209,6 +228,45 @@ export class ChatApiManager {
|
|||
}
|
||||
}
|
||||
|
||||
private async callCodexProvider(
|
||||
systemMessage: string,
|
||||
message: string,
|
||||
): Promise<string> {
|
||||
const tokens = getCodexTokens(this.app);
|
||||
if (!tokens?.access || !tokens.refresh || !tokens.accountId) {
|
||||
new Notice(
|
||||
"⚠️ Codex: not signed in — open Settings → InlineAI and click 'Sign in with ChatGPT'",
|
||||
);
|
||||
return "⚠️ Codex not authenticated.";
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await getValidCodexToken(
|
||||
tokens,
|
||||
async (refreshed) => {
|
||||
setCodexTokens(this.app, refreshed);
|
||||
},
|
||||
);
|
||||
|
||||
if (!accessToken) {
|
||||
new Notice("⚠️ Codex: session expired — please sign in again");
|
||||
return "⚠️ Codex session expired.";
|
||||
}
|
||||
|
||||
return await callCodexApi(
|
||||
systemMessage,
|
||||
message,
|
||||
accessToken,
|
||||
tokens.accountId,
|
||||
this.settings.model,
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("Codex error:", error);
|
||||
new Notice(`❌ Codex: ${error.message}`);
|
||||
return "⚠️ Codex request failed.";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles user input and updates the editor with the response.
|
||||
* @param systemPrompt - The system prompt to send to the chat API.
|
||||
|
|
@ -246,6 +304,73 @@ export class ChatApiManager {
|
|||
return "⚠️ Failed to process request.";
|
||||
}
|
||||
}
|
||||
private extractNoteContext(selectionText: string): string {
|
||||
try {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
const noteTitle = file?.basename ?? "";
|
||||
const markdownView =
|
||||
this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!markdownView) return "";
|
||||
|
||||
const cm = (markdownView.editor as any).cm as EditorView;
|
||||
const doc = cm.state.doc.toString();
|
||||
const cursor = cm.state.selection.main.from;
|
||||
|
||||
// Find nearest heading above cursor
|
||||
const docBeforeCursor = doc.slice(0, cursor);
|
||||
const lines = docBeforeCursor.split("\n");
|
||||
let nearestHeading = "";
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (/^#{1,3}\s/.test(lines[i])) {
|
||||
nearestHeading = lines[i].replace(/^#+\s*/, "").trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get surrounding paragraphs (split by blank lines)
|
||||
const selectionStart = doc.indexOf(
|
||||
selectionText,
|
||||
Math.max(0, cursor - selectionText.length - 200),
|
||||
);
|
||||
const before =
|
||||
selectionStart > 0
|
||||
? doc.slice(0, selectionStart)
|
||||
: docBeforeCursor;
|
||||
const after =
|
||||
selectionStart >= 0
|
||||
? doc.slice(selectionStart + selectionText.length)
|
||||
: doc.slice(cursor);
|
||||
|
||||
const beforeParas = before
|
||||
.split(/\n\n+/)
|
||||
.filter((p) => p.trim())
|
||||
.slice(-3);
|
||||
const afterParas = after
|
||||
.split(/\n\n+/)
|
||||
.filter((p) => p.trim())
|
||||
.slice(0, 3);
|
||||
|
||||
if (beforeParas.length === 0 && afterParas.length === 0) return "";
|
||||
|
||||
const MAX_CONTEXT_CHARS = 1500;
|
||||
let contextStr = "";
|
||||
if (noteTitle) contextStr += `Note: ${noteTitle}\n`;
|
||||
if (nearestHeading) contextStr += `Section: ${nearestHeading}\n`;
|
||||
if (beforeParas.length > 0)
|
||||
contextStr += `\nContext before:\n${beforeParas.join("\n\n")}`;
|
||||
if (afterParas.length > 0)
|
||||
contextStr += `\n\nContext after:\n${afterParas.join("\n\n")}`;
|
||||
|
||||
if (contextStr.length > MAX_CONTEXT_CHARS) {
|
||||
contextStr = contextStr.slice(0, MAX_CONTEXT_CHARS) + "\n[…]";
|
||||
}
|
||||
|
||||
return contextStr.trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes selected text using the specified prompt and transformation.
|
||||
* @param userPrompt - The transformation prompt (e.g., "Add Emojis").
|
||||
|
|
@ -288,7 +413,11 @@ export class ChatApiManager {
|
|||
|
||||
**Output:**`;
|
||||
}
|
||||
return this.handleEditorUpdate(systemPrompt, finalUserPrompt);
|
||||
const noteContext = this.extractNoteContext(selectedText);
|
||||
const enhancedSystemPrompt = noteContext
|
||||
? `${systemPrompt}\n\n---\nDocument context (for reference only — do not include in output):\n${noteContext}`
|
||||
: systemPrompt;
|
||||
return this.handleEditorUpdate(enhancedSystemPrompt, finalUserPrompt);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
445
src/codex-auth.ts
Normal file
445
src/codex-auth.ts
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
import * as http from "http";
|
||||
import { App, Notice, Platform, requestUrl } from "obsidian";
|
||||
|
||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
||||
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
||||
const REDIRECT_URI = "http://localhost:1455/auth/callback";
|
||||
const DEVICE_USERCODE_URL =
|
||||
"https://auth.openai.com/api/accounts/deviceauth/usercode";
|
||||
const DEVICE_TOKEN_URL =
|
||||
"https://auth.openai.com/api/accounts/deviceauth/token";
|
||||
const DEVICE_VERIFICATION_URL = "https://auth.openai.com/codex/device";
|
||||
const DEVICE_REDIRECT_URI = "https://auth.openai.com/deviceauth/callback";
|
||||
const SCOPE = "openid profile email offline_access";
|
||||
const CALLBACK_PORT = 1455;
|
||||
const DEVICE_AUTH_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const DEVICE_POLL_SAFETY_MARGIN_MS = 3000;
|
||||
|
||||
export interface CodexTokens {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
/** Open a URL in the system browser (works on Android/iOS where window.open often fails). */
|
||||
export function openExternalUrl(url: string): void {
|
||||
if (Platform.isMobileApp) {
|
||||
const plugins = (
|
||||
window as unknown as {
|
||||
Capacitor?: { Plugins?: Record<string, unknown> };
|
||||
}
|
||||
).Capacitor?.Plugins as
|
||||
| {
|
||||
AppLauncher?: {
|
||||
openUrl: (opts: { url: string }) => Promise<unknown>;
|
||||
};
|
||||
Browser?: {
|
||||
open: (opts: { url: string }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (plugins?.AppLauncher?.openUrl) {
|
||||
void plugins.AppLauncher.openUrl({ url });
|
||||
return;
|
||||
}
|
||||
if (plugins?.Browser?.open) {
|
||||
void plugins.Browser.open({ url });
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url);
|
||||
}
|
||||
|
||||
async function generatePKCE(): Promise<{
|
||||
verifier: string;
|
||||
challenge: string;
|
||||
}> {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
const verifier = btoa(String.fromCharCode(...array))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
function randomState(): string {
|
||||
const array = new Uint8Array(16);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through to legacy fallback
|
||||
}
|
||||
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const copied = document.execCommand("copy");
|
||||
textarea.remove();
|
||||
return copied;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function decodeJWT(token: string): Record<string, any> | null {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
return JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractAccountId(accessToken: string): string | null {
|
||||
const payload = decodeJWT(accessToken);
|
||||
if (!payload) return null;
|
||||
const auth = payload["https://api.openai.com/auth"];
|
||||
return auth?.user_id ?? auth?.account_id ?? null;
|
||||
}
|
||||
|
||||
async function exchangeAuthorizationCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
redirectUri: string,
|
||||
): Promise<CodexTokens | null> {
|
||||
const res = await requestUrl({
|
||||
url: TOKEN_URL,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirectUri,
|
||||
}).toString(),
|
||||
throw: false,
|
||||
});
|
||||
|
||||
if (res.status < 200 || res.status >= 300) return null;
|
||||
|
||||
const json = res.json as any;
|
||||
if (!json.access_token || !json.refresh_token) return null;
|
||||
|
||||
const accountId = extractAccountId(json.access_token);
|
||||
if (!accountId) return null;
|
||||
|
||||
return {
|
||||
access: json.access_token,
|
||||
refresh: json.refresh_token,
|
||||
expires: Date.now() + json.expires_in * 1000,
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshCodexToken(
|
||||
tokens: CodexTokens,
|
||||
): Promise<CodexTokens | null> {
|
||||
const res = await requestUrl({
|
||||
url: TOKEN_URL,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: tokens.refresh,
|
||||
client_id: CLIENT_ID,
|
||||
}).toString(),
|
||||
throw: false,
|
||||
});
|
||||
|
||||
if (res.status < 200 || res.status >= 300) return null;
|
||||
|
||||
const json = res.json as any;
|
||||
if (!json.access_token || !json.refresh_token) return null;
|
||||
|
||||
return {
|
||||
access: json.access_token,
|
||||
refresh: json.refresh_token,
|
||||
expires: Date.now() + json.expires_in * 1000,
|
||||
accountId: tokens.accountId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getValidCodexToken(
|
||||
tokens: CodexTokens,
|
||||
onRefresh: (t: CodexTokens) => Promise<void>,
|
||||
): Promise<string | null> {
|
||||
if (tokens.expires > Date.now() + 60_000) return tokens.access;
|
||||
|
||||
const refreshed = await refreshCodexToken(tokens);
|
||||
if (!refreshed) {
|
||||
new Notice(
|
||||
"⚠️ Codex: session expired — open Settings → InlineAI to sign in again",
|
||||
10000,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
await onRefresh(refreshed);
|
||||
return refreshed.access;
|
||||
}
|
||||
|
||||
function isPortInUse(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const tester = http.createServer();
|
||||
tester.once("error", () => resolve(true));
|
||||
tester.once("listening", () => {
|
||||
tester.close();
|
||||
resolve(false);
|
||||
});
|
||||
tester.listen(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
async function startCodexDeviceAuthFlow(app: App): Promise<CodexTokens | null> {
|
||||
const userCodeRes = await requestUrl({
|
||||
url: DEVICE_USERCODE_URL,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ client_id: CLIENT_ID }),
|
||||
throw: false,
|
||||
});
|
||||
|
||||
if (userCodeRes.status < 200 || userCodeRes.status >= 300) {
|
||||
new Notice(
|
||||
"❌ Codex: device sign-in unavailable — try again later",
|
||||
8000,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const json = userCodeRes.json as Record<string, unknown>;
|
||||
const deviceAuthId =
|
||||
typeof json.device_auth_id === "string" ? json.device_auth_id : null;
|
||||
const userCode =
|
||||
typeof json.user_code === "string"
|
||||
? json.user_code
|
||||
: typeof json.usercode === "string"
|
||||
? json.usercode
|
||||
: null;
|
||||
const intervalSec = Math.max(
|
||||
parseInt(String(json.interval ?? "5"), 10) || 5,
|
||||
1,
|
||||
);
|
||||
|
||||
if (!deviceAuthId || !userCode) {
|
||||
new Notice("❌ Codex: invalid device sign-in response");
|
||||
return null;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const { CodexDeviceAuthModal } = await import("./codex-device-auth-modal");
|
||||
const modal = new CodexDeviceAuthModal(
|
||||
app,
|
||||
userCode,
|
||||
DEVICE_VERIFICATION_URL,
|
||||
() => {
|
||||
cancelled = true;
|
||||
},
|
||||
);
|
||||
modal.open();
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
const pollIntervalMs = intervalSec * 1000 + DEVICE_POLL_SAFETY_MARGIN_MS;
|
||||
|
||||
while (!cancelled && Date.now() - startedAt < DEVICE_AUTH_TIMEOUT_MS) {
|
||||
const pollRes = await requestUrl({
|
||||
url: DEVICE_TOKEN_URL,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
device_auth_id: deviceAuthId,
|
||||
user_code: userCode,
|
||||
}),
|
||||
throw: false,
|
||||
});
|
||||
|
||||
if (pollRes.status >= 200 && pollRes.status < 300) {
|
||||
const pollJson = pollRes.json as Record<string, unknown>;
|
||||
const authCode =
|
||||
typeof pollJson.authorization_code === "string"
|
||||
? pollJson.authorization_code
|
||||
: null;
|
||||
const codeVerifier =
|
||||
typeof pollJson.code_verifier === "string"
|
||||
? pollJson.code_verifier
|
||||
: null;
|
||||
|
||||
if (!authCode || !codeVerifier) {
|
||||
new Notice("❌ Codex: invalid device sign-in response");
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await exchangeAuthorizationCode(
|
||||
authCode,
|
||||
codeVerifier,
|
||||
DEVICE_REDIRECT_URI,
|
||||
);
|
||||
if (!tokens) {
|
||||
new Notice("❌ Codex: failed to exchange auth code for tokens");
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
if (pollRes.status !== 403 && pollRes.status !== 404) {
|
||||
new Notice("❌ Codex: device sign-in failed");
|
||||
return null;
|
||||
}
|
||||
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
new Notice("⚠️ Codex: sign-in timed out");
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
modal.closeByApp();
|
||||
}
|
||||
}
|
||||
|
||||
async function startCodexDesktopOAuthFlow(): Promise<CodexTokens | null> {
|
||||
if (await isPortInUse(CALLBACK_PORT)) {
|
||||
new Notice(
|
||||
"❌ Codex: port 1455 is already in use — close the Codex CLI or any other app using it, then try again",
|
||||
8000,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
const state = randomState();
|
||||
|
||||
const url = new URL(AUTHORIZE_URL);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", CLIENT_ID);
|
||||
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
url.searchParams.set("scope", SCOPE);
|
||||
url.searchParams.set("code_challenge", challenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("id_token_add_organizations", "true");
|
||||
url.searchParams.set("codex_cli_simplified_flow", "true");
|
||||
url.searchParams.set("originator", "codex_cli_rs");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
let server: http.Server | null = null;
|
||||
|
||||
const done = (tokens: CodexTokens | null) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
server?.close();
|
||||
resolve(tokens);
|
||||
};
|
||||
|
||||
server = http.createServer(async (req, res) => {
|
||||
if (!req.url?.startsWith("/auth/callback")) {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URL(req.url, "http://localhost").searchParams;
|
||||
const code = params.get("code");
|
||||
const returnedState = params.get("state");
|
||||
|
||||
if (!code || returnedState !== state) {
|
||||
res.writeHead(400);
|
||||
res.end("Invalid callback");
|
||||
done(null);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
"<html><body><h2>Signed in! You can close this tab.</h2></body></html>",
|
||||
);
|
||||
|
||||
const tokens = await exchangeAuthorizationCode(
|
||||
code,
|
||||
verifier,
|
||||
REDIRECT_URI,
|
||||
);
|
||||
if (!tokens) {
|
||||
new Notice("❌ Codex: failed to exchange auth code for tokens");
|
||||
}
|
||||
done(tokens);
|
||||
});
|
||||
|
||||
server.on("error", (e: any) => {
|
||||
if (e.code === "EADDRINUSE") {
|
||||
new Notice(
|
||||
"❌ Codex: port 1455 in use — close other Codex sessions first",
|
||||
);
|
||||
}
|
||||
done(null);
|
||||
});
|
||||
|
||||
server.listen(CALLBACK_PORT, "127.0.0.1", () => {
|
||||
openExternalUrl(url.toString());
|
||||
new Notice(
|
||||
"🔐 Codex: browser opened — complete sign-in to continue",
|
||||
);
|
||||
});
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
if (!resolved) {
|
||||
new Notice("⚠️ Codex: sign-in timed out");
|
||||
done(null);
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function startCodexOAuthFlow(app: App): Promise<CodexTokens | null> {
|
||||
if (Platform.isMobileApp) {
|
||||
return startCodexDeviceAuthFlow(app);
|
||||
}
|
||||
return startCodexDesktopOAuthFlow();
|
||||
}
|
||||
172
src/codex-client.ts
Normal file
172
src/codex-client.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { requestUrl } from "obsidian";
|
||||
|
||||
const CODEX_API_URL = "https://chatgpt.com/backend-api/codex/responses";
|
||||
|
||||
interface ResponsesInput {
|
||||
type: "message";
|
||||
role: "developer" | "user" | "assistant";
|
||||
content: Array<{ type: "input_text"; text: string }>;
|
||||
}
|
||||
|
||||
interface ResponsesBody {
|
||||
model: string;
|
||||
input: ResponsesInput[];
|
||||
instructions: string;
|
||||
store: false;
|
||||
stream: true;
|
||||
reasoning: { effort: string; summary: string };
|
||||
text: { verbosity: string };
|
||||
include: string[];
|
||||
}
|
||||
|
||||
function normalizeModel(model: string): string {
|
||||
const m = model.toLowerCase().trim();
|
||||
if (m === "gpt-5.5" || m.includes("gpt-5.5")) return "gpt-5.5";
|
||||
if (m === "gpt-5.4-mini" || m.includes("gpt-5.4-mini"))
|
||||
return "gpt-5.4-mini";
|
||||
if (m.includes("gpt-5.3-codex-spark") || m.includes("codex-spark"))
|
||||
return "gpt-5.3-codex-spark";
|
||||
if (m.includes("gpt-5.2-codex") || m.includes("gpt 5.2 codex"))
|
||||
return "gpt-5.2-codex";
|
||||
if (m.includes("gpt-5.1-codex-max") || m.includes("codex-max"))
|
||||
return "gpt-5.1-codex-max";
|
||||
if (m.includes("codex-mini-latest") || m.includes("codex-mini"))
|
||||
return "codex-mini-latest";
|
||||
if (m.includes("gpt-5.1-codex") || m.includes("codex"))
|
||||
return "gpt-5.1-codex";
|
||||
if (m.includes("gpt-5.2")) return "gpt-5.2";
|
||||
if (m.includes("gpt-5.1")) return "gpt-5.1";
|
||||
return m; // pass through unknown models as-is
|
||||
}
|
||||
|
||||
function parseSseText(sseBody: string): string {
|
||||
const lines = sseBody.split("\n");
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const data = line.slice(6).trim();
|
||||
if (data === "[DONE]") break;
|
||||
|
||||
try {
|
||||
const json = JSON.parse(data) as any;
|
||||
|
||||
// response.output_text.delta — delta is a plain string
|
||||
if (
|
||||
json.type === "response.output_text.delta" &&
|
||||
typeof json.delta === "string"
|
||||
) {
|
||||
parts.push(json.delta);
|
||||
}
|
||||
|
||||
// response.output_text.done — full text for this content part
|
||||
if (
|
||||
json.type === "response.output_text.done" &&
|
||||
typeof json.text === "string" &&
|
||||
parts.length === 0
|
||||
) {
|
||||
parts.push(json.text);
|
||||
}
|
||||
|
||||
// response.completed — fallback if no deltas/done events
|
||||
if (json.type === "response.completed" && parts.length === 0) {
|
||||
for (const item of json.response?.output ?? []) {
|
||||
for (const c of item.content ?? []) {
|
||||
if (
|
||||
c.type === "output_text" &&
|
||||
typeof c.text === "string"
|
||||
) {
|
||||
parts.push(c.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
const MAX_INPUT_CHARS = 60_000; // ~15k tokens, well under Codex limits
|
||||
|
||||
export async function callCodexApi(
|
||||
systemMessage: string,
|
||||
userMessage: string,
|
||||
accessToken: string,
|
||||
accountId: string,
|
||||
model: string,
|
||||
): Promise<string> {
|
||||
if (!model.trim())
|
||||
throw new Error("No model selected — set one in Settings → InlineAI");
|
||||
|
||||
const normalizedModel = normalizeModel(model);
|
||||
|
||||
// Truncate very long inputs to avoid silent API failures
|
||||
const truncatedUser =
|
||||
userMessage.length > MAX_INPUT_CHARS
|
||||
? userMessage.slice(0, MAX_INPUT_CHARS) + "\n\n[…truncated]"
|
||||
: userMessage;
|
||||
|
||||
const body: ResponsesBody = {
|
||||
model: normalizedModel,
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "developer",
|
||||
content: [{ type: "input_text", text: systemMessage }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: truncatedUser }],
|
||||
},
|
||||
],
|
||||
instructions: "",
|
||||
store: false,
|
||||
stream: true,
|
||||
reasoning: { effort: "medium", summary: "auto" },
|
||||
text: { verbosity: "medium" },
|
||||
include: ["reasoning.encrypted_content"],
|
||||
};
|
||||
|
||||
const res = await requestUrl({
|
||||
url: CODEX_API_URL,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"chatgpt-account-id": accountId,
|
||||
"OpenAI-Beta": "responses=experimental",
|
||||
originator: "codex_cli_rs",
|
||||
accept: "text/event-stream",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
throw: false,
|
||||
});
|
||||
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
let msg = `Codex API error ${res.status}`;
|
||||
try {
|
||||
const err = JSON.parse(res.text)?.error;
|
||||
if (res.status === 429) {
|
||||
msg =
|
||||
"Subscription limit reached — wait a moment and try again";
|
||||
} else if (res.status === 403) {
|
||||
msg = `Model not available on your plan${err?.message ? ": " + err.message : " — try gpt-5.4-mini instead"}`;
|
||||
} else if (err?.message) {
|
||||
msg = err.message;
|
||||
}
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const rawText = res.text;
|
||||
const result = parseSseText(rawText).trim();
|
||||
if (!result)
|
||||
throw new Error(
|
||||
"Codex returned an empty response — the model may only have produced reasoning tokens. Try a different prompt or model.",
|
||||
);
|
||||
return result;
|
||||
}
|
||||
66
src/codex-device-auth-modal.ts
Normal file
66
src/codex-device-auth-modal.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { App, Modal, Notice } from "obsidian";
|
||||
import { copyToClipboard, openExternalUrl } from "./codex-auth";
|
||||
|
||||
export class CodexDeviceAuthModal extends Modal {
|
||||
private closedByApp = false;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private userCode: string,
|
||||
private verificationUrl: string,
|
||||
private onCancel: () => void,
|
||||
) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
closeByApp(): void {
|
||||
this.closedByApp = true;
|
||||
this.close();
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
this.titleEl.setText("Sign in with ChatGPT");
|
||||
|
||||
contentEl.createEl("p", {
|
||||
text: "Enter this one-time code on the ChatGPT sign-in page:",
|
||||
});
|
||||
|
||||
contentEl.createEl("div", {
|
||||
cls: "inlineai-codex-device-code",
|
||||
text: this.userCode,
|
||||
});
|
||||
|
||||
const buttonRow = contentEl.createDiv({ cls: "inlineai-codex-device-buttons" });
|
||||
|
||||
buttonRow.createEl("button", { text: "Copy code" }).addEventListener(
|
||||
"click",
|
||||
async () => {
|
||||
const copied = await copyToClipboard(this.userCode);
|
||||
new Notice(
|
||||
copied ? "✅ Code copied to clipboard" : "❌ Could not copy code",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
buttonRow
|
||||
.createEl("button", { text: "Open sign-in page", cls: "mod-cta" })
|
||||
.addEventListener("click", () => {
|
||||
openExternalUrl(this.verificationUrl);
|
||||
});
|
||||
|
||||
contentEl.createEl("p", {
|
||||
cls: "setting-item-description",
|
||||
text: "Complete sign-in in your browser, then return here. This dialog closes automatically when sign-in succeeds.",
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
if (!this.closedByApp) {
|
||||
this.onCancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
124
src/credentials.ts
Normal file
124
src/credentials.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { App } from "obsidian";
|
||||
import { CodexTokens } from "./codex-auth";
|
||||
|
||||
export const API_KEY_ID = "inlineai-api-key";
|
||||
export const CODEX_AUTH_ID = "inlineai-codex-auth";
|
||||
|
||||
/** Minimal SecretStorage surface (Obsidian 1.11.4+). */
|
||||
interface SecretStorageApi {
|
||||
getSecret(id: string): string | null;
|
||||
setSecret(id: string, secret: string): void;
|
||||
}
|
||||
|
||||
/** Legacy fields that may exist in data.json from older plugin versions. */
|
||||
export interface LegacySecretFields {
|
||||
apiKey?: string;
|
||||
codexAccess?: string;
|
||||
codexRefresh?: string;
|
||||
codexExpires?: number;
|
||||
codexAccountId?: string;
|
||||
}
|
||||
|
||||
export function isSecretStorageAvailable(app: App): boolean {
|
||||
return "secretStorage" in app && (app as { secretStorage?: unknown }).secretStorage != null;
|
||||
}
|
||||
|
||||
function getSecretStorage(app: App): SecretStorageApi | null {
|
||||
if (!isSecretStorageAvailable(app)) return null;
|
||||
return (app as unknown as { secretStorage: SecretStorageApi }).secretStorage;
|
||||
}
|
||||
|
||||
export function getApiKey(app: App): string | null {
|
||||
const storage = getSecretStorage(app);
|
||||
if (!storage) return null;
|
||||
const value = storage.getSecret(API_KEY_ID);
|
||||
return value && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function setApiKey(app: App, key: string): void {
|
||||
const storage = getSecretStorage(app);
|
||||
if (!storage) return;
|
||||
storage.setSecret(API_KEY_ID, key.length === 0 ? "" : key);
|
||||
}
|
||||
|
||||
export function clearApiKey(app: App): void {
|
||||
setApiKey(app, "");
|
||||
}
|
||||
|
||||
function parseCodexTokens(raw: string): CodexTokens | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as CodexTokens;
|
||||
if (
|
||||
typeof parsed.access === "string" &&
|
||||
typeof parsed.refresh === "string" &&
|
||||
typeof parsed.expires === "number" &&
|
||||
typeof parsed.accountId === "string" &&
|
||||
parsed.access.length > 0 &&
|
||||
parsed.accountId.length > 0
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// invalid JSON
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getCodexTokens(app: App): CodexTokens | null {
|
||||
const storage = getSecretStorage(app);
|
||||
if (!storage) return null;
|
||||
const raw = storage.getSecret(CODEX_AUTH_ID);
|
||||
if (!raw || raw.length === 0) return null;
|
||||
return parseCodexTokens(raw);
|
||||
}
|
||||
|
||||
export function setCodexTokens(app: App, tokens: CodexTokens): void {
|
||||
const storage = getSecretStorage(app);
|
||||
if (!storage) return;
|
||||
storage.setSecret(CODEX_AUTH_ID, JSON.stringify(tokens));
|
||||
}
|
||||
|
||||
export function clearCodexTokens(app: App): void {
|
||||
const storage = getSecretStorage(app);
|
||||
if (!storage) return;
|
||||
storage.setSecret(CODEX_AUTH_ID, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves plaintext secrets from data.json into SecretStorage.
|
||||
* Returns true if any legacy fields were migrated (caller should re-save settings).
|
||||
*/
|
||||
export function migrateLegacySecrets(
|
||||
app: App,
|
||||
settings: LegacySecretFields,
|
||||
): boolean {
|
||||
if (!isSecretStorageAvailable(app)) return false;
|
||||
|
||||
let migrated = false;
|
||||
|
||||
if (settings.apiKey && settings.apiKey.length > 0) {
|
||||
setApiKey(app, settings.apiKey);
|
||||
delete settings.apiKey;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (
|
||||
settings.codexAccess &&
|
||||
settings.codexRefresh &&
|
||||
settings.codexAccountId
|
||||
) {
|
||||
setCodexTokens(app, {
|
||||
access: settings.codexAccess,
|
||||
refresh: settings.codexRefresh,
|
||||
expires: settings.codexExpires ?? 0,
|
||||
accountId: settings.codexAccountId,
|
||||
});
|
||||
delete settings.codexAccess;
|
||||
delete settings.codexRefresh;
|
||||
delete settings.codexExpires;
|
||||
delete settings.codexAccountId;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
return migrated;
|
||||
}
|
||||
13
src/main.ts
13
src/main.ts
|
|
@ -20,6 +20,7 @@ import {
|
|||
setSelectionInfoEffect,
|
||||
} from "./modules/SelectionState";
|
||||
import { diffExtension } from "./modules/diffExtension";
|
||||
import { migrateLegacySecrets, LegacySecretFields } from "./credentials";
|
||||
|
||||
export default class InlineAIChatPlugin extends Plugin {
|
||||
settings: InlineAISettings = DEFAULT_SETTINGS;
|
||||
|
|
@ -79,7 +80,7 @@ export default class InlineAIChatPlugin extends Plugin {
|
|||
cmEditor.dispatch({ effects });
|
||||
}
|
||||
},
|
||||
hotkeys: [],
|
||||
hotkeys: [{ modifiers: ["Ctrl", "Shift"], key: " " }],
|
||||
});
|
||||
this.addCommand({
|
||||
id: "accept-tooltip",
|
||||
|
|
@ -137,11 +138,11 @@ export default class InlineAIChatPlugin extends Plugin {
|
|||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign(
|
||||
{},
|
||||
DEFAULT_SETTINGS,
|
||||
await this.loadData(),
|
||||
);
|
||||
const raw: LegacySecretFields = (await this.loadData()) ?? {};
|
||||
if (migrateLegacySecrets(this.app, raw)) {
|
||||
await this.saveData(raw);
|
||||
}
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, raw);
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import { SlashCommand } from "./source";
|
||||
import { SlashCommand, BUILT_IN_COMMANDS } from "./source";
|
||||
|
||||
export function parseCommand(
|
||||
userInput: string,
|
||||
prefix: string,
|
||||
customCommands: SlashCommand[],
|
||||
): string {
|
||||
for (const command of customCommands) {
|
||||
const allCommands = [...BUILT_IN_COMMANDS, ...customCommands];
|
||||
for (const command of allCommands) {
|
||||
const commandPattern = `${prefix}${command.keyword}`;
|
||||
if (userInput.includes(commandPattern)) {
|
||||
return userInput.replace(commandPattern, command.prompt);
|
||||
}
|
||||
}
|
||||
return userInput; // Return original text if no command matches
|
||||
return userInput;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,41 @@ export interface SlashCommand {
|
|||
prompt: string;
|
||||
}
|
||||
|
||||
export const BUILT_IN_COMMANDS: SlashCommand[] = [
|
||||
{
|
||||
keyword: "fix",
|
||||
prompt: "Fix grammar, spelling, and punctuation. Keep the original meaning and style. Output only the corrected text.",
|
||||
},
|
||||
{
|
||||
keyword: "shorter",
|
||||
prompt: "Make this text more concise. Remove filler words and redundancy. Keep all key information. Output only the shortened text.",
|
||||
},
|
||||
{
|
||||
keyword: "longer",
|
||||
prompt: "Expand this text with more detail, examples, or explanation. Keep the same tone. Output only the expanded text.",
|
||||
},
|
||||
{
|
||||
keyword: "formal",
|
||||
prompt: "Rewrite this text in a formal, professional tone. Output only the rewritten text.",
|
||||
},
|
||||
{
|
||||
keyword: "casual",
|
||||
prompt: "Rewrite this text in a friendly, casual tone. Output only the rewritten text.",
|
||||
},
|
||||
{
|
||||
keyword: "bullets",
|
||||
prompt: "Convert this text into a clear bullet-point list using Obsidian markdown. Output only the bullet list.",
|
||||
},
|
||||
{
|
||||
keyword: "summarize",
|
||||
prompt: "Write a concise summary of this text in 2-3 sentences. Output only the summary.",
|
||||
},
|
||||
{
|
||||
keyword: "continue",
|
||||
prompt: "Continue writing from where this text ends, matching the tone and style. Output only the continuation — do not repeat existing text.",
|
||||
},
|
||||
];
|
||||
|
||||
// Factory function that creates a completion source with custom parameters
|
||||
function createSlashCommandSource(
|
||||
options: {
|
||||
|
|
@ -28,12 +63,13 @@ function createSlashCommandSource(
|
|||
},
|
||||
) {
|
||||
const { prefix, customCommands } = options;
|
||||
const allCommands = [...BUILT_IN_COMMANDS, ...customCommands];
|
||||
return (context: CompletionContext) => {
|
||||
let word = context.matchBefore(new RegExp(`^\\${prefix}\\w*`));
|
||||
if (!word || (word.from == word.to && !context.explicit)) return null;
|
||||
return {
|
||||
from: word.from + 1,
|
||||
options: customCommands.map((cmd) => ({
|
||||
options: allCommands.map((cmd) => ({
|
||||
label: cmd.keyword,
|
||||
type: undefined,
|
||||
detail: cmd.prompt,
|
||||
|
|
@ -82,7 +118,8 @@ export function createSlashCommandHighlighter({
|
|||
}
|
||||
|
||||
buildDecorations(view: EditorView) {
|
||||
const keywords = customCommands
|
||||
const allCommands = [...BUILT_IN_COMMANDS, ...customCommands];
|
||||
const keywords = allCommands
|
||||
.map((cmd) => cmd.keyword)
|
||||
.join("|");
|
||||
const regexp = new RegExp(`\\${prefix}(${keywords})\\b`, "g");
|
||||
|
|
|
|||
244
src/settings.ts
244
src/settings.ts
|
|
@ -1,13 +1,21 @@
|
|||
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||
import { App, PluginSettingTab, Setting, Notice, Platform } from "obsidian";
|
||||
import MyPlugin from "./main";
|
||||
import { cursorPrompt, selectionPrompt } from "./default_prompts";
|
||||
import { SlashCommand } from "./modules/commands/source";
|
||||
import { SlashCommand, BUILT_IN_COMMANDS } from "./modules/commands/source";
|
||||
import { startCodexOAuthFlow } from "./codex-auth";
|
||||
import {
|
||||
clearCodexTokens,
|
||||
getApiKey,
|
||||
getCodexTokens,
|
||||
isSecretStorageAvailable,
|
||||
setApiKey,
|
||||
setCodexTokens,
|
||||
} from "./credentials";
|
||||
|
||||
// Interface for the settings
|
||||
export interface InlineAISettings {
|
||||
provider: "openai" | "ollama" | "custom" | "gemini" | "azure";
|
||||
provider: "openai" | "ollama" | "custom" | "gemini" | "azure" | "codex";
|
||||
model: string;
|
||||
apiKey?: string;
|
||||
customURL?: string;
|
||||
azureEndpoint?: string;
|
||||
azureApiVersion?: string;
|
||||
|
|
@ -22,7 +30,6 @@ export interface InlineAISettings {
|
|||
export const DEFAULT_SETTINGS: InlineAISettings = {
|
||||
provider: "ollama",
|
||||
model: "llama3.2",
|
||||
apiKey: "",
|
||||
customURL: "",
|
||||
azureEndpoint: "",
|
||||
azureApiVersion: "2024-02-15-preview",
|
||||
|
|
@ -51,11 +58,18 @@ export class InlineAISettingsTab extends PluginSettingTab {
|
|||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
if (!isSecretStorageAvailable(this.app)) {
|
||||
containerEl.createEl("p", {
|
||||
text: "⚠️ InlineAI requires Obsidian 1.11.4 or later for secure credential storage. Please update Obsidian to use API keys and Codex sign-in.",
|
||||
cls: "setting-item-description",
|
||||
});
|
||||
}
|
||||
|
||||
// Provider setting
|
||||
new Setting(containerEl)
|
||||
.setName("Provider")
|
||||
.setDesc(
|
||||
"Choose between OpenAI, Ollama, Azure OpenAI, Gemini, or a custom OpenAI-compatible endpoint.",
|
||||
"Choose between OpenAI, Ollama, Azure OpenAI, Gemini, a custom OpenAI-compatible endpoint, or Codex (ChatGPT subscription).",
|
||||
)
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
|
|
@ -64,31 +78,209 @@ export class InlineAISettingsTab extends PluginSettingTab {
|
|||
.addOption("azure", "Azure OpenAI")
|
||||
.addOption("gemini", "Gemini")
|
||||
.addOption("custom", "Custom/OpenAI-compatible")
|
||||
.addOption("codex", "Codex (ChatGPT subscription)")
|
||||
.setValue(this.plugin.settings.provider)
|
||||
.onChange(async (value) => {
|
||||
const CODEX_MODEL_IDS = [
|
||||
"gpt-5.5",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.3-codex-spark",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-codex-max",
|
||||
"codex-mini-latest",
|
||||
];
|
||||
this.plugin.settings.provider = value as
|
||||
| "openai"
|
||||
| "ollama"
|
||||
| "azure"
|
||||
| "custom"
|
||||
| "gemini";
|
||||
| "gemini"
|
||||
| "codex";
|
||||
// Reset model to a sane default when switching to Codex
|
||||
if (
|
||||
value === "codex" &&
|
||||
!CODEX_MODEL_IDS.includes(
|
||||
this.plugin.settings.model,
|
||||
)
|
||||
) {
|
||||
this.plugin.settings.model = "gpt-5.4-mini";
|
||||
}
|
||||
await this.saveSettings();
|
||||
this.display(); // Refresh UI to show/hide API key field
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
// Codex subscription auth section
|
||||
if (this.plugin.settings.provider === "codex") {
|
||||
const codexTokens = getCodexTokens(this.app);
|
||||
const isSignedIn = !!(
|
||||
codexTokens?.access && codexTokens?.accountId
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("ChatGPT account")
|
||||
.setDesc(
|
||||
isSignedIn
|
||||
? `Signed in (account: ${codexTokens!.accountId})`
|
||||
: "Not signed in — click to authenticate with your ChatGPT Plus/Pro subscription.",
|
||||
)
|
||||
.addButton((btn) => {
|
||||
btn.setButtonText(
|
||||
isSignedIn ? "Sign out" : "Sign in with ChatGPT",
|
||||
)
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
if (isSignedIn) {
|
||||
clearCodexTokens(this.app);
|
||||
this.display();
|
||||
} else {
|
||||
if (!isSecretStorageAvailable(this.app)) {
|
||||
new Notice(
|
||||
"⚠️ InlineAI requires Obsidian 1.11.4+ for Codex sign-in.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
new Notice(
|
||||
Platform.isMobileApp
|
||||
? "Complete sign-in using the dialog that opens"
|
||||
: "Opening browser for ChatGPT sign-in…",
|
||||
);
|
||||
const tokens = await startCodexOAuthFlow(this.app);
|
||||
if (tokens) {
|
||||
setCodexTokens(this.app, tokens);
|
||||
new Notice(
|
||||
"✅ Codex: signed in successfully",
|
||||
);
|
||||
this.display();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (isSignedIn) {
|
||||
containerEl.createEl("p", {
|
||||
text: "Credentials are stored in Obsidian's keychain (Settings → Security). They are not synced with your vault and must be set up on each device.",
|
||||
cls: "setting-item-description",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Model setting
|
||||
new Setting(containerEl)
|
||||
.setName("Model")
|
||||
.setDesc("Specify the model to use.")
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("e.g., gpt-4o-mini")
|
||||
.setValue(this.plugin.settings.model)
|
||||
.inputEl.addEventListener("blur", async () => {
|
||||
this.plugin.settings.model = text.getValue();
|
||||
if (this.plugin.settings.provider === "codex") {
|
||||
const CODEX_MODELS: {
|
||||
value: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "gpt-5.5",
|
||||
label: "GPT-5.5",
|
||||
desc: "Most capable — best for complex rewrites and reasoning",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini",
|
||||
label: "GPT-5.4 mini ✦ recommended",
|
||||
desc: "Fast and cost-efficient — ideal for inline edits",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-spark",
|
||||
label: "GPT-5.3 Codex Spark (Pro only)",
|
||||
desc: "Near-instant iteration — requires ChatGPT Pro",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex",
|
||||
label: "GPT-5.2 Codex",
|
||||
desc: "Strong coding and structured writing",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex",
|
||||
label: "GPT-5.1 Codex",
|
||||
desc: "Balanced coding model",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max",
|
||||
label: "GPT-5.1 Codex Max",
|
||||
desc: "High-effort variant of GPT-5.1 Codex",
|
||||
},
|
||||
{
|
||||
value: "codex-mini-latest",
|
||||
label: "Codex Mini",
|
||||
desc: "Lightest and fastest option",
|
||||
},
|
||||
{
|
||||
value: "custom",
|
||||
label: "Custom…",
|
||||
desc: "Enter a model ID manually",
|
||||
},
|
||||
];
|
||||
const isCustom = !CODEX_MODELS.some(
|
||||
(m) =>
|
||||
m.value === this.plugin.settings.model &&
|
||||
m.value !== "custom",
|
||||
);
|
||||
const dropdownValue = isCustom
|
||||
? "custom"
|
||||
: this.plugin.settings.model;
|
||||
const selectedModel = CODEX_MODELS.find(
|
||||
(m) => m.value === dropdownValue,
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Model")
|
||||
.setDesc(
|
||||
selectedModel?.desc ??
|
||||
"Select the model to use for Codex requests.",
|
||||
)
|
||||
.addDropdown((dd) => {
|
||||
CODEX_MODELS.forEach((m) => dd.addOption(m.value, m.label));
|
||||
dd.setValue(dropdownValue).onChange(async (value) => {
|
||||
this.plugin.settings.model =
|
||||
value === "custom" ? "" : value;
|
||||
await this.saveSettings();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (isCustom || dropdownValue === "custom") {
|
||||
new Setting(containerEl)
|
||||
.setName("Custom model ID")
|
||||
.setDesc(
|
||||
"Enter the exact model ID as used by the Codex API.",
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("e.g., gpt-5.1-codex")
|
||||
.setValue(
|
||||
isCustom ? this.plugin.settings.model : "",
|
||||
)
|
||||
.inputEl.addEventListener("blur", async () => {
|
||||
this.plugin.settings.model = text
|
||||
.getValue()
|
||||
.trim();
|
||||
await this.saveSettings();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
if (!this.plugin.settings.model.trim()) {
|
||||
containerEl.createEl("p", {
|
||||
text: "⚠️ No model ID entered — requests will fail until you set one.",
|
||||
cls: "setting-item-description",
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
new Setting(containerEl)
|
||||
.setName("Model")
|
||||
.setDesc("Specify the model to use.")
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("e.g., gpt-4o-mini")
|
||||
.setValue(this.plugin.settings.model)
|
||||
.inputEl.addEventListener("blur", async () => {
|
||||
this.plugin.settings.model = text.getValue();
|
||||
await this.saveSettings();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// API Key setting (conditionally displayed for OpenAI-supported endpoints)
|
||||
if (
|
||||
|
|
@ -102,10 +294,18 @@ export class InlineAISettingsTab extends PluginSettingTab {
|
|||
.setDesc("Enter your API key.")
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("sk-...")
|
||||
.setValue(this.plugin.settings.apiKey || "")
|
||||
.setValue(getApiKey(this.app) ?? "")
|
||||
.inputEl.addEventListener("blur", async () => {
|
||||
this.plugin.settings.apiKey = text.getValue();
|
||||
await this.saveSettings();
|
||||
if (!isSecretStorageAvailable(this.app)) {
|
||||
new Notice(
|
||||
"⚠️ InlineAI requires Obsidian 1.11.4+ to store API keys securely.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setApiKey(this.app, text.getValue());
|
||||
this.plugin.chatapi.updateSettings(
|
||||
this.plugin.settings,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -226,6 +426,10 @@ export class InlineAISettingsTab extends PluginSettingTab {
|
|||
containerEl.createEl("p", {
|
||||
text: "Add your own custom commands. Triggered with the prefix defined in the Command Prefix setting.",
|
||||
});
|
||||
containerEl.createEl("p", {
|
||||
text: `Built-in: ${BUILT_IN_COMMANDS.map((c) => this.plugin.settings.commandPrefix + c.keyword).join(" • ")}`,
|
||||
cls: "setting-item-description",
|
||||
});
|
||||
|
||||
// Command Prefix setting
|
||||
new Setting(containerEl)
|
||||
|
|
|
|||
21
styles.css
21
styles.css
|
|
@ -156,3 +156,24 @@
|
|||
.cm-slash-command {
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
/* Codex device sign-in modal */
|
||||
.inlineai-codex-device-code {
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-align: center;
|
||||
padding: 1em;
|
||||
margin: 1em 0;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 8px;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.inlineai-codex-device-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75em;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
"1.0.0": "0.15.0",
|
||||
"1.2.4": "1.11.4",
|
||||
"1.2.5": "1.11.4",
|
||||
"1.2.6": "1.11.4",
|
||||
"1.2.7": "1.11.4"
|
||||
}
|
||||
Loading…
Reference in a new issue