init
This commit is contained in:
commit
ba443ba770
21 changed files with 6559 additions and 0 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
tab_width = 4
|
||||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# vscode
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
# Intellij
|
||||||
|
*.iml
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# npm
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Don't include the compiled main.js file in the repo.
|
||||||
|
# They should be uploaded to GitHub releases instead.
|
||||||
|
main.js
|
||||||
|
|
||||||
|
# Exclude sourcemaps
|
||||||
|
*.map
|
||||||
|
|
||||||
|
# obsidian
|
||||||
|
data.json
|
||||||
|
|
||||||
|
# Exclude macOS Finder (System Explorer) View States
|
||||||
|
.DS_Store
|
||||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
tag-version-prefix=""
|
||||||
251
AGENTS.md
Normal file
251
AGENTS.md
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
# Obsidian community plugin
|
||||||
|
|
||||||
|
## Project overview
|
||||||
|
|
||||||
|
- Target: Obsidian Community Plugin (TypeScript → bundled JavaScript).
|
||||||
|
- Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian.
|
||||||
|
- Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`.
|
||||||
|
|
||||||
|
## Environment & tooling
|
||||||
|
|
||||||
|
- Node.js: use current LTS (Node 18+ recommended).
|
||||||
|
- **Package manager: npm** (required for this sample - `package.json` defines npm scripts and dependencies).
|
||||||
|
- **Bundler: esbuild** (required for this sample - `esbuild.config.mjs` and build scripts depend on it). Alternative bundlers like Rollup or webpack are acceptable for other projects if they bundle all external dependencies into `main.js`.
|
||||||
|
- Types: `obsidian` type definitions.
|
||||||
|
|
||||||
|
**Note**: This sample project has specific technical dependencies on npm and esbuild. If you're creating a plugin from scratch, you can choose different tools, but you'll need to replace the build configuration accordingly.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dev (watch)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Linting
|
||||||
|
|
||||||
|
- To use eslint install eslint from terminal: `npm install -g eslint`
|
||||||
|
- To use eslint to analyze this project use this command: `eslint main.ts`
|
||||||
|
- eslint will then create a report with suggestions for code improvement by file and line number.
|
||||||
|
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: `eslint ./src/`
|
||||||
|
|
||||||
|
## File & folder conventions
|
||||||
|
|
||||||
|
- **Organize code into multiple files**: Split functionality across separate modules rather than putting everything in `main.ts`.
|
||||||
|
- Source lives in `src/`. Keep `main.ts` small and focused on plugin lifecycle (loading, unloading, registering commands).
|
||||||
|
- **Example file structure**:
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main.ts # Plugin entry point, lifecycle management
|
||||||
|
settings.ts # Settings interface and defaults
|
||||||
|
commands/ # Command implementations
|
||||||
|
command1.ts
|
||||||
|
command2.ts
|
||||||
|
ui/ # UI components, modals, views
|
||||||
|
modal.ts
|
||||||
|
view.ts
|
||||||
|
utils/ # Utility functions, helpers
|
||||||
|
helpers.ts
|
||||||
|
constants.ts
|
||||||
|
types.ts # TypeScript interfaces and types
|
||||||
|
```
|
||||||
|
- **Do not commit build artifacts**: Never commit `node_modules/`, `main.js`, or other generated files to version control.
|
||||||
|
- Keep the plugin small. Avoid large dependencies. Prefer browser-compatible packages.
|
||||||
|
- Generated output should be placed at the plugin root or `dist/` depending on your build setup. Release artifacts must end up at the top level of the plugin folder in the vault (`main.js`, `manifest.json`, `styles.css`).
|
||||||
|
|
||||||
|
## Manifest rules (`manifest.json`)
|
||||||
|
|
||||||
|
- Must include (non-exhaustive):
|
||||||
|
- `id` (plugin ID; for local dev it should match the folder name)
|
||||||
|
- `name`
|
||||||
|
- `version` (Semantic Versioning `x.y.z`)
|
||||||
|
- `minAppVersion`
|
||||||
|
- `description`
|
||||||
|
- `isDesktopOnly` (boolean)
|
||||||
|
- Optional: `author`, `authorUrl`, `fundingUrl` (string or map)
|
||||||
|
- Never change `id` after release. Treat it as stable API.
|
||||||
|
- Keep `minAppVersion` accurate when using newer APIs.
|
||||||
|
- Canonical requirements are coded here: https://github.com/obsidianmd/obsidian-releases/blob/master/.github/workflows/validate-plugin-entry.yml
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Manual install for testing: copy `main.js`, `manifest.json`, `styles.css` (if any) to:
|
||||||
|
```
|
||||||
|
<Vault>/.obsidian/plugins/<plugin-id>/
|
||||||
|
```
|
||||||
|
- Reload Obsidian and enable the plugin in **Settings → Community plugins**.
|
||||||
|
|
||||||
|
## Commands & settings
|
||||||
|
|
||||||
|
- Any user-facing commands should be added via `this.addCommand(...)`.
|
||||||
|
- If the plugin has configuration, provide a settings tab and sensible defaults.
|
||||||
|
- Persist settings using `this.loadData()` / `this.saveData()`.
|
||||||
|
- Use stable command IDs; avoid renaming once released.
|
||||||
|
|
||||||
|
## Versioning & releases
|
||||||
|
|
||||||
|
- Bump `version` in `manifest.json` (SemVer) and update `versions.json` to map plugin version → minimum app version.
|
||||||
|
- Create a GitHub release whose tag exactly matches `manifest.json`'s `version`. Do not use a leading `v`.
|
||||||
|
- Attach `manifest.json`, `main.js`, and `styles.css` (if present) to the release as individual assets.
|
||||||
|
- After the initial release, follow the process to add/update your plugin in the community catalog as required.
|
||||||
|
|
||||||
|
## Security, privacy, and compliance
|
||||||
|
|
||||||
|
Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular:
|
||||||
|
|
||||||
|
- Default to local/offline operation. Only make network requests when essential to the feature.
|
||||||
|
- No hidden telemetry. If you collect optional analytics or call third-party services, require explicit opt-in and document clearly in `README.md` and in settings.
|
||||||
|
- Never execute remote code, fetch and eval scripts, or auto-update plugin code outside of normal releases.
|
||||||
|
- Minimize scope: read/write only what's necessary inside the vault. Do not access files outside the vault.
|
||||||
|
- Clearly disclose any external services used, data sent, and risks.
|
||||||
|
- Respect user privacy. Do not collect vault contents, filenames, or personal information unless absolutely necessary and explicitly consented.
|
||||||
|
- Avoid deceptive patterns, ads, or spammy notifications.
|
||||||
|
- Register and clean up all DOM, app, and interval listeners using the provided `register*` helpers so the plugin unloads safely.
|
||||||
|
|
||||||
|
## UX & copy guidelines (for UI text, commands, settings)
|
||||||
|
|
||||||
|
- Prefer sentence case for headings, buttons, and titles.
|
||||||
|
- Use clear, action-oriented imperatives in step-by-step copy.
|
||||||
|
- Use **bold** to indicate literal UI labels. Prefer "select" for interactions.
|
||||||
|
- Use arrow notation for navigation: **Settings → Community plugins**.
|
||||||
|
- Keep in-app strings short, consistent, and free of jargon.
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- Keep startup light. Defer heavy work until needed.
|
||||||
|
- Avoid long-running tasks during `onload`; use lazy initialization.
|
||||||
|
- Batch disk access and avoid excessive vault scans.
|
||||||
|
- Debounce/throttle expensive operations in response to file system events.
|
||||||
|
|
||||||
|
## Coding conventions
|
||||||
|
|
||||||
|
- TypeScript with `"strict": true` preferred.
|
||||||
|
- **Keep `main.ts` minimal**: Focus only on plugin lifecycle (onload, onunload, addCommand calls). Delegate all feature logic to separate modules.
|
||||||
|
- **Split large files**: If any file exceeds ~200-300 lines, consider breaking it into smaller, focused modules.
|
||||||
|
- **Use clear module boundaries**: Each file should have a single, well-defined responsibility.
|
||||||
|
- Bundle everything into `main.js` (no unbundled runtime deps).
|
||||||
|
- Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly.
|
||||||
|
- Prefer `async/await` over promise chains; handle errors gracefully.
|
||||||
|
|
||||||
|
## Mobile
|
||||||
|
|
||||||
|
- Where feasible, test on iOS and Android.
|
||||||
|
- Don't assume desktop-only behavior unless `isDesktopOnly` is `true`.
|
||||||
|
- Avoid large in-memory structures; be mindful of memory and storage constraints.
|
||||||
|
|
||||||
|
## Agent do/don't
|
||||||
|
|
||||||
|
**Do**
|
||||||
|
- Add commands with stable IDs (don't rename once released).
|
||||||
|
- Provide defaults and validation in settings.
|
||||||
|
- Write idempotent code paths so reload/unload doesn't leak listeners or intervals.
|
||||||
|
- Use `this.register*` helpers for everything that needs cleanup.
|
||||||
|
|
||||||
|
**Don't**
|
||||||
|
- Introduce network calls without an obvious user-facing reason and documentation.
|
||||||
|
- Ship features that require cloud services without clear disclosure and explicit opt-in.
|
||||||
|
- Store or transmit vault contents unless essential and consented.
|
||||||
|
|
||||||
|
## Common tasks
|
||||||
|
|
||||||
|
### Organize code across multiple files
|
||||||
|
|
||||||
|
**main.ts** (minimal, lifecycle only):
|
||||||
|
```ts
|
||||||
|
import { Plugin } from "obsidian";
|
||||||
|
import { MySettings, DEFAULT_SETTINGS } from "./settings";
|
||||||
|
import { registerCommands } from "./commands";
|
||||||
|
|
||||||
|
export default class MyPlugin extends Plugin {
|
||||||
|
settings: MySettings;
|
||||||
|
|
||||||
|
async onload() {
|
||||||
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||||
|
registerCommands(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**settings.ts**:
|
||||||
|
```ts
|
||||||
|
export interface MySettings {
|
||||||
|
enabled: boolean;
|
||||||
|
apiKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: MySettings = {
|
||||||
|
enabled: true,
|
||||||
|
apiKey: "",
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**commands/index.ts**:
|
||||||
|
```ts
|
||||||
|
import { Plugin } from "obsidian";
|
||||||
|
import { doSomething } from "./my-command";
|
||||||
|
|
||||||
|
export function registerCommands(plugin: Plugin) {
|
||||||
|
plugin.addCommand({
|
||||||
|
id: "do-something",
|
||||||
|
name: "Do something",
|
||||||
|
callback: () => doSomething(plugin),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add a command
|
||||||
|
|
||||||
|
```ts
|
||||||
|
this.addCommand({
|
||||||
|
id: "your-command-id",
|
||||||
|
name: "Do the thing",
|
||||||
|
callback: () => this.doTheThing(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Persist settings
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface MySettings { enabled: boolean }
|
||||||
|
const DEFAULT_SETTINGS: MySettings = { enabled: true };
|
||||||
|
|
||||||
|
async onload() {
|
||||||
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||||
|
await this.saveData(this.settings);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Register listeners safely
|
||||||
|
|
||||||
|
```ts
|
||||||
|
this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ }));
|
||||||
|
this.registerDomEvent(window, "resize", () => { /* ... */ });
|
||||||
|
this.registerInterval(window.setInterval(() => { /* ... */ }, 1000));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `<Vault>/.obsidian/plugins/<plugin-id>/`.
|
||||||
|
- Build issues: if `main.js` is missing, run `npm run build` or `npm run dev` to compile your TypeScript source code.
|
||||||
|
- Commands not appearing: verify `addCommand` runs after `onload` and IDs are unique.
|
||||||
|
- Settings not persisting: ensure `loadData`/`saveData` are awaited and you re-render the UI after changes.
|
||||||
|
- Mobile-only issues: confirm you're not using desktop-only APIs; check `isDesktopOnly` and adjust.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin
|
||||||
|
- API documentation: https://docs.obsidian.md
|
||||||
|
- Developer policies: https://docs.obsidian.md/Developer+policies
|
||||||
|
- Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
|
||||||
|
- Style guide: https://help.obsidian.md/style-guide
|
||||||
5
LICENSE
Normal file
5
LICENSE
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
Copyright (C) 2020-2025 by Dynalist Inc.
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
27
README.md
Normal file
27
README.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Single Text Input
|
||||||
|
|
||||||
|
This Obsidian plugin adds a ribbon button that opens a small workspace view with a single text input and turns short ideas into detailed notes with OpenRouter.
|
||||||
|
|
||||||
|
When generating a note, the plugin gathers your existing vault tags, gives that list to the model, and asks it to reuse relevant tags whenever possible while still allowing a few sensible new ones.
|
||||||
|
The plugin also includes a command that asks OpenRouter to inspect your notes through local search tools and suggest useful next steps.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
- Enable the plugin in **Settings → Community plugins**.
|
||||||
|
- Open the plugin settings and add your OpenRouter API key.
|
||||||
|
- Select the pencil icon in the left ribbon.
|
||||||
|
- Type a short topic or idea into the input field that opens in the right sidebar.
|
||||||
|
- Select **Create note** to generate a longer markdown note and save it in your vault.
|
||||||
|
- The created note includes YAML frontmatter tags chosen from your existing tag list and, when appropriate, a few new tags.
|
||||||
|
- Run the command **Suggest what to do next** from the Command palette to get note-aware recommendations based on your current vault.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
- This plugin sends the text you enter in the input field to OpenRouter when you select **Create note**.
|
||||||
|
- No telemetry or background network requests are included.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
- Install dependencies with `npm install`
|
||||||
|
- Build once with `npm run build`
|
||||||
|
- Run watch mode with `npm run dev`
|
||||||
49
esbuild.config.mjs
Normal file
49
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import esbuild from "esbuild";
|
||||||
|
import process from "process";
|
||||||
|
import { builtinModules } from 'node:module';
|
||||||
|
|
||||||
|
const banner =
|
||||||
|
`/*
|
||||||
|
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||||
|
if you want to view the source, please visit the github repository of this plugin
|
||||||
|
*/
|
||||||
|
`;
|
||||||
|
|
||||||
|
const prod = (process.argv[2] === "production");
|
||||||
|
|
||||||
|
const context = await esbuild.context({
|
||||||
|
banner: {
|
||||||
|
js: banner,
|
||||||
|
},
|
||||||
|
entryPoints: ["src/main.ts"],
|
||||||
|
bundle: true,
|
||||||
|
external: [
|
||||||
|
"obsidian",
|
||||||
|
"electron",
|
||||||
|
"@codemirror/autocomplete",
|
||||||
|
"@codemirror/collab",
|
||||||
|
"@codemirror/commands",
|
||||||
|
"@codemirror/language",
|
||||||
|
"@codemirror/lint",
|
||||||
|
"@codemirror/search",
|
||||||
|
"@codemirror/state",
|
||||||
|
"@codemirror/view",
|
||||||
|
"@lezer/common",
|
||||||
|
"@lezer/highlight",
|
||||||
|
"@lezer/lr",
|
||||||
|
...builtinModules],
|
||||||
|
format: "cjs",
|
||||||
|
target: "es2018",
|
||||||
|
logLevel: "info",
|
||||||
|
sourcemap: prod ? false : "inline",
|
||||||
|
treeShaking: true,
|
||||||
|
outfile: "main.js",
|
||||||
|
minify: prod,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prod) {
|
||||||
|
await context.rebuild();
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
await context.watch();
|
||||||
|
}
|
||||||
34
eslint.config.mts
Normal file
34
eslint.config.mts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||||
|
import globals from "globals";
|
||||||
|
import { globalIgnores } from "eslint/config";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
projectService: {
|
||||||
|
allowDefaultProject: [
|
||||||
|
'eslint.config.js',
|
||||||
|
'manifest.json'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
extraFileExtensions: ['.json']
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...obsidianmd.configs.recommended,
|
||||||
|
globalIgnores([
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"esbuild.config.mjs",
|
||||||
|
"eslint.config.js",
|
||||||
|
"version-bump.mjs",
|
||||||
|
"versions.json",
|
||||||
|
"main.js",
|
||||||
|
]),
|
||||||
|
);
|
||||||
9
manifest.json
Normal file
9
manifest.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"id": "obsidian-sample-plugin",
|
||||||
|
"name": "Single Text Input",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minAppVersion": "0.15.0",
|
||||||
|
"description": "Adds a ribbon button that opens a simple interface with one text input.",
|
||||||
|
"author": "Pavel",
|
||||||
|
"isDesktopOnly": false
|
||||||
|
}
|
||||||
5160
package-lock.json
generated
Normal file
5160
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
package.json
Normal file
29
package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "obsidian-sample-plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
|
||||||
|
"main": "main.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node esbuild.config.mjs",
|
||||||
|
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||||
|
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"license": "0-BSD",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^16.11.6",
|
||||||
|
"esbuild": "0.25.5",
|
||||||
|
"eslint-plugin-obsidianmd": "0.1.9",
|
||||||
|
"globals": "14.0.0",
|
||||||
|
"tslib": "2.4.0",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"typescript-eslint": "8.35.1",
|
||||||
|
"@eslint/js": "9.30.1",
|
||||||
|
"jiti": "2.6.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"obsidian": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/main.ts
Normal file
181
src/main.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { Notice, Plugin, TFile, WorkspaceLeaf, normalizePath } from "obsidian";
|
||||||
|
import { DEFAULT_SETTINGS, OpenRouterSettingTab, SingleTextInputPluginSettings } from "./settings";
|
||||||
|
import { requestOpenRouterResponse } from "./openrouter";
|
||||||
|
import { generateNextStepSuggestions } from "./suggestions";
|
||||||
|
import { SuggestionsModal } from "./suggestions-modal";
|
||||||
|
import { SingleTextInputView, VIEW_TYPE_SINGLE_TEXT_INPUT } from "./view";
|
||||||
|
|
||||||
|
export default class SingleTextInputPlugin extends Plugin {
|
||||||
|
settings: SingleTextInputPluginSettings;
|
||||||
|
private inputValue = "";
|
||||||
|
private statusMessage = "";
|
||||||
|
|
||||||
|
async onload() {
|
||||||
|
await this.loadSettings();
|
||||||
|
|
||||||
|
this.registerView(
|
||||||
|
VIEW_TYPE_SINGLE_TEXT_INPUT,
|
||||||
|
(leaf) => new SingleTextInputView(leaf, {
|
||||||
|
getValue: () => this.inputValue,
|
||||||
|
getStatus: () => this.statusMessage,
|
||||||
|
setValue: (value) => {
|
||||||
|
this.inputValue = value;
|
||||||
|
},
|
||||||
|
submit: async (value) => {
|
||||||
|
const existingTags = this.getExistingTags();
|
||||||
|
const generatedNote = await requestOpenRouterResponse({
|
||||||
|
apiKey: this.settings.apiKey,
|
||||||
|
model: this.settings.model,
|
||||||
|
input: value,
|
||||||
|
existingTags,
|
||||||
|
referer: this.settings.httpReferer,
|
||||||
|
title: this.settings.appTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = await this.createGeneratedNote(value, generatedNote);
|
||||||
|
const tagSummary = generatedNote.tags.length > 0
|
||||||
|
? ` with tags: ${generatedNote.tags.map((tag) => `#${tag}`).join(", ")}`
|
||||||
|
: "";
|
||||||
|
this.statusMessage = `Created note: ${file.path}${tagSummary}`;
|
||||||
|
await this.app.workspace.getLeaf(true).openFile(file);
|
||||||
|
new Notice(`Created note: ${file.basename}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.addRibbonIcon("pencil", "Open text input", () => {
|
||||||
|
void this.activateView();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: "open-single-text-input",
|
||||||
|
name: "Open text input",
|
||||||
|
callback: () => {
|
||||||
|
void this.activateView();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: "suggest-what-to-do-next",
|
||||||
|
name: "Suggest what to do next",
|
||||||
|
callback: () => {
|
||||||
|
void this.openSuggestionsModal();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addSettingTab(new OpenRouterSettingTab(this.app, this));
|
||||||
|
}
|
||||||
|
|
||||||
|
onunload() {
|
||||||
|
this.app.workspace.getLeavesOfType(VIEW_TYPE_SINGLE_TEXT_INPUT).forEach((leaf) => {
|
||||||
|
leaf.detach();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async activateView(): Promise<void> {
|
||||||
|
const existingLeaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_SINGLE_TEXT_INPUT)[0];
|
||||||
|
const leaf = existingLeaf ?? this.app.workspace.getRightLeaf(false);
|
||||||
|
|
||||||
|
if (!leaf) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.openInputView(leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openInputView(leaf: WorkspaceLeaf): Promise<void> {
|
||||||
|
await leaf.setViewState({
|
||||||
|
type: VIEW_TYPE_SINGLE_TEXT_INPUT,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.workspace.revealLeaf(leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSettings(): Promise<void> {
|
||||||
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSettings(): Promise<void> {
|
||||||
|
await this.saveData(this.settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createGeneratedNote(input: string, generatedNote: { tags: string[]; content: string }): Promise<TFile> {
|
||||||
|
const baseName = this.buildNoteName(input);
|
||||||
|
const path = await this.getAvailableNotePath(baseName);
|
||||||
|
return this.app.vault.create(path, this.buildNoteFileContents(generatedNote.tags, generatedNote.content));
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildNoteName(input: string): string {
|
||||||
|
const sanitized = input
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, "")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.slice(0, 60);
|
||||||
|
|
||||||
|
return sanitized || `generated-note-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAvailableNotePath(baseName: string): Promise<string> {
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const suffix = counter === 0 ? "" : `-${counter}`;
|
||||||
|
const candidate = normalizePath(`${baseName}${suffix}.md`);
|
||||||
|
|
||||||
|
if (!this.app.vault.getAbstractFileByPath(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExistingTags(): string[] {
|
||||||
|
const tags = new Set<string>();
|
||||||
|
|
||||||
|
this.app.vault.getMarkdownFiles().forEach((file) => {
|
||||||
|
const cache = this.app.metadataCache.getFileCache(file);
|
||||||
|
cache?.tags?.forEach((tag) => {
|
||||||
|
const normalizedTag = tag.tag.replace(/^#/, "").trim();
|
||||||
|
if (normalizedTag) {
|
||||||
|
tags.add(normalizedTag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(tags).sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildNoteFileContents(tags: string[], content: string): string {
|
||||||
|
const frontmatter = tags.length > 0
|
||||||
|
? `---\ntags:\n${tags.map((tag) => ` - ${tag}`).join("\n")}\n---\n\n`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `${frontmatter}${content.trim()}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openSuggestionsModal(): Promise<void> {
|
||||||
|
const modal = new SuggestionsModal(this.app);
|
||||||
|
modal.open();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const suggestions = await generateNextStepSuggestions({
|
||||||
|
apiKey: this.settings.apiKey,
|
||||||
|
model: this.settings.model,
|
||||||
|
referer: this.settings.httpReferer,
|
||||||
|
title: this.settings.appTitle,
|
||||||
|
app: this.app,
|
||||||
|
});
|
||||||
|
|
||||||
|
await modal.setSuggestions(suggestions);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unexpected error.";
|
||||||
|
modal.setError(message);
|
||||||
|
new Notice(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
146
src/openrouter.ts
Normal file
146
src/openrouter.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
interface OpenRouterRequestOptions {
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
input: string;
|
||||||
|
existingTags: string[];
|
||||||
|
referer?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenRouterApiResponse {
|
||||||
|
error?: {
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
choices?: Array<{
|
||||||
|
message?: {
|
||||||
|
content?: string | Array<{ type?: string; text?: string }>;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneratedNoteResult {
|
||||||
|
tags: string[];
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestOpenRouterResponse({
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
input,
|
||||||
|
existingTags,
|
||||||
|
referer,
|
||||||
|
title,
|
||||||
|
}: OpenRouterRequestOptions): Promise<GeneratedNoteResult> {
|
||||||
|
const trimmedApiKey = apiKey.trim();
|
||||||
|
const trimmedModel = model.trim();
|
||||||
|
const trimmedInput = input.trim();
|
||||||
|
const trimmedReferer = referer?.trim() ?? "";
|
||||||
|
const trimmedTitle = title?.trim() ?? "";
|
||||||
|
|
||||||
|
if (!trimmedApiKey) {
|
||||||
|
throw new Error("Add your OpenRouter API key in the plugin settings.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmedInput) {
|
||||||
|
throw new Error("Enter some text before sending a request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmedModel) {
|
||||||
|
throw new Error("Add an OpenRouter model slug in the plugin settings.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${trimmedApiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (trimmedReferer) {
|
||||||
|
headers["HTTP-Referer"] = trimmedReferer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedTitle) {
|
||||||
|
headers["X-OpenRouter-Title"] = trimmedTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: trimmedModel,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: "You expand short user inputs into clear, specific, richly detailed Obsidian note drafts. Return valid JSON only with exactly two keys: tags and content. tags must be an array of strings without # prefixes. Prefer relevant tags from the provided existing tag list. You may add a few new tags only when they are clearly appropriate. content must be markdown that starts with a level-1 heading and then a thorough note body. Do not wrap the JSON in markdown fences.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `Create a longer, more detailed note based on this input:\n\n${trimmedInput}\n\nExisting tags in this vault:\n${formatExistingTags(existingTags)}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json() as OpenRouterApiResponse;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error?.message ?? "OpenRouter returned an unexpected error.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageContent = data.choices?.[0]?.message?.content;
|
||||||
|
|
||||||
|
if (typeof messageContent === "string" && messageContent.trim()) {
|
||||||
|
return parseGeneratedNoteResult(messageContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(messageContent)) {
|
||||||
|
const combinedText = messageContent
|
||||||
|
.map((item) => item.text ?? "")
|
||||||
|
.join("")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (combinedText) {
|
||||||
|
return parseGeneratedNoteResult(combinedText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("OpenRouter returned an empty response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExistingTags(existingTags: string[]): string {
|
||||||
|
if (existingTags.length === 0) {
|
||||||
|
return "No existing tags were found.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingTags.map((tag) => `- ${tag}`).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGeneratedNoteResult(rawContent: string): GeneratedNoteResult {
|
||||||
|
let parsed: Partial<GeneratedNoteResult>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawContent) as Partial<GeneratedNoteResult>;
|
||||||
|
} catch {
|
||||||
|
throw new Error("OpenRouter returned note data in an unexpected format.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = typeof parsed.content === "string" ? parsed.content.trim() : "";
|
||||||
|
const tags = Array.isArray(parsed.tags)
|
||||||
|
? parsed.tags
|
||||||
|
.filter((tag): tag is string => typeof tag === "string")
|
||||||
|
.map((tag) => sanitizeTag(tag))
|
||||||
|
.filter((tag) => tag.length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
throw new Error("OpenRouter returned note data without content.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tags: Array.from(new Set(tags)),
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTag(tag: string): string {
|
||||||
|
return tag.trim().replace(/^#+/, "").replace(/\s+/g, "-").toLowerCase();
|
||||||
|
}
|
||||||
77
src/settings.ts
Normal file
77
src/settings.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||||
|
import SingleTextInputPlugin from "./main";
|
||||||
|
|
||||||
|
export interface SingleTextInputPluginSettings {
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
httpReferer: string;
|
||||||
|
appTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: SingleTextInputPluginSettings = {
|
||||||
|
apiKey: "",
|
||||||
|
model: "openai/gpt-4.1-mini",
|
||||||
|
httpReferer: "",
|
||||||
|
appTitle: "Single Text Input",
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OpenRouterSettingTab extends PluginSettingTab {
|
||||||
|
constructor(app: App, private readonly plugin: SingleTextInputPlugin) {
|
||||||
|
super(app, plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
display(): void {
|
||||||
|
const { containerEl } = this;
|
||||||
|
containerEl.empty();
|
||||||
|
|
||||||
|
containerEl.createEl("h2", { text: "OpenRouter settings" });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("API key")
|
||||||
|
.setDesc("Used to send your text input to OpenRouter.")
|
||||||
|
.addText((text) => {
|
||||||
|
text
|
||||||
|
.setPlaceholder("sk-or-...")
|
||||||
|
.setValue(this.plugin.settings.apiKey)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.apiKey = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
text.inputEl.type = "password";
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Model")
|
||||||
|
.setDesc("OpenRouter model slug used for requests.")
|
||||||
|
.addText((text) => text
|
||||||
|
.setPlaceholder("openai/gpt-4.1-mini")
|
||||||
|
.setValue(this.plugin.settings.model)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.model = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}));
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("HTTP referer")
|
||||||
|
.setDesc("Optional OpenRouter attribution header.")
|
||||||
|
.addText((text) => text
|
||||||
|
.setPlaceholder("https://example.com")
|
||||||
|
.setValue(this.plugin.settings.httpReferer)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.httpReferer = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}));
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("App title")
|
||||||
|
.setDesc("Optional OpenRouter app title header.")
|
||||||
|
.addText((text) => text
|
||||||
|
.setPlaceholder("Single Text Input")
|
||||||
|
.setValue(this.plugin.settings.appTitle)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.appTitle = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/suggestions-modal.ts
Normal file
38
src/suggestions-modal.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { App, Component, MarkdownRenderer, Modal } from "obsidian";
|
||||||
|
|
||||||
|
export class SuggestionsModal extends Modal {
|
||||||
|
private readonly markdownComponent = new Component();
|
||||||
|
|
||||||
|
constructor(app: App) {
|
||||||
|
super(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen(): void {
|
||||||
|
this.markdownComponent.load();
|
||||||
|
this.setTitle("What to do next");
|
||||||
|
this.contentEl.empty();
|
||||||
|
this.contentEl.addClass("single-text-input-suggestions-modal");
|
||||||
|
this.contentEl.createEl("p", {
|
||||||
|
text: "Reviewing your notes and thinking through useful next steps...",
|
||||||
|
cls: "single-text-input-suggestions-modal__status",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSuggestions(markdown: string): Promise<void> {
|
||||||
|
this.contentEl.empty();
|
||||||
|
await MarkdownRenderer.renderMarkdown(markdown, this.contentEl, "", this.markdownComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(message: string): void {
|
||||||
|
this.contentEl.empty();
|
||||||
|
this.contentEl.createEl("p", {
|
||||||
|
text: message,
|
||||||
|
cls: "single-text-input-suggestions-modal__error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(): void {
|
||||||
|
this.markdownComponent.unload();
|
||||||
|
this.contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
328
src/suggestions.ts
Normal file
328
src/suggestions.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
import { App } from "obsidian";
|
||||||
|
|
||||||
|
interface OpenRouterSuggestionsOptions {
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
referer?: string;
|
||||||
|
title?: string;
|
||||||
|
app: App;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenRouterApiResponse {
|
||||||
|
error?: {
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
choices?: Array<{
|
||||||
|
message?: OpenRouterMessage;
|
||||||
|
finish_reason?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenRouterMessage {
|
||||||
|
role: "system" | "user" | "assistant" | "tool";
|
||||||
|
content?: string;
|
||||||
|
tool_calls?: OpenRouterToolCall[];
|
||||||
|
tool_call_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenRouterToolCall {
|
||||||
|
id: string;
|
||||||
|
type: "function";
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolDefinition {
|
||||||
|
type: "function";
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: {
|
||||||
|
type: "object";
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateNextStepSuggestions({
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
referer,
|
||||||
|
title,
|
||||||
|
app,
|
||||||
|
}: OpenRouterSuggestionsOptions): Promise<string> {
|
||||||
|
const trimmedApiKey = apiKey.trim();
|
||||||
|
const trimmedModel = model.trim();
|
||||||
|
const trimmedReferer = referer?.trim() ?? "";
|
||||||
|
const trimmedTitle = title?.trim() ?? "";
|
||||||
|
|
||||||
|
if (!trimmedApiKey) {
|
||||||
|
throw new Error("Add your OpenRouter API key in the plugin settings.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmedModel) {
|
||||||
|
throw new Error("Add an OpenRouter model slug in the plugin settings.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${trimmedApiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (trimmedReferer) {
|
||||||
|
headers["HTTP-Referer"] = trimmedReferer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedTitle) {
|
||||||
|
headers["X-OpenRouter-Title"] = trimmedTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools = getToolDefinitions();
|
||||||
|
const messages: OpenRouterMessage[] = [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: [
|
||||||
|
"You are helping the user decide what to do next in Obsidian.",
|
||||||
|
"You must inspect the vault by using the available tools before answering.",
|
||||||
|
"Search for relevant notes, read the most useful ones, and then produce a concise but thoughtful markdown response.",
|
||||||
|
"Your final answer should include:",
|
||||||
|
"1. A short summary of what seems active or important right now.",
|
||||||
|
"2. Three to five concrete next-step suggestions.",
|
||||||
|
"3. A brief reason for each suggestion grounded in the notes you inspected.",
|
||||||
|
"4. A short section called 'Notes considered' listing the note paths you relied on most.",
|
||||||
|
].join(" "),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: "Look through my current notes and suggest what I should do next.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let iteration = 0; iteration < 6; iteration += 1) {
|
||||||
|
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: trimmedModel,
|
||||||
|
messages,
|
||||||
|
tools,
|
||||||
|
tool_choice: "auto",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json() as OpenRouterApiResponse;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error?.message ?? "OpenRouter returned an unexpected error.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = data.choices?.[0]?.message;
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
throw new Error("OpenRouter returned an empty response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push(message);
|
||||||
|
|
||||||
|
if (!message.tool_calls || message.tool_calls.length === 0) {
|
||||||
|
if (message.content?.trim()) {
|
||||||
|
return message.content.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("OpenRouter returned a response without content.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const toolCall of message.tool_calls) {
|
||||||
|
const toolResult = await executeToolCall(app, toolCall);
|
||||||
|
messages.push({
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: toolCall.id,
|
||||||
|
content: JSON.stringify(toolResult),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("The suggestions workflow hit the tool-use limit before finishing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolDefinitions(): ToolDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "search_notes",
|
||||||
|
description: "Search note titles and contents for notes relevant to a topic.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "The topic or phrase to search for." },
|
||||||
|
limit: { type: "number", description: "Maximum number of results to return." },
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_note",
|
||||||
|
description: "Read the contents of a specific note by path.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "Exact vault path of the note to read." },
|
||||||
|
maxChars: { type: "number", description: "Optional maximum number of characters to return." },
|
||||||
|
},
|
||||||
|
required: ["path"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "list_recent_notes",
|
||||||
|
description: "List recently modified markdown notes in the vault.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
limit: { type: "number", description: "Maximum number of notes to list." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeToolCall(app: App, toolCall: OpenRouterToolCall): Promise<unknown> {
|
||||||
|
const args = parseToolArguments(toolCall.function.arguments);
|
||||||
|
|
||||||
|
switch (toolCall.function.name) {
|
||||||
|
case "search_notes":
|
||||||
|
return searchNotes(app, args.query, args.limit);
|
||||||
|
case "get_note":
|
||||||
|
return getNote(app, args.path, args.maxChars);
|
||||||
|
case "list_recent_notes":
|
||||||
|
return listRecentNotes(app, args.limit);
|
||||||
|
default:
|
||||||
|
return { error: `Unknown tool: ${toolCall.function.name}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseToolArguments(rawArguments: string): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawArguments) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchNotes(app: App, queryValue: unknown, limitValue: unknown): Promise<unknown> {
|
||||||
|
const query = typeof queryValue === "string" ? queryValue.trim().toLowerCase() : "";
|
||||||
|
const limit = normalizeLimit(limitValue, 6);
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return { results: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = app.vault.getMarkdownFiles();
|
||||||
|
const matches: Array<{ path: string; basename: string; snippet: string; score: number }> = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const pathText = file.path.toLowerCase();
|
||||||
|
const baseText = file.basename.toLowerCase();
|
||||||
|
const content = await app.vault.cachedRead(file);
|
||||||
|
const contentText = content.toLowerCase();
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
if (baseText.includes(query)) {
|
||||||
|
score += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathText.includes(query)) {
|
||||||
|
score += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentText.includes(query)) {
|
||||||
|
score += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > 0) {
|
||||||
|
matches.push({
|
||||||
|
path: file.path,
|
||||||
|
basename: file.basename,
|
||||||
|
snippet: buildSnippet(content, query),
|
||||||
|
score,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.sort((left, right) => right.score - left.score || left.path.localeCompare(right.path));
|
||||||
|
|
||||||
|
return {
|
||||||
|
results: matches.slice(0, limit).map(({ score, ...result }) => result),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNote(app: App, pathValue: unknown, maxCharsValue: unknown): Promise<unknown> {
|
||||||
|
const path = typeof pathValue === "string" ? pathValue.trim() : "";
|
||||||
|
const maxChars = normalizeLimit(maxCharsValue, 4000, 12000);
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
return { error: "A note path is required." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = app.vault.getMarkdownFiles().find((candidate) => candidate.path === path);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return { error: `Note not found: ${path}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await app.vault.cachedRead(file);
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: file.path,
|
||||||
|
content: content.slice(0, maxChars),
|
||||||
|
truncated: content.length > maxChars,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listRecentNotes(app: App, limitValue: unknown): unknown {
|
||||||
|
const limit = normalizeLimit(limitValue, 8, 20);
|
||||||
|
|
||||||
|
return {
|
||||||
|
notes: app.vault.getMarkdownFiles()
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => right.stat.mtime - left.stat.mtime)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((file) => ({
|
||||||
|
path: file.path,
|
||||||
|
basename: file.basename,
|
||||||
|
modifiedTime: new Date(file.stat.mtime).toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLimit(value: unknown, fallback: number, maximum = 10): number {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, Math.min(Math.floor(value), maximum));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSnippet(content: string, query: string): string {
|
||||||
|
const lowerContent = content.toLowerCase();
|
||||||
|
const matchIndex = lowerContent.indexOf(query);
|
||||||
|
|
||||||
|
if (matchIndex === -1) {
|
||||||
|
return content.replace(/\s+/g, " ").trim().slice(0, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(0, matchIndex - 80);
|
||||||
|
const end = Math.min(content.length, matchIndex + query.length + 100);
|
||||||
|
return content.slice(start, end).replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
102
src/view.ts
Normal file
102
src/view.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { ItemView, WorkspaceLeaf } from "obsidian";
|
||||||
|
|
||||||
|
export const VIEW_TYPE_SINGLE_TEXT_INPUT = "single-text-input-view";
|
||||||
|
|
||||||
|
interface ViewState {
|
||||||
|
getValue: () => string;
|
||||||
|
getStatus: () => string;
|
||||||
|
setValue: (value: string) => void;
|
||||||
|
submit: (value: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SingleTextInputView extends ItemView {
|
||||||
|
constructor(
|
||||||
|
leaf: WorkspaceLeaf,
|
||||||
|
private readonly state: ViewState,
|
||||||
|
) {
|
||||||
|
super(leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
getViewType(): string {
|
||||||
|
return VIEW_TYPE_SINGLE_TEXT_INPUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayText(): string {
|
||||||
|
return "Text input";
|
||||||
|
}
|
||||||
|
|
||||||
|
getIcon(): string {
|
||||||
|
return "pencil";
|
||||||
|
}
|
||||||
|
|
||||||
|
async onOpen(): Promise<void> {
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onClose(): Promise<void> {
|
||||||
|
this.contentEl.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(): void {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
contentEl.addClass("single-text-input-view");
|
||||||
|
|
||||||
|
contentEl.createEl("h2", { text: "Text input" });
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "Type a short idea, then create a detailed note that reuses relevant existing tags and can add new ones when needed.",
|
||||||
|
cls: "single-text-input-view__description",
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = contentEl.createEl("input", {
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Enter a topic or idea",
|
||||||
|
cls: "single-text-input-view__input",
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionsEl = contentEl.createDiv({ cls: "single-text-input-view__actions" });
|
||||||
|
const submitButton = actionsEl.createEl("button", {
|
||||||
|
text: "Create note",
|
||||||
|
cls: "mod-cta",
|
||||||
|
});
|
||||||
|
const statusEl = contentEl.createEl("p", {
|
||||||
|
cls: "single-text-input-view__status",
|
||||||
|
});
|
||||||
|
|
||||||
|
input.value = this.state.getValue();
|
||||||
|
statusEl.setText(this.state.getStatus());
|
||||||
|
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
this.state.setValue(input.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const runRequest = async () => {
|
||||||
|
submitButton.disabled = true;
|
||||||
|
statusEl.setText("Generating note...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.state.setValue(input.value);
|
||||||
|
await this.state.submit(input.value);
|
||||||
|
statusEl.setText(this.state.getStatus());
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unexpected error.";
|
||||||
|
statusEl.setText(message);
|
||||||
|
} finally {
|
||||||
|
submitButton.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
submitButton.addEventListener("click", () => {
|
||||||
|
void runRequest();
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
void runRequest();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.setTimeout(() => input.focus(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
styles.css
Normal file
40
styles.css
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
.single-text-input-view {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-text-input-view__description {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-text-input-view__input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--background-primary);
|
||||||
|
color: var(--text-normal);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-text-input-view__input:focus {
|
||||||
|
outline: 2px solid var(--interactive-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-text-input-view__actions {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-text-input-view__status {
|
||||||
|
min-height: 1.5em;
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-text-input-suggestions-modal__status,
|
||||||
|
.single-text-input-suggestions-modal__error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
30
tsconfig.json
Normal file
30
tsconfig.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "src",
|
||||||
|
"inlineSourceMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"target": "ES6",
|
||||||
|
"allowJs": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"importHelpers": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"useUnknownInCatchVariables": true,
|
||||||
|
"lib": [
|
||||||
|
"DOM",
|
||||||
|
"ES5",
|
||||||
|
"ES6",
|
||||||
|
"ES7"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
17
version-bump.mjs
Normal file
17
version-bump.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { readFileSync, writeFileSync } from "fs";
|
||||||
|
|
||||||
|
const targetVersion = process.env.npm_package_version;
|
||||||
|
|
||||||
|
// read minAppVersion from manifest.json and bump version to target version
|
||||||
|
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||||
|
const { minAppVersion } = manifest;
|
||||||
|
manifest.version = targetVersion;
|
||||||
|
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||||
|
|
||||||
|
// update versions.json with target version and minAppVersion from manifest.json
|
||||||
|
// but only if the target version is not already in versions.json
|
||||||
|
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
||||||
|
if (!Object.values(versions).includes(minAppVersion)) {
|
||||||
|
versions[targetVersion] = minAppVersion;
|
||||||
|
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
|
||||||
|
}
|
||||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"1.0.0": "0.15.0"
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue