diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 087dd25..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,266 +0,0 @@ -# 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: - ``` - /.obsidian/plugins// - ``` -- 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 `/.obsidian/plugins//`. -- 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 diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 17a7aed..2fc1653 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,89 +1,84 @@ -# Claude 开发指南 +# Claude Development Guide -本文档为 Claude AI 助手提供项目特定的开发指导。 +This document provides project-specific development guidance for the Claude AI assistant. -## 项目概述 +## Project Overview -OnyxMind 是一款基于 OpenCode AI Agent 框架的 Obsidian 智能助手插件。 +OnyxMind is an Obsidian smart assistant plugin built on the OpenCode AI Agent framework. -**核心原理**: +**Core principles**: -- 插件仅作为用户交互入口 -- 所有 AI 处理和文件操作由 OpenCode Agent 在服务端完成 -- 通过 OpenCode SDK 与服务通信 +- The plugin serves only as the user interaction entry point +- All AI processing and file operations are performed by the OpenCode Agent on the server +- Communication with the service is via the OpenCode SDK -## 参考资源 +## Reference Resources -### 代码参考 +### Code Reference -当实现插件功能时遇到不确定的问题,可以参考以下项目的代码实现: +When implementing plugin features and encountering uncertain issues, you may refer to the following project’s code: -**infio-copilot** (类似功能的 Obsidian 插件) +**infio-copilot** (Obsidian plugin with similar functionality) -- 路径: `/Users/jiachengkun/opensource/infio-copilot` -- 用途: 参考 UI 实现、Obsidian API 使用模式、用户交互设计 +- Path: `/Users/jiachengkun/opensource/infio-copilot` +- Use: Reference for UI implementation, Obsidian API usage patterns, and user interaction design -### 技术文档 +### Technical Documentation - OpenCode SDK: https://opencode.ai/docs/sdk/ - Obsidian API: https://docs.obsidian.md -- 项目设计文档: 见 `PRD.md`, `ARCHITECTURE.md`, `OBSIDIAN_BEST_PRACTICES.md` +- Project design docs: see `PRD.md`, `ARCHITECTURE.md`, `OBSIDIAN_BEST_PRACTICES.md` -## 开发规范 +## Development Standards -### 必须遵守的 Obsidian 最佳实践 +### Mandatory Obsidian Best Practices -1. **内存管理** - - ✅ 使用 `registerEvent()`, `addCommand()` 等注册方法 - - ❌ 不要存储视图引用在插件属性中 +1. **Memory management** + - ✅ Use registration methods such as `registerEvent()`, `addCommand()` + - ❌ Do not store view references in plugin properties -2. **类型安全** - - ✅ 使用 `instanceof` 检查 TFile/TFolder - - ❌ 不要使用类型转换 (as TFile) +2. **Type safety** + - ✅ Use `instanceof` to check TFile/TFolder + - ❌ Do not use type assertions (as TFile) -3. **命名规范** - - 插件 ID: "onyxmind" - - 命令名称: sentence case (如 "Open chat") - - 不包含 "command" 字样 +3. **Naming conventions** + - Plugin ID: "onyxmind" + - Command names: sentence case (e.g. "Open chat") + - Do not include the word "command" -4. **文件操作** - - ✅ 使用 Editor API 编辑活动文件 - - ✅ 使用 `Vault.process()` 后台修改文件 - - ✅ 使用 `requestUrl()` 而非 `fetch()` +4. **File operations** + - ✅ Use Editor API to edit the active file + - ✅ Use `Vault.process()` for background file modifications + - ✅ Use `requestUrl()` instead of `fetch()` -5. **可访问性(强制)** - - 所有交互元素支持键盘导航 - - 图标按钮添加 aria-label - - 使用 `:focus-visible` 定义焦点样式 +5. **Accessibility (required)** + - All interactive elements must support keyboard navigation + - Add aria-label to icon buttons + - Use `:focus-visible` for focus styles -详细规范见 `OBSIDIAN_BEST_PRACTICES.md` +See `OBSIDIAN_BEST_PRACTICES.md` for full details. -## 项目结构 +## Project Structure ``` onyxmind/ ├── src/ -│ ├── main.ts # 插件主类 -│ ├── settings.ts # 设置管理 │ ├── services/ -│ │ ├── opencode.ts # OpenCode 服务封装 -│ │ └── session.ts # 会话管理 │ ├── views/ -│ │ └── chat-view.ts # 聊天界面 +│ │ └── chat/ +│ │ ├── components/ +│ │ │ └── tools/ +│ │ └── hooks/ +│ ├── utils/ │ └── commands/ -│ └── editor-commands.ts -├── styles.css # 样式文件 -├── manifest.json # 插件清单 -└── docs/ - ├── PRD.md - ├── ARCHITECTURE.md - ├── OBSIDIAN_BEST_PRACTICES.md - ├── ROADMAP.md - └── IMPLEMENTATION_EXAMPLE.ts +├── docs/ +├── tests/ +└── .github/ + └── workflows/ ``` -## 开发规范 +## Development Workflow -1. 我们遵循 fail-fast 原则,除非必要否则不增加容错代码; -2. 每次完成代码编写,你需要执行 `bun run lint` 验证代码的格式问题并进行修复; -3. 所有检查完成后,你需要执行 `bun run build` 构建插件; +1. We follow a fail-fast approach; do not add defensive/error-handling code unless necessary. +2. After writing code, run `bun run lint` to check formatting and fix any issues. +3. After all checks pass, run `bun run build` to build the plugin. diff --git a/src/main.ts b/src/main.ts index 11bc205..e7e2a66 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,6 +14,16 @@ export default class OnyxMindPlugin extends Plugin { opencodeService: OpencodeService; sessionManager: SessionManager; chatService: ChatService; + private settingsChangeListeners: Array<() => void> = []; + + onSettingsChange(listener: () => void): () => void { + this.settingsChangeListeners.push(listener); + return () => { + this.settingsChangeListeners = this.settingsChangeListeners.filter( + (l) => l !== listener, + ); + }; + } async onload() { await this.loadSettings(); @@ -163,5 +173,8 @@ export default class OnyxMindPlugin extends Plugin { if (this.opencodeService) { this.opencodeService.updateSettings(this.settings); } + for (const listener of this.settingsChangeListeners) { + listener(); + } } } diff --git a/src/services/opencode-service.ts b/src/services/opencode-service.ts index 4ad8911..63e45dc 100644 --- a/src/services/opencode-service.ts +++ b/src/services/opencode-service.ts @@ -223,27 +223,31 @@ export class OpencodeService { const agentPrompt = buildAgentPrompt({ vaultPath: this.vaultPath }); // 1. Start the server (patched version; CORS origins passed via --cors flag) + // Build provider config map from all configured providers + const providerConfigMap: Record = {}; + for (const p of this.settings.providers) { + if (!p.apiKey) continue; + providerConfigMap[p.id] = { + models: Object.fromEntries( + p.models.map((m) => [m.modelId, { name: m.modelId }]), + ), + options: { + apiKey: p.apiKey, + ...(p.apiBase ? { baseURL: p.apiBase } : {}), + }, + }; + } + const serverResult = await createOpencodeServerPatched({ hostname: SERVER_HOSTNAME, port: this.port, signal: this.abortController.signal, config: { - model: this.settings.modelId, + model: this.settings.activeModelId, server: { cors: [OBSIDIAN_CORS_ORIGIN], }, - provider: { - [this.settings.providerId]: { - models: { - [this.settings.modelId]: { - name: this.settings.modelId, - }, - }, - options: { - apiKey: this.settings.apiKey, - }, - }, - }, + provider: providerConfigMap, permission: { websearch: "allow", }, @@ -506,8 +510,8 @@ export class OpencodeService { sessionID: sessionId, parts: [{ type: PART_TYPE_TEXT, text: prompt }], model: { - providerID: this.settings.providerId, - modelID: this.settings.modelId, + providerID: this.settings.activeProviderId, + modelID: this.settings.activeModelId, }, }); @@ -745,8 +749,8 @@ export class OpencodeService { sessionID: sessionId, parts: [{ type: PART_TYPE_TEXT, text: prompt }], model: { - providerID: this.settings.providerId, - modelID: this.settings.modelId, + providerID: this.settings.activeProviderId, + modelID: this.settings.activeModelId, }, }); diff --git a/src/settings.ts b/src/settings.ts index 9526cb9..cdebb12 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,12 +1,58 @@ -import { App, PluginSettingTab, Setting } from "obsidian"; +import { App, PluginSettingTab } from "obsidian"; +import React from "react"; +import { createRoot, type Root } from "react-dom/client"; import type OnyxMindPlugin from "./main"; +import { SettingsPanel } from "./views/settings/SettingsPanel"; + +export type ProviderId = "openai" | "anthropic" | "kimi" | "openrouter"; + +export interface ModelConfig { + modelId: string; + maxTokens: number; + isReasoning: boolean; +} + +export interface ProviderConfig { + id: ProviderId; + apiKey: string; + apiBase: string; + models: ModelConfig[]; +} + +export const PROVIDER_META: Record< + ProviderId, + { name: string; defaultApiBase: string; fixedApiBase: boolean } +> = { + openai: { + name: "OpenAI", + defaultApiBase: "https://api.openai.com/v1", + fixedApiBase: false, + }, + anthropic: { + name: "Anthropic", + defaultApiBase: "https://api.anthropic.com", + fixedApiBase: false, + }, + kimi: { + name: "Kimi", + defaultApiBase: "https://api.moonshot.cn/v1", + fixedApiBase: false, + }, + openrouter: { + name: "OpenRouter", + defaultApiBase: "https://openrouter.ai/api/v1", + fixedApiBase: true, + }, +}; export interface OnyxMindSettings { // OpenCode connection opencodeBaseUrl: string; - apiKey: string; - providerId: string; - modelId: string; + + // Provider configuration + providers: ProviderConfig[]; + activeProviderId: ProviderId; + activeModelId: string; // Behavior settings defaultSearchScope: "current-folder" | "vault"; @@ -23,11 +69,65 @@ export interface OnyxMindSettings { } export const DEFAULT_SETTINGS: OnyxMindSettings = { - // OpenCode connection opencodeBaseUrl: "http://localhost:4096", - apiKey: "", - providerId: "kimi-for-coding", - modelId: "kimi-for-coding/k2p5", + providers: [ + { + id: "openai", + apiKey: "", + apiBase: "", + models: [ + { modelId: "gpt-4o", maxTokens: 128000, isReasoning: false }, + { modelId: "gpt-4o-mini", maxTokens: 16000, isReasoning: false }, + { modelId: "o3-mini", maxTokens: 100000, isReasoning: true }, + ], + }, + { + id: "anthropic", + apiKey: "", + apiBase: "", + models: [ + { modelId: "claude-opus-4-6", maxTokens: 200000, isReasoning: false }, + { + modelId: "claude-sonnet-4-6", + maxTokens: 200000, + isReasoning: false, + }, + { modelId: "claude-haiku-4-5", maxTokens: 200000, isReasoning: false }, + ], + }, + { + id: "kimi", + apiKey: "", + apiBase: "", + models: [ + { + modelId: "moonshot-v1-128k", + maxTokens: 128000, + isReasoning: false, + }, + { + modelId: "kimi-k2-0711-preview", + maxTokens: 128000, + isReasoning: false, + }, + ], + }, + { + id: "openrouter", + apiKey: "", + apiBase: "", + models: [ + { modelId: "openai/gpt-4o", maxTokens: 128000, isReasoning: false }, + { + modelId: "anthropic/claude-sonnet-4-5", + maxTokens: 200000, + isReasoning: false, + }, + ], + }, + ], + activeProviderId: "kimi", + activeModelId: "kimi-k2-0711-preview", // Behavior settings defaultSearchScope: "vault", @@ -45,6 +145,7 @@ export const DEFAULT_SETTINGS: OnyxMindSettings = { export class OnyxMindSettingTab extends PluginSettingTab { plugin: OnyxMindPlugin; + private root: Root | null = null; constructor(app: App, plugin: OnyxMindPlugin) { super(app, plugin); @@ -53,202 +154,16 @@ export class OnyxMindSettingTab extends PluginSettingTab { display(): void { const { containerEl } = this; - containerEl.empty(); + const mount = containerEl.createDiv("onyxmind-settings-panel-mount"); + this.root = createRoot(mount); + this.root.render( + React.createElement(SettingsPanel, { plugin: this.plugin }), + ); + } - // Connection settings - new Setting(containerEl).setHeading().setName("Provider"); - - new Setting(containerEl) - .setName("Service URL") - .setDesc("Base URL for the service.") - .addText((text) => - text - .setPlaceholder("Enter service URL") - .setValue(this.plugin.settings.opencodeBaseUrl) - .onChange(async (value) => { - this.plugin.settings.opencodeBaseUrl = value; - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("Provider") - .setDesc("AI model provider.") - .addDropdown((dropdown) => - dropdown - .addOption("openai", "OpenAI") - .addOption("anthropic", "Anthropic") - .addOption("kimi", "Kimi") - .addOption("kimi-for-coding", "Kimi for coding") - .setValue(this.plugin.settings.providerId) - .onChange(async (value) => { - this.plugin.settings.providerId = value; - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("API key") - .setDesc("API key for model provider.") - .addText((text) => { - text - .setPlaceholder("Enter API key") - .setValue(this.plugin.settings.apiKey) - .onChange(async (value) => { - this.plugin.settings.apiKey = value; - await this.plugin.saveSettings(); - }); - // Make it a password field - text.inputEl.type = "password"; - return text; - }); - - new Setting(containerEl) - .setName("Model") - .setDesc("Model identifier to use.") - .addText((text) => - text - .setPlaceholder("Enter model ID") - .setValue(this.plugin.settings.modelId) - .onChange(async (value) => { - this.plugin.settings.modelId = value; - await this.plugin.saveSettings(); - }), - ); - - // Behavior settings - new Setting(containerEl).setHeading().setName("Behavior"); - - new Setting(containerEl) - .setName("Default search scope") - .setDesc("Where to search for notes by default.") - .addDropdown((dropdown) => - dropdown - .addOption("vault", "Entire vault") - .addOption("current-folder", "Current folder") - .setValue(this.plugin.settings.defaultSearchScope) - .onChange(async (value: "vault" | "current-folder") => { - this.plugin.settings.defaultSearchScope = value; - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("Auto-save conversations") - .setDesc("Automatically save conversation history.") - .addToggle((toggle) => - toggle - .setValue(this.plugin.settings.autoSave) - .onChange(async (value) => { - this.plugin.settings.autoSave = value; - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("Maximum active sessions") - .setDesc("Limit how many sessions can stay open at the same time.") - .addText((text) => - text - .setPlaceholder("3") - .setValue(String(this.plugin.settings.maxActiveSessions)) - .onChange(async (value) => { - const num = parseInt(value); - if (!isNaN(num) && num > 0) { - this.plugin.settings.maxActiveSessions = num; - await this.plugin.saveSettings(); - } - }), - ); - - new Setting(containerEl) - .setName("Confirm file operations") - .setDesc("Ask for confirmation before AI modifies files.") - .addToggle((toggle) => - toggle - .setValue(this.plugin.settings.confirmFileOperations) - .onChange(async (value) => { - this.plugin.settings.confirmFileOperations = value; - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName("Maximum history messages") - .setDesc("Maximum number of messages to keep in history.") - .addText((text) => - text - .setPlaceholder("50") - .setValue(String(this.plugin.settings.maxHistoryMessages)) - .onChange(async (value) => { - const num = parseInt(value); - if (!isNaN(num) && num > 0) { - this.plugin.settings.maxHistoryMessages = num; - await this.plugin.saveSettings(); - } - }), - ); - - new Setting(containerEl) - .setName("Show tool calls after streaming") - .setDesc( - "Keep tool call details visible after a response finishes streaming.", - ) - .addToggle((toggle) => - toggle - .setValue(this.plugin.settings.showToolCallsAfterStreaming) - .onChange(async (value) => { - this.plugin.settings.showToolCallsAfterStreaming = value; - await this.plugin.saveSettings(); - }), - ); - - // Advanced settings - new Setting(containerEl).setHeading().setName("Advanced"); - - new Setting(containerEl) - .setName("Request timeout") - .setDesc("Timeout for API requests in milliseconds.") - .addText((text) => - text - .setPlaceholder("30000") - .setValue(String(this.plugin.settings.timeout)) - .onChange(async (value) => { - const num = parseInt(value); - if (!isNaN(num) && num > 0) { - this.plugin.settings.timeout = num; - await this.plugin.saveSettings(); - } - }), - ); - - new Setting(containerEl) - .setName("Maximum retries") - .setDesc("Maximum number of retry attempts for failed requests.") - .addText((text) => - text - .setPlaceholder("3") - .setValue(String(this.plugin.settings.maxRetries)) - .onChange(async (value) => { - const num = parseInt(value); - if (!isNaN(num) && num >= 0) { - this.plugin.settings.maxRetries = num; - await this.plugin.saveSettings(); - } - }), - ); - - new Setting(containerEl) - .setName("Stream responses") - .setDesc("Show AI responses in real-time as they are generated.") - .addToggle((toggle) => - toggle - .setValue(this.plugin.settings.streamResponse) - .onChange(async (value) => { - this.plugin.settings.streamResponse = value; - await this.plugin.saveSettings(); - }), - ); + hide(): void { + this.root?.unmount(); + this.root = null; } } diff --git a/src/views/chat/chat-view-app.tsx b/src/views/chat/chat-view-app.tsx index 5e51272..cdff3c9 100644 --- a/src/views/chat/chat-view-app.tsx +++ b/src/views/chat/chat-view-app.tsx @@ -57,6 +57,7 @@ export function ChatViewApp(props: ChatViewAppProps) { filteredCommands={controller.filteredCommands} slashSelectedIndex={controller.slashSelectedIndex} providerId={controller.providerId} + providerName={controller.providerName} modelId={controller.modelId} noteChipPath={controller.noteChipPath} noteChipAttached={controller.noteChipAttached} diff --git a/src/views/chat/components/chat-composer.tsx b/src/views/chat/components/chat-composer.tsx index 95389e6..fc3a08b 100644 --- a/src/views/chat/components/chat-composer.tsx +++ b/src/views/chat/components/chat-composer.tsx @@ -25,6 +25,7 @@ interface ChatComposerProps { filteredCommands: AvailableCommand[]; slashSelectedIndex: number; providerId: string; + providerName: string; modelId: string; noteChipPath?: string | null; noteChipAttached?: boolean; @@ -94,6 +95,7 @@ export function ChatComposer(props: ChatComposerProps) { filteredCommands, slashSelectedIndex, providerId, + providerName, modelId, noteChipPath, noteChipAttached = false, @@ -204,8 +206,14 @@ export function ChatComposer(props: ChatComposerProps) {
- {providerId} - {modelId} + + {providerName} + · + {modelId} + {LABEL_SLASH_HINT} {LABEL_LOCAL_SAFE}
diff --git a/src/views/chat/hooks/use-chat-controller.ts b/src/views/chat/hooks/use-chat-controller.ts index 2fd3e0f..ff45011 100644 --- a/src/views/chat/hooks/use-chat-controller.ts +++ b/src/views/chat/hooks/use-chat-controller.ts @@ -18,6 +18,7 @@ import type { CreateSessionResult, OnyxMindSession, } from "../../../services/session-manager"; +import { PROVIDER_META } from "../../../settings"; import { findSlashMatch } from "../slash"; import type { ToolCardMap } from "../types"; @@ -39,6 +40,7 @@ interface UseChatControllerResult { filteredCommands: AvailableCommand[]; slashSelectedIndex: number; providerId: string; + providerName: string; modelId: string; historyMenuOpen: boolean; historySessions: OnyxMindSession[]; @@ -114,6 +116,12 @@ export function useChatController( const [historyMenuOpen, setHistoryMenuOpen] = useState(false); const [historySessions, setHistorySessions] = useState([]); const [historySelectedIndex, setHistorySelectedIndex] = useState(0); + const [activeProviderId, setActiveProviderId] = useState( + () => plugin.settings.activeProviderId, + ); + const [activeModelId, setActiveModelId] = useState( + () => plugin.settings.activeModelId, + ); const activeSession = useMemo( () => sessions.find((session) => session.id === activeSessionId) ?? null, @@ -407,7 +415,7 @@ export function useChatController( // Summarize session after first complete conversation if ( - session.messages.length === 1 && + session.messages.length === 2 && !plugin.sessionManager.isSessionSummarized(session.id) ) { const success = await plugin.sessionManager.summarizeActiveSession(); @@ -696,6 +704,15 @@ export function useChatController( activeSessionIdRef.current = activeSessionId; }, [activeSessionId]); + // Sync active provider/model when settings change from the settings panel + useEffect(() => { + const unsubscribe = plugin.onSettingsChange(() => { + setActiveProviderId(plugin.settings.activeProviderId); + setActiveModelId(plugin.settings.activeModelId); + }); + return unsubscribe; + }, [plugin]); + useEffect(() => { const ref = plugin.app.workspace.on("file-open", (file) => { const newPath = file?.path ?? null; @@ -782,8 +799,9 @@ export function useChatController( slashMenuOpen, filteredCommands, slashSelectedIndex, - providerId: plugin.settings.providerId, - modelId: plugin.settings.modelId, + providerId: activeProviderId, + providerName: PROVIDER_META[activeProviderId]?.name ?? activeProviderId, + modelId: activeModelId, historyMenuOpen, historySessions, historySelectedIndex, diff --git a/src/views/settings/SettingsPanel.tsx b/src/views/settings/SettingsPanel.tsx new file mode 100644 index 0000000..2f819a9 --- /dev/null +++ b/src/views/settings/SettingsPanel.tsx @@ -0,0 +1,537 @@ +import { type ReactNode, useState } from "react"; +import type OnyxMindPlugin from "../../main"; +import { + type ModelConfig, + type OnyxMindSettings, + type ProviderConfig, + type ProviderId, + PROVIDER_META, +} from "../../settings"; + +// ── Primitive controls ──────────────────────────────────────────────────── + +function Toggle({ + checked, + onChange, +}: { + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( + + ); +} + +function SettingRow({ + name, + desc, + children, +}: { + name: string; + desc?: string; + children: ReactNode; +}) { + return ( +
+
+
{name}
+ {desc &&
{desc}
} +
+
{children}
+
+ ); +} + +// ── Provider tab ────────────────────────────────────────────────────────── + +const PROVIDER_IDS: ProviderId[] = [ + "openai", + "anthropic", + "kimi", + "openrouter", +]; + +function ProviderTab({ plugin }: { plugin: OnyxMindPlugin }) { + const [selectedId, setSelectedId] = useState("openai"); + const [providers, setProviders] = useState( + () => plugin.settings.providers, + ); + const [activeProviderId, setActiveProviderId] = useState( + () => plugin.settings.activeProviderId, + ); + const [activeModelId, setActiveModelId] = useState( + () => plugin.settings.activeModelId, + ); + + const getProvider = (id: ProviderId) => + providers.find((p) => p.id === id) ?? plugin.settings.providers.find((p) => p.id === id)!; + + const saveProviders = async (next: ProviderConfig[]) => { + setProviders(next); + plugin.settings.providers = next; + await plugin.saveSettings(); + }; + + const updateProvider = async ( + id: ProviderId, + updates: Partial>, + ) => { + await saveProviders( + providers.map((p) => (p.id === id ? { ...p, ...updates } : p)), + ); + }; + + const updateModel = async ( + pid: ProviderId, + idx: number, + updates: Partial, + ) => { + const p = getProvider(pid); + await updateProvider(pid, { + models: p.models.map((m, i) => (i === idx ? { ...m, ...updates } : m)), + }); + }; + + const addModel = async (pid: ProviderId) => { + const p = getProvider(pid); + await updateProvider(pid, { + models: [ + ...p.models, + { modelId: "", maxTokens: 128000, isReasoning: false }, + ], + }); + }; + + const removeModel = async (pid: ProviderId, idx: number) => { + const p = getProvider(pid); + await updateProvider(pid, { + models: p.models.filter((_, i) => i !== idx), + }); + }; + + const updateActiveModel = async (pid: ProviderId, mid: string) => { + setActiveProviderId(pid); + setActiveModelId(mid); + plugin.settings.activeProviderId = pid; + plugin.settings.activeModelId = mid; + await plugin.saveSettings(); + }; + + const current = getProvider(selectedId); + const meta = PROVIDER_META[selectedId]; + const activeProvider = providers.find((p) => p.id === activeProviderId); + + return ( + <> + {/* Two-column layout */} +
+ {/* Left sidebar */} + + + {/* Right config panel */} +
+
{meta.name}
+ +
+ + { + const v = e.target.value; + void updateProvider(selectedId, { apiKey: v }); + }} + /> +
+ + {!meta.fixedApiBase && ( +
+ + { + const v = e.target.value; + void updateProvider(selectedId, { apiBase: v }); + }} + /> +
+ )} + + {/* Models table */} +
+
+ Model ID + Max Tokens + Reasoning + +
+ + {current.models.map((model, idx) => ( +
+ { + const v = e.target.value; + void updateModel(selectedId, idx, { modelId: v }); + }} + /> + { + const v = parseInt(e.target.value); + if (!isNaN(v) && v > 0) + void updateModel(selectedId, idx, { maxTokens: v }); + }} + /> + + void updateModel(selectedId, idx, { isReasoning: v }) + } + /> + +
+ ))} + + +
+
+
+ + {/* Active model — full width strip below two-column layout */} +
+
Active Model
+
+
+ + +
+
+ + +
+
+
+ + ); +} + +// ── Agent tab ───────────────────────────────────────────────────────────── + +function AgentTab({ plugin }: { plugin: OnyxMindPlugin }) { + const s = plugin.settings; + + const save = async (updates: Partial) => { + Object.assign(plugin.settings, updates); + await plugin.saveSettings(); + }; + + const [searchScope, setSearchScope] = useState(s.defaultSearchScope); + const [autoSave, setAutoSave] = useState(s.autoSave); + const [maxSessions, setMaxSessions] = useState(String(s.maxActiveSessions)); + const [confirmOps, setConfirmOps] = useState(s.confirmFileOperations); + const [maxHistory, setMaxHistory] = useState(String(s.maxHistoryMessages)); + const [showTools, setShowTools] = useState(s.showToolCallsAfterStreaming); + + return ( +
+ + + + + + { + setAutoSave(v); + void save({ autoSave: v }); + }} + /> + + + + { + setMaxSessions(e.target.value); + const n = parseInt(e.target.value); + if (!isNaN(n) && n > 0) void save({ maxActiveSessions: n }); + }} + /> + + + + { + setConfirmOps(v); + void save({ confirmFileOperations: v }); + }} + /> + + + + { + setMaxHistory(e.target.value); + const n = parseInt(e.target.value); + if (!isNaN(n) && n > 0) void save({ maxHistoryMessages: n }); + }} + /> + + + + { + setShowTools(v); + void save({ showToolCallsAfterStreaming: v }); + }} + /> + +
+ ); +} + +// ── Advanced tab ────────────────────────────────────────────────────────── + +function AdvancedTab({ plugin }: { plugin: OnyxMindPlugin }) { + const s = plugin.settings; + + const save = async (updates: Partial) => { + Object.assign(plugin.settings, updates); + await plugin.saveSettings(); + }; + + const [serviceUrl, setServiceUrl] = useState(s.opencodeBaseUrl); + const [timeout, setTimeout_] = useState(String(s.timeout)); + const [retries, setRetries] = useState(String(s.maxRetries)); + const [stream, setStream] = useState(s.streamResponse); + + return ( +
+ + { + setServiceUrl(e.target.value); + void save({ opencodeBaseUrl: e.target.value }); + }} + /> + + + + { + setTimeout_(e.target.value); + const n = parseInt(e.target.value); + if (!isNaN(n) && n > 0) void save({ timeout: n }); + }} + /> + + + + { + setRetries(e.target.value); + const n = parseInt(e.target.value); + if (!isNaN(n) && n >= 0) void save({ maxRetries: n }); + }} + /> + + + + { + setStream(v); + void save({ streamResponse: v }); + }} + /> + +
+ ); +} + +// ── Main export ─────────────────────────────────────────────────────────── + +type TabId = "provider" | "agent" | "advanced"; + +const TABS: { id: TabId; label: string }[] = [ + { id: "provider", label: "Provider" }, + { id: "agent", label: "Agent" }, + { id: "advanced", label: "Advanced" }, +]; + +export function SettingsPanel({ plugin }: { plugin: OnyxMindPlugin }) { + const [activeTab, setActiveTab] = useState("provider"); + + return ( +
+ {/* Top tab bar */} +
+ {TABS.map(({ id, label }) => ( + + ))} +
+ + {/* Tab content */} +
+ {activeTab === "provider" && } + {activeTab === "agent" && } + {activeTab === "advanced" && } +
+
+ ); +} diff --git a/styles.css b/styles.css index a5ab7c8..5dd0113 100644 --- a/styles.css +++ b/styles.css @@ -1,5 +1,389 @@ /* OnyxMind chat UI (Claudian-inspired sidebar layout) */ +/* ── Settings Panel ──────────────────────────────────────────────────────── */ + +.onyxmind-settings-panel-mount { + padding-bottom: 24px; +} + +.onyxmind-sp { + display: flex; + flex-direction: column; +} + +/* Top tab bar */ +.onyxmind-sp-tabbar { + display: flex; + border-bottom: 1px solid var(--background-modifier-border); + margin-bottom: 20px; + gap: 0; +} + +.onyxmind-sp-tab { + padding: 8px 18px; + border: none; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--text-muted); + cursor: pointer; + font-size: var(--font-ui-small); + font-weight: 500; + margin-bottom: -1px; + transition: color 0.15s, border-color 0.15s; +} + +.onyxmind-sp-tab:hover { + color: var(--text-normal); +} + +.onyxmind-sp-tab.is-active { + color: var(--text-normal); + border-bottom-color: var(--interactive-accent); +} + +/* Tab content wrapper */ +.onyxmind-sp-content { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* ── Provider tab ─────────────────────────────────────────────────────────── */ + +.onyxmind-sp-provider-layout { + display: flex; + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + overflow: hidden; + min-height: 280px; +} + +/* Left sidebar */ +.onyxmind-sp-sidebar { + width: 130px; + flex-shrink: 0; + border-right: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + display: flex; + flex-direction: column; +} + +.onyxmind-sp-provider-item { + width: 100%; + padding: 10px 14px; + display: flex; + align-items: center; + justify-content: space-between; + border: none; + border-left: 2px solid transparent; + background: transparent; + text-align: left; + cursor: pointer; + color: var(--text-muted); + font-size: var(--font-ui-small); + transition: background 0.1s, color 0.1s, border-color 0.1s; +} + +.onyxmind-sp-provider-item:hover:not(.is-selected) { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.onyxmind-sp-provider-item.is-selected { + background: var(--background-primary); + color: var(--text-normal); + border-left-color: var(--interactive-accent); +} + +.onyxmind-sp-provider-name { + flex: 1; + min-width: 0; +} + +/* Status dot: no-key=empty ring, has-key=filled, is-active=accent */ +.onyxmind-sp-provider-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; + border: 1.5px solid var(--text-faint); + background: transparent; + transition: background 0.15s, border-color 0.15s; +} + +.onyxmind-sp-provider-dot.has-key { + border-color: var(--text-muted); + background: var(--text-muted); +} + +.onyxmind-sp-provider-dot.is-active { + border-color: var(--interactive-accent); + background: var(--interactive-accent); +} + +/* Right config panel */ +.onyxmind-sp-config { + flex: 1; + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + background: var(--background-primary); +} + +.onyxmind-sp-config-title { + font-size: var(--font-ui-small); + font-weight: 600; + color: var(--text-normal); + margin-bottom: 4px; + padding-bottom: 8px; + border-bottom: 1px solid var(--background-modifier-border); +} + +/* Field row */ +.onyxmind-sp-field { + display: flex; + align-items: center; + gap: 10px; +} + +.onyxmind-sp-field-label { + flex: 0 0 88px; + font-size: var(--font-ui-small); + color: var(--text-muted); +} + +/* Models section */ +.onyxmind-sp-models-section { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 4px; +} + +.onyxmind-sp-models-head, +.onyxmind-sp-model-row { + display: flex; + align-items: center; + gap: 6px; +} + +.onyxmind-sp-models-head { + font-size: 10px; + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: 0.04em; + padding-bottom: 4px; + border-bottom: 1px solid var(--background-modifier-border); +} + +.onyxmind-sp-col-model { flex: 1; min-width: 0; } +.onyxmind-sp-col-tokens { flex: 0 0 84px; } +.onyxmind-sp-col-reas { flex: 0 0 60px; display: flex; justify-content: center; } +.onyxmind-sp-col-del { flex: 0 0 26px; } + +.onyxmind-sp-remove-btn { + flex: 0 0 26px; + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: var(--radius-s); + background: transparent; + color: var(--text-faint); + cursor: pointer; + font-size: 11px; + padding: 0; + transition: background 0.1s, color 0.1s; +} + +.onyxmind-sp-remove-btn:hover { + background: var(--background-modifier-error); + color: var(--text-error); +} + +.onyxmind-sp-add-btn { + align-self: flex-start; + margin-top: 4px; + padding: 3px 10px; + border: 1px dashed var(--background-modifier-border); + border-radius: var(--radius-s); + background: transparent; + color: var(--text-muted); + font-size: var(--font-ui-small); + cursor: pointer; + transition: border-color 0.1s, color 0.1s; +} + +.onyxmind-sp-add-btn:hover { + border-color: var(--interactive-accent); + color: var(--interactive-accent); +} + +/* Active model strip */ +.onyxmind-sp-active-model { + padding: 14px 16px; + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background: var(--background-primary); +} + +.onyxmind-sp-active-model-title { + font-size: var(--font-ui-small); + font-weight: 600; + color: var(--text-normal); + margin-bottom: 10px; +} + +.onyxmind-sp-active-model-row { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.onyxmind-sp-active-field { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; +} + +/* ── Agent / Advanced tabs ────────────────────────────────────────────────── */ + +.onyxmind-sp-rows { + display: flex; + flex-direction: column; + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background: var(--background-primary); + overflow: hidden; +} + +.onyxmind-sp-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + gap: 20px; + border-bottom: 1px solid var(--background-modifier-border); +} + +.onyxmind-sp-row:last-child { + border-bottom: none; +} + +.onyxmind-sp-row-info { + flex: 1; + min-width: 0; +} + +.onyxmind-sp-row-name { + font-size: var(--font-ui-small); + font-weight: 500; + color: var(--text-normal); +} + +.onyxmind-sp-row-desc { + font-size: var(--font-ui-smaller); + color: var(--text-muted); + margin-top: 2px; + line-height: 1.4; +} + +.onyxmind-sp-row-ctrl { + flex-shrink: 0; +} + +/* ── Shared form controls ─────────────────────────────────────────────────── */ + +.onyxmind-sp-input { + padding: 4px 8px; + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background: var(--background-modifier-form-field); + color: var(--text-normal); + font-size: var(--font-ui-small); + width: 100%; + box-sizing: border-box; +} + +.onyxmind-sp-input:focus { + outline: none; + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px rgba(var(--interactive-accent-rgb, 139 125 255) / 0.2); +} + +/* size variants */ +.onyxmind-sp-input-sm { width: 80px; } +.onyxmind-sp-input-md { width: 240px; } + +.onyxmind-sp-select { + padding: 4px 8px; + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background: var(--background-modifier-form-field); + color: var(--text-normal); + font-size: var(--font-ui-small); + cursor: pointer; +} + +.onyxmind-sp-select-sm { width: 140px; } + +/* ── Toggle ───────────────────────────────────────────────────────────────── */ + +.onyxmind-sp-toggle { + position: relative; + display: inline-flex; + align-items: center; + cursor: pointer; + user-select: none; +} + +.onyxmind-sp-toggle input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; + margin: 0; +} + +.onyxmind-sp-toggle-track { + position: relative; + width: 38px; + height: 20px; + border-radius: 10px; + background: var(--background-modifier-border); + transition: background 0.2s; +} + +.onyxmind-sp-toggle input[type="checkbox"]:checked + .onyxmind-sp-toggle-track { + background: var(--interactive-accent); +} + +.onyxmind-sp-toggle-thumb { + position: absolute; + top: 3px; + left: 3px; + width: 14px; + height: 14px; + border-radius: 50%; + background: white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + transition: transform 0.2s; +} + +.onyxmind-sp-toggle input[type="checkbox"]:checked + .onyxmind-sp-toggle-track .onyxmind-sp-toggle-thumb { + transform: translateX(18px); +} + +.onyxmind-sp-toggle:focus-within .onyxmind-sp-toggle-track { + box-shadow: 0 0 0 2px var(--interactive-accent-hover); +} + .onyxmind-react-root { height: 100%; } @@ -865,10 +1249,44 @@ code .onyxmind-file-link:hover { display: flex; align-items: center; flex-wrap: wrap; - gap: 8px; + gap: 6px; flex: 1; } +.onyxmind-footer-model-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 7px; + border-radius: 10px; + background: var(--background-modifier-form-field); + border: 1px solid var(--background-modifier-border); + font-size: 10px; + line-height: 1.6; + color: var(--onyxmind-muted); + white-space: nowrap; + max-width: 240px; + overflow: hidden; +} + +.onyxmind-footer-provider { + font-weight: 600; + color: var(--onyxmind-faint); + flex-shrink: 0; +} + +.onyxmind-footer-sep { + color: var(--onyxmind-faint); + opacity: 0.5; + flex-shrink: 0; +} + +.onyxmind-footer-model { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .onyxmind-footer-item { padding: 0; }