mirror of
https://github.com/fbarrca/obsidian-inlineAI.git
synced 2026-07-22 11:50:24 +00:00
Compare commits
33 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b995101048 | ||
|
|
86601be0f0 | ||
|
|
d900ef1ca6 | ||
|
|
1c61d23cc6 | ||
|
|
69817bffc2 | ||
|
|
b1933fb823 | ||
|
|
053119ed15 | ||
|
|
05dc259155 | ||
|
|
71a6447f98 | ||
|
|
15fe7f02c5 | ||
|
|
3abc20fe6e | ||
|
|
d51f10b33e | ||
|
|
1024c37991 | ||
|
|
b389c5991d | ||
|
|
5f21d0bdd5 | ||
|
|
6880043c8b | ||
|
|
3912da8047 | ||
|
|
2a0be90245 | ||
|
|
3e25d3e8d1 | ||
|
|
149953d1bd | ||
|
|
59960a9b1c | ||
|
|
2ec2eabdb2 | ||
|
|
958533a0f0 | ||
|
|
ad60944974 | ||
|
|
41e73ea28a | ||
|
|
9e95818df3 | ||
|
|
95136efdbb | ||
|
|
3f8dda5ac0 | ||
|
|
5e878508cc | ||
|
|
46ef109abf | ||
|
|
f0f35ac3fa | ||
|
|
495e8742ff | ||
|
|
8b37439f84 |
27 changed files with 3501 additions and 990 deletions
25
.github/workflows/format.yml
vendored
Normal file
25
.github/workflows/format.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
name: Format Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prettier:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Prettier check
|
||||||
|
run: npx prettier --check .
|
||||||
88
.github/workflows/release.yml
vendored
88
.github/workflows/release.yml
vendored
|
|
@ -1,57 +1,57 @@
|
||||||
name: Build and Release Obsidian Plugin
|
name: Build and Release Obsidian Plugin
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*' # Triggers on any tag
|
- "*" # Triggers on any tag
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PLUGIN_NAME: obsidian-inlineAI
|
PLUGIN_NAME: obsidian-inlineAI
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-release:
|
build-and-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# 1. Checkout the repository
|
# 1. Checkout the repository
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
# 2. Set up Node.js environment with caching
|
# 2. Set up Node.js environment with caching
|
||||||
- name: Set Up Node.js
|
- name: Set Up Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 'lts/*' # Use the latest LTS version
|
node-version: "lts/*" # Use the latest LTS version
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
# 3. Install dependencies and build the project
|
# 3. Install dependencies and build the project
|
||||||
- name: Install and Build
|
- name: Install and Build
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
npm run build --if-present
|
npm run build --if-present
|
||||||
|
|
||||||
# 4. Prepare release assets
|
# 4. Prepare release assets
|
||||||
- name: Prepare Release Assets
|
- name: Prepare Release Assets
|
||||||
run: |
|
run: |
|
||||||
mkdir -p $PLUGIN_NAME
|
mkdir -p $PLUGIN_NAME
|
||||||
cp main.js manifest.json styles.css $PLUGIN_NAME
|
cp main.js manifest.json styles.css $PLUGIN_NAME
|
||||||
zip -r ${PLUGIN_NAME}.zip $PLUGIN_NAME
|
zip -r ${PLUGIN_NAME}.zip $PLUGIN_NAME
|
||||||
|
|
||||||
# 5. Retrieve the tag name
|
# 5. Retrieve the tag name
|
||||||
- name: Get Tag Name
|
- name: Get Tag Name
|
||||||
id: get_tag
|
id: get_tag
|
||||||
run: echo "tag_name=$(git describe --tags --abbrev=0)" >> $GITHUB_OUTPUT
|
run: echo "tag_name=$(git describe --tags --abbrev=0)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
# 6. Create a new release and upload all assets in one step
|
# 6. Create a new release and upload all assets in one step
|
||||||
- name: Create Release and Upload Assets
|
- name: Create Release and Upload Assets
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@v1
|
||||||
with:
|
with:
|
||||||
tag: ${{ steps.get_tag.outputs.tag_name }}
|
tag: ${{ steps.get_tag.outputs.tag_name }}
|
||||||
name: ${{ steps.get_tag.outputs.tag_name }}
|
name: ${{ steps.get_tag.outputs.tag_name }}
|
||||||
artifacts: |
|
artifacts: |
|
||||||
${{ env.PLUGIN_NAME }}.zip
|
${{ env.PLUGIN_NAME }}.zip
|
||||||
main.js
|
main.js
|
||||||
manifest.json
|
manifest.json
|
||||||
styles.css
|
styles.css
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
node_modules/*
|
|
||||||
.git/*
|
|
||||||
package-lock.json
|
|
||||||
main.js
|
|
||||||
0
.husky/pre-commit
Normal file
0
.husky/pre-commit
Normal file
5
.prettierignore
Normal file
5
.prettierignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
main.js
|
||||||
|
|
||||||
1
.prettierrc
Normal file
1
.prettierrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
23
README.md
23
README.md
|
|
@ -59,15 +59,14 @@
|
||||||
### Initial Setup
|
### Initial Setup
|
||||||
|
|
||||||
1. **Set up your API key**:
|
1. **Set up your API key**:
|
||||||
|
- Open the plugin settings in Obsidian.
|
||||||
|
- Enter your API key for OpenAI, Ollama, Gemini or any supported model provider.
|
||||||
|
|
||||||
- Open the plugin settings in Obsidian.
|
|
||||||
- Enter your API key for OpenAI, Ollama, Gemini or any supported model provider.
|
|
||||||
2. **Choose a model**:
|
2. **Choose a model**:
|
||||||
|
- Supported models include `gpt-4`, `llama3.2`, and others.
|
||||||
|
|
||||||
- Supported models include `gpt-4`, `llama3.2`, and others.
|
|
||||||
3. **Configure prompts**:
|
3. **Configure prompts**:
|
||||||
|
- Define system and transformation prompts in settings for customized interactions.
|
||||||
- Define system and transformation prompts in settings for customized interactions.
|
|
||||||
|
|
||||||
### How to Use
|
### How to Use
|
||||||
|
|
||||||
|
|
@ -76,18 +75,6 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔮 Roadmap
|
|
||||||
|
|
||||||
InlineAI is actively evolving! Here’s what’s next:
|
|
||||||
|
|
||||||
- **Tool use**: Save your commonly used tools as commands for easy use.
|
|
||||||
|
|
||||||
Stay updated on our progress via the [GitHub Project Board](https://github.com/FBarrca/obsidian-inlineai/projects).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🙏 Feedback and Support
|
## 🙏 Feedback and Support
|
||||||
|
|
||||||
We value your feedback and aim to make InlineAI the ultimate AI writing assistant:
|
We value your feedback and aim to make InlineAI the ultimate AI writing assistant:
|
||||||
|
|
@ -109,6 +96,7 @@ Want to contribute? Here’s how:
|
||||||
git clone https://github.com/FBarrca/obsidian-inlineai.git
|
git clone https://github.com/FBarrca/obsidian-inlineai.git
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install dependencies:
|
2. Install dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -116,6 +104,7 @@ Want to contribute? Here’s how:
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Build the plugin:
|
3. Build the plugin:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"id": "inlineai",
|
"id": "inlineai",
|
||||||
"name": "InlineAI",
|
"name": "InlineAI",
|
||||||
"version": "1.2.0",
|
"version": "1.2.7",
|
||||||
"minAppVersion": "0.15.0",
|
"minAppVersion": "1.11.4",
|
||||||
"description": "AI-powered suggestions, contextual edits, and advanced text transformations directly into your editor.",
|
"description": "AI-powered suggestions, contextual edits, and advanced text transformations directly into your editor.",
|
||||||
"author": "FBarrca",
|
"author": "FBarrca",
|
||||||
"authorUrl": "https://github.com/FBarrca/",
|
"authorUrl": "https://github.com/FBarrca/",
|
||||||
|
|
|
||||||
839
package-lock.json
generated
839
package-lock.json
generated
File diff suppressed because it is too large
Load diff
15
package.json
15
package.json
|
|
@ -1,12 +1,15 @@
|
||||||
{
|
{
|
||||||
"name": "obsidian-inlineai",
|
"name": "obsidian-inlineai",
|
||||||
"version": "1.2.0",
|
"version": "1.2.7",
|
||||||
"description": "Cursor or Copilot like Inline AI interface for Obsidian",
|
"description": "Cursor or Copilot like Inline AI interface for Obsidian",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node esbuild.config.mjs",
|
"dev": "node esbuild.config.mjs",
|
||||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "FBarrca",
|
"author": "FBarrca",
|
||||||
|
|
@ -18,9 +21,17 @@
|
||||||
"@typescript-eslint/parser": "^8.11.0",
|
"@typescript-eslint/parser": "^8.11.0",
|
||||||
"builtin-modules": "^4.0.0",
|
"builtin-modules": "^4.0.0",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^15.5.2",
|
||||||
"obsidian": "latest",
|
"obsidian": "latest",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
},
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{ts,tsx,js,jsx,css,scss,md,json,yml,yaml}": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.18.4",
|
"@codemirror/autocomplete": "^6.18.4",
|
||||||
"@codemirror/commands": "^6.8.0",
|
"@codemirror/commands": "^6.8.0",
|
||||||
|
|
|
||||||
563
src/api.ts
563
src/api.ts
|
|
@ -1,11 +1,18 @@
|
||||||
// api.ts
|
// api.ts
|
||||||
import { ChatOpenAI } from "@langchain/openai";
|
import { ChatOpenAI, AzureChatOpenAI } from "@langchain/openai";
|
||||||
import { ChatOllama } from "@langchain/ollama";
|
import { ChatOllama } from "@langchain/ollama";
|
||||||
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
||||||
import { SystemMessage, HumanMessage, AIMessage } from "@langchain/core/messages";
|
import {
|
||||||
|
SystemMessage,
|
||||||
|
HumanMessage,
|
||||||
|
AIMessage,
|
||||||
|
} from "@langchain/core/messages";
|
||||||
import { InlineAISettings } from "./settings";
|
import { InlineAISettings } from "./settings";
|
||||||
import { App, MarkdownView, Notice } from "obsidian";
|
import { App, MarkdownView, Notice } from "obsidian";
|
||||||
import { EditorView } from "@codemirror/view";
|
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 { setGeneratedResponseEffect } from "./modules/AIExtension";
|
||||||
import { parseCommand } from "./modules/commands/parser";
|
import { parseCommand } from "./modules/commands/parser";
|
||||||
import { MessageQueue } from "./modules/messageHistory/queue";
|
import { MessageQueue } from "./modules/messageHistory/queue";
|
||||||
|
|
@ -13,196 +20,420 @@ import { MessageQueue } from "./modules/messageHistory/queue";
|
||||||
const MESSAGE_HISTORY_LIMIT = 20;
|
const MESSAGE_HISTORY_LIMIT = 20;
|
||||||
|
|
||||||
export type HistoryMessage = {
|
export type HistoryMessage = {
|
||||||
mode: string;
|
mode: string;
|
||||||
userPrompt: string;
|
userPrompt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class to manage interactions with different chat APIs.
|
* Class to manage interactions with different chat APIs.
|
||||||
*/
|
*/
|
||||||
export class ChatApiManager {
|
export class ChatApiManager {
|
||||||
private chatClient: ChatOpenAI | ChatOllama | ChatGoogleGenerativeAI | null;
|
private chatClient:
|
||||||
private app: App;
|
| ChatOpenAI
|
||||||
private settings: InlineAISettings;
|
| ChatOllama
|
||||||
private messageHistory: MessageQueue<HistoryMessage>;
|
| ChatGoogleGenerativeAI
|
||||||
/**
|
| AzureChatOpenAI
|
||||||
* Initializes the ChatApiManager with the given settings.
|
| null;
|
||||||
* @param settings - Configuration settings for the chat API.
|
private app: App;
|
||||||
* @param app - The Obsidian App instance.
|
private settings: InlineAISettings;
|
||||||
*/
|
private messageHistory: MessageQueue<HistoryMessage>;
|
||||||
constructor(settings: InlineAISettings, app: App) {
|
/**
|
||||||
this.app = app;
|
* Initializes the ChatApiManager with the given settings.
|
||||||
this.chatClient = this.initializeChatClient(settings);
|
* @param settings - Configuration settings for the chat API.
|
||||||
this.settings = settings;
|
* @param app - The Obsidian App instance.
|
||||||
this.messageHistory = new MessageQueue<HistoryMessage>(MESSAGE_HISTORY_LIMIT);
|
*/
|
||||||
}
|
constructor(settings: InlineAISettings, app: App) {
|
||||||
|
this.app = app;
|
||||||
|
this.chatClient = this.initializeChatClient(settings);
|
||||||
|
this.settings = settings;
|
||||||
|
this.messageHistory = new MessageQueue<HistoryMessage>(
|
||||||
|
MESSAGE_HISTORY_LIMIT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the appropriate chat client based on the provider specified in settings.
|
* Extracts the instance name from an Azure endpoint URL.
|
||||||
* @param settings - Configuration settings for the chat API.
|
* @param endpoint - The Azure endpoint URL.
|
||||||
* @returns An instance of ChatOpenAI, ChatOllama, or null if initialization fails.
|
* @returns The instance name or null if invalid.
|
||||||
*/
|
*/
|
||||||
private initializeChatClient(settings: InlineAISettings): ChatOpenAI | ChatOllama | ChatGoogleGenerativeAI | null {
|
private extractAzureInstanceName(endpoint: string): string | null {
|
||||||
try {
|
const trimmedEndpoint = endpoint.trim();
|
||||||
if (settings.messageHistory) {
|
|
||||||
this.messageHistory = new MessageQueue<HistoryMessage>(MESSAGE_HISTORY_LIMIT);
|
|
||||||
} else {
|
|
||||||
this.messageHistory = new MessageQueue<HistoryMessage>(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (settings.provider) {
|
|
||||||
case "openai":
|
|
||||||
if (!settings.apiKey) {
|
|
||||||
new Notice("⚠️ OpenAI API key is required. Please check your settings.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return new ChatOpenAI({
|
|
||||||
modelName: settings.model,
|
|
||||||
temperature: 0,
|
|
||||||
apiKey: settings.apiKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
case "ollama":
|
// Match both openai.azure.com and cognitiveservices.azure.com formats
|
||||||
return new ChatOllama({
|
const openaiMatch = trimmedEndpoint.match(
|
||||||
model: settings.model,
|
/https:\/\/([^.]+)\.openai\.azure\.com/,
|
||||||
});
|
);
|
||||||
case "gemini":
|
if (openaiMatch) {
|
||||||
return new ChatGoogleGenerativeAI({
|
return openaiMatch[1];
|
||||||
model: settings.model,
|
}
|
||||||
apiKey: settings.apiKey,
|
|
||||||
});
|
|
||||||
case "custom":
|
|
||||||
if (!settings.apiKey || !settings.customURL) {
|
|
||||||
new Notice("⚠️ API key and custom base URL are required for custom providers.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return new ChatOpenAI({
|
|
||||||
modelName: settings.model,
|
|
||||||
temperature: 0,
|
|
||||||
openAIApiKey: settings.apiKey,
|
|
||||||
// 'configuration.basePath' is the recognized property
|
|
||||||
configuration: {
|
|
||||||
baseURL: settings.customURL.trim(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
default:
|
const cognitiveservicesMatch = trimmedEndpoint.match(
|
||||||
new Notice(`⚠️ Unsupported provider: ${settings.provider}`);
|
/https:\/\/([^.]+)\.cognitiveservices\.azure\.com/,
|
||||||
return null;
|
);
|
||||||
}
|
if (cognitiveservicesMatch) {
|
||||||
} catch (error: any) {
|
return cognitiveservicesMatch[1];
|
||||||
console.error("Error initializing chat client:", error);
|
}
|
||||||
new Notice(`❌ Error initializing chat client: ${error.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
return null;
|
||||||
* Calls the chat API with the provided content and context.
|
}
|
||||||
* @param systemMessage - The system message to send to the chat API.
|
|
||||||
* @param message - The user's message to send to the chat API.
|
|
||||||
* @returns A promise that resolves with the generated content or an error message.
|
|
||||||
*/
|
|
||||||
public async callApi(systemMessage: string, message: string): Promise<string> {
|
|
||||||
if (!this.chatClient) {
|
|
||||||
new Notice("⚠️ Chat client is not initialized. Please check your settings.");
|
|
||||||
return "⚠️ Chat client is not available.";
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = [
|
/**
|
||||||
new SystemMessage(systemMessage),
|
* Initializes the appropriate chat client based on the provider specified in settings.
|
||||||
new HumanMessage(message),
|
* @param settings - Configuration settings for the chat API.
|
||||||
];
|
* @returns An instance of ChatOpenAI, ChatOllama, AzureChatOpenAI, or null if initialization fails.
|
||||||
|
*/
|
||||||
|
private initializeChatClient(
|
||||||
|
settings: InlineAISettings,
|
||||||
|
):
|
||||||
|
| ChatOpenAI
|
||||||
|
| ChatOllama
|
||||||
|
| ChatGoogleGenerativeAI
|
||||||
|
| AzureChatOpenAI
|
||||||
|
| null {
|
||||||
|
try {
|
||||||
|
if (settings.messageHistory) {
|
||||||
|
this.messageHistory = new MessageQueue<HistoryMessage>(
|
||||||
|
MESSAGE_HISTORY_LIMIT,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.messageHistory = new MessageQueue<HistoryMessage>(0);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
switch (settings.provider) {
|
||||||
const aiMessage: AIMessage = await this.chatClient.invoke(messages);
|
case "openai": {
|
||||||
return aiMessage.content.toString();
|
const apiKey = getApiKey(this.app);
|
||||||
} catch (error: any) {
|
if (!apiKey) {
|
||||||
console.error("Error calling the chat model:", error);
|
new Notice(
|
||||||
new Notice(`❌ Error calling the chat model: ${error.message}`);
|
"⚠️ OpenAI API key is required. Please check your settings.",
|
||||||
return "⚠️ Failed to generate a response. Please try again later.";
|
);
|
||||||
}
|
return null;
|
||||||
}
|
}
|
||||||
|
return new ChatOpenAI({
|
||||||
|
modelName: settings.model,
|
||||||
|
temperature: 0,
|
||||||
|
apiKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
case "ollama":
|
||||||
* Handles user input and updates the editor with the response.
|
return new ChatOllama({
|
||||||
* @param systemPrompt - The system prompt to send to the chat API.
|
model: settings.model,
|
||||||
* @param userRequest - The user's request to process.
|
});
|
||||||
* @returns The AI-generated response or an error message.
|
case "gemini": {
|
||||||
*/
|
const apiKey = getApiKey(this.app);
|
||||||
private async handleEditorUpdate(systemPrompt: string, userRequest: string): Promise<string> {
|
return new ChatGoogleGenerativeAI({
|
||||||
try {
|
model: settings.model,
|
||||||
const response = await this.callApi(systemPrompt, userRequest);
|
apiKey: apiKey ?? undefined,
|
||||||
if (!response) return "⚠️ No response generated.";
|
});
|
||||||
|
}
|
||||||
|
case "azure": {
|
||||||
|
const apiKey = getApiKey(this.app);
|
||||||
|
if (!apiKey || !settings.azureEndpoint) {
|
||||||
|
new Notice(
|
||||||
|
"⚠️ API key and Azure endpoint are required for Azure provider.",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
// Extract instance name from the endpoint URL
|
||||||
if (!markdownView) {
|
const instanceName = this.extractAzureInstanceName(
|
||||||
new Notice("⚠️ No active Markdown editor found.");
|
settings.azureEndpoint,
|
||||||
return "";
|
);
|
||||||
}
|
if (!instanceName) {
|
||||||
|
new Notice(
|
||||||
|
"⚠️ Invalid Azure endpoint format. Expected: https://your-resource.openai.azure.com",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const mainEditorView = (markdownView.editor as any).cm as EditorView;
|
return new AzureChatOpenAI({
|
||||||
mainEditorView?.dispatch({
|
azureOpenAIApiKey: apiKey,
|
||||||
effects: setGeneratedResponseEffect.of({ airesponse: response, prompt: userRequest }),
|
azureOpenAIApiInstanceName: instanceName,
|
||||||
});
|
azureOpenAIApiDeploymentName: settings.model,
|
||||||
|
azureOpenAIApiVersion:
|
||||||
|
settings.azureApiVersion || "2024-02-15-preview",
|
||||||
|
temperature: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case "custom": {
|
||||||
|
const apiKey = getApiKey(this.app);
|
||||||
|
if (!apiKey || !settings.customURL) {
|
||||||
|
new Notice(
|
||||||
|
"⚠️ API key and custom base URL are required for custom providers.",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new ChatOpenAI({
|
||||||
|
modelName: settings.model,
|
||||||
|
temperature: 0,
|
||||||
|
openAIApiKey: apiKey,
|
||||||
|
// 'configuration.basePath' is the recognized property
|
||||||
|
configuration: {
|
||||||
|
baseURL: settings.customURL.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return response;
|
case "codex":
|
||||||
} catch (error: any) {
|
// Handled directly in callApi — no LangChain client needed
|
||||||
console.error("Error processing request:", error);
|
return null;
|
||||||
new Notice(`❌ Error processing request: ${error.message}`);
|
|
||||||
return "⚠️ Failed to process request.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Processes selected text using the specified prompt and transformation.
|
|
||||||
* @param userPrompt - The transformation prompt (e.g., "Add Emojis").
|
|
||||||
* @param selectedText - The selected text to transform.
|
|
||||||
* @returns The transformed text or an error message.
|
|
||||||
*/
|
|
||||||
public async callSelection(userPrompt: string, selectedText: string): Promise<string> {
|
|
||||||
userPrompt = parseCommand(userPrompt, this.settings.commandPrefix, this.settings.customCommands);
|
|
||||||
|
|
||||||
let isCursor = false;
|
default:
|
||||||
if (selectedText.trim().length === 0) {
|
new Notice(`⚠️ Unsupported provider: ${settings.provider}`);
|
||||||
isCursor = true;
|
return null;
|
||||||
}
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error initializing chat client:", error);
|
||||||
|
new Notice(`❌ Error initializing chat client: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const systemPrompt = isCursor ? this.settings.cursorPrompt : this.settings.selectionPrompt;
|
/**
|
||||||
let finalUserPrompt = ``;
|
* Calls the chat API with the provided content and context.
|
||||||
const mode = isCursor ? "cursor" : "selection";
|
* @param systemMessage - The system message to send to the chat API.
|
||||||
if (this.settings.messageHistory) {
|
* @param message - The user's message to send to the chat API.
|
||||||
this.messageHistory.enqueue({ mode, userPrompt });
|
* @returns A promise that resolves with the generated content or an error message.
|
||||||
}
|
*/
|
||||||
|
public async callApi(
|
||||||
|
systemMessage: string,
|
||||||
|
message: string,
|
||||||
|
): Promise<string> {
|
||||||
|
if (this.settings.provider === "codex") {
|
||||||
|
return this.callCodexProvider(systemMessage, message);
|
||||||
|
}
|
||||||
|
|
||||||
if (isCursor) {
|
if (!this.chatClient) {
|
||||||
finalUserPrompt = `
|
new Notice(
|
||||||
|
"⚠️ Chat client is not initialized. Please check your settings.",
|
||||||
|
);
|
||||||
|
return "⚠️ Chat client is not available.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
new SystemMessage(systemMessage),
|
||||||
|
new HumanMessage(message),
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const aiMessage = await this.chatClient.invoke(messages);
|
||||||
|
if (typeof aiMessage === "string") {
|
||||||
|
return aiMessage;
|
||||||
|
}
|
||||||
|
return aiMessage.content.toString();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error calling the chat model:", error);
|
||||||
|
new Notice(`❌ Error calling the chat model: ${error.message}`);
|
||||||
|
return "⚠️ Failed to generate a response. Please try again later.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
* @param userRequest - The user's request to process.
|
||||||
|
* @returns The AI-generated response or an error message.
|
||||||
|
*/
|
||||||
|
private async handleEditorUpdate(
|
||||||
|
systemPrompt: string,
|
||||||
|
userRequest: string,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await this.callApi(systemPrompt, userRequest);
|
||||||
|
if (!response) return "⚠️ No response generated.";
|
||||||
|
|
||||||
|
const markdownView =
|
||||||
|
this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||||
|
if (!markdownView) {
|
||||||
|
new Notice("⚠️ No active Markdown editor found.");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainEditorView = (markdownView.editor as any)
|
||||||
|
.cm as EditorView;
|
||||||
|
mainEditorView?.dispatch({
|
||||||
|
effects: setGeneratedResponseEffect.of({
|
||||||
|
airesponse: response,
|
||||||
|
prompt: userRequest,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error processing request:", error);
|
||||||
|
new Notice(`❌ Error processing request: ${error.message}`);
|
||||||
|
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").
|
||||||
|
* @param selectedText - The selected text to transform.
|
||||||
|
* @returns The transformed text or an error message.
|
||||||
|
*/
|
||||||
|
public async callSelection(
|
||||||
|
userPrompt: string,
|
||||||
|
selectedText: string,
|
||||||
|
): Promise<string> {
|
||||||
|
userPrompt = parseCommand(
|
||||||
|
userPrompt,
|
||||||
|
this.settings.commandPrefix,
|
||||||
|
this.settings.customCommands,
|
||||||
|
);
|
||||||
|
|
||||||
|
let isCursor = false;
|
||||||
|
if (selectedText.trim().length === 0) {
|
||||||
|
isCursor = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPrompt = isCursor
|
||||||
|
? this.settings.cursorPrompt
|
||||||
|
: this.settings.selectionPrompt;
|
||||||
|
let finalUserPrompt = ``;
|
||||||
|
const mode = isCursor ? "cursor" : "selection";
|
||||||
|
if (this.settings.messageHistory) {
|
||||||
|
this.messageHistory.enqueue({ mode, userPrompt });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCursor) {
|
||||||
|
finalUserPrompt = `
|
||||||
**Task:** ${userPrompt}
|
**Task:** ${userPrompt}
|
||||||
**Output:**`;
|
**Output:**`;
|
||||||
} else {
|
} else {
|
||||||
finalUserPrompt = `
|
finalUserPrompt = `
|
||||||
**Task:** ${userPrompt}
|
**Task:** ${userPrompt}
|
||||||
**Input:**
|
**Input:**
|
||||||
${selectedText}
|
${selectedText}
|
||||||
|
|
||||||
**Output:**`;
|
**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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the manager's settings and reinitializes the chat client.
|
* Updates the manager's settings and reinitializes the chat client.
|
||||||
* @param settings - New configuration settings for the chat API.
|
* @param settings - New configuration settings for the chat API.
|
||||||
*/
|
*/
|
||||||
public updateSettings(settings: InlineAISettings): void {
|
public updateSettings(settings: InlineAISettings): void {
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
const newChatClient = this.initializeChatClient(settings);
|
const newChatClient = this.initializeChatClient(settings);
|
||||||
if (!newChatClient) {
|
if (!newChatClient) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.chatClient = newChatClient;
|
this.chatClient = newChatClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMessageHistory(): HistoryMessage[] {
|
public getMessageHistory(): HistoryMessage[] {
|
||||||
return this.messageHistory.getItems();
|
return this.messageHistory.getItems();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
@ -30,7 +30,7 @@ It is **very important** that you follow the examples. Do not add anything at th
|
||||||
| John | 30 | Engineer|
|
| John | 30 | Engineer|
|
||||||
|
|
||||||
---
|
---
|
||||||
`
|
`;
|
||||||
|
|
||||||
export const cursorPrompt = `
|
export const cursorPrompt = `
|
||||||
You are an advanced language model specialized in following specific instructions to create and process markdown documents. Always use **Obsidian-flavored Markdown** syntax in your responses whenever applicable.
|
You are an advanced language model specialized in following specific instructions to create and process markdown documents. Always use **Obsidian-flavored Markdown** syntax in your responses whenever applicable.
|
||||||
|
|
|
||||||
80
src/main.ts
80
src/main.ts
|
|
@ -1,12 +1,26 @@
|
||||||
// main.ts
|
// main.ts
|
||||||
import { Plugin, MarkdownView, App } from "obsidian";
|
import { Plugin, MarkdownView, App } from "obsidian";
|
||||||
import { EditorView } from "@codemirror/view";
|
import { EditorView } from "@codemirror/view";
|
||||||
import { InlineAISettings, DEFAULT_SETTINGS, InlineAISettingsTab } from "./settings";
|
import {
|
||||||
import { acceptTooltipEffect, commandEffect, dismissTooltipEffect, FloatingTooltipExtension } from "./modules/WidgetExtension";
|
InlineAISettings,
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
InlineAISettingsTab,
|
||||||
|
} from "./settings";
|
||||||
|
import {
|
||||||
|
acceptTooltipEffect,
|
||||||
|
commandEffect,
|
||||||
|
dismissTooltipEffect,
|
||||||
|
FloatingTooltipExtension,
|
||||||
|
} from "./modules/WidgetExtension";
|
||||||
import { ChatApiManager } from "./api";
|
import { ChatApiManager } from "./api";
|
||||||
import { generatedResponseState } from "./modules/AIExtension";
|
import { generatedResponseState } from "./modules/AIExtension";
|
||||||
import { buildSelectionHiglightState, currentSelectionState, setSelectionInfoEffect } from "./modules/SelectionState";
|
import {
|
||||||
|
buildSelectionHiglightState,
|
||||||
|
currentSelectionState,
|
||||||
|
setSelectionInfoEffect,
|
||||||
|
} from "./modules/SelectionState";
|
||||||
import { diffExtension } from "./modules/diffExtension";
|
import { diffExtension } from "./modules/diffExtension";
|
||||||
|
import { migrateLegacySecrets, LegacySecretFields } from "./credentials";
|
||||||
|
|
||||||
export default class InlineAIChatPlugin extends Plugin {
|
export default class InlineAIChatPlugin extends Plugin {
|
||||||
settings: InlineAISettings = DEFAULT_SETTINGS;
|
settings: InlineAISettings = DEFAULT_SETTINGS;
|
||||||
|
|
@ -21,7 +35,7 @@ export default class InlineAIChatPlugin extends Plugin {
|
||||||
generatedResponseState,
|
generatedResponseState,
|
||||||
currentSelectionState,
|
currentSelectionState,
|
||||||
buildSelectionHiglightState,
|
buildSelectionHiglightState,
|
||||||
diffExtension
|
diffExtension,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Add command to show tooltip
|
// Add command to show tooltip
|
||||||
|
|
@ -29,9 +43,11 @@ export default class InlineAIChatPlugin extends Plugin {
|
||||||
id: "show-cursor-tooltip",
|
id: "show-cursor-tooltip",
|
||||||
name: "Show cursor tooltip",
|
name: "Show cursor tooltip",
|
||||||
callback: () => {
|
callback: () => {
|
||||||
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
const markdownView =
|
||||||
|
this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||||
if (markdownView) {
|
if (markdownView) {
|
||||||
const cmEditor = (markdownView.editor as any).cm as EditorView;
|
const cmEditor = (markdownView.editor as any)
|
||||||
|
.cm as EditorView;
|
||||||
|
|
||||||
// Grab the main selection range
|
// Grab the main selection range
|
||||||
const { from, to } = cmEditor.state.selection.main;
|
const { from, to } = cmEditor.state.selection.main;
|
||||||
|
|
@ -39,13 +55,22 @@ export default class InlineAIChatPlugin extends Plugin {
|
||||||
|
|
||||||
if (from !== to) {
|
if (from !== to) {
|
||||||
// If there is a real selection, store it
|
// If there is a real selection, store it
|
||||||
const selectedText = cmEditor.state.doc.sliceString(from, to);
|
const selectedText = cmEditor.state.doc.sliceString(
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
);
|
||||||
effects.push(
|
effects.push(
|
||||||
setSelectionInfoEffect.of({ from, to, text: selectedText })
|
setSelectionInfoEffect.of({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
text: selectedText,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// If no selection, store cursor position instead of null
|
// If no selection, store cursor position instead of null
|
||||||
effects.push(setSelectionInfoEffect.of({ from, to, text: "" }));
|
effects.push(
|
||||||
|
setSelectionInfoEffect.of({ from, to, text: "" }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also trigger the overlay
|
// Also trigger the overlay
|
||||||
|
|
@ -55,18 +80,22 @@ export default class InlineAIChatPlugin extends Plugin {
|
||||||
cmEditor.dispatch({ effects });
|
cmEditor.dispatch({ effects });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
hotkeys: [
|
hotkeys: [{ modifiers: ["Ctrl", "Shift"], key: " " }],
|
||||||
],
|
|
||||||
});
|
});
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: "accept-tooltip",
|
id: "accept-tooltip",
|
||||||
name: "Accept tooltip suggestion",
|
name: "Accept tooltip suggestion",
|
||||||
callback: () => {
|
callback: () => {
|
||||||
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
const markdownView =
|
||||||
|
this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||||
if (markdownView) {
|
if (markdownView) {
|
||||||
const cmEditor = (markdownView.editor as any).cm as EditorView;
|
const cmEditor = (markdownView.editor as any)
|
||||||
|
.cm as EditorView;
|
||||||
|
|
||||||
const response = cmEditor.state.field(generatedResponseState, false);
|
const response = cmEditor.state.field(
|
||||||
|
generatedResponseState,
|
||||||
|
false,
|
||||||
|
);
|
||||||
if (response) {
|
if (response) {
|
||||||
cmEditor.dispatch({
|
cmEditor.dispatch({
|
||||||
effects: acceptTooltipEffect.of(null),
|
effects: acceptTooltipEffect.of(null),
|
||||||
|
|
@ -76,25 +105,28 @@ export default class InlineAIChatPlugin extends Plugin {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: "discard-tooltip",
|
id: "discard-tooltip",
|
||||||
name: "Discard tooltip suggestion",
|
name: "Discard tooltip suggestion",
|
||||||
callback: () => {
|
callback: () => {
|
||||||
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
const markdownView =
|
||||||
|
this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||||
if (markdownView) {
|
if (markdownView) {
|
||||||
const cmEditor = (markdownView.editor as any).cm as EditorView;
|
const cmEditor = (markdownView.editor as any)
|
||||||
const response = cmEditor.state.field(generatedResponseState, false);
|
.cm as EditorView;
|
||||||
|
const response = cmEditor.state.field(
|
||||||
|
generatedResponseState,
|
||||||
|
false,
|
||||||
|
);
|
||||||
if (response) {
|
if (response) {
|
||||||
cmEditor.dispatch({
|
cmEditor.dispatch({
|
||||||
effects: dismissTooltipEffect.of(null),
|
effects: dismissTooltipEffect.of(null),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add settings tab
|
// Add settings tab
|
||||||
|
|
@ -106,7 +138,11 @@ export default class InlineAIChatPlugin extends Plugin {
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadSettings() {
|
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() {
|
async saveSettings() {
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,36 @@
|
||||||
import {
|
import { StateEffect, StateField } from "@codemirror/state";
|
||||||
StateEffect,
|
|
||||||
StateField,
|
|
||||||
} from "@codemirror/state";
|
|
||||||
|
|
||||||
import { dismissTooltipEffect } from "./WidgetExtension";
|
import { dismissTooltipEffect } from "./WidgetExtension";
|
||||||
|
|
||||||
// Custom structure that has a airesponse and context fields
|
// Custom structure that has a airesponse and context fields
|
||||||
export interface AIResponse {
|
export interface AIResponse {
|
||||||
airesponse: string;
|
airesponse: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State Effect to set the AI response.
|
* State Effect to set the AI response.
|
||||||
*/
|
*/
|
||||||
export const setGeneratedResponseEffect = StateEffect.define<AIResponse | null>();
|
export const setGeneratedResponseEffect =
|
||||||
|
StateEffect.define<AIResponse | null>();
|
||||||
|
|
||||||
// State field of type text to store the response from the API
|
// State field of type text to store the response from the API
|
||||||
export const generatedResponseState = StateField.define<AIResponse | null>({
|
export const generatedResponseState = StateField.define<AIResponse | null>({
|
||||||
create() {
|
create() {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
update(value, tr) {
|
update(value, tr) {
|
||||||
// Check if the transaction contains an effect to set the response
|
// Check if the transaction contains an effect to set the response
|
||||||
if (tr.effects.some((e) => e.is(setGeneratedResponseEffect))) {
|
if (tr.effects.some((e) => e.is(setGeneratedResponseEffect))) {
|
||||||
const effect = tr.effects.find((e) => e.is(setGeneratedResponseEffect));
|
const effect = tr.effects.find((e) =>
|
||||||
return effect ? effect.value : value;
|
e.is(setGeneratedResponseEffect),
|
||||||
}
|
);
|
||||||
// if we geta dismmisTooltipEffect we should clear the response
|
return effect ? effect.value : value;
|
||||||
if (tr.effects.some((e) => e.is(dismissTooltipEffect))) {
|
}
|
||||||
return null;
|
// if we geta dismmisTooltipEffect we should clear the response
|
||||||
}
|
if (tr.effects.some((e) => e.is(dismissTooltipEffect))) {
|
||||||
return value;
|
return null;
|
||||||
},
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,62 +8,67 @@ import { dismissTooltipEffect } from "./WidgetExtension";
|
||||||
* Interface describing the selection range and text.
|
* Interface describing the selection range and text.
|
||||||
*/
|
*/
|
||||||
export interface SelectionInfo {
|
export interface SelectionInfo {
|
||||||
from: number;
|
from: number;
|
||||||
to: number;
|
to: number;
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect used to set or clear the selection info.
|
* Effect used to set or clear the selection info.
|
||||||
*/
|
*/
|
||||||
export const setSelectionInfoEffect = StateEffect.define<SelectionInfo | null>();
|
export const setSelectionInfoEffect =
|
||||||
|
StateEffect.define<SelectionInfo | null>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Field that holds the most recently preserved selection info.
|
* Field that holds the most recently preserved selection info.
|
||||||
*/
|
*/
|
||||||
export const currentSelectionState = StateField.define<SelectionInfo | null>({
|
export const currentSelectionState = StateField.define<SelectionInfo | null>({
|
||||||
create() {
|
create() {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
update(value, tr) {
|
update(value, tr) {
|
||||||
// Look for a setSelectionInfoEffect in this transaction
|
// Look for a setSelectionInfoEffect in this transaction
|
||||||
const effect = tr.effects.find(e => e.is(setSelectionInfoEffect));
|
const effect = tr.effects.find((e) => e.is(setSelectionInfoEffect));
|
||||||
if (effect) {
|
if (effect) {
|
||||||
return effect.value;
|
return effect.value;
|
||||||
} else if (tr.effects.some(e => e.is(dismissTooltipEffect))) {
|
} else if (tr.effects.some((e) => e.is(dismissTooltipEffect))) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decoration to highlight the selected text.
|
* Decoration to highlight the selected text.
|
||||||
*/
|
*/
|
||||||
const highlightDecoration = Decoration.mark({
|
const highlightDecoration = Decoration.mark({
|
||||||
class: "cm-selectionBackground", // CSS class for highlighting
|
class: "cm-selectionBackground", // CSS class for highlighting
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* StateField that manages the decoration set for highlighting.
|
* StateField that manages the decoration set for highlighting.
|
||||||
*/
|
*/
|
||||||
export const buildSelectionHiglightState = StateField.define<DecorationSet>({
|
export const buildSelectionHiglightState = StateField.define<DecorationSet>({
|
||||||
create(state) {
|
create(state) {
|
||||||
const info = state.field(currentSelectionState);
|
const info = state.field(currentSelectionState);
|
||||||
if (info && info.from !== info.to) {
|
if (info && info.from !== info.to) {
|
||||||
console.log("info", info);
|
console.log("info", info);
|
||||||
return Decoration.set([highlightDecoration.range(info.from, info.to)]);
|
return Decoration.set([
|
||||||
}
|
highlightDecoration.range(info.from, info.to),
|
||||||
return Decoration.none;
|
]);
|
||||||
},
|
}
|
||||||
update(decos, tr) {
|
return Decoration.none;
|
||||||
// Check if selectionInfoField has changed
|
},
|
||||||
const info = tr.state.field(currentSelectionState);
|
update(decos, tr) {
|
||||||
if (info && info.from !== info.to) {
|
// Check if selectionInfoField has changed
|
||||||
return Decoration.set([highlightDecoration.range(info.from, info.to)]);
|
const info = tr.state.field(currentSelectionState);
|
||||||
}
|
if (info && info.from !== info.to) {
|
||||||
return Decoration.none;
|
return Decoration.set([
|
||||||
},
|
highlightDecoration.range(info.from, info.to),
|
||||||
provide: f => EditorView.decorations.from(f),
|
]);
|
||||||
|
}
|
||||||
|
return Decoration.none;
|
||||||
|
},
|
||||||
|
provide: (f) => EditorView.decorations.from(f),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,418 +1,531 @@
|
||||||
// modules/WidgetExtension.ts
|
// modules/WidgetExtension.ts
|
||||||
import {
|
import { EditorState, StateEffect, StateField } from "@codemirror/state";
|
||||||
EditorState,
|
|
||||||
StateEffect,
|
|
||||||
StateField,
|
|
||||||
} from "@codemirror/state";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EditorView,
|
EditorView,
|
||||||
Decoration,
|
Decoration,
|
||||||
DecorationSet,
|
DecorationSet,
|
||||||
WidgetType,
|
WidgetType,
|
||||||
placeholder,
|
placeholder,
|
||||||
keymap,
|
keymap,
|
||||||
} from "@codemirror/view";
|
} from "@codemirror/view";
|
||||||
|
|
||||||
import { setIcon } from "obsidian";
|
import { setIcon } from "obsidian";
|
||||||
import { ChatApiManager } from "../api";
|
import { ChatApiManager } from "../api";
|
||||||
|
|
||||||
import { currentSelectionState, SelectionInfo } from "./SelectionState";
|
import { currentSelectionState, SelectionInfo } from "./SelectionState";
|
||||||
import { createSlashCommandHighlighter, slashCommandAutocompletion } from "./commands/source";
|
import {
|
||||||
|
createSlashCommandHighlighter,
|
||||||
|
slashCommandAutocompletion,
|
||||||
|
} from "./commands/source";
|
||||||
import InlineAIChatPlugin from "src/main";
|
import InlineAIChatPlugin from "src/main";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Some existing exports
|
// Some existing exports
|
||||||
export const commandEffect = StateEffect.define<null>();
|
export const commandEffect = StateEffect.define<null>();
|
||||||
export const dismissTooltipEffect = StateEffect.define<null>();
|
export const dismissTooltipEffect = StateEffect.define<null>();
|
||||||
export const acceptTooltipEffect = StateEffect.define<null>();
|
export const acceptTooltipEffect = StateEffect.define<null>();
|
||||||
|
|
||||||
class FloatingWidget extends WidgetType {
|
class FloatingWidget extends WidgetType {
|
||||||
private chatApiManager: ChatApiManager;
|
private chatApiManager: ChatApiManager;
|
||||||
private selectionInfo: SelectionInfo | null;
|
private selectionInfo: SelectionInfo | null;
|
||||||
private plugin: InlineAIChatPlugin;
|
private plugin: InlineAIChatPlugin;
|
||||||
|
|
||||||
private outerEditorView: EditorView | null = null;
|
private outerEditorView: EditorView | null = null;
|
||||||
|
|
||||||
private dom: HTMLElement;
|
private dom: HTMLElement;
|
||||||
private innerDom: HTMLElement;
|
private innerDom: HTMLElement;
|
||||||
|
|
||||||
private textFieldView?: EditorView;
|
private textFieldView?: EditorView;
|
||||||
// Primary Action Buttons
|
// Primary Action Buttons
|
||||||
private submitButton!: HTMLButtonElement;
|
private submitButton!: HTMLButtonElement;
|
||||||
private loaderElement!: HTMLElement;
|
private loaderElement!: HTMLElement;
|
||||||
|
|
||||||
//Secondary Action Buttons
|
//Secondary Action Buttons
|
||||||
private acceptButton!: HTMLButtonElement;
|
private acceptButton!: HTMLButtonElement;
|
||||||
private discardButton!: HTMLButtonElement;
|
private discardButton!: HTMLButtonElement;
|
||||||
|
|
||||||
// Track the current index in message history for up/down navigation
|
// Track the current index in message history for up/down navigation
|
||||||
private messageHistoryIndex: number | null = null;
|
private messageHistoryIndex: number | null = null;
|
||||||
|
|
||||||
constructor(chatApiManager: ChatApiManager, selectionInfo: SelectionInfo | null, plugin: InlineAIChatPlugin) {
|
// Store references to event listeners for cleanup
|
||||||
super();
|
private onClickOutside: ((event: MouseEvent) => void) | null = null;
|
||||||
this.chatApiManager = chatApiManager;
|
private onEscape: ((event: KeyboardEvent) => void) | null = null;
|
||||||
this.selectionInfo = selectionInfo;
|
private escapeWindows: Window[] = [];
|
||||||
this.plugin = plugin;
|
|
||||||
|
|
||||||
// Create main DOM structure using createEl
|
// (Focus suppression moved to diffExtension focusGuardPlugin)
|
||||||
this.dom = createEl("div", { cls: "cm-cursor-overlay", attr: { style: "user-select: none;" } });
|
|
||||||
this.innerDom = this.dom.createEl("div", { cls: "cm-cursor-overlay-inner" });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
constructor(
|
||||||
* Overriding toDOM(view: EditorView) instead of just toDOM().
|
chatApiManager: ChatApiManager,
|
||||||
*/
|
selectionInfo: SelectionInfo | null,
|
||||||
public override toDOM(view: EditorView): HTMLElement {
|
plugin: InlineAIChatPlugin,
|
||||||
// Capture the outer EditorView
|
) {
|
||||||
this.outerEditorView = view;
|
super();
|
||||||
|
this.chatApiManager = chatApiManager;
|
||||||
|
this.selectionInfo = selectionInfo;
|
||||||
|
this.plugin = plugin;
|
||||||
|
|
||||||
this.createPencilIcon();
|
// Create main DOM structure using createEl
|
||||||
this.createInputField();
|
this.dom = createEl("div", {
|
||||||
this.createSubmitButton();
|
cls: "cm-cursor-overlay",
|
||||||
this.createLoader();
|
attr: { style: "user-select: none;" },
|
||||||
|
});
|
||||||
|
this.innerDom = this.dom.createEl("div", {
|
||||||
|
cls: "cm-cursor-overlay-inner",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
/**
|
||||||
this.textFieldView?.focus();
|
* Overriding toDOM(view: EditorView) instead of just toDOM().
|
||||||
}, 0);
|
*/
|
||||||
|
public override toDOM(view: EditorView): HTMLElement {
|
||||||
|
// Capture the outer EditorView
|
||||||
|
this.outerEditorView = view;
|
||||||
|
|
||||||
// Setup "click outside" and "Escape" dismissal
|
this.createPencilIcon();
|
||||||
const onClickOutside = (event: MouseEvent) => {
|
this.createInputField();
|
||||||
if (!this.dom.contains(event.target as Node)) {
|
this.createSubmitButton();
|
||||||
this.dismissTooltip();
|
this.createLoader();
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onEscape = (event: KeyboardEvent) => {
|
setTimeout(() => {
|
||||||
if (event.key === "Escape") {
|
this.textFieldView?.focus();
|
||||||
this.dismissTooltip();
|
}, 0);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", onClickOutside);
|
// Setup "click outside" and "Escape" dismissal
|
||||||
document.addEventListener("keydown", onEscape);
|
this.onClickOutside = (event: MouseEvent) => {
|
||||||
|
if (!this.dom.contains(event.target as Node)) {
|
||||||
|
this.dismissTooltip();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Cleanup
|
// Escape key: listen on all windows
|
||||||
this.dom.addEventListener("destroy", () => {
|
this.onEscape = (event: KeyboardEvent) => {
|
||||||
document.removeEventListener("mousedown", onClickOutside);
|
if (event.key === "Escape") {
|
||||||
document.removeEventListener("keydown", onEscape);
|
this.dismissTooltip();
|
||||||
});
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return this.dom;
|
// Always add to main window
|
||||||
}
|
window.addEventListener("mousedown", this.onClickOutside);
|
||||||
|
window.addEventListener("keydown", this.onEscape);
|
||||||
|
|
||||||
public override destroy(): void {
|
// Try to add to all popout windows (if any)
|
||||||
this.textFieldView?.destroy();
|
this.escapeWindows = [window];
|
||||||
this.innerDom.empty();
|
// @ts-ignore
|
||||||
|
if (window.app && window.app.openPopouts instanceof Function) {
|
||||||
|
// Obsidian 1.4+ openPopouts returns array of popout windows
|
||||||
|
// @ts-ignore
|
||||||
|
const popouts: Window[] = window.app.openPopouts();
|
||||||
|
for (const popout of popouts) {
|
||||||
|
try {
|
||||||
|
popout.addEventListener("keydown", this.onEscape!);
|
||||||
|
this.escapeWindows.push(popout);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
(window as any).app &&
|
||||||
|
Array.isArray((window as any).app?.workspace?.getWindows?.())
|
||||||
|
) {
|
||||||
|
// Legacy: app.workspace.getWindows() returns array of windows
|
||||||
|
const popouts: Window[] = (
|
||||||
|
window as any
|
||||||
|
).app.workspace.getWindows();
|
||||||
|
for (const popout of popouts) {
|
||||||
|
try {
|
||||||
|
popout.addEventListener("keydown", this.onEscape!);
|
||||||
|
this.escapeWindows.push(popout);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.submitButton.remove();
|
// Cleanup
|
||||||
this.loaderElement.remove();
|
this.dom.addEventListener("destroy", () => {
|
||||||
|
document.removeEventListener("mousedown", this.onClickOutside!);
|
||||||
|
for (const win of this.escapeWindows) {
|
||||||
|
try {
|
||||||
|
win.removeEventListener("keydown", this.onEscape!);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.escapeWindows = [];
|
||||||
|
});
|
||||||
|
|
||||||
if (this.acceptButton) this.acceptButton.remove();
|
// Add a body-level flag so we can style/coordinate behavior if needed
|
||||||
if (this.discardButton) this.discardButton.remove();
|
document.body.classList.add("inlineai-widget-open");
|
||||||
|
|
||||||
this.textFieldView = undefined;
|
// Focus guard is handled globally by diffExtension's focusGuardPlugin
|
||||||
this.outerEditorView = null;
|
|
||||||
this.messageHistoryIndex = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private dismissTooltip() {
|
return this.dom;
|
||||||
if (this.outerEditorView) {
|
}
|
||||||
this.outerEditorView.dispatch({
|
|
||||||
effects: dismissTooltipEffect.of(null),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
public override destroy(): void {
|
||||||
|
this.textFieldView?.destroy();
|
||||||
|
this.innerDom.empty();
|
||||||
|
|
||||||
private createPencilIcon() {
|
this.submitButton.remove();
|
||||||
if (!this.innerDom.querySelector(".cm-pencil-icon")) {
|
this.loaderElement.remove();
|
||||||
const icon = this.innerDom.createEl("div", { cls: "cm-pencil-icon" });
|
|
||||||
setIcon(icon, "pencil");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private createInputField() {
|
if (this.acceptButton) this.acceptButton.remove();
|
||||||
const editorDom = this.innerDom.createEl("div", { cls: "cm-tooltip-editor", attr: { style: "user-select: text;" } });
|
if (this.discardButton) this.discardButton.remove();
|
||||||
|
|
||||||
this.textFieldView = new EditorView({
|
// Remove flag/class
|
||||||
state: EditorState.create({
|
document.body.classList.remove("inlineai-widget-open");
|
||||||
doc: "",
|
|
||||||
extensions: [
|
|
||||||
// 1) Show a placeholder in the input field
|
|
||||||
placeholder("Ask copilot"),
|
|
||||||
// 2) Add key bindings (including default ones for typical editor commands)
|
|
||||||
keymap.of([
|
|
||||||
{
|
|
||||||
key: "Enter",
|
|
||||||
run: () => {
|
|
||||||
this.submitAction()
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
preventDefault: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "Mod-a",
|
|
||||||
run: (view) => {
|
|
||||||
const doc = view.state.doc;
|
|
||||||
view.dispatch({
|
|
||||||
selection: { anchor: 0, head: doc.length },
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
preventDefault: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "ArrowUp",
|
|
||||||
run: () => {
|
|
||||||
const messageHistory = this.chatApiManager.getMessageHistory();
|
|
||||||
if (messageHistory.length === 0 || !this.textFieldView) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not started, set to last index
|
// Focus suppression cleanup no longer needed (handled by focusGuardPlugin)
|
||||||
if (this.messageHistoryIndex === null) {
|
|
||||||
this.messageHistoryIndex = messageHistory.length - 1;
|
|
||||||
} else if (this.messageHistoryIndex > 0) {
|
|
||||||
this.messageHistoryIndex--;
|
|
||||||
}
|
|
||||||
// Clamp to 0
|
|
||||||
if (this.messageHistoryIndex < 0) this.messageHistoryIndex = 0;
|
|
||||||
|
|
||||||
const msg = messageHistory[this.messageHistoryIndex].userPrompt;
|
this.textFieldView = undefined;
|
||||||
this.textFieldView.dispatch({
|
this.outerEditorView = null;
|
||||||
changes: {
|
this.messageHistoryIndex = null;
|
||||||
from: 0,
|
}
|
||||||
to: this.textFieldView.state.doc.length,
|
|
||||||
insert: msg,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Move cursor to end
|
|
||||||
this.textFieldView.dispatch({
|
|
||||||
selection: { anchor: msg.length, head: msg.length }
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
preventDefault: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "ArrowDown",
|
|
||||||
run: () => {
|
|
||||||
const messageHistory = this.chatApiManager.getMessageHistory();
|
|
||||||
if (messageHistory.length === 0 || !this.textFieldView) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (this.messageHistoryIndex === null) {
|
|
||||||
// Do nothing if not in history navigation
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (this.messageHistoryIndex < messageHistory.length - 1) {
|
|
||||||
this.messageHistoryIndex++;
|
|
||||||
const msg = messageHistory[this.messageHistoryIndex].userPrompt;
|
|
||||||
this.textFieldView.dispatch({
|
|
||||||
changes: {
|
|
||||||
from: 0,
|
|
||||||
to: this.textFieldView.state.doc.length,
|
|
||||||
insert: msg,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Move cursor to end
|
|
||||||
this.textFieldView.dispatch({
|
|
||||||
selection: { anchor: msg.length, head: msg.length }
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// If at the end, clear the field and exit history navigation
|
|
||||||
this.messageHistoryIndex = null;
|
|
||||||
this.textFieldView.dispatch({
|
|
||||||
changes: {
|
|
||||||
from: 0,
|
|
||||||
to: this.textFieldView.state.doc.length,
|
|
||||||
insert: "",
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
preventDefault: true,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
|
|
||||||
// 3) Enable slash-command autocompletion
|
private dismissTooltip() {
|
||||||
slashCommandAutocompletion({
|
if (this.outerEditorView) {
|
||||||
prefix: this.plugin.settings.commandPrefix,
|
this.outerEditorView.dispatch({
|
||||||
customCommands: this.plugin.settings.customCommands
|
effects: dismissTooltipEffect.of(null),
|
||||||
}),
|
});
|
||||||
createSlashCommandHighlighter({
|
}
|
||||||
prefix: this.plugin.settings.commandPrefix,
|
}
|
||||||
customCommands: this.plugin.settings.customCommands
|
|
||||||
})
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
parent: editorDom,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Reset messageHistoryIndex when user types or changes input
|
private createPencilIcon() {
|
||||||
this.textFieldView.dom.addEventListener("input", () => {
|
if (!this.innerDom.querySelector(".cm-pencil-icon")) {
|
||||||
// Only reset if the input is not the result of an up/down navigation
|
const icon = this.innerDom.createEl("div", {
|
||||||
// (i.e., if the value doesn't match the current history entry)
|
cls: "cm-pencil-icon",
|
||||||
// But for simplicity, always reset if user types
|
});
|
||||||
this.messageHistoryIndex = null;
|
setIcon(icon, "pencil");
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private createSubmitButton() {
|
private createInputField() {
|
||||||
this.submitButton = this.innerDom.createEl("button", {
|
const editorDom = this.innerDom.createEl("div", {
|
||||||
cls: "submit-button tooltip-button",
|
cls: "cm-tooltip-editor",
|
||||||
text: "Submit",
|
attr: { style: "user-select: text;" },
|
||||||
});
|
});
|
||||||
setIcon(this.submitButton, "send-horizontal");
|
|
||||||
|
|
||||||
this.submitButton.onclick = () => {
|
this.textFieldView = new EditorView({
|
||||||
this.submitAction();
|
state: EditorState.create({
|
||||||
};
|
doc: "",
|
||||||
}
|
extensions: [
|
||||||
|
// 1) Show a placeholder in the input field
|
||||||
|
placeholder("Ask copilot"),
|
||||||
|
// 2) Add key bindings (including default ones for typical editor commands)
|
||||||
|
keymap.of([
|
||||||
|
{
|
||||||
|
key: "Enter",
|
||||||
|
run: () => {
|
||||||
|
this.submitAction();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
preventDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Mod-a",
|
||||||
|
run: (view) => {
|
||||||
|
const doc = view.state.doc;
|
||||||
|
view.dispatch({
|
||||||
|
selection: { anchor: 0, head: doc.length },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
preventDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ArrowUp",
|
||||||
|
run: () => {
|
||||||
|
const messageHistory =
|
||||||
|
this.chatApiManager.getMessageHistory();
|
||||||
|
if (
|
||||||
|
messageHistory.length === 0 ||
|
||||||
|
!this.textFieldView
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private createLoader() {
|
// If not started, set to last index
|
||||||
this.loaderElement = this.innerDom.createEl("div", { cls: "loader" });
|
if (this.messageHistoryIndex === null) {
|
||||||
this.toggleLoading(false);
|
this.messageHistoryIndex =
|
||||||
}
|
messageHistory.length - 1;
|
||||||
|
} else if (this.messageHistoryIndex > 0) {
|
||||||
|
this.messageHistoryIndex--;
|
||||||
|
}
|
||||||
|
// Clamp to 0
|
||||||
|
if (this.messageHistoryIndex < 0)
|
||||||
|
this.messageHistoryIndex = 0;
|
||||||
|
|
||||||
/**
|
const msg =
|
||||||
* Handles the submit action by calling the AI with the user input and the selected text.
|
messageHistory[this.messageHistoryIndex]
|
||||||
*/
|
.userPrompt;
|
||||||
private submitAction() {
|
this.textFieldView.dispatch({
|
||||||
const userPrompt = this.textFieldView?.state.doc.toString() ?? "";
|
changes: {
|
||||||
|
from: 0,
|
||||||
|
to: this.textFieldView.state.doc.length,
|
||||||
|
insert: msg,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Move cursor to end
|
||||||
|
this.textFieldView.dispatch({
|
||||||
|
selection: {
|
||||||
|
anchor: msg.length,
|
||||||
|
head: msg.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
preventDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ArrowDown",
|
||||||
|
run: () => {
|
||||||
|
const messageHistory =
|
||||||
|
this.chatApiManager.getMessageHistory();
|
||||||
|
if (
|
||||||
|
messageHistory.length === 0 ||
|
||||||
|
!this.textFieldView
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (this.messageHistoryIndex === null) {
|
||||||
|
// Do nothing if not in history navigation
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.messageHistoryIndex <
|
||||||
|
messageHistory.length - 1
|
||||||
|
) {
|
||||||
|
this.messageHistoryIndex++;
|
||||||
|
const msg =
|
||||||
|
messageHistory[this.messageHistoryIndex]
|
||||||
|
.userPrompt;
|
||||||
|
this.textFieldView.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: 0,
|
||||||
|
to: this.textFieldView.state.doc
|
||||||
|
.length,
|
||||||
|
insert: msg,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Move cursor to end
|
||||||
|
this.textFieldView.dispatch({
|
||||||
|
selection: {
|
||||||
|
anchor: msg.length,
|
||||||
|
head: msg.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If at the end, clear the field and exit history navigation
|
||||||
|
this.messageHistoryIndex = null;
|
||||||
|
this.textFieldView.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: 0,
|
||||||
|
to: this.textFieldView.state.doc
|
||||||
|
.length,
|
||||||
|
insert: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
preventDefault: true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
|
||||||
if (!userPrompt.trim()) {
|
// 3) Enable slash-command autocompletion
|
||||||
console.warn("Empty input. Submission aborted.");
|
slashCommandAutocompletion({
|
||||||
return;
|
prefix: this.plugin.settings.commandPrefix,
|
||||||
}
|
customCommands: this.plugin.settings.customCommands,
|
||||||
|
}),
|
||||||
|
createSlashCommandHighlighter({
|
||||||
|
prefix: this.plugin.settings.commandPrefix,
|
||||||
|
customCommands: this.plugin.settings.customCommands,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
parent: editorDom,
|
||||||
|
});
|
||||||
|
|
||||||
// Grab the selected text from the stored selection info
|
// Reset messageHistoryIndex when user types or changes input
|
||||||
const selectedText = this.selectionInfo?.text ?? "";
|
this.textFieldView.dom.addEventListener("input", () => {
|
||||||
|
// Only reset if the input is not the result of an up/down navigation
|
||||||
|
// (i.e., if the value doesn't match the current history entry)
|
||||||
|
// But for simplicity, always reset if user types
|
||||||
|
this.messageHistoryIndex = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Show loader
|
private createSubmitButton() {
|
||||||
this.toggleLoading(true);
|
this.submitButton = this.innerDom.createEl("button", {
|
||||||
|
cls: "submit-button tooltip-button",
|
||||||
|
text: "Submit",
|
||||||
|
});
|
||||||
|
setIcon(this.submitButton, "send-horizontal");
|
||||||
|
|
||||||
this.chatApiManager
|
this.submitButton.onclick = () => {
|
||||||
.callSelection(userPrompt, selectedText)
|
this.submitAction();
|
||||||
.then((aiResponse) => {
|
};
|
||||||
this.showActionButtons();
|
}
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Error calling AI:", error);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
// Hide loader
|
|
||||||
this.toggleLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset message history navigation after submit
|
private createLoader() {
|
||||||
this.messageHistoryIndex = null;
|
this.loaderElement = this.innerDom.createEl("div", { cls: "loader" });
|
||||||
}
|
this.toggleLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggles the visibility of the submit button and loader.
|
* Handles the submit action by calling the AI with the user input and the selected text.
|
||||||
* @param isLoading - Whether to show the loader.
|
*/
|
||||||
*/
|
private submitAction() {
|
||||||
private toggleLoading(isLoading: boolean) {
|
// Before doing anything, return focus to the main editor to prevent
|
||||||
if (isLoading) {
|
// Obsidian from re-rendering code blocks due to the editor losing focus.
|
||||||
this.submitButton.classList.add("hidden");
|
// After submitting, users typically review diffs and click Accept/Discard,
|
||||||
this.loaderElement.classList.remove("hidden");
|
// so keeping focus on the outer editor avoids preview toggles.
|
||||||
} else {
|
this.outerEditorView?.focus();
|
||||||
this.submitButton.classList.remove("hidden");
|
|
||||||
this.loaderElement.classList.add("hidden");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const userPrompt = this.textFieldView?.state.doc.toString() ?? "";
|
||||||
|
|
||||||
/**
|
if (!userPrompt.trim()) {
|
||||||
* Transitions the widget to show Accept, Discard, and Reload buttons.
|
console.warn("Empty input. Submission aborted.");
|
||||||
*/
|
return;
|
||||||
private showActionButtons() {
|
}
|
||||||
this.submitButton.classList.add("hidden");
|
|
||||||
this.createAcceptButton();
|
|
||||||
this.createDiscardButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Grab the selected text from the stored selection info
|
||||||
* Creates the Accept button.
|
const selectedText = this.selectionInfo?.text ?? "";
|
||||||
*/
|
|
||||||
private createAcceptButton() {
|
|
||||||
if (!this.acceptButton) {
|
|
||||||
this.acceptButton = this.innerDom.createEl("button", {
|
|
||||||
cls: "accept-button tooltip-button primary-action",
|
|
||||||
text: "Accept",
|
|
||||||
});
|
|
||||||
setIcon(this.acceptButton, "check");
|
|
||||||
|
|
||||||
this.acceptButton.onclick = () => {
|
// Show loader
|
||||||
this.acceptAction();
|
this.toggleLoading(true);
|
||||||
};
|
|
||||||
|
|
||||||
this.innerDom.appendChild(this.acceptButton);
|
this.chatApiManager
|
||||||
}
|
.callSelection(userPrompt, selectedText)
|
||||||
}
|
.then((aiResponse) => {
|
||||||
|
this.showActionButtons();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error calling AI:", error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
// Hide loader
|
||||||
|
this.toggleLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
// Reset message history navigation after submit
|
||||||
* Creates the Discard button.
|
this.messageHistoryIndex = null;
|
||||||
*/
|
}
|
||||||
private createDiscardButton() {
|
|
||||||
if (!this.discardButton) {
|
|
||||||
this.discardButton = this.innerDom.createEl("button", {
|
|
||||||
cls: "discard-button tooltip-button",
|
|
||||||
text: "Discard",
|
|
||||||
});
|
|
||||||
setIcon(this.discardButton, "cross");
|
|
||||||
|
|
||||||
this.discardButton.onclick = () => {
|
/**
|
||||||
this.discardAction();
|
* Toggles the visibility of the submit button and loader.
|
||||||
};
|
* @param isLoading - Whether to show the loader.
|
||||||
|
*/
|
||||||
|
private toggleLoading(isLoading: boolean) {
|
||||||
|
if (isLoading) {
|
||||||
|
this.submitButton.classList.add("hidden");
|
||||||
|
this.loaderElement.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
this.submitButton.classList.remove("hidden");
|
||||||
|
this.loaderElement.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.innerDom.appendChild(this.discardButton);
|
/**
|
||||||
}
|
* Transitions the widget to show Accept, Discard, and Reload buttons.
|
||||||
}
|
*/
|
||||||
|
private showActionButtons() {
|
||||||
|
this.submitButton.classList.add("hidden");
|
||||||
|
this.createAcceptButton();
|
||||||
|
this.createDiscardButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the Accept button.
|
||||||
|
*/
|
||||||
|
private createAcceptButton() {
|
||||||
|
if (!this.acceptButton) {
|
||||||
|
this.acceptButton = this.innerDom.createEl("button", {
|
||||||
|
cls: "accept-button tooltip-button primary-action",
|
||||||
|
text: "Accept",
|
||||||
|
});
|
||||||
|
setIcon(this.acceptButton, "check");
|
||||||
|
|
||||||
/**
|
// Prevent the button from stealing focus from the editor
|
||||||
* Handles the Accept action.
|
this.acceptButton.addEventListener("mousedown", (e) => {
|
||||||
* Confirms the result, applies changes, and closes the tooltip.
|
e.preventDefault();
|
||||||
*/
|
e.stopPropagation();
|
||||||
private acceptAction() {
|
});
|
||||||
if (this.outerEditorView) {
|
|
||||||
this.outerEditorView.dispatch({
|
|
||||||
effects: acceptTooltipEffect.of(null),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.dismissTooltip();
|
|
||||||
}
|
|
||||||
|
|
||||||
private discardAction() {
|
this.acceptButton.onclick = () => {
|
||||||
this.dismissTooltip();
|
this.acceptAction();
|
||||||
}
|
};
|
||||||
|
|
||||||
|
this.innerDom.appendChild(this.acceptButton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the Discard button.
|
||||||
|
*/
|
||||||
|
private createDiscardButton() {
|
||||||
|
if (!this.discardButton) {
|
||||||
|
this.discardButton = this.innerDom.createEl("button", {
|
||||||
|
cls: "discard-button tooltip-button",
|
||||||
|
text: "Discard",
|
||||||
|
});
|
||||||
|
setIcon(this.discardButton, "cross");
|
||||||
|
|
||||||
|
// Prevent the button from stealing focus from the editor
|
||||||
|
this.discardButton.addEventListener("mousedown", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.discardButton.onclick = () => {
|
||||||
|
this.discardAction();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.innerDom.appendChild(this.discardButton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the Accept action.
|
||||||
|
* Confirms the result, applies changes, and closes the tooltip.
|
||||||
|
*/
|
||||||
|
private acceptAction() {
|
||||||
|
if (this.outerEditorView) {
|
||||||
|
this.outerEditorView.dispatch({
|
||||||
|
effects: acceptTooltipEffect.of(null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.dismissTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
private discardAction() {
|
||||||
|
this.dismissTooltip();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build decorations for the first non-empty selection range.
|
* Build decorations for the first non-empty selection range.
|
||||||
*/
|
*/
|
||||||
function renderFloatingWidget(
|
function renderFloatingWidget(
|
||||||
state: EditorState,
|
state: EditorState,
|
||||||
chatApiManager: ChatApiManager,
|
chatApiManager: ChatApiManager,
|
||||||
plugin: InlineAIChatPlugin
|
plugin: InlineAIChatPlugin,
|
||||||
): DecorationSet {
|
): DecorationSet {
|
||||||
const firstSelectedRange = state.selection.ranges.find((range) => !range.empty) ?? state.selection.main;
|
const firstSelectedRange =
|
||||||
|
state.selection.ranges.find((range) => !range.empty) ??
|
||||||
|
state.selection.main;
|
||||||
|
|
||||||
const selectionInfo = state.field(currentSelectionState, false) ?? null;
|
const selectionInfo = state.field(currentSelectionState, false) ?? null;
|
||||||
|
|
||||||
const deco = Decoration.widget({
|
const deco = Decoration.widget({
|
||||||
widget: new FloatingWidget(chatApiManager, selectionInfo, plugin),
|
widget: new FloatingWidget(chatApiManager, selectionInfo, plugin),
|
||||||
above: true,
|
above: true,
|
||||||
inline: true,
|
inline: true,
|
||||||
side: -9999,
|
|
||||||
}).range(firstSelectedRange.from);
|
|
||||||
|
|
||||||
return Decoration.set([deco]);
|
side: -1,
|
||||||
|
}).range(firstSelectedRange.from);
|
||||||
|
|
||||||
|
return Decoration.set([deco]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -425,30 +538,36 @@ function renderFloatingWidget(
|
||||||
* When the user dismisses the tooltip, it clears the decoration set.
|
* When the user dismisses the tooltip, it clears the decoration set.
|
||||||
* Otherwise, it returns the existing decoration set.
|
* Otherwise, it returns the existing decoration set.
|
||||||
*/
|
*/
|
||||||
function FloatingTooltipState(chatApiManager: ChatApiManager, plugin: InlineAIChatPlugin) {
|
function FloatingTooltipState(
|
||||||
return StateField.define<DecorationSet>({
|
chatApiManager: ChatApiManager,
|
||||||
create(state) {
|
plugin: InlineAIChatPlugin,
|
||||||
return Decoration.none;
|
) {
|
||||||
},
|
return StateField.define<DecorationSet>({
|
||||||
update(decorations, tr) {
|
create(state) {
|
||||||
// Recompute if the user triggers the command
|
return Decoration.none;
|
||||||
if (tr.effects.some((e) => e.is(commandEffect))) {
|
},
|
||||||
return renderFloatingWidget(tr.state, chatApiManager, plugin);
|
update(decorations, tr) {
|
||||||
}
|
// Recompute if the user triggers the command
|
||||||
// Or dismiss it
|
if (tr.effects.some((e) => e.is(commandEffect))) {
|
||||||
if (tr.effects.some((e) => e.is(dismissTooltipEffect))) {
|
return renderFloatingWidget(tr.state, chatApiManager, plugin);
|
||||||
return Decoration.none;
|
}
|
||||||
}
|
// Or dismiss it
|
||||||
// Otherwise, return the existing overlay
|
if (tr.effects.some((e) => e.is(dismissTooltipEffect))) {
|
||||||
return decorations;
|
return Decoration.none;
|
||||||
},
|
}
|
||||||
provide: (field) => EditorView.decorations.from(field),
|
// Otherwise, return the existing overlay
|
||||||
});
|
return decorations;
|
||||||
|
},
|
||||||
|
provide: (field) => EditorView.decorations.from(field),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension enabling selection overlay widgets.
|
* Extension enabling selection overlay widgets.
|
||||||
*/
|
*/
|
||||||
export function FloatingTooltipExtension(chatApiManager: ChatApiManager, plugin: InlineAIChatPlugin) {
|
export function FloatingTooltipExtension(
|
||||||
return [FloatingTooltipState(chatApiManager, plugin)];
|
chatApiManager: ChatApiManager,
|
||||||
|
plugin: InlineAIChatPlugin,
|
||||||
|
) {
|
||||||
|
return [FloatingTooltipState(chatApiManager, plugin)];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import { SlashCommand } from "./source";
|
import { SlashCommand, BUILT_IN_COMMANDS } from "./source";
|
||||||
|
|
||||||
|
export function parseCommand(
|
||||||
export function parseCommand(userInput: string, prefix: string, customCommands: SlashCommand[]): string {
|
userInput: string,
|
||||||
for (const command of customCommands) {
|
prefix: string,
|
||||||
const commandPattern = `${prefix}${command.keyword}`;
|
customCommands: SlashCommand[],
|
||||||
if (userInput.includes(commandPattern)) {
|
): string {
|
||||||
return userInput.replace(commandPattern, command.prompt);
|
const allCommands = [...BUILT_IN_COMMANDS, ...customCommands];
|
||||||
}
|
for (const command of allCommands) {
|
||||||
}
|
const commandPattern = `${prefix}${command.keyword}`;
|
||||||
return userInput; // Return original text if no command matches
|
if (userInput.includes(commandPattern)) {
|
||||||
|
return userInput.replace(commandPattern, command.prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return userInput;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,155 @@
|
||||||
import { autocompletion, CompletionContext } from "@codemirror/autocomplete"
|
import { autocompletion, CompletionContext } from "@codemirror/autocomplete";
|
||||||
import { Extension, Range } from "@codemirror/state"
|
import { Extension, Range } from "@codemirror/state";
|
||||||
import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"
|
import {
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* Interface describing a slash command.
|
* Interface describing a slash command.
|
||||||
*/
|
*/
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
prompt: string;
|
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
|
// Factory function that creates a completion source with custom parameters
|
||||||
function createSlashCommandSource(options: {
|
function createSlashCommandSource(
|
||||||
prefix: string,
|
options: {
|
||||||
customCommands: SlashCommand[]
|
prefix: string;
|
||||||
} = {
|
customCommands: SlashCommand[];
|
||||||
prefix: '/',
|
} = {
|
||||||
customCommands: []
|
prefix: "/",
|
||||||
}) {
|
customCommands: [],
|
||||||
const { prefix, customCommands } = options;
|
},
|
||||||
return (context: CompletionContext) => {
|
) {
|
||||||
let word = context.matchBefore(new RegExp(`^\\${prefix}\\w*`))
|
const { prefix, customCommands } = options;
|
||||||
if (!word || (word.from == word.to && !context.explicit))
|
const allCommands = [...BUILT_IN_COMMANDS, ...customCommands];
|
||||||
return null
|
return (context: CompletionContext) => {
|
||||||
return {
|
let word = context.matchBefore(new RegExp(`^\\${prefix}\\w*`));
|
||||||
from: word.from+1,
|
if (!word || (word.from == word.to && !context.explicit)) return null;
|
||||||
options: (customCommands).map(cmd => ({
|
return {
|
||||||
label: cmd.keyword,
|
from: word.from + 1,
|
||||||
type: undefined,
|
options: allCommands.map((cmd) => ({
|
||||||
detail: cmd.prompt,
|
label: cmd.keyword,
|
||||||
}))
|
type: undefined,
|
||||||
}
|
detail: cmd.prompt,
|
||||||
}
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the extension that uses our custom completion source.
|
// Create the extension that uses our custom completion source.
|
||||||
export function slashCommandAutocompletion(options: { prefix: string, customCommands: SlashCommand[] } = { prefix: '/', customCommands: [] }) {
|
export function slashCommandAutocompletion(
|
||||||
return autocompletion({
|
options: { prefix: string; customCommands: SlashCommand[] } = {
|
||||||
override: [createSlashCommandSource(options)],
|
prefix: "/",
|
||||||
tooltipClass: () => "tooltip-autocomplete",
|
customCommands: [],
|
||||||
optionClass: () => "completion-label",
|
},
|
||||||
})
|
) {
|
||||||
|
return autocompletion({
|
||||||
|
override: [createSlashCommandSource(options)],
|
||||||
|
tooltipClass: () => "tooltip-autocomplete",
|
||||||
|
optionClass: () => "completion-label",
|
||||||
|
icons: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a decoration for highlighting slash commands
|
// Create a decoration for highlighting slash commands
|
||||||
const slashCommandMark = Decoration.mark({ class: "cm-slash-command" })
|
const slashCommandMark = Decoration.mark({ class: "cm-slash-command" });
|
||||||
|
|
||||||
export function createSlashCommandHighlighter({ prefix, customCommands }: { prefix: string, customCommands: SlashCommand[] }) {
|
export function createSlashCommandHighlighter({
|
||||||
return ViewPlugin.fromClass(class {
|
prefix,
|
||||||
decorations: DecorationSet
|
customCommands,
|
||||||
|
}: {
|
||||||
|
prefix: string;
|
||||||
|
customCommands: SlashCommand[];
|
||||||
|
}) {
|
||||||
|
return ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
constructor(view: EditorView) {
|
||||||
this.decorations = this.buildDecorations(view)
|
this.decorations = this.buildDecorations(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
if (update.docChanged || update.viewportChanged) {
|
if (update.docChanged || update.viewportChanged) {
|
||||||
this.decorations = this.buildDecorations(update.view)
|
this.decorations = this.buildDecorations(update.view);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildDecorations(view: EditorView) {
|
buildDecorations(view: EditorView) {
|
||||||
const keywords = customCommands.map(cmd => cmd.keyword).join('|')
|
const allCommands = [...BUILT_IN_COMMANDS, ...customCommands];
|
||||||
const regexp = new RegExp(`\\${prefix}(${keywords})\\b`, 'g')
|
const keywords = allCommands
|
||||||
|
.map((cmd) => cmd.keyword)
|
||||||
|
.join("|");
|
||||||
|
const regexp = new RegExp(`\\${prefix}(${keywords})\\b`, "g");
|
||||||
|
|
||||||
const decorations = view.visibleRanges.flatMap(({ from, to }) => {
|
const decorations = view.visibleRanges.flatMap(
|
||||||
const text = view.state.doc.sliceString(from, to)
|
({ from, to }) => {
|
||||||
return [...text.matchAll(regexp)]
|
const text = view.state.doc.sliceString(from, to);
|
||||||
.map(match =>
|
return [...text.matchAll(regexp)]
|
||||||
match.index !== undefined
|
.map((match) =>
|
||||||
? slashCommandMark.range(from + match.index, from + match.index + 1 + match[1].length)
|
match.index !== undefined
|
||||||
: null
|
? slashCommandMark.range(
|
||||||
)
|
from + match.index,
|
||||||
.filter((dec): dec is Range<Decoration> => dec !== null)
|
from +
|
||||||
})
|
match.index +
|
||||||
|
1 +
|
||||||
|
match[1].length,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(dec): dec is Range<Decoration> => dec !== null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return Decoration.set(decorations)
|
return Decoration.set(decorations);
|
||||||
}
|
}
|
||||||
}, {
|
},
|
||||||
decorations: v => v.decorations
|
{
|
||||||
})
|
decorations: (v) => v.decorations,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,20 @@
|
||||||
// modules/diffExtension.ts
|
// modules/diffExtension.ts
|
||||||
|
import { EditorState, StateField, RangeSetBuilder } from "@codemirror/state";
|
||||||
import {
|
import {
|
||||||
EditorState,
|
Decoration,
|
||||||
StateField,
|
DecorationSet,
|
||||||
RangeSetBuilder,
|
EditorView,
|
||||||
} from "@codemirror/state";
|
WidgetType,
|
||||||
import {
|
ViewPlugin,
|
||||||
Decoration,
|
ViewUpdate,
|
||||||
DecorationSet,
|
|
||||||
EditorView,
|
|
||||||
WidgetType,
|
|
||||||
ViewPlugin,
|
|
||||||
ViewUpdate,
|
|
||||||
} from "@codemirror/view";
|
} from "@codemirror/view";
|
||||||
import DiffMatchPatch from "diff-match-patch";
|
import DiffMatchPatch from "diff-match-patch";
|
||||||
|
|
||||||
import { acceptTooltipEffect, dismissTooltipEffect } from "./WidgetExtension";
|
import { acceptTooltipEffect, dismissTooltipEffect } from "./WidgetExtension";
|
||||||
import { generatedResponseState, setGeneratedResponseEffect } from "./AIExtension";
|
import {
|
||||||
|
generatedResponseState,
|
||||||
|
setGeneratedResponseEffect,
|
||||||
|
} from "./AIExtension";
|
||||||
import { currentSelectionState } from "./SelectionState";
|
import { currentSelectionState } from "./SelectionState";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -23,30 +22,43 @@ import { currentSelectionState } from "./SelectionState";
|
||||||
* Improves accessibility by using appropriate ARIA attributes.
|
* Improves accessibility by using appropriate ARIA attributes.
|
||||||
*/
|
*/
|
||||||
class ChangeContentWidget extends WidgetType {
|
class ChangeContentWidget extends WidgetType {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly content: string,
|
private readonly content: string,
|
||||||
private readonly type: 'added' | 'removed'
|
private readonly type: "added" | "removed",
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
toDOM(): HTMLElement {
|
toDOM(): HTMLElement {
|
||||||
const wrapper = document.createElement("span");
|
const wrapper = document.createElement("span");
|
||||||
wrapper.className = `cm-change-widget cm-change-${this.type}`;
|
wrapper.className = `cm-change-widget cm-change-${this.type}`;
|
||||||
wrapper.textContent = this.content;
|
wrapper.textContent = this.content;
|
||||||
|
|
||||||
// Accessibility: Provide ARIA label
|
// Accessibility: Provide ARIA label
|
||||||
wrapper.setAttribute(
|
wrapper.setAttribute(
|
||||||
"aria-label",
|
"aria-label",
|
||||||
this.type === 'added' ? "Added content" : "Removed content"
|
this.type === "added" ? "Added content" : "Removed content",
|
||||||
);
|
);
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
ignoreEvent(): boolean {
|
// Prevent mouse interactions from stealing focus from the editor
|
||||||
// Decide whether to ignore events on the widget
|
// which can trigger Obsidian to re-render code blocks. We don't want
|
||||||
return false;
|
// these inline diff widgets to affect editor selection or focus.
|
||||||
}
|
wrapper.addEventListener("mousedown", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
wrapper.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
// Ensure events on diff widgets are ignored by the outer editor
|
||||||
|
// to avoid focus/selection changes that can trigger re-renders.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -56,58 +68,57 @@ class ChangeContentWidget extends WidgetType {
|
||||||
* @returns A DecorationSet with the appropriate widgets.
|
* @returns A DecorationSet with the appropriate widgets.
|
||||||
*/
|
*/
|
||||||
function generateDiffView(state: EditorState): DecorationSet {
|
function generateDiffView(state: EditorState): DecorationSet {
|
||||||
try {
|
try {
|
||||||
// Retrieve the AI response and the current context text from the state
|
// Retrieve the AI response and the current context text from the state
|
||||||
const response = state.field(generatedResponseState);
|
const response = state.field(generatedResponseState);
|
||||||
const context = state.field(currentSelectionState);
|
const context = state.field(currentSelectionState);
|
||||||
|
|
||||||
const aiText: string = response?.airesponse ?? "";
|
const aiText: string = response?.airesponse ?? "";
|
||||||
const contextText: string = context?.text ?? "";
|
const contextText: string = context?.text ?? "";
|
||||||
|
|
||||||
// Use diff_match_patch instead of diffWords
|
// Use diff_match_patch instead of diffWords
|
||||||
const dmp = new DiffMatchPatch();
|
const dmp = new DiffMatchPatch();
|
||||||
let diffs = dmp.diff_main(contextText, aiText);
|
let diffs = dmp.diff_main(contextText, aiText);
|
||||||
|
|
||||||
// Perform semantic cleanup
|
// Perform semantic cleanup
|
||||||
dmp.diff_cleanupSemantic(diffs);
|
dmp.diff_cleanupSemantic(diffs);
|
||||||
|
|
||||||
// Initialize RangeSetBuilder for efficient decoration construction
|
// Initialize RangeSetBuilder for efficient decoration construction
|
||||||
const builder = new RangeSetBuilder<Decoration>();
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
let currentPos = context?.from ?? 0;
|
let currentPos = context?.from ?? 0;
|
||||||
|
|
||||||
|
diffs.forEach(([op, text]) => {
|
||||||
|
const length = text.length;
|
||||||
|
|
||||||
diffs.forEach(([op, text]) => {
|
if (op === DiffMatchPatch.DIFF_INSERT) {
|
||||||
const length = text.length;
|
// AI text added
|
||||||
|
const widget = new ChangeContentWidget(text, "added");
|
||||||
|
builder.add(
|
||||||
|
currentPos,
|
||||||
|
currentPos,
|
||||||
|
Decoration.widget({ widget, side: 1 }),
|
||||||
|
);
|
||||||
|
} else if (op === DiffMatchPatch.DIFF_DELETE) {
|
||||||
|
// Context text removed
|
||||||
|
const widget = new ChangeContentWidget(text, "removed");
|
||||||
|
// Attach the widget over the removed range
|
||||||
|
builder.add(
|
||||||
|
currentPos,
|
||||||
|
currentPos + length,
|
||||||
|
Decoration.widget({ widget, side: -1 }),
|
||||||
|
);
|
||||||
|
currentPos += length;
|
||||||
|
} else {
|
||||||
|
// No change, move currentPos forward
|
||||||
|
currentPos += length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (op === DiffMatchPatch.DIFF_INSERT) {
|
return builder.finish();
|
||||||
// AI text added
|
} catch (error) {
|
||||||
const widget = new ChangeContentWidget(text, "added");
|
console.error("Error generating diff view:", error);
|
||||||
builder.add(
|
return Decoration.none;
|
||||||
currentPos,
|
}
|
||||||
currentPos,
|
|
||||||
Decoration.widget({ widget, side: 1 })
|
|
||||||
);
|
|
||||||
} else if (op === DiffMatchPatch.DIFF_DELETE) {
|
|
||||||
// Context text removed
|
|
||||||
const widget = new ChangeContentWidget(text, "removed");
|
|
||||||
// Attach the widget over the removed range
|
|
||||||
builder.add(
|
|
||||||
currentPos,
|
|
||||||
currentPos + length,
|
|
||||||
Decoration.widget({ widget, side: -1 })
|
|
||||||
);
|
|
||||||
currentPos += length;
|
|
||||||
} else {
|
|
||||||
// No change, move currentPos forward
|
|
||||||
currentPos += length;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return builder.finish();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error generating diff view:", error);
|
|
||||||
return Decoration.none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -117,69 +128,141 @@ function generateDiffView(state: EditorState): DecorationSet {
|
||||||
* @param view - The EditorView instance.
|
* @param view - The EditorView instance.
|
||||||
*/
|
*/
|
||||||
function dispatchAIChanges(state: EditorState, view: EditorView): void {
|
function dispatchAIChanges(state: EditorState, view: EditorView): void {
|
||||||
try {
|
try {
|
||||||
// Grab the AI text and selection info (original context)
|
// Grab the AI text and selection info (original context)
|
||||||
const response = state.field(generatedResponseState);
|
const response = state.field(generatedResponseState);
|
||||||
const context = state.field(currentSelectionState);
|
const context = state.field(currentSelectionState);
|
||||||
|
|
||||||
const aiText: string = response?.airesponse ?? "";
|
const aiText: string = response?.airesponse ?? "";
|
||||||
const selectionFrom = context?.from ?? 0;
|
const selectionFrom = context?.from ?? 0;
|
||||||
const selectionTo = context?.to ?? 0;
|
const selectionTo = context?.to ?? 0;
|
||||||
|
|
||||||
// Dispatch the transaction to apply the AI changes
|
// Dispatch the transaction to apply the AI changes
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
changes: { from: selectionFrom, to: selectionTo, insert: aiText },
|
changes: { from: selectionFrom, to: selectionTo, insert: aiText },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error applying diff changes:", error);
|
console.error("Error applying diff changes:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const diffDecorationState = StateField.define<DecorationSet>({
|
export const diffDecorationState = StateField.define<DecorationSet>({
|
||||||
create(): DecorationSet {
|
create(): DecorationSet {
|
||||||
return Decoration.none;
|
return Decoration.none;
|
||||||
},
|
},
|
||||||
update(decorations: DecorationSet, tr) {
|
update(decorations: DecorationSet, tr) {
|
||||||
// Check if we got the AI response effect
|
// Check if we got the AI response effect
|
||||||
if (tr.effects.some(e => e.is(setGeneratedResponseEffect))) {
|
if (tr.effects.some((e) => e.is(setGeneratedResponseEffect))) {
|
||||||
return generateDiffView(tr.state);
|
return generateDiffView(tr.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the dismiss tooltip effect is present
|
// Check if the dismiss tooltip effect is present
|
||||||
const hasDismissEffect = tr.effects.some(e => e.is(dismissTooltipEffect));
|
const hasDismissEffect = tr.effects.some((e) =>
|
||||||
if (hasDismissEffect) {
|
e.is(dismissTooltipEffect),
|
||||||
return Decoration.none;
|
);
|
||||||
}
|
if (hasDismissEffect) {
|
||||||
|
return Decoration.none;
|
||||||
|
}
|
||||||
|
|
||||||
// Retain the existing decorations if no relevant changes
|
// Retain the existing decorations if no relevant changes
|
||||||
return decorations;
|
return decorations;
|
||||||
},
|
},
|
||||||
provide: (field) => EditorView.decorations.from(field),
|
provide: (field) => EditorView.decorations.from(field),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin to handle applying diff changes when the accept tooltip effect is triggered.
|
* Plugin to handle applying diff changes when the accept tooltip effect is triggered.
|
||||||
*/
|
*/
|
||||||
const applyDiffPlugin = ViewPlugin.fromClass(class {
|
const applyDiffPlugin = ViewPlugin.fromClass(
|
||||||
update(update: ViewUpdate) {
|
class {
|
||||||
// Iterate through all transactions in the update
|
update(update: ViewUpdate) {
|
||||||
for (const transaction of update.transactions) {
|
// Iterate through all transactions in the update
|
||||||
for (const effect of transaction.effects) {
|
for (const transaction of update.transactions) {
|
||||||
if (effect.is(acceptTooltipEffect)) {
|
for (const effect of transaction.effects) {
|
||||||
// Apply the diff changes by dispatching the transaction
|
if (effect.is(acceptTooltipEffect)) {
|
||||||
setTimeout(() => {
|
// Apply the diff changes by dispatching the transaction
|
||||||
dispatchAIChanges(update.state, update.view);
|
setTimeout(() => {
|
||||||
}, 0);
|
dispatchAIChanges(update.state, update.view);
|
||||||
}
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exported extension to be included in the EditorView.
|
* Exported extension to be included in the EditorView.
|
||||||
|
* Declared after focusGuardPlugin so it is initialized in order.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus guard to suppress editor-level blur/focusout side effects while the
|
||||||
|
* InlineAI widget is open. This mirrors the behavior in WidgetExtension but
|
||||||
|
* ensures that moving focus between the diff overlay and the widget does not
|
||||||
|
* trigger Obsidian re-renders (e.g., code block previews).
|
||||||
|
*/
|
||||||
|
const focusGuardPlugin = ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
private onFocusOut: ((e: FocusEvent) => void) | null = null;
|
||||||
|
private onBlur: ((e: FocusEvent) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(private view: EditorView) {
|
||||||
|
const handler = (evt: FocusEvent) => {
|
||||||
|
// Only guard when the InlineAI widget is open
|
||||||
|
if (document.body.classList.contains("inlineai-widget-open")) {
|
||||||
|
// Stop Obsidian/global listeners from reacting to focus loss
|
||||||
|
evt.stopImmediatePropagation?.();
|
||||||
|
evt.stopPropagation();
|
||||||
|
// Do not preventDefault so the focus can move as intended
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.onFocusOut = handler;
|
||||||
|
this.onBlur = handler;
|
||||||
|
|
||||||
|
// Attach on the editor root in capture phase to intercept early
|
||||||
|
this.view.dom.addEventListener("focusout", this.onFocusOut, true);
|
||||||
|
this.view.dom.addEventListener("blur", this.onBlur, true);
|
||||||
|
|
||||||
|
// Also attach at the document level to guard cases where focus moves
|
||||||
|
// between editor and non-editor nodes within the document
|
||||||
|
document.addEventListener("focusout", this.onFocusOut, true);
|
||||||
|
document.addEventListener("blur", this.onBlur, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
try {
|
||||||
|
if (this.onFocusOut) {
|
||||||
|
this.view.dom.removeEventListener(
|
||||||
|
"focusout",
|
||||||
|
this.onFocusOut,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
document.removeEventListener(
|
||||||
|
"focusout",
|
||||||
|
this.onFocusOut,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.onBlur) {
|
||||||
|
this.view.dom.removeEventListener(
|
||||||
|
"blur",
|
||||||
|
this.onBlur,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
document.removeEventListener("blur", this.onBlur, true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore cleanup issues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Export a single extension that includes the focus guard
|
||||||
export const diffExtension = [
|
export const diffExtension = [
|
||||||
diffDecorationState,
|
diffDecorationState,
|
||||||
applyDiffPlugin,
|
applyDiffPlugin,
|
||||||
|
focusGuardPlugin,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -5,51 +5,51 @@
|
||||||
* - Provides standard queue operations: enqueue, dequeue, peek, size, isEmpty, and getItems.
|
* - Provides standard queue operations: enqueue, dequeue, peek, size, isEmpty, and getItems.
|
||||||
*/
|
*/
|
||||||
export class MessageQueue<T> {
|
export class MessageQueue<T> {
|
||||||
private items: T[] = [];
|
private items: T[] = [];
|
||||||
private maxLength: number;
|
private maxLength: number;
|
||||||
|
|
||||||
constructor(maxLength: number) {
|
constructor(maxLength: number) {
|
||||||
this.maxLength = maxLength;
|
this.maxLength = maxLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adds an item to the queue
|
// Adds an item to the queue
|
||||||
enqueue(item: T): void {
|
enqueue(item: T): void {
|
||||||
// If the new item is exactly the same as the previous one, do not add it
|
// If the new item is exactly the same as the previous one, do not add it
|
||||||
if (this.items.length > 0) {
|
if (this.items.length > 0) {
|
||||||
const lastItem = this.items[this.items.length - 1];
|
const lastItem = this.items[this.items.length - 1];
|
||||||
if (lastItem === item) {
|
if (lastItem === item) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.items.length >= this.maxLength) {
|
if (this.items.length >= this.maxLength) {
|
||||||
// Remove the oldest item if the queue is full
|
// Remove the oldest item if the queue is full
|
||||||
this.items.shift();
|
this.items.shift();
|
||||||
}
|
}
|
||||||
this.items.push(item);
|
this.items.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removes and returns the item at the front of the queue
|
// Removes and returns the item at the front of the queue
|
||||||
dequeue(): T | undefined {
|
dequeue(): T | undefined {
|
||||||
return this.items.shift();
|
return this.items.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the item at the front of the queue without removing it
|
// Returns the item at the front of the queue without removing it
|
||||||
peek(): T | undefined {
|
peek(): T | undefined {
|
||||||
return this.items[0];
|
return this.items[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the current size of the queue
|
// Returns the current size of the queue
|
||||||
size(): number {
|
size(): number {
|
||||||
return this.items.length;
|
return this.items.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checks if the queue is empty
|
// Checks if the queue is empty
|
||||||
isEmpty(): boolean {
|
isEmpty(): boolean {
|
||||||
return this.items.length === 0;
|
return this.items.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the items in the queue
|
// Returns the items in the queue
|
||||||
getItems(): T[] {
|
getItems(): T[] {
|
||||||
return [...this.items];
|
return [...this.items];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
376
src/settings.ts
376
src/settings.ts
|
|
@ -1,14 +1,24 @@
|
||||||
import { App, PluginSettingTab, Setting } from "obsidian";
|
import { App, PluginSettingTab, Setting, Notice, Platform } from "obsidian";
|
||||||
import MyPlugin from "./main";
|
import MyPlugin from "./main";
|
||||||
import { cursorPrompt, selectionPrompt } from "./default_prompts";
|
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
|
// Interface for the settings
|
||||||
export interface InlineAISettings {
|
export interface InlineAISettings {
|
||||||
provider: "openai" | "ollama" | "custom" | "gemini";
|
provider: "openai" | "ollama" | "custom" | "gemini" | "azure" | "codex";
|
||||||
model: string;
|
model: string;
|
||||||
apiKey?: string;
|
|
||||||
customURL?: string;
|
customURL?: string;
|
||||||
|
azureEndpoint?: string;
|
||||||
|
azureApiVersion?: string;
|
||||||
selectionPrompt: string;
|
selectionPrompt: string;
|
||||||
cursorPrompt: string;
|
cursorPrompt: string;
|
||||||
customCommands: SlashCommand[];
|
customCommands: SlashCommand[];
|
||||||
|
|
@ -20,13 +30,14 @@ export interface InlineAISettings {
|
||||||
export const DEFAULT_SETTINGS: InlineAISettings = {
|
export const DEFAULT_SETTINGS: InlineAISettings = {
|
||||||
provider: "ollama",
|
provider: "ollama",
|
||||||
model: "llama3.2",
|
model: "llama3.2",
|
||||||
apiKey: "",
|
|
||||||
customURL: "",
|
customURL: "",
|
||||||
|
azureEndpoint: "",
|
||||||
|
azureApiVersion: "2024-02-15-preview",
|
||||||
selectionPrompt: selectionPrompt,
|
selectionPrompt: selectionPrompt,
|
||||||
cursorPrompt: cursorPrompt,
|
cursorPrompt: cursorPrompt,
|
||||||
customCommands: [],
|
customCommands: [],
|
||||||
commandPrefix: "/",
|
commandPrefix: "/",
|
||||||
messageHistory: false
|
messageHistory: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class InlineAISettingsTab extends PluginSettingTab {
|
export class InlineAISettingsTab extends PluginSettingTab {
|
||||||
|
|
@ -47,48 +58,254 @@ export class InlineAISettingsTab extends PluginSettingTab {
|
||||||
const { containerEl } = this;
|
const { containerEl } = this;
|
||||||
containerEl.empty();
|
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
|
// Provider setting
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Provider")
|
.setName("Provider")
|
||||||
.setDesc("Choose between OpenAI, Ollama, or a custom OpenAI-compatible endpoint.")
|
.setDesc(
|
||||||
|
"Choose between OpenAI, Ollama, Azure OpenAI, Gemini, a custom OpenAI-compatible endpoint, or Codex (ChatGPT subscription).",
|
||||||
|
)
|
||||||
.addDropdown((dropdown) =>
|
.addDropdown((dropdown) =>
|
||||||
dropdown
|
dropdown
|
||||||
.addOption("openai", "OpenAI")
|
.addOption("openai", "OpenAI")
|
||||||
.addOption("ollama", "Ollama")
|
.addOption("ollama", "Ollama")
|
||||||
|
.addOption("azure", "Azure OpenAI")
|
||||||
.addOption("gemini", "Gemini")
|
.addOption("gemini", "Gemini")
|
||||||
.addOption("custom", "Custom/OpenAI-compatible")
|
.addOption("custom", "Custom/OpenAI-compatible")
|
||||||
|
.addOption("codex", "Codex (ChatGPT subscription)")
|
||||||
.setValue(this.plugin.settings.provider)
|
.setValue(this.plugin.settings.provider)
|
||||||
.onChange(async (value) => {
|
.onChange(async (value) => {
|
||||||
this.plugin.settings.provider = value as "openai" | "ollama" | "custom" | "gemini";
|
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"
|
||||||
|
| "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();
|
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
|
// Model setting
|
||||||
new Setting(containerEl)
|
if (this.plugin.settings.provider === "codex") {
|
||||||
.setName("Model")
|
const CODEX_MODELS: {
|
||||||
.setDesc("Specify the model to use.")
|
value: string;
|
||||||
.addText((text) => {
|
label: string;
|
||||||
text.setPlaceholder("e.g., gpt-4o-mini")
|
desc: string;
|
||||||
.setValue(this.plugin.settings.model)
|
}[] = [
|
||||||
.inputEl.addEventListener("blur", async () => {
|
{
|
||||||
this.plugin.settings.model = text.getValue();
|
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();
|
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)
|
// API Key setting (conditionally displayed for OpenAI-supported endpoints)
|
||||||
if (this.plugin.settings.provider === "openai" || this.plugin.settings.provider === "custom" || this.plugin.settings.provider === "gemini") {
|
if (
|
||||||
|
this.plugin.settings.provider === "openai" ||
|
||||||
|
this.plugin.settings.provider === "custom" ||
|
||||||
|
this.plugin.settings.provider === "gemini" ||
|
||||||
|
this.plugin.settings.provider === "azure"
|
||||||
|
) {
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("API key")
|
.setName("API key")
|
||||||
.setDesc("Enter your API key.")
|
.setDesc("Enter your API key.")
|
||||||
.addText((text) => {
|
.addText((text) => {
|
||||||
text.setPlaceholder("sk-...")
|
text.setPlaceholder("sk-...")
|
||||||
.setValue(this.plugin.settings.apiKey || "")
|
.setValue(getApiKey(this.app) ?? "")
|
||||||
.inputEl.addEventListener("blur", async () => {
|
.inputEl.addEventListener("blur", async () => {
|
||||||
this.plugin.settings.apiKey = text.getValue();
|
if (!isSecretStorageAvailable(this.app)) {
|
||||||
await this.saveSettings();
|
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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -97,7 +314,9 @@ export class InlineAISettingsTab extends PluginSettingTab {
|
||||||
if (this.plugin.settings.provider === "custom") {
|
if (this.plugin.settings.provider === "custom") {
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Custom endpoint")
|
.setName("Custom endpoint")
|
||||||
.setDesc("Enter your OpenAI-compatible base URL (e.g. https://api.groq.com/openai/v1).")
|
.setDesc(
|
||||||
|
"Enter your OpenAI-compatible base URL (e.g. https://api.groq.com/openai/v1).",
|
||||||
|
)
|
||||||
.addText((text) => {
|
.addText((text) => {
|
||||||
text.setPlaceholder("https://api.mycustomhost.com/v1")
|
text.setPlaceholder("https://api.mycustomhost.com/v1")
|
||||||
.setValue(this.plugin.settings.customURL || "")
|
.setValue(this.plugin.settings.customURL || "")
|
||||||
|
|
@ -108,17 +327,61 @@ export class InlineAISettingsTab extends PluginSettingTab {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Azure-specific settings
|
||||||
|
if (this.plugin.settings.provider === "azure") {
|
||||||
|
// Azure endpoint setting
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Azure endpoint")
|
||||||
|
.setDesc(
|
||||||
|
"Enter your Azure OpenAI endpoint URL (e.g. https://your-resource.openai.azure.com).",
|
||||||
|
)
|
||||||
|
.addText((text) => {
|
||||||
|
text.setPlaceholder(
|
||||||
|
"https://your-resource.openai.azure.com",
|
||||||
|
)
|
||||||
|
.setValue(this.plugin.settings.azureEndpoint || "")
|
||||||
|
.inputEl.addEventListener("blur", async () => {
|
||||||
|
this.plugin.settings.azureEndpoint = text
|
||||||
|
.getValue()
|
||||||
|
.trim();
|
||||||
|
await this.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Azure API version setting
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Azure API version")
|
||||||
|
.setDesc("Enter the Azure OpenAI API version to use.")
|
||||||
|
.addText((text) => {
|
||||||
|
text.setPlaceholder("2024-02-15-preview")
|
||||||
|
.setValue(
|
||||||
|
this.plugin.settings.azureApiVersion ||
|
||||||
|
"2024-02-15-preview",
|
||||||
|
)
|
||||||
|
.inputEl.addEventListener("blur", async () => {
|
||||||
|
this.plugin.settings.azureApiVersion = text
|
||||||
|
.getValue()
|
||||||
|
.trim();
|
||||||
|
await this.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Advanced Section
|
// Advanced Section
|
||||||
containerEl.createEl("h3", { text: "Advanced" });
|
containerEl.createEl("h3", { text: "Advanced" });
|
||||||
// Selection Prompt setting
|
// Selection Prompt setting
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Selection prompt")
|
.setName("Selection prompt")
|
||||||
.setDesc("System Prompt used when the tooltip is triggered with selected text.")
|
.setDesc(
|
||||||
|
"System Prompt used when the tooltip is triggered with selected text.",
|
||||||
|
)
|
||||||
.addTextArea((textarea) => {
|
.addTextArea((textarea) => {
|
||||||
textarea.setPlaceholder("e.g., Summarize the selected text.")
|
textarea
|
||||||
|
.setPlaceholder("e.g., Summarize the selected text.")
|
||||||
.setValue(this.plugin.settings.selectionPrompt)
|
.setValue(this.plugin.settings.selectionPrompt)
|
||||||
.inputEl.addEventListener("blur", async () => {
|
.inputEl.addEventListener("blur", async () => {
|
||||||
this.plugin.settings.selectionPrompt = textarea.getValue();
|
this.plugin.settings.selectionPrompt =
|
||||||
|
textarea.getValue();
|
||||||
await this.saveSettings();
|
await this.saveSettings();
|
||||||
});
|
});
|
||||||
textarea.inputEl.classList.add("wide-text-settings");
|
textarea.inputEl.classList.add("wide-text-settings");
|
||||||
|
|
@ -127,9 +390,14 @@ export class InlineAISettingsTab extends PluginSettingTab {
|
||||||
// Cursor Prompt setting
|
// Cursor Prompt setting
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Cursor prompt")
|
.setName("Cursor prompt")
|
||||||
.setDesc("System Prompt used when the tooltip is triggered with selected text.")
|
.setDesc(
|
||||||
|
"System Prompt used when the tooltip is triggered with selected text.",
|
||||||
|
)
|
||||||
.addTextArea((textarea) => {
|
.addTextArea((textarea) => {
|
||||||
textarea.setPlaceholder("e.g., Generate text based on cursor position.")
|
textarea
|
||||||
|
.setPlaceholder(
|
||||||
|
"e.g., Generate text based on cursor position.",
|
||||||
|
)
|
||||||
.setValue(this.plugin.settings.cursorPrompt)
|
.setValue(this.plugin.settings.cursorPrompt)
|
||||||
.inputEl.addEventListener("blur", async () => {
|
.inputEl.addEventListener("blur", async () => {
|
||||||
this.plugin.settings.cursorPrompt = textarea.getValue();
|
this.plugin.settings.cursorPrompt = textarea.getValue();
|
||||||
|
|
@ -141,28 +409,41 @@ export class InlineAISettingsTab extends PluginSettingTab {
|
||||||
// Message History setting
|
// Message History setting
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Message History")
|
.setName("Message History")
|
||||||
.setDesc("Enable message history, you can navigate through the history using the up/down arrow keys.")
|
.setDesc(
|
||||||
|
"Enable message history, you can navigate through the history using the up/down arrow keys.",
|
||||||
|
)
|
||||||
.addToggle((toggle) => {
|
.addToggle((toggle) => {
|
||||||
toggle.setValue(this.plugin.settings.messageHistory)
|
toggle
|
||||||
|
.setValue(this.plugin.settings.messageHistory)
|
||||||
.onChange(async (value) => {
|
.onChange(async (value) => {
|
||||||
this.plugin.settings.messageHistory = value;
|
this.plugin.settings.messageHistory = value;
|
||||||
await this.saveSettings();
|
await this.saveSettings();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Custom Commands Section
|
// Custom Commands Section
|
||||||
containerEl.createEl("h3", { text: "Custom Commands" });
|
containerEl.createEl("h3", { text: "Custom Commands" });
|
||||||
containerEl.createEl("p", { text: "Add your own custom commands. Triggered with the prefix defined in the Command Prefix setting." });
|
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
|
// Command Prefix setting
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Command Prefix")
|
.setName("Command Prefix")
|
||||||
.setDesc("The prefix used to trigger custom commands (e.g., /, !, #)")
|
.setDesc(
|
||||||
|
"The prefix used to trigger custom commands (e.g., /, !, #)",
|
||||||
|
)
|
||||||
.addText((text) => {
|
.addText((text) => {
|
||||||
text.setPlaceholder("/")
|
text.setPlaceholder("/")
|
||||||
.setValue(this.plugin.settings.commandPrefix)
|
.setValue(this.plugin.settings.commandPrefix)
|
||||||
.inputEl.addEventListener("blur", async () => {
|
.inputEl.addEventListener("blur", async () => {
|
||||||
this.plugin.settings.commandPrefix = text.getValue().charAt(0);
|
this.plugin.settings.commandPrefix = text
|
||||||
|
.getValue()
|
||||||
|
.charAt(0);
|
||||||
await this.saveSettings();
|
await this.saveSettings();
|
||||||
this.display();
|
this.display();
|
||||||
});
|
});
|
||||||
|
|
@ -177,15 +458,18 @@ export class InlineAISettingsTab extends PluginSettingTab {
|
||||||
text.setValue(command.keyword)
|
text.setValue(command.keyword)
|
||||||
.setPlaceholder("Command name")
|
.setPlaceholder("Command name")
|
||||||
.inputEl.addEventListener("blur", async () => {
|
.inputEl.addEventListener("blur", async () => {
|
||||||
this.plugin.settings.customCommands[index].keyword = text.getValue();
|
this.plugin.settings.customCommands[index].keyword =
|
||||||
|
text.getValue();
|
||||||
await this.saveSettings();
|
await this.saveSettings();
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.addTextArea((textarea) => {
|
.addTextArea((textarea) => {
|
||||||
textarea.setValue(command.prompt)
|
textarea
|
||||||
|
.setValue(command.prompt)
|
||||||
.setPlaceholder("Command prompt")
|
.setPlaceholder("Command prompt")
|
||||||
.inputEl.addEventListener("blur", async () => {
|
.inputEl.addEventListener("blur", async () => {
|
||||||
this.plugin.settings.customCommands[index].prompt = textarea.getValue();
|
this.plugin.settings.customCommands[index].prompt =
|
||||||
|
textarea.getValue();
|
||||||
await this.saveSettings();
|
await this.saveSettings();
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
@ -194,25 +478,29 @@ export class InlineAISettingsTab extends PluginSettingTab {
|
||||||
.setIcon("trash")
|
.setIcon("trash")
|
||||||
.setTooltip("Delete this command")
|
.setTooltip("Delete this command")
|
||||||
.onClick(async () => {
|
.onClick(async () => {
|
||||||
this.plugin.settings.customCommands.splice(index, 1);
|
this.plugin.settings.customCommands.splice(
|
||||||
|
index,
|
||||||
|
1,
|
||||||
|
);
|
||||||
await this.saveSettings();
|
await this.saveSettings();
|
||||||
this.display();
|
this.display();
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Add new command button
|
// Add new command button
|
||||||
new Setting(containerEl).addButton((btn) =>
|
new Setting(containerEl).addButton((btn) =>
|
||||||
btn
|
btn
|
||||||
.setButtonText("Add Command")
|
.setButtonText("Add Command")
|
||||||
.setCta()
|
.setCta()
|
||||||
.onClick(async () => {
|
.onClick(async () => {
|
||||||
this.plugin.settings.customCommands.push({ keyword: "new_command", prompt: "" });
|
this.plugin.settings.customCommands.push({
|
||||||
|
keyword: "new_command",
|
||||||
|
prompt: "",
|
||||||
|
});
|
||||||
await this.saveSettings();
|
await this.saveSettings();
|
||||||
this.display();
|
this.display();
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
styles.css
52
styles.css
|
|
@ -27,7 +27,7 @@
|
||||||
.cm-tooltip-editor {
|
.cm-tooltip-editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 1px 0 1px 10px;
|
padding: 1px 0 1px 10px;
|
||||||
height: var(--line-height);
|
height: auto;
|
||||||
width: auto;
|
width: auto;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -46,11 +46,11 @@
|
||||||
|
|
||||||
/* Button Styles */
|
/* Button Styles */
|
||||||
.submit-button {
|
.submit-button {
|
||||||
height: var(--line-height);
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-button {
|
.tooltip-button {
|
||||||
height: var(--line-height);
|
height: auto;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
border: 1px solid var(--background-modifier-border-focus);
|
border: 1px solid var(--background-modifier-border-focus);
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
|
|
@ -79,12 +79,16 @@
|
||||||
@keyframes l5 {
|
@keyframes l5 {
|
||||||
0%,
|
0%,
|
||||||
33% {
|
33% {
|
||||||
box-shadow: 10px 0 var(--text-accent), -10px 0 var(--text-faint);
|
box-shadow:
|
||||||
|
10px 0 var(--text-accent),
|
||||||
|
-10px 0 var(--text-faint);
|
||||||
background: var(--text-accent);
|
background: var(--text-accent);
|
||||||
}
|
}
|
||||||
66%,
|
66%,
|
||||||
100% {
|
100% {
|
||||||
box-shadow: 10px 0 var(--text-faint), -10px 0 var(--text-accent);
|
box-shadow:
|
||||||
|
10px 0 var(--text-faint),
|
||||||
|
-10px 0 var(--text-accent);
|
||||||
background: var(--text-faint);
|
background: var(--text-faint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -118,11 +122,11 @@
|
||||||
height: 10em;
|
height: 10em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-autocomplete{
|
.tooltip-autocomplete {
|
||||||
background-color: var(--background-primary-alt) !important;
|
background-color: var(--background-primary-alt) !important;
|
||||||
color: var(--text-normal) !important;
|
color: var(--text-normal) !important;
|
||||||
width: 101.9% !important;
|
width: 101.9% !important;
|
||||||
margin-left: -1.05rem;
|
margin-left: -1.1rem !important;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--background-modifier-border-focus);
|
border: 1px solid var(--background-modifier-border-focus);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
@ -130,26 +134,46 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-autocomplete li[aria-selected] {
|
.tooltip-autocomplete li[aria-selected] {
|
||||||
background-color: var(--text-selection)!important;
|
background-color: var(--text-selection) !important;
|
||||||
color: var(--text-accent) !important;
|
color: var(--text-accent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.completion-label{
|
.completion-label {
|
||||||
font-size: var(--font-ui-small);
|
font-size: var(--font-ui-small);
|
||||||
font-weight: var(--font-normal);
|
font-weight: var(--font-normal);
|
||||||
font-family: Arial, Helvetica, sans-serif !important;
|
font-family: Arial, Helvetica, sans-serif !important;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin-left: -1em;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm-tooltip.cm-tooltip-below {
|
.cm-tooltip.cm-tooltip-below {
|
||||||
top: 100%;
|
top: 100%;
|
||||||
margin-top: 0.8em;
|
margin-top: 0.8em;
|
||||||
bottom: auto;
|
bottom: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Slash command highlighting */
|
/* Slash command highlighting */
|
||||||
.cm-slash-command {
|
.cm-slash-command {
|
||||||
color: var(--text-accent);
|
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