Compare commits

..

No commits in common. "master" and "3.3.1" have entirely different histories.

153 changed files with 8663 additions and 1907 deletions

View file

@ -12,7 +12,6 @@ Copilot for Obsidian is an AI-powered assistant plugin that integrates various L
- **NEVER RUN `npm run dev`** - The user will handle all builds manually
- `npm run build` - Production build (TypeScript check + minified output)
- `npm run test:vault` - macOS only. Installs deps, builds, symlinks `main.js` / `manifest.json` / `styles.css` from the current worktree into `$COPILOT_TEST_VAULT_PATH/.obsidian/plugins/copilot/`, then reloads the plugin via the Obsidian CLI. Requires the user-level env var `COPILOT_TEST_VAULT_PATH` to be set to a vault that has been opened in Obsidian at least once. Use this when the user asks you to load the plugin into their test vault — it replaces manual build + copy + reload.
### Code Quality

View file

@ -53,34 +53,6 @@ In the case of Copilot for Obsidian, you will need to:
Try to be descriptive in your branch names and pull requests. Happy coding!
#### Fast Iteration with `npm run test:vault` (macOS)
If you work across multiple worktrees or just want one command to build and load the plugin into a test vault, use `npm run test:vault`. It runs `npm install`, builds, symlinks `main.js` / `manifest.json` / `styles.css` from the worktree into the vault's `.obsidian/plugins/copilot/` folder, and reloads the plugin in Obsidian via its CLI.
**One-time setup:**
1. Create or pick a vault dedicated to plugin testing and open it in Obsidian at least once so `.obsidian/` is created.
2. Enable community plugins in that vault (Settings → Community plugins → Turn on).
3. Set an env var pointing at the vault path. Add this to `~/.zshrc`, `~/.bashrc`, or `~/.config/fish/config.fish`:
```bash
export COPILOT_TEST_VAULT_PATH="$HOME/Obsidian/CopilotTestVault"
```
**Per change:**
From any worktree, run:
```bash
npm run test:vault
```
The script installs deps, builds the plugin, symlinks the build artifacts into the vault, then calls `plugin:enable` and `plugin:reload` on the Obsidian CLI. If Obsidian isn't running, the symlinks are still in place — start Obsidian and the new build will load.
Because the script symlinks files (not the worktree root), the vault's plugin `data.json` (settings, chat history) stays vault-local and is preserved across worktrees and rebuilds.
Requires macOS with Obsidian installed at `/Applications/Obsidian.app`.
## Commit Signing
Commits to `master` must be signed and verified by GitHub. The easiest path is SSH signing using your existing SSH key.

View file

@ -56,7 +56,7 @@ This is the future we believe in. If you share this vision, please support this
- [The What](#the-what)
- [The Why](#the-why)
- [Key Features](#key-features)
- [Copilot v4: Agent Mode, Reimagined 🚀](#copilot-v4-agent-mode-reimagined-)
- [Copilot V3 is a New Era 🔥](#copilot-v3-is-a-new-era-)
- [Why People Love It ❤️](#why-people-love-it-)
- [Get Started](#get-started)
- [Install Obsidian Copilot](#install-obsidian-copilot)
@ -78,13 +78,15 @@ This is the future we believe in. If you share this vision, please support this
- [**Copilot Plus Disclosure**](#copilot-plus-disclosure)
- [**Authors**](#authors)
## Copilot v4: Agent Mode, Reimagined 🚀
## Copilot V3 is a New Era 🔥
Our biggest leap yet. **Copilot v4** lets you run the most capable coding agents available — **opencode**, **Claude Code**, or **Codex** — natively inside your vault, tuned for knowledge work and entirely on your terms. Bring your own agent, keep every note on your device, and let it plan, search, and act across your Second Brain. No lock-in, no compromise.
After months of hard work, we have revamped the codebase and adopted a new paradigm for our agentic infrastructure. It opens the door for easier addition of agentic tools (MCP support coming). We will provide a new version of the documentation soon. Here is a couple of new things that you cannot miss!
**Join Supporter to experience the magic of Copilot v4 now!**
- FOR ALL USERS: You can do vault search out-of-the-box **without building an index first** (Indexing is still available but optional behind the "Semantic Search" toggle in QA settings).
- FOR FREE USERS: Image support and chat context menu are available to all users starting from v3.0.0!
- FOR PLUS USERS: **Autonomous agent** is available with vault search, web search, youtube, composer and soon a lot other tools! **Long-term memory** is also a tool the agent can use by itself starting from 3.1.0!
👉 **[Discover Copilot v4 →](https://www.obsidiancopilot.com/v4)**
Read the [Changelog](https://github.com/logancyang/obsidian-copilot/releases/tag/3.0.0).
## Why People Love It ❤️

View file

@ -1,67 +1,5 @@
# Release Notes
# Copilot for Obsidian - Release v3.3.3 🛠️
This patch brings a fresh Gemini model, squashes a nasty Copilot Plus freeze, and makes it much easier to verify your dev build is actually loaded in Obsidian. Thanks to @logancyang and @zeroliu for the quick turnaround!
- 💡 **Gemini 3.5 Flash is now a built-in model** — Google's latest generally-available Gemini model (`gemini-3.5-flash`) is now available out of the box, enabled by default with Vision and Reasoning support. It replaces the old `gemini-3-flash-preview` entry. Your existing default model (`google/gemini-2.5-flash` on OpenRouter) is unchanged — this is an additional option. (@logancyang)
- 🛠️ **Copilot Plus no longer freezes when you apply your license key** — Applying a Plus license key was causing Obsidian to freeze indefinitely. The root cause was a chain-rebuild loop: multiple concurrent initializations were each capturing the chain type, then writing it back after awaiting the model switch, bouncing the state between `LLM_CHAIN` and `COPILOT_PLUS_CHAIN` and triggering endless rebuilds. This is now fixed — applying your key fires a single clean rebuild and the UI stays responsive. (@zeroliu)
- 🔧 **New `npm run test:vault` command for developers** — A single command now builds the plugin and hot-reloads it directly into your test vault via the Obsidian CLI. It also stamps the loaded build's name with the current git branch and timestamp, so you can glance at Obsidian's Community plugins list to confirm you're running the build you think you are. (@zeroliu)
More details in the changelog:
### Improvements
- #2477 Add npm run test:vault for fast worktree-to-vault plugin reload @zeroliu
- #2492 feat(models): add GA gemini-3.5-flash builtin @logancyang
### Bug Fixes
- #2478 fix(chain): stop writing chainType back inside setChain (apply-Plus freeze) @zeroliu
## Troubleshoot
- If models are missing, navigate to Copilot settings -> Models tab and click "Refresh Built-in Models".
- Please report any issue you see in the member channel!
---
# Copilot for Obsidian - Release v3.3.2 🛠️
This patch is all about stability and speed. **Claude Opus 4.7+ now works correctly with adaptive thinking** on both direct Anthropic and AWS Bedrock, project-mode file ingestion is fixed, Quick Ask no longer crashes on `@`-mention, and the plugin is 75 KB lighter thanks to a clean dependency deduplication. Kudos to @logancyang and @zeroliu for the focused clean-up!
- 🧠 **Claude Opus 4.7+ adaptive thinking is fixed** — Adding `claude-opus-4-7` (or any Opus 4.7+) as a model and enabling reasoning used to return a 400 error because the plugin was sending the legacy `thinking.type=enabled` shape, which Opus 4.7+ no longer accepts. It now correctly sends `thinking.type=adaptive` and requests a summarized display so the thinking block appears in chat. Both the direct Anthropic and AWS Bedrock code paths are fixed. (@logancyang)
- 📂 **Project-mode file ingestion is fixed** — PDFs, images, audio files, and Office docs added to a project's source files were silently failing to upload because Obsidian's CORS-bypass path can't handle native `FormData` streams. The upload path now uses `requestUrl` with a manually-constructed multipart body, matching how the rest of the plugin talks to Brevilabs. (@zeroliu)
- 💬 **Quick Ask no longer crashes on `@`-mention** — Opening the Quick Ask panel and typing `@` to mention a note was crashing with "useApp() called outside of AppContext.Provider". Fixed by wrapping the panel's React root in the same `AppContext` that the main chat view uses. (@zeroliu)
- ⚡ **75 KB smaller bundle**`@langchain/community` is dropped (Jina embeddings now run against a hand-rolled Obsidian-native client), and `openai` is bumped to v6 to eliminate a duplicate copy of the SDK that was sneaking in alongside `@langchain/openai`. The plugin is now around 3.3 MB. (@zeroliu)
- 🔴 **Clearer error when a Bedrock model ID is missing its inference-profile prefix** — If you configure a bare regional Bedrock model ID (e.g. `anthropic.claude-sonnet-4-6` without a `global.`, `us.`, etc. prefix), the error used to surface as raw JSON. It now shows a plain-English message pointing you to Settings → Models and listing the four valid prefix forms. (@logancyang)
- 🧹 **React state management cleanup** — 56 `setState`-in-`useEffect` anti-patterns were replaced with idiomatic React patterns across 21 files: derived state via `useMemo`, `useSyncExternalStore` for external stores, and `key`-prop resets where appropriate. Side effects include: the token-count badge in chat now updates correctly after regeneration, chain/setting toggles in ChatInput no longer flash stale state, and the draggable quick-command modal no longer resets your resize when content grows. (@zeroliu)
More details in the changelog:
### Improvements
- #2460 chore(deps): remove unused deps and dead code to shrink bundle @zeroliu
- #2463 chore(deps): drop @langchain/community + bump openai to v6 to dedupe SDKs @zeroliu
- #2467 chore(react): centralize React root creation via createPluginRoot helper @zeroliu
- #2469 Remove dead ChainFactory and chain validation helpers @zeroliu
- #2470 style(css): expand #ccc to 6-digit hex format for consistency @zeroliu
### Bug Fixes
- #2399 fix(brevilabs): rewrite uploads via requestUrl multipart (W8/9) @zeroliu
- #2454 chore(lint): fix no-direct-set-state-in-use-effect violations @zeroliu
- #2466 fix(quick-ask): provide AppContext to overlay panel @zeroliu
- #2471 fix(anthropic+bedrock): adaptive thinking + summarized display for claude-opus-4-7+ @logancyang
- #2472 fix(bedrock): explain inference-profile requirement when AWS rejects on-demand model ID @logancyang
## Troubleshoot
- If models are missing, navigate to Copilot settings -> Models tab and click "Refresh Built-in Models".
- Please report any issue you see in the member channel!
---
# Copilot for Obsidian - Release v3.3.1 🔐
This release is all about keeping your API keys safe and your plugin running smoothly everywhere. **API keys can now be stored in Obsidian's built-in Keychain** so they never touch `data.json`, the encryption toggle is removed (it was more trouble than it was worth), and a wave of reliability fixes ensures chat works correctly on mobile, in popout windows, and with Plus mode. Underneath, **@zeroliu landed nineteen back-to-back PRs of code-quality work** — tightening ESLint, eliminating ~395 `any` types, swapping out unmaintained dependencies, and modernizing the React layer. Huge thanks to both @Emt-lin (Keychain) and @zeroliu (codebase hardening) for carrying this release. 🙌

View file

@ -1,25 +1,10 @@
// __mocks__/obsidian.js
import { parse as parseYamlString } from "yaml";
// Per-test overrides set via the exported `__setRequestUrlImpl` helper.
// Default: empty success response. Tests that exercise network paths should
// install their own implementation.
let requestUrlImpl = jest.fn().mockResolvedValue({
status: 200,
text: "",
json: undefined,
arrayBuffer: new ArrayBuffer(0),
headers: {},
});
module.exports = {
// Reason: normalizePath is used by projectPaths.ts; identity function is sufficient for tests
normalizePath: jest.fn().mockImplementation((p) => p),
moment: jest.requireActual("moment"),
requestUrl: (...args) => requestUrlImpl(...args),
__setRequestUrlImpl: (impl) => {
requestUrlImpl = impl;
},
Vault: jest.fn().mockImplementation(() => {
return {
getMarkdownFiles: jest.fn().mockImplementation(() => {

View file

@ -47,7 +47,7 @@ Access to Claude models (Opus, Sonnet, etc.).
Access to Google's Gemini family of models.
- **Get a key**: https://makersuite.google.com/app/apikey
- **Models include**: gemini-2.5-pro, gemini-2.5-flash, gemini-3.5-flash, gemini-3.1-pro-preview
- **Models include**: gemini-2.5-pro, gemini-2.5-flash, gemini-3-flash-preview, gemini-3.1-pro-preview
- **Setting key**: `googleApiKey`
### XAI / Grok
@ -102,12 +102,12 @@ A Chinese AI cloud platform with access to DeepSeek and Qwen models.
Access to OpenAI models deployed on Microsoft Azure. Requires four fields to be configured:
| Setting | Description |
| --------------- | -------------------------- |
| API Key | Your Azure OpenAI key |
| Instance Name | Your Azure resource name |
| Setting | Description |
|---|---|
| API Key | Your Azure OpenAI key |
| Instance Name | Your Azure resource name |
| Deployment Name | Your model deployment name |
| API Version | e.g., `2024-02-01` |
| API Version | e.g., `2024-02-01` |
- **Note**: Unlike other providers, Azure OpenAI uses your own Azure deployment
- **Embedding**: Can also use Azure for embeddings (separate deployment name required)
@ -121,7 +121,6 @@ Access to models hosted on AWS Bedrock.
- **Setting key**: `amazonBedrockApiKey`
**Important**: Always use cross-region inference profile IDs, not bare model IDs. For example:
- Use: `us.anthropic.claude-sonnet-4-5-20250929-v1:0`
- Not: `anthropic.claude-sonnet-4-5-20250929-v1:0`
@ -172,14 +171,14 @@ For any API that follows the OpenAI API format. Useful for custom deployments, p
## Provider-Specific Gotchas
| Provider | Common Issue | Fix |
| -------------- | ----------------------------------- | -------------------------------------------------------------------------------------- |
| Azure OpenAI | Missing one of four required fields | Check all four settings: key, instance name, deployment name, API version |
| Amazon Bedrock | Rate limit or model not found | Use cross-region inference profile IDs with `us.`, `eu.`, `apac.`, or `global.` prefix |
| GitHub Copilot | Token expired | Re-authenticate via the OAuth button in API key dialog |
| Ollama | Connection refused | Make sure Ollama is running (`ollama serve`) and the port is correct |
| Google Gemini | Quota exceeded | Use a different model or check your quota at console.cloud.google.com |
| DeepSeek | Streaming errors | Try disabling streaming in the per-session settings if you encounter issues |
| Provider | Common Issue | Fix |
|---|---|---|
| Azure OpenAI | Missing one of four required fields | Check all four settings: key, instance name, deployment name, API version |
| Amazon Bedrock | Rate limit or model not found | Use cross-region inference profile IDs with `us.`, `eu.`, `apac.`, or `global.` prefix |
| GitHub Copilot | Token expired | Re-authenticate via the OAuth button in API key dialog |
| Ollama | Connection refused | Make sure Ollama is running (`ollama serve`) and the port is correct |
| Google Gemini | Quota exceeded | Use a different model or check your quota at console.cloud.google.com |
| DeepSeek | Streaming errors | Try disabling streaming in the per-session settings if you encounter issues |
---

View file

@ -10,27 +10,27 @@ This guide explains how to manage chat models, embedding models, and the paramet
Copilot comes with a set of built-in models across many providers. Some are always included ("core" models); others can be enabled or disabled.
| Model | Provider | Capabilities |
| ----------------------------- | ------------ | ----------------------- |
| copilot-plus-flash | Copilot Plus | Vision (Plus exclusive) |
| google/gemini-2.5-flash | OpenRouter | Vision |
| google/gemini-2.5-pro | OpenRouter | Vision |
| google/gemini-3.5-flash | OpenRouter | Vision, Reasoning |
| google/gemini-3.1-pro-preview | OpenRouter | Vision, Reasoning |
| openai/gpt-5.4 | OpenRouter | Vision |
| openai/gpt-5-mini | OpenRouter | Vision |
| gpt-5.4 | OpenAI | Vision |
| gpt-5-mini | OpenAI | Vision |
| gpt-4.1 | OpenAI | Vision |
| gpt-4.1-mini | OpenAI | Vision |
| claude-opus-4-6 | Anthropic | Vision, Reasoning |
| claude-sonnet-4-5-20250929 | Anthropic | Vision, Reasoning |
| gemini-2.5-pro | Google | Vision |
| gemini-2.5-flash | Google | Vision |
| gemini-3.5-flash | Google | Vision, Reasoning |
| grok-4-1-fast | XAI | Vision |
| deepseek-chat | DeepSeek | — |
| deepseek-reasoner | DeepSeek | Reasoning |
| Model | Provider | Capabilities |
|---|---|---|
| copilot-plus-flash | Copilot Plus | Vision (Plus exclusive) |
| google/gemini-2.5-flash | OpenRouter | Vision |
| google/gemini-2.5-pro | OpenRouter | Vision |
| google/gemini-3-flash-preview | OpenRouter | Vision, Reasoning |
| google/gemini-3.1-pro-preview | OpenRouter | Vision, Reasoning |
| openai/gpt-5.4 | OpenRouter | Vision |
| openai/gpt-5-mini | OpenRouter | Vision |
| gpt-5.4 | OpenAI | Vision |
| gpt-5-mini | OpenAI | Vision |
| gpt-4.1 | OpenAI | Vision |
| gpt-4.1-mini | OpenAI | Vision |
| claude-opus-4-6 | Anthropic | Vision, Reasoning |
| claude-sonnet-4-5-20250929 | Anthropic | Vision, Reasoning |
| gemini-2.5-pro | Google | Vision |
| gemini-2.5-flash | Google | Vision |
| gemini-3-flash-preview | Google | Vision, Reasoning |
| grok-4-1-fast | XAI | Vision |
| deepseek-chat | DeepSeek | — |
| deepseek-reasoner | DeepSeek | Reasoning |
### Model Capability Badges
@ -75,18 +75,18 @@ Embedding models convert text into numerical vectors, which powers semantic (mea
### Built-In Embedding Models
| Model | Provider |
| ----------------------------- | --------------------------------- |
| copilot-plus-small | Copilot Plus (Plus exclusive) |
| copilot-plus-large | Copilot Plus (Believer exclusive) |
| copilot-plus-multilingual | Copilot Plus (Plus exclusive) |
| openai/text-embedding-3-small | OpenRouter |
| text-embedding-3-small | OpenAI |
| text-embedding-3-large | OpenAI |
| embed-multilingual-light-v3.0 | Cohere |
| text-embedding-004 | Google |
| gemini-embedding-001 | Google |
| Qwen3-Embedding-0.6B | SiliconFlow |
| Model | Provider |
|---|---|
| copilot-plus-small | Copilot Plus (Plus exclusive) |
| copilot-plus-large | Copilot Plus (Believer exclusive) |
| copilot-plus-multilingual | Copilot Plus (Plus exclusive) |
| openai/text-embedding-3-small | OpenRouter |
| text-embedding-3-small | OpenAI |
| text-embedding-3-large | OpenAI |
| embed-multilingual-light-v3.0 | Cohere |
| text-embedding-004 | Google |
| gemini-embedding-001 | Google |
| Qwen3-Embedding-0.6B | SiliconFlow |
### Selecting an Embedding Model

View file

@ -103,8 +103,10 @@ export default [
// are candidates to enable in small follow-up PRs.
// --- Heavy: any-flow through Obsidian/LangChain APIs ---
// no-unsafe-member-access: enabled globally; tests are exempted via the
// test-file override below.
// no-unsafe-member-access: enabled globally; tests and heavy source files
// are exempted via per-file overrides below (see "no-unsafe-member-access
// exemptions"). Remaining source files (≤5 violations each) were fixed in
// this PR.
"@typescript-eslint/no-unsafe-assignment": "off", // enabled for tests below; follow-up PR for production
"@typescript-eslint/no-unsafe-call": "off", // 107 violations
@ -119,26 +121,6 @@ export default [
},
},
// Guardrail: every standalone React root in the plugin must go through
// `createPluginRoot` so descendants can rely on `useApp()` unconditionally
// (the bug class fixed in PR #2466). Forbid importing `createRoot` from
// `react-dom/client` anywhere except the helper itself.
{
files: ["src/**/*.{ts,tsx}"],
ignores: ["src/utils/react/createPluginRoot.tsx"],
rules: {
"no-restricted-syntax": [
"error",
{
selector:
"ImportDeclaration[source.value='react-dom/client'] ImportSpecifier[imported.name='createRoot']",
message:
"Use createPluginRoot from '@/utils/react/createPluginRoot' instead. It wraps the root in <AppContext.Provider> so descendants can rely on useApp() unconditionally (see PR #2466).",
},
],
},
},
// Test files need Jest globals
{
files: ["**/*.test.{js,jsx,ts,tsx}", "jest.setup.js", "__mocks__/**"],
@ -156,6 +138,58 @@ export default [
},
},
// no-unsafe-member-access exemptions: heavy source files that flow `any`
// through Obsidian / LangChain / Bedrock APIs. Counts are current as of the
// PR that enabled the rule; pick these off one at a time in follow-up PRs.
{
files: [
"src/LLMProviders/BedrockChatModel.ts", // 106
"src/LLMProviders/ChatOpenRouter.ts", // 28
"src/LLMProviders/CustomOpenAIEmbeddings.ts", // 16
"src/LLMProviders/brevilabsClient.ts", // 7
"src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts", // 13
"src/LLMProviders/chainRunner/BaseChainRunner.ts", // 7
"src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts", // 55
"src/LLMProviders/chainRunner/VaultQAChainRunner.ts", // 9
"src/LLMProviders/chainRunner/utils/ActionBlockStreamer.ts", // 8
"src/LLMProviders/chainRunner/utils/ThinkBlockStreamer.ts", // 33
"src/LLMProviders/chainRunner/utils/chatHistoryUtils.ts", // 17
"src/LLMProviders/chainRunner/utils/citationUtils.ts", // 11
"src/LLMProviders/chainRunner/utils/finishReasonDetector.ts", // 29
"src/LLMProviders/chainRunner/utils/modelAdapter.ts", // 9
"src/LLMProviders/chainRunner/utils/promptPayloadRecorder.ts", // 12
"src/LLMProviders/chainRunner/utils/searchResultUtils.ts", // 81
"src/LLMProviders/chainRunner/utils/toolExecution.ts", // 9
"src/LLMProviders/chatModelManager.ts", // 9
"src/LLMProviders/selfHostServices.ts", // 9
"src/commands/customCommandManager.ts", // 10
"src/commands/customCommandUtils.ts", // 10
"src/commands/index.ts", // 14
"src/components/chat-components/ChatControls.tsx", // 8
"src/components/chat-components/ChatInput.tsx", // 14
"src/components/modals/SourcesModal.tsx", // 33
"src/contextProcessor.ts", // 17
"src/core/ChatPersistenceManager.ts", // 10
"src/encryptionService.ts", // 6
"src/projects/projectUtils.ts", // 38
"src/search/chunkedStorage.ts", // 28
"src/search/dbOperations.ts", // 27
"src/search/hybridRetriever.ts", // 11
"src/search/indexOperations.ts", // 15
"src/search/v3/TieredLexicalRetriever.ts", // 9
"src/settings/providerModels.ts", // 20
"src/system-prompts/systemPromptUtils.ts", // 9
"src/tools/FileParserManager.ts", // 11
"src/tools/SearchTools.ts", // 11
"src/tools/ToolResultFormatter.ts", // 106
"src/utils.ts", // 49
"src/utils/rateLimitUtils.ts", // 10
],
rules: {
"@typescript-eslint/no-unsafe-member-access": "off",
},
},
// Tests have been cleaned of unsafe `any` assignments. Production code
// (~499 violations) is a follow-up; keep tests enforced.
{

View file

@ -6,6 +6,7 @@ module.exports = {
"^.+\\.(js|jsx|ts|tsx)$": "ts-jest",
},
moduleNameMapper: {
"\\.(css|less|scss|sass)$": "identity-obj-proxy",
"^@/(.*)$": "<rootDir>/src/$1",
"^obsidian$": "<rootDir>/__mocks__/obsidian.js",
// The yaml package's "exports" field defaults to a browser ESM entry under

View file

@ -1,7 +0,0 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["src/main.ts", "scripts/printPromptDebug.js", "scripts/printPromptDebugEntry.ts"],
"project": ["src/**/*.{ts,tsx,js,jsx}", "scripts/**/*.{ts,js,mjs}"],
"ignore": ["src/styles/tailwind.css", "src/integration_tests/**"],
"ignoreDependencies": ["buffer"]
}

View file

@ -1,7 +1,7 @@
{
"id": "copilot",
"name": "Copilot",
"version": "3.3.3",
"version": "3.3.1",
"minAppVersion": "1.11.4",
"isDesktopOnly": false,
"description": "Your AI Copilot: Chat with Your Second Brain, Learn Faster, Work Smarter.",

5762
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-copilot",
"version": "3.3.3",
"version": "3.3.1",
"description": "Your AI Copilot: Chat with Your Second Brain, Learn Faster, Work Smarter.",
"main": "main.js",
"scripts": {
@ -11,7 +11,6 @@
"build:tailwind": "npx tailwindcss -i src/styles/tailwind.css -o styles.css --minify",
"build:esbuild": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"lint": "eslint .",
"lint:dead": "npx --yes knip@5",
"lint:fix": "eslint . --fix",
"format": "prettier --write 'src/**/*.{js,ts,tsx,md}'",
"format:check": "prettier --check 'src/**/*.{js,ts,tsx,md}'",
@ -19,8 +18,7 @@
"test": "jest --testPathIgnorePatterns=src/integration_tests/",
"test:integration": "jest src/integration_tests/",
"prepare": "husky",
"prompt:debug": "node scripts/printPromptDebug.js",
"test:vault": "bash scripts/test-vault.sh"
"prompt:debug": "node scripts/printPromptDebug.js"
},
"keywords": [],
"author": "Logan Yang",
@ -34,30 +32,32 @@
},
"license": "AGPL-3.0",
"devDependencies": {
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4",
"@eslint-react/eslint-plugin": "^1.38.4",
"@google/generative-ai": "^0.24.0",
"@jest/globals": "^29.7.0",
"@langchain/ollama": "^1.2.2",
"@tailwindcss/container-queries": "^0.1.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@types/diff": "^7.0.1",
"@types/events": "^3.0.0",
"@types/jest": "^29.5.11",
"@types/koa": "^2.13.7",
"@types/koa__cors": "^4.0.0",
"@types/luxon": "^3.4.2",
"@types/node": "^16.11.6",
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
"@types/react-syntax-highlighter": "^15.5.6",
"@types/turndown": "^5.0.6",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.19.1",
"@typescript-eslint/parser": "^8.19.1",
"electron": "^27.3.2",
"esbuild": "^0.25.0",
"esbuild-plugin-svg": "^0.1.0",
"eslint": "^9.18.0",
"eslint-plugin-obsidianmd": "^0.3.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-tailwindcss": "^3.18.0",
"globals": "^15.14.0",
"husky": "^9.1.5",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.5.0",
@ -66,7 +66,6 @@
"obsidian": "^1.2.5",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.15",
"tailwindcss-animate": "^1.0.7",
"ts-jest": "^29.1.0",
"tslib": "2.4.0",
"typescript": "^5.7.2",
@ -74,11 +73,17 @@
"yaml": "^2.6.1"
},
"dependencies": {
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@langchain/anthropic": "^1.3.29",
"@google/generative-ai": "^0.24.0",
"@huggingface/inference": "^4.11.3",
"@koa/cors": "^5.0.0",
"@langchain/anthropic": "^1.0.0",
"@langchain/classic": "^1.0.9",
"@langchain/community": "^1.0.0",
"@langchain/core": "^1.1.29",
"@langchain/deepseek": "^1.0.0",
"@langchain/google-genai": "^2.1.23",
@ -86,7 +91,10 @@
"@langchain/openai": "^1.0.0",
"@langchain/textsplitters": "^1.0.0",
"@langchain/xai": "^1.0.0",
"@lexical/plain-text": "^0.34.0",
"@lexical/react": "^0.34.0",
"@lexical/selection": "^0.34.0",
"@lexical/utils": "^0.34.0",
"@orama/orama": "^3.0.0-rc-2",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.2",
@ -100,24 +108,42 @@
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^2.14.0",
"async-mutex": "^0.5.0",
"buffer": "^6.0.3",
"chrono-node": "^2.7.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror-companion-extension": "^0.0.11",
"diff": "^7.0.0",
"esbuild-plugin-svg": "^0.1.0",
"eventsource-parser": "^1.0.0",
"fuzzysort": "^3.1.0",
"jotai": "^2.10.3",
"koa": "^2.14.2",
"koa-proxies": "^0.12.3",
"langchain": "^1.2.28",
"lexical": "^0.34.0",
"lucide-react": "^0.462.0",
"luxon": "^3.5.0",
"minisearch": "^7.2.0",
"openai": "^6.10.0",
"next-i18next": "^13.2.2",
"openai": "^4.95.1",
"p-queue": "^8.1.0",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.3.5",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^3.0.2",
"react-syntax-highlighter": "^15.5.0",
"sse": "github:mpetazzoni/sse.js",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"trie-search": "^2.2.0",
"turndown": "^7.2.2",
"uuid": "^11.1.0",
"zod": "^3.25.76"

View file

@ -1,91 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OBSIDIAN_BIN="/Applications/Obsidian.app/Contents/MacOS/obsidian"
if [[ -z "${COPILOT_TEST_VAULT_PATH:-}" ]]; then
cat >&2 <<'EOF'
error: COPILOT_TEST_VAULT_PATH is not set.
Set it once at the user level (e.g. in ~/.zshrc or ~/.config/fish/config.fish)
to the absolute path of an Obsidian vault you've opened at least once:
export COPILOT_TEST_VAULT_PATH="$HOME/Obsidian/CopilotTestVault"
Then re-run: npm run test:vault
EOF
exit 1
fi
VAULT_PATH="$COPILOT_TEST_VAULT_PATH"
if [[ ! -d "$VAULT_PATH" ]]; then
echo "error: vault directory not found: $VAULT_PATH" >&2
exit 1
fi
if [[ ! -d "$VAULT_PATH/.obsidian" ]]; then
echo "error: $VAULT_PATH has no .obsidian/ folder." >&2
echo "Open the folder as a vault in Obsidian once, then re-run." >&2
exit 1
fi
WORKTREE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$WORKTREE_ROOT"
echo "==> Installing dependencies"
npm install --prefer-offline --no-audit --no-fund
echo "==> Building plugin"
npm run build
PLUGIN_ID="$(node -p "require('./manifest.json').id")"
if [[ -z "$PLUGIN_ID" ]]; then
echo "error: could not read plugin id from manifest.json" >&2
exit 1
fi
PLUGIN_DIR="$VAULT_PATH/.obsidian/plugins/$PLUGIN_ID"
mkdir -p "$PLUGIN_DIR"
echo "==> Linking artifacts into $PLUGIN_DIR"
for f in main.js styles.css; do
if [[ ! -f "$WORKTREE_ROOT/$f" ]]; then
echo "error: expected build artifact missing: $WORKTREE_ROOT/$f" >&2
exit 1
fi
ln -sfn "$WORKTREE_ROOT/$f" "$PLUGIN_DIR/$f"
done
# Write a branch- and timestamp-tagged manifest.json (real file, not a symlink)
# so Obsidian's Community plugins list visibly reflects which worktree/branch
# is loaded and when this build was deployed.
BRANCH="$(git -C "$WORKTREE_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
BUILD_TS="$(date +%Y%m%d-%H%M%S)"
echo "==> Writing branch-tagged manifest.json (branch: $BRANCH, build: $BUILD_TS)"
rm -f "$PLUGIN_DIR/manifest.json"
SRC="$WORKTREE_ROOT/manifest.json" DEST="$PLUGIN_DIR/manifest.json" BRANCH="$BRANCH" BUILD_TS="$BUILD_TS" node -e '
const fs = require("fs");
const m = JSON.parse(fs.readFileSync(process.env.SRC, "utf8"));
m.name = m.name + " [" + process.env.BRANCH + " @ " + process.env.BUILD_TS + "]";
m.description = "[branch: " + process.env.BRANCH + " | build: " + process.env.BUILD_TS + "] " + m.description;
fs.writeFileSync(process.env.DEST, JSON.stringify(m, null, 2) + "\n");
'
echo "==> Reloading plugin in Obsidian"
if [[ ! -x "$OBSIDIAN_BIN" ]]; then
echo "warning: Obsidian CLI not found at $OBSIDIAN_BIN; skipping reload." >&2
else
if ! "$OBSIDIAN_BIN" plugin:enable id="$PLUGIN_ID" >/dev/null 2>&1 \
|| ! "$OBSIDIAN_BIN" plugin:reload id="$PLUGIN_ID" >/dev/null 2>&1; then
echo "warning: Obsidian doesn't appear to be running. Start it and the symlinked plugin will load on next open." >&2
fi
fi
echo
echo "Done."
echo " worktree: $WORKTREE_ROOT"
echo " branch: $BRANCH"
echo " build: $BUILD_TS"
echo " vault: $VAULT_PATH"
echo " plugin: $PLUGIN_ID"

View file

@ -22,9 +22,7 @@ type ImageContent = {
} | null;
type RequestBody = {
thinking?:
| { type: "enabled"; budget_tokens: number }
| { type: "adaptive"; display?: "summarized" | "omitted" };
thinking?: { type: string; budget_tokens: number };
temperature?: number;
anthropic_version?: string;
messages: Array<{
@ -79,38 +77,18 @@ const buildEventStreamChunk = (payload: string): string => {
return Buffer.from(buffer).toString("base64");
};
const createModel = (
enableThinking = false,
modelId = "anthropic.claude-3-haiku-20240307-v1:0"
): BedrockChatModel =>
const createModel = (enableThinking = false): BedrockChatModel =>
new BedrockChatModel({
modelId,
modelId: "anthropic.claude-3-haiku-20240307-v1:0",
apiKey: "test-key",
endpoint: `https://example.com/model/${encodeURIComponent(modelId)}/invoke`,
streamEndpoint: `https://example.com/model/${encodeURIComponent(modelId)}/invoke-with-response-stream`,
endpoint: "https://example.com/model/anthropic.claude-3-haiku-20240307-v1%3A0/invoke",
streamEndpoint:
"https://example.com/model/anthropic.claude-3-haiku-20240307-v1%3A0/invoke-with-response-stream",
anthropicVersion: "bedrock-2023-05-31",
enableThinking,
fetchImplementation: jest.fn(),
});
const createModelWithFetch = (
fetchMock: jest.Mock,
opts?: { modelId?: string; noStream?: boolean }
): BedrockChatModel =>
new BedrockChatModel({
modelId: opts?.modelId ?? "anthropic.claude-sonnet-4-5-20250929-v1:0",
apiKey: "test-key",
endpoint: "https://example.com/model/anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke",
...(opts?.noStream
? {}
: {
streamEndpoint:
"https://example.com/model/anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke-with-response-stream",
}),
anthropicVersion: "bedrock-2023-05-31",
fetchImplementation: fetchMock,
});
describe("BedrockChatModel streaming decode", () => {
it("decodes simple base64 JSON payloads", () => {
const payload = JSON.stringify({
@ -459,68 +437,6 @@ describe("BedrockChatModel streaming decode", () => {
expect(requestBody.temperature).toBe(1);
expect(requestBody.thinking).toBeDefined();
});
it("uses adaptive thinking with summarized display for claude-opus-4-7", () => {
const model = createModel(true, "anthropic.claude-opus-4-7-20260115-v1:0");
const requestBody = asInternal(model).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]);
expect(requestBody.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(requestBody.temperature).toBe(1);
});
it("uses adaptive thinking for opus-4-7 cross-region inference profiles", () => {
const model = createModel(true, "global.anthropic.claude-opus-4-7-20260115-v1:0");
const requestBody = asInternal(model).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]);
expect(requestBody.thinking).toEqual({ type: "adaptive", display: "summarized" });
});
it("keeps legacy thinking for opus-4-6 and earlier", () => {
const model = createModel(true, "anthropic.claude-opus-4-6-20250115-v1:0");
const requestBody = asInternal(model).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]);
expect(requestBody.thinking).toEqual({ type: "enabled", budget_tokens: 2048 });
});
it("keeps legacy thinking for sonnet-4 and 3-7-sonnet", () => {
const sonnet45 = createModel(true, "anthropic.claude-sonnet-4-5-20250929-v1:0");
expect(
asInternal(sonnet45).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]).thinking
).toEqual({ type: "enabled", budget_tokens: 2048 });
const sonnet37 = createModel(true, "anthropic.claude-3-7-sonnet-20250219-v1:0");
expect(
asInternal(sonnet37).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]).thinking
).toEqual({ type: "enabled", budget_tokens: 2048 });
});
it("keeps legacy thinking for dated Opus 4.0 snapshot IDs", () => {
// anthropic.claude-opus-4-20250514-v1:0 is the dated snapshot of Opus 4.0, not 4.20250514.
const opus40 = createModel(true, "anthropic.claude-opus-4-20250514-v1:0");
expect(
asInternal(opus40).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]).thinking
).toEqual({ type: "enabled", budget_tokens: 2048 });
// anthropic.claude-opus-4-1-20250805-v1:0 is dated 4.1, not adaptive.
const opus41 = createModel(true, "anthropic.claude-opus-4-1-20250805-v1:0");
expect(
asInternal(opus41).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]).thinking
).toEqual({ type: "enabled", budget_tokens: 2048 });
});
});
describe("vision support", () => {
@ -741,94 +657,3 @@ describe("BedrockChatModel streaming decode", () => {
});
});
});
describe("BedrockChatModel inference-profile error rewriting", () => {
const awsInferenceProfileError = JSON.stringify({
message:
"Invocation of model ID anthropic.claude-sonnet-4-5 with on-demand throughput isn't supported. Retry your request with the ID or ARN of an inference profile that contains this model.",
});
const makeErrorResponse = (status: number, body: string): Response =>
({
ok: false,
status,
text: () => Promise.resolve(body),
}) as unknown as Response;
it("rewrites 400 inference-profile error in non-streaming path to actionable message", async () => {
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, awsInferenceProfileError));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(
/cross-region inference profile ID/
);
});
it("rewrites 400 inference-profile error in streaming path to actionable message", async () => {
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, awsInferenceProfileError));
const model = createModelWithFetch(fetchMock);
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
const gen = model._streamResponseChunks(messages as never, {});
await expect(gen.next()).rejects.toThrow(/cross-region inference profile ID/);
});
it("includes the bare model ID in the rewritten message", async () => {
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, awsInferenceProfileError));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(
/anthropic\.claude-sonnet-4-5/
);
});
it("does not rewrite a 400 error that is unrelated to inference profiles", async () => {
const genericBody = JSON.stringify({ message: "ValidationException: bad request" });
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, genericBody));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(
/Amazon Bedrock request failed with status 400/
);
});
it("does not rewrite non-400 errors", async () => {
const body = JSON.stringify({ message: "Internal Server Error" });
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(500, body));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(
/Amazon Bedrock request failed with status 500/
);
});
it("rewrites the error even when AWS uses a curly apostrophe in 'isnt supported'", async () => {
const curlyApostropheBody = JSON.stringify({
message:
"Invocation of model ID anthropic.claude-sonnet-4-5 with on-demand throughput isnt supported. Retry your request with the ID or ARN of an inference profile that contains this model.",
});
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, curlyApostropheBody));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(
/cross-region inference profile ID/
);
});
it("uses the provider segment from the bare model ID in the prefix guidance", async () => {
const nonAnthropicBody = JSON.stringify({
message:
"Invocation of model ID meta.llama4-maverick-17b with on-demand throughput isn't supported. Retry your request with the ID or ARN of an inference profile that contains this model.",
});
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, nonAnthropicBody));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(/global\.meta\.<id>/);
});
});

View file

@ -41,42 +41,6 @@ export interface BedrockChatModelFields extends BaseChatModelParams {
streaming?: boolean;
}
/**
* Rewrites Bedrock HTTP error messages into actionable text when possible.
* Falls back to the original "Amazon Bedrock ... failed with status N: body" form.
*
* The detection uses two stable substrings from the AWS ValidationException body
* ("on-demand throughput" and "inference profile") rather than the apostrophe-bearing
* phrase "isn't supported", because AWS has been observed serving both straight (')
* and curly () apostrophe variants. False positives are harmless: the rewritten
* message still names the bare model ID from the original body.
*/
function rewriteBedrockErrorMessage(status: number, body: string, streaming = false): string {
const prefix = streaming
? "Amazon Bedrock streaming request failed with status"
: "Amazon Bedrock request failed with status";
if (
status === 400 &&
body.includes("on-demand throughput") &&
body.includes("inference profile")
) {
const modelIdMatch = body.match(/model ID ([^\s]+) with/);
const bareId = modelIdMatch?.[1] ?? "<model-id>";
// Provider segment of the model ID (e.g. "anthropic" from "anthropic.claude-...").
// Falls back to a generic placeholder when the ID isn't in <provider>.<model> form.
const providerSegment = bareId.includes(".") ? bareId.split(".")[0] : "<provider>";
return (
`This Bedrock model requires a cross-region inference profile ID, not a bare regional model ID. ` +
`Update the model name in Settings → Models to use one of the prefixed forms: ` +
`global.${providerSegment}.<id> (recommended), us.${providerSegment}.<id>, eu.${providerSegment}.<id>, or apac.${providerSegment}.<id>. ` +
`The current value "${bareId}" is not accepted by AWS on-demand throughput.`
);
}
return `${prefix} ${status}: ${body}`;
}
/**
* Lightweight ChatModel integration for Amazon Bedrock using a simple API key header.
* This implementation issues JSON requests against the public Bedrock runtime endpoint.
@ -177,7 +141,10 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
return tools.map((tool) => {
let inputSchema: Record<string, unknown> = { type: "object", properties: {} };
if (tool.schema) {
inputSchema = isInteropZodSchema(tool.schema) ? toJsonSchema(tool.schema) : tool.schema;
// Use LangChain's schema conversion utilities
inputSchema = (
isInteropZodSchema(tool.schema) ? toJsonSchema(tool.schema) : tool.schema
) as Record<string, unknown>;
}
return {
name: tool.name,
@ -231,7 +198,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
if (!response.ok) {
const errorText = await response.text();
throw new Error(rewriteBedrockErrorMessage(response.status, errorText));
throw new Error(`Amazon Bedrock request failed with status ${response.status}: ${errorText}`);
}
const data = (await response.json()) as Record<string, unknown>;
@ -313,7 +280,9 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
if (!response.ok) {
const errorText = await response.text();
throw new Error(rewriteBedrockErrorMessage(response.status, errorText, true));
throw new Error(
`Amazon Bedrock streaming request failed with status ${response.status}: ${errorText}`
);
}
if (!response.body) {
@ -1473,19 +1442,12 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
// Handle thinking mode for Claude models
// Only enable if user has explicitly enabled REASONING capability for this model
if (this.enableThinking) {
// claude-opus-4-7+ rejects { type: "enabled", budget_tokens } with a 400 and requires
// { type: "adaptive" }. Unanchored match because Bedrock IDs include provider/profile
// prefixes (e.g. "global.anthropic.claude-opus-4-7-20260115-v1:0"). Constrain the minor
// to 1-2 digits followed by a delimiter so dated snapshot IDs like
// "claude-opus-4-20250514-v1:0" aren't misread as Opus 4.20250514.
const opusMinorMatch = this.modelName.match(/claude-opus-4-(\d{1,2})(?:[-.]|$)/);
const usesAdaptiveThinking = opusMinorMatch ? parseInt(opusMinorMatch[1], 10) >= 7 : false;
// Opus 4.7+ defaults thinking.display to "omitted" so thinking summaries
// never reach the UI; force "summarized" for the adaptive branch. Pre-4.7
// models default to "summarized" server-side.
payload.thinking = usesAdaptiveThinking
? { type: "adaptive", display: "summarized" }
: { type: "enabled", budget_tokens: 2048 };
// Enable thinking mode for Claude models on Bedrock
// This allows the model to generate reasoning tokens
payload.thinking = {
type: "enabled",
budget_tokens: 2048,
};
// When thinking is enabled, temperature must be 1
// https://docs.claude.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
payload.temperature = 1;

View file

@ -448,19 +448,13 @@ export class ChatOpenRouter extends ChatOpenAI {
return undefined;
}
return toolCalls.map((rawCall) => {
const call = rawCall as
| { function?: { name?: string; arguments?: string }; id?: string; index?: number }
| null
| undefined;
return {
name: call?.function?.name,
args: call?.function?.arguments,
id: call?.id,
index: call?.index,
type: "tool_call_chunk" as const,
};
});
return toolCalls.map((call) => ({
name: call?.function?.name,
args: call?.function?.arguments,
id: call?.id,
index: call?.index,
type: "tool_call_chunk" as const,
}));
}
/**

View file

@ -1,186 +1,15 @@
/*
* Adapted from @langchain/community JinaEmbeddings.
* Copyright (c) LangChain, Inc. Licensed under the MIT License.
* Source: https://github.com/langchain-ai/langchainjs-community/blob/886df5749a926f59e6fdf38a3465c62ec9e7ce32/libs/community/src/embeddings/jina.ts
*/
import { JinaEmbeddings, JinaEmbeddingsParams } from "@langchain/community/embeddings/jina";
import { Embeddings, type EmbeddingsParams } from "@langchain/core/embeddings";
import { chunkArray } from "@langchain/core/utils/chunk_array";
import { getEnvironmentVariable } from "@langchain/core/utils/env";
export interface JinaEmbeddingsParams extends EmbeddingsParams {
/** Model name to use. */
model: string;
/** Compatibility alias used by this plugin's embedding manager. */
modelName?: string;
/** Jina-compatible embeddings endpoint. */
baseUrl?: string;
/** Timeout to use when making requests to Jina. */
timeout?: number;
/** The maximum number of documents to embed in a single request. */
batchSize?: number;
/** Whether to strip new lines from the input text. */
stripNewLines?: boolean;
/** The dimensions of the embedding. */
dimensions?: number;
/** Whether to L2-normalize the embedding vectors. */
normalized?: boolean;
}
type JinaMultiModelInput =
| {
text: string;
image?: never;
}
| {
image: string;
text?: never;
};
export type JinaEmbeddingsInput = string | JinaMultiModelInput;
interface EmbeddingCreateParams {
model: JinaEmbeddingsParams["model"];
input: JinaEmbeddingsInput[];
dimensions: number;
task: "retrieval.query" | "retrieval.passage";
normalized?: boolean;
}
interface EmbeddingResponse {
model: string;
object: string;
usage: {
total_tokens: number;
prompt_tokens: number;
};
data: {
object: string;
index: number;
embedding: number[];
}[];
}
interface EmbeddingErrorResponse {
detail: string;
}
export class CustomJinaEmbeddings extends Embeddings implements JinaEmbeddingsParams {
model: JinaEmbeddingsParams["model"] = "jina-clip-v2";
batchSize = 24;
baseUrl = "https://api.jina.ai/v1/embeddings";
stripNewLines = true;
dimensions = 1024;
apiKey: string;
normalized = true;
/**
* Creates a Jina embeddings client using local configuration or Jina environment variables.
*/
export class CustomJinaEmbeddings extends JinaEmbeddings {
constructor(
fields?: Partial<JinaEmbeddingsParams> & {
apiKey?: string;
baseUrl?: string;
}
) {
const fieldsWithDefaults = { maxConcurrency: 2, ...fields };
super(fieldsWithDefaults);
const apiKey =
fieldsWithDefaults?.apiKey ||
getEnvironmentVariable("JINA_API_KEY") ||
getEnvironmentVariable("JINA_AUTH_TOKEN");
if (!apiKey) throw new Error("Jina API key not found");
this.apiKey = apiKey;
this.model = fieldsWithDefaults?.model ?? fieldsWithDefaults?.modelName ?? this.model;
this.baseUrl = fieldsWithDefaults?.baseUrl ?? this.baseUrl;
this.dimensions = fieldsWithDefaults?.dimensions ?? this.dimensions;
this.batchSize = fieldsWithDefaults?.batchSize ?? this.batchSize;
this.stripNewLines = fieldsWithDefaults?.stripNewLines ?? this.stripNewLines;
this.normalized = fieldsWithDefaults?.normalized ?? this.normalized;
}
/**
* Embeds passage documents with Jina retrieval-passage task parameters.
*/
async embedDocuments(input: JinaEmbeddingsInput[]): Promise<number[][]> {
const batches = chunkArray(this.doStripNewLines(input), this.batchSize);
const batchRequests = batches.map((batch) => {
const params = this.getParams(batch);
return this.embeddingWithRetry(params);
});
const batchResponses = await Promise.all(batchRequests);
const embeddings: number[][] = [];
for (let i = 0; i < batchResponses.length; i += 1) {
const batch = batches[i];
const batchResponse = batchResponses[i] || [];
for (let j = 0; j < batch.length; j += 1) {
embeddings.push(batchResponse[j]);
}
super(fields);
if (fields?.baseUrl) {
this.baseUrl = fields.baseUrl;
}
return embeddings;
}
/**
* Embeds a query with Jina retrieval-query task parameters.
*/
async embedQuery(input: JinaEmbeddingsInput): Promise<number[]> {
const params = this.getParams(this.doStripNewLines([input]), true);
const embeddings = (await this.embeddingWithRetry(params)) || [[]];
return embeddings[0];
}
/**
* Removes newlines from string inputs when configured to match upstream Jina behavior.
*/
private doStripNewLines(input: JinaEmbeddingsInput[]): JinaEmbeddingsInput[] {
if (this.stripNewLines) {
return input.map((item) => {
if (typeof item === "string") {
return item.replace(/\n/g, " ");
}
if (item.text) {
return { text: item.text.replace(/\n/g, " ") };
}
return item;
});
}
return input;
}
/**
* Builds the request body for Jina's retrieval embedding API.
*/
private getParams(input: JinaEmbeddingsInput[], query?: boolean): EmbeddingCreateParams {
return {
model: this.model,
input,
dimensions: this.dimensions,
task: query ? "retrieval.query" : "retrieval.passage",
normalized: this.normalized,
};
}
/**
* Sends a single embeddings request and returns vectors in response order.
*/
private async embeddingWithRetry(body: EmbeddingCreateParams): Promise<number[][]> {
const response = await fetch(this.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify(body),
});
const embeddingData: EmbeddingResponse | EmbeddingErrorResponse = await response.json();
if ("detail" in embeddingData && embeddingData.detail) {
throw new Error(`${embeddingData.detail}`);
}
return (embeddingData as EmbeddingResponse).data.map(({ embedding }) => embedding);
}
}

View file

@ -1,4 +1,3 @@
import { safeFetchNoThrow } from "@/utils";
import { OpenAIEmbeddings } from "@langchain/openai";
export class CustomOpenAIEmbeddings extends OpenAIEmbeddings {
@ -36,7 +35,7 @@ export class CustomOpenAIEmbeddings extends OpenAIEmbeddings {
const baseURL = configuration?.baseURL || "https://api.openai.com/v1";
const url = `${baseURL}/embeddings`;
const apiKey = this.customConfig.apiKey as string;
const fetchFn = configuration?.fetch || safeFetchNoThrow;
const fetchFn = configuration?.fetch || fetch;
const response = await fetchFn(url, {
method: "POST",
@ -55,13 +54,13 @@ export class CustomOpenAIEmbeddings extends OpenAIEmbeddings {
);
}
const responseData = (await response.json()) as { data?: Array<{ embedding?: unknown }> };
const responseData = await response.json();
if (!responseData.data || !Array.isArray(responseData.data)) {
throw new Error("Invalid API response format: missing or invalid data array");
}
return responseData.data.map((item) => {
return (responseData.data as Array<{ embedding?: unknown }>).map((item) => {
if (!item.embedding || !Array.isArray(item.embedding)) {
throw new Error("Invalid API response format: missing or invalid embedding array");
}

View file

@ -4,86 +4,8 @@ import { MissingPlusLicenseError } from "@/error";
import { logInfo } from "@/logger";
import { turnOffPlus, turnOnPlus } from "@/plusUtils";
import { getSettings } from "@/settings/model";
import { safeFetchNoThrow } from "@/utils";
import { arrayBufferToBase64 } from "@/utils/base64";
import { requestUrl } from "obsidian";
/**
* Build a multipart/form-data body buffer from a FormData instance.
* Returned as an ArrayBuffer suitable for passing to Obsidian's requestUrl.
*
* @param formData - FormData containing strings and/or File/Blob entries.
* @returns The serialized multipart body and the Content-Type header (including boundary).
*/
async function buildMultipartFromFormData(
formData: FormData
): Promise<{ body: ArrayBuffer; contentType: string }> {
const boundary = `----CopilotBoundary${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`;
const encoder = new TextEncoder();
const parts: Uint8Array[] = [];
for (const [name, value] of formData.entries()) {
parts.push(encoder.encode(`--${boundary}\r\n`));
if (value instanceof Blob) {
const filename = value instanceof File ? value.name : "blob";
const contentType = value.type || "application/octet-stream";
parts.push(
encoder.encode(
`Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` +
`Content-Type: ${contentType}\r\n\r\n`
)
);
const buf = await value.arrayBuffer();
parts.push(new Uint8Array(buf));
parts.push(encoder.encode("\r\n"));
} else {
parts.push(encoder.encode(`Content-Disposition: form-data; name="${name}"\r\n\r\n`));
parts.push(encoder.encode(String(value)));
parts.push(encoder.encode("\r\n"));
}
}
parts.push(encoder.encode(`--${boundary}--\r\n`));
const totalLength = parts.reduce((sum, p) => sum + p.byteLength, 0);
const out = new Uint8Array(totalLength);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.byteLength;
}
return {
body: out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength),
contentType: `multipart/form-data; boundary=${boundary}`,
};
}
/**
* Normalize a requestUrl response into the {data, error} shape used by Brevilabs API methods.
* Handles the case where `response.json` is a raw string (non-JSON body, e.g. HTML error page).
*/
function parseBrevilabsResponse<T>(
response: { status: number; json: unknown },
endpoint: string
): { data: T | null; error?: Error } {
let data: unknown = response.json;
if (typeof data === "string") {
try {
data = JSON.parse(data);
} catch {
// Non-JSON body — fall through to status-based error.
}
}
if (response.status < 200 || response.status >= 300) {
const detail = (data as { detail?: { reason?: string; error?: string } } | null)?.detail;
if (detail?.reason) {
const error = new Error(detail.reason);
if (detail.error) error.name = detail.error;
return { data: null, error };
}
return { data: null, error: new Error(`HTTP error: ${response.status}`) };
}
logInfo(`[API ${endpoint} request]:`, data);
return { data: data as T };
}
export interface RerankResponse {
response: {
@ -201,14 +123,25 @@ export class BrevilabsClient {
if (!excludeAuthHeader) {
headers.Authorization = `Bearer ${await getDecryptedKey(getSettings().plusLicenseKey)}`;
}
const response = await requestUrl({
url: url.toString(),
const response = await safeFetchNoThrow(url.toString(), {
method,
headers,
...(method === "POST" && { body: JSON.stringify(body) }),
throw: false,
});
return parseBrevilabsResponse<T>(response, endpoint);
const data = await response.json();
if (!response.ok) {
try {
const errorDetail = data.detail;
const error = new Error(errorDetail.reason as string);
error.name = errorDetail.error as string;
return { data: null, error };
} catch {
return { data: null, error: new Error("Unknown error") };
}
}
logInfo(`[API ${endpoint} request]:`, data);
return { data };
}
private async makeFormDataRequest<T>(
@ -226,21 +159,29 @@ export class BrevilabsClient {
const url = new URL(`${BREVILABS_API_BASE_URL}${endpoint}`);
try {
// Build multipart body manually for requestUrl (does not natively support FormData).
const { body, contentType } = await buildMultipartFromFormData(formData);
const response = await requestUrl({
url: url.toString(),
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": contentType,
// No Content-Type header - browser will set it automatically with boundary
Authorization: `Bearer ${await getDecryptedKey(getSettings().plusLicenseKey)}`,
"X-Client-Version": this.pluginVersion,
},
body,
throw: false,
body: formData,
});
return parseBrevilabsResponse<T>(response, `${endpoint} form-data`);
const data = await response.json();
if (!response.ok) {
try {
const errorDetail = data.detail;
const error = new Error(errorDetail.reason as string);
error.name = errorDetail.error as string;
return { data: null, error };
} catch {
return { data: null, error: new Error(`HTTP error: ${response.status}`) };
}
}
logInfo(`[API ${endpoint} form-data request]:`, data);
return { data };
} catch (error) {
return { data: null, error: error instanceof Error ? error : new Error(String(error)) };
}

View file

@ -1,4 +1,11 @@
import { getChainType, getCurrentProject, getModelKey, SetChainOptions } from "@/aiParams";
import {
getChainType,
getCurrentProject,
getModelKey,
SetChainOptions,
setChainType,
} from "@/aiParams";
import ChainFactory, { Document } from "@/chainFactory";
import { ChainType } from "@/chainType";
import { BUILTIN_CHAT_MODELS, USER_SENDER } from "@/constants";
import {
@ -13,14 +20,14 @@ import { logError, logInfo } from "@/logger";
import { getSettings, subscribeToSettingsChange } from "@/settings/model";
import { getSystemPrompt } from "@/system-prompts/systemPromptBuilder";
import { ChatMessage } from "@/types/message";
import { findCustomModel, isOSeriesModel } from "@/utils";
import { findCustomModel, isOSeriesModel, isSupportedChain } from "@/utils";
import { MissingModelKeyError } from "@/error";
import {
ChatPromptTemplate,
HumanMessagePromptTemplate,
MessagesPlaceholder,
} from "@langchain/core/prompts";
import { Document } from "@langchain/core/documents";
import { RunnableSequence } from "@langchain/core/runnables";
import { App, Notice } from "obsidian";
import ChatModelManager from "./chatModelManager";
import MemoryManager from "./memoryManager";
@ -28,6 +35,10 @@ import PromptManager from "./promptManager";
import { UserMemoryManager } from "@/memory/UserMemoryManager";
export default class ChainManager {
// TODO: These chains are deprecated since we now use direct chat model calls in chain runners
// Consider removing after verifying no dependencies remain
private chain: RunnableSequence;
private retrievalChain: RunnableSequence;
private retrievedDocuments: Document[] = [];
public getRetrievedDocuments(): Document[] {
@ -63,6 +74,16 @@ export default class ChainManager {
await this.createChainWithNewModel();
}
// TODO: These methods are deprecated - chain runners now use direct chat model calls
// Remove after confirming no usage remains
public getChain(): RunnableSequence {
return this.chain;
}
public getRetrievalChain(): RunnableSequence {
return this.retrievalChain;
}
private validateChainType(chainType: ChainType): void {
if (chainType === undefined || chainType === null) throw new Error("No chain type set");
}
@ -79,6 +100,17 @@ export default class ChainManager {
}
}
// TODO: This method is deprecated - chain validation no longer needed
// Remove after confirming no dependencies
private validateChainInitialization() {
if (!this.chain || !isSupportedChain(this.chain)) {
logInfo("Reinitializing chat chain after detecting missing or unsupported instance.");
void this.createChainWithNewModel({}, false).catch((err) =>
logError("createChainWithNewModel failed", err)
);
}
}
public storeRetrieverDocuments(documents: Document[]) {
this.retrievedDocuments = documents;
}
@ -143,23 +175,10 @@ export default class ChainManager {
this.pendingModelError = null;
}
// Chain-type housekeeping. Do NOT write `chainType` back to the atom —
// the atom is owned by the UI dropdowns and `applyPlusSettings`. The
// captured local `chainType` may already be stale by the time we reach
// here (we just awaited `setChatModel(...)`), and writing it back used
// to create a self-sustaining `setChainType` → ProjectManager
// subscriber → `createChainWithNewModel` loop that froze Obsidian on
// apply-Plus-key.
if (this.chatModelManager.validateChatModel(this.chatModelManager.getChatModel())) {
this.validateChainType(chainType);
if (options.refreshIndex) {
await this.refreshVaultIndex();
}
} else {
console.error(
"createChainWithNewModel: skipping chain-type housekeeping — no chat model set."
);
}
// Must update the chatModel for chain because ChainFactory always
// retrieves the old chain without the chatModel change if it exists!
// Create a new chain with the new chatModel
await this.setChain(chainType, options);
logInfo(`Setting model to ${newModelKey}`);
} catch (error) {
this.pendingModelError = error instanceof Error ? error : new Error(String(error));
@ -168,6 +187,110 @@ export default class ChainManager {
}
}
// TODO: This method is deprecated - chain runners now handle chain logic directly
// Remove after confirming no usage remains
async setChain(chainType: ChainType, options: SetChainOptions = {}): Promise<void> {
if (!this.chatModelManager.validateChatModel(this.chatModelManager.getChatModel())) {
console.error("setChain failed: No chat model set.");
return;
}
this.validateChainType(chainType);
// Get chatModel, memory, prompt, and embeddingAPI from respective managers
const chatModel = this.chatModelManager.getChatModel();
const memory = this.memoryManager.getMemory();
const chatPrompt = this.promptManager.getChatPrompt();
switch (chainType) {
case ChainType.LLM_CHAIN: {
// TODO: LLMChainRunner now handles this directly without chains
this.chain = ChainFactory.createNewLLMChain({
llm: chatModel,
memory: memory,
prompt: options.prompt || chatPrompt,
abortController: options.abortController,
});
setChainType(ChainType.LLM_CHAIN);
break;
}
case ChainType.VAULT_QA_CHAIN: {
// TODO: VaultQAChainRunner now handles this directly without chains
await this.initializeQAChain(options);
// Create retriever based on semantic search setting
const settings = getSettings();
const retriever = settings.enableSemanticSearchV3
? new (await import("@/search/hybridRetriever")).HybridRetriever({
minSimilarityScore: 0.01,
maxK: settings.maxSourceChunks,
salientTerms: [],
})
: new (await import("@/search/v3/TieredLexicalRetriever")).TieredLexicalRetriever(app, {
minSimilarityScore: 0.01,
maxK: settings.maxSourceChunks,
salientTerms: [],
textWeight: undefined,
returnAll: false,
useRerankerThreshold: undefined,
});
// Create new conversational retrieval chain
this.retrievalChain = ChainFactory.createConversationalRetrievalChain(
{
llm: chatModel,
retriever: retriever,
systemMessage: getSystemPrompt(),
},
this.storeRetrieverDocuments.bind(this) as (
documents: import("@langchain/core/documents").Document[]
) => void,
getSettings().debug
);
setChainType(ChainType.VAULT_QA_CHAIN);
if (getSettings().debug) {
logInfo("New Vault QA chain with hybrid retriever created for entire vault");
logInfo("Set chain:", ChainType.VAULT_QA_CHAIN);
}
break;
}
case ChainType.COPILOT_PLUS_CHAIN: {
// For initial load of the plugin
await this.initializeQAChain(options);
this.chain = ChainFactory.createNewLLMChain({
llm: chatModel,
memory: memory,
prompt: options.prompt || chatPrompt,
abortController: options.abortController,
});
setChainType(ChainType.COPILOT_PLUS_CHAIN);
break;
}
case ChainType.PROJECT_CHAIN: {
// For initial load of the plugin
await this.initializeQAChain(options);
this.chain = ChainFactory.createNewLLMChain({
llm: chatModel,
memory: memory,
prompt: options.prompt || chatPrompt,
abortController: options.abortController,
});
setChainType(ChainType.PROJECT_CHAIN);
break;
}
default:
this.validateChainType(chainType);
break;
}
}
private getChainRunner(): ChainRunner {
const chainType = getChainType();
const settings = getSettings();
@ -190,15 +313,17 @@ export default class ChainManager {
}
}
/**
* Re-index the vault into the Orama vector store. No-op when legacy
* semantic search is disabled v3 lexical search builds its index on
* demand and doesn't need a precomputed store.
*/
private async refreshVaultIndex() {
if (!getSettings().enableSemanticSearchV3) return;
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
await VectorStoreManager.getInstance().indexVaultToVectorStore(false);
private async initializeQAChain(options: SetChainOptions) {
// Handle index refresh if needed
if (options.refreshIndex) {
const settings = getSettings();
if (settings.enableSemanticSearchV3) {
// Use VectorStoreManager for Orama indexing
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
await VectorStoreManager.getInstance().indexVaultToVectorStore(false);
}
// V3 search builds indexes on demand, no action needed
}
}
async runChain(
@ -221,6 +346,7 @@ export default class ChainManager {
);
this.validateChatModel();
this.validateChainInitialization();
const chatModel = this.chatModelManager.getChatModel();

View file

@ -1030,15 +1030,9 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
})
);
for await (const rawChunk of stream) {
for await (const chunk of stream) {
if (abortController.signal.aborted) break;
const chunk = rawChunk as {
response_metadata?: { finish_reason?: string };
tool_call_chunks?: unknown;
content?: unknown;
};
// Check for MALFORMED_FUNCTION_CALL error - throw to trigger fallback
const finishReason = chunk.response_metadata?.finish_reason;
if (finishReason === "MALFORMED_FUNCTION_CALL") {

View file

@ -1224,7 +1224,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
}
try {
const parsed = JSON.parse(toolResult.result) as { type?: unknown; documents?: unknown };
const parsed = JSON.parse(toolResult.result);
const searchResults =
parsed &&
typeof parsed === "object" &&

View file

@ -1,7 +1,26 @@
// Main exports for chain runners
export type { ChainRunner } from "./BaseChainRunner";
export { BaseChainRunner } from "./BaseChainRunner";
export { LLMChainRunner } from "./LLMChainRunner";
export { VaultQAChainRunner } from "./VaultQAChainRunner";
export { CopilotPlusChainRunner } from "./CopilotPlusChainRunner";
export { ProjectChainRunner } from "./ProjectChainRunner";
export { AutonomousAgentChainRunner } from "./AutonomousAgentChainRunner";
// Utility exports (for internal use or testing)
export { ThinkBlockStreamer } from "./utils/ThinkBlockStreamer";
export {
executeSequentialToolCall,
getToolDisplayName,
getToolEmoji,
logToolCall,
logToolResult,
deduplicateSources,
} from "./utils/toolExecution";
export type { ToolExecutionResult } from "./utils/toolExecution";
export {
createToolResultMessage,
generateToolCallId,
extractNativeToolCalls,
} from "./utils/nativeToolCalling";
export type { NativeToolCall } from "./utils/nativeToolCalling";

View file

@ -5,7 +5,7 @@
// ===== CITATION RULES =====
const CITATION_RULES = `CITATION RULES:
export const CITATION_RULES = `CITATION RULES:
1. START with [^1] and increment sequentially ([^1], [^2], [^3], etc.) with NO gaps
2. BE SELECTIVE: ONLY cite when introducing NEW factual claims, specific data, or direct quotes from sources
3. IMPORTANT: Do NOT cite every sentence or bullet point. This creates clutter and poor readability.
@ -21,7 +21,7 @@ const CITATION_RULES = `CITATION RULES:
9. If multiple source chunks come from the same document, cite each relevant chunk separately (e.g., [^1] and [^2] can both be from the same document title)
10. End with '#### Sources' section containing: [^n]: [[Title]] (one per line, matching citation order)`;
const WEB_CITATION_RULES = `WEB CITATION RULES:
export const WEB_CITATION_RULES = `WEB CITATION RULES:
1. START with [^1] and increment sequentially ([^1], [^2], [^3], etc.) with NO gaps
2. Cite ONLY when introducing new factual claims, statistics, or direct quotes from the search results
3. After every cited claim, place the corresponding footnote immediately after the sentence ("The study found X [^1]")
@ -193,7 +193,7 @@ export function getWebSearchCitationInstructions(enableInlineCitations: boolean
// ===== CITATION PROCESSING UTILITIES =====
interface SourcesSection {
export interface SourcesSection {
mainContent: string;
sourcesBlock: string;
}
@ -254,7 +254,7 @@ export function extractSourcesSection(content: string): SourcesSection | null {
/**
* Normalizes sources block by adding line breaks if everything is on one line.
*/
function normalizeSourcesBlock(sourcesBlock: string): string {
export function normalizeSourcesBlock(sourcesBlock: string): string {
if (!sourcesBlock.includes("\n")) {
// Ensure a break before every [n]
sourcesBlock = sourcesBlock.replace(/\s*\[(\d+)\]\s*/g, "\n[$1] ");
@ -268,7 +268,7 @@ function normalizeSourcesBlock(sourcesBlock: string): string {
/**
* Parses footnote definitions from sources block.
*/
function parseFootnoteDefinitions(sourcesBlock: string): string[] {
export function parseFootnoteDefinitions(sourcesBlock: string): string[] {
return sourcesBlock
.split("\n")
.map((l) => l.trim())
@ -278,7 +278,10 @@ function parseFootnoteDefinitions(sourcesBlock: string): string[] {
/**
* Builds a citation renumbering map based on first-mention order in content.
*/
function buildCitationMap(mainContent: string, footnoteLines: string[]): Map<number, number> {
export function buildCitationMap(
mainContent: string,
footnoteLines: string[]
): Map<number, number> {
const map = new Map<number, number>();
const seen = new Set<number>();
const firstMention: number[] = [];
@ -334,7 +337,7 @@ export function normalizeCitations(content: string, map: Map<number, number>): s
});
// Handle multiple citations: [^n, ^m] -> [n, m]
result = result.replace(/\[\^(\d+(?:\s*,\s*\^?\d+)*)\]/g, (match, citationList: string) => {
result = result.replace(/\[\^(\d+(?:\s*,\s*\^?\d+)*)\]/g, (match, citationList) => {
// Split and process each number in the list
const processedNumbers = citationList
.split(",")
@ -365,7 +368,10 @@ export function normalizeCitations(content: string, map: Map<number, number>): s
/**
* Converts footnote definitions to simple display items.
*/
function convertFootnoteDefinitions(sourcesBlock: string, map: Map<number, number>): string[] {
export function convertFootnoteDefinitions(
sourcesBlock: string,
map: Map<number, number>
): string[] {
const items: string[] = [];
sourcesBlock.split("\n").forEach((line) => {
const m = line.match(/^\[\^(\d+)\]:\s*(.*)$/);
@ -403,7 +409,7 @@ function convertFootnoteDefinitions(sourcesBlock: string, map: Map<number, numbe
/**
* Consolidates duplicate sources and returns mapping for citation updates.
*/
function consolidateDuplicateSources(items: string[]): {
export function consolidateDuplicateSources(items: string[]): {
uniqueItems: string[];
consolidationMap: Map<number, number>;
} {
@ -585,7 +591,7 @@ function buildSourcesDetails(mainContent: string, items: SourcesDisplayItem[]):
* These spans provide visual feedback during streaming (styled as pending links)
* and are replaced by linkInlineCitations with actual clickable anchors after streaming.
*/
function wrapCitationPlaceholders(content: string): string {
export function wrapCitationPlaceholders(content: string): string {
return content.replace(
/\[(\d+(?:\s*,\s*\d+)*)\](?!\()/g,
'<span class="copilot-citation-ref">[$1]</span>'

View file

@ -6,6 +6,7 @@
*/
import { AIMessage, ToolMessage } from "@langchain/core/messages";
import { ToolCall as LangChainToolCall } from "@langchain/core/messages/tool";
import { logError } from "@/logger";
/**
@ -26,6 +27,37 @@ export interface ToolCallChunk {
args: string; // JSON string accumulated from chunks
}
/**
* Extract native tool calls from an AIMessage.
* Returns empty array if no tool calls present.
*
* @param message - AIMessage from LLM response
* @returns Array of standardized tool calls
*/
export function extractNativeToolCalls(message: AIMessage): NativeToolCall[] {
const toolCalls = message.tool_calls;
if (!toolCalls || toolCalls.length === 0) {
return [];
}
return toolCalls.map((tc: LangChainToolCall) => ({
id: tc.id || generateToolCallId(),
name: tc.name,
args: (tc.args as Record<string, unknown>) || {},
}));
}
/**
* Check if an AIMessage contains tool calls
*
* @param message - AIMessage to check
* @returns true if message has tool calls
*/
export function hasToolCalls(message: AIMessage): boolean {
return (message.tool_calls?.length ?? 0) > 0;
}
/**
* Create a ToolMessage for returning tool execution results to the LLM.
*

View file

@ -26,7 +26,7 @@ export interface SearchDoc {
* Quality summary for search results.
* Helps the LLM evaluate whether results are adequate or if re-search is needed.
*/
interface QualitySummary {
export interface QualitySummary {
high: number; // Count of results with score >= 0.7
medium: number; // Count of results with score >= 0.3 and < 0.7
low: number; // Count of results with score < 0.3
@ -212,7 +212,7 @@ function toIsoString(ts: unknown): string {
* Create a concise, single-line summary of an explanation object.
* Includes lexical matches, semantic score, folder/graph boosts, and score adjustments.
*/
function summarizeExplanation(explanation: unknown): string {
export function summarizeExplanation(explanation: unknown): string {
if (!explanation) return "";
const parts: string[] = [];

View file

@ -17,7 +17,7 @@ export interface ErrorMarker {
endIndex: number;
}
interface ParsedMessage {
export interface ParsedMessage {
segments: Array<{
type: "text" | "toolCall" | "error";
content: string;
@ -45,7 +45,7 @@ function encodeResultForMarker(result: string): string {
/**
* Decode tool result previously encoded for marker embedding
*/
function decodeResultFromMarker(result: string | undefined): string | undefined {
export function decodeResultFromMarker(result: string | undefined): string | undefined {
if (typeof result !== "string") return result;
if (!result.startsWith("ENC:")) return result;
try {
@ -65,6 +65,38 @@ function buildOmittedResultMessage(toolName: string): string {
return `Tool '${toolName}' ${TOOL_RESULT_OMITTED_THRESHOLD_MESSAGE}`;
}
/**
* For logging only: decode any encoded tool results embedded in markers
*/
export function decodeToolCallMarkerResults(message: string): string {
if (!message || typeof message !== "string") return message;
return message.replace(
/<!--TOOL_CALL_END:([^:]+):(ENC:[\s\S]*?)-->/g,
(_match, id: string, encoded: string) => {
const decoded = decodeResultFromMarker(encoded) || encoded;
return `<!--TOOL_CALL_END:${id}:${decoded}-->`;
}
);
}
/**
* Ensure any TOOL_CALL_END results are encoded. Useful for sanitizing messages
* that might contain unencoded results due to legacy or partial updates.
*/
export function ensureEncodedToolCallMarkerResults(message: string): string {
if (!message || typeof message !== "string") return message;
return message.replace(
/<!--TOOL_CALL_END:([^:]+):([\s\S]*?)-->/g,
(_match, id: string, content: string) => {
if (content.startsWith("ENC:")) {
return _match;
}
const safe = encodeResultForMarker(content);
return `<!--TOOL_CALL_END:${id}:${safe}-->`;
}
);
}
/**
* Parse error chunks from a text segment
* Format: <errorChunk>error content</errorChunk>

View file

@ -15,7 +15,7 @@ export interface ToolCall {
args: Record<string, unknown>;
}
interface ToolExecutionResult {
export interface ToolExecutionResult {
toolName: string;
result: string;
success: boolean;
@ -146,7 +146,7 @@ export async function executeSequentialToolCall(
/**
* Get display name for tool (user-friendly version)
*/
function getToolDisplayName(toolName: string): string {
export function getToolDisplayName(toolName: string): string {
// Special handling for localSearch to show the actual search type being used
if (toolName === "localSearch") {
const settings = getSettings();
@ -184,7 +184,7 @@ function getToolDisplayName(toolName: string): string {
/**
* Get emoji for tool display
*/
function getToolEmoji(toolName: string): string {
export function getToolEmoji(toolName: string): string {
const emojiMap: Record<string, string> = {
localSearch: "🔍",
webSearch: "🌐",
@ -211,6 +211,32 @@ function getToolEmoji(toolName: string): string {
return emojiMap[toolName] || "🔧";
}
/**
* Get user confirmation message for tool call
*/
export function getToolConfirmtionMessage(
toolName: string,
toolArgs?: Record<string, unknown>
): string | null {
if (toolName == "writeFile" || toolName == "editFile") {
return "Accept / reject in the Preview";
}
// Display salient terms for lexical search
if (toolName === "localSearch" && toolArgs?.salientTerms) {
const settings = getSettings();
// Only show salient terms for lexical search (index-free)
if (!settings.enableSemanticSearchV3) {
const terms = Array.isArray(toolArgs.salientTerms) ? toolArgs.salientTerms : [];
if (terms.length > 0) {
return `Terms: ${terms.slice(0, 3).join(", ")}${terms.length > 3 ? "..." : ""}`;
}
}
}
return null;
}
/**
* Log tool call details for debugging
*/

View file

@ -4,7 +4,7 @@ import { processRawChatHistory, processedMessagesToTextOnly } from "./chatHistor
/**
* Options for building prompt debug sections with annotated provenance.
*/
interface BuildPromptDebugSectionsOptions {
export interface BuildPromptDebugSectionsOptions {
systemSections: PromptSection[];
rawHistory?: unknown[];
adapterName: string;
@ -27,7 +27,9 @@ export interface PromptDebugReport {
* @param options - Data required to assemble annotated prompt sections.
* @returns Prompt sections with provenance metadata.
*/
function buildPromptDebugSections(options: BuildPromptDebugSectionsOptions): PromptSection[] {
export function buildPromptDebugSections(
options: BuildPromptDebugSectionsOptions
): PromptSection[] {
const { systemSections, rawHistory, adapterName, originalUserMessage, enhancedUserMessage } =
options;
const sections: PromptSection[] = [...systemSections];
@ -74,7 +76,7 @@ function buildPromptDebugSections(options: BuildPromptDebugSectionsOptions): Pro
* @param sections - Prompt sections with provenance metadata.
* @returns Multiline string with section headers that identify code sources.
*/
function formatPromptSectionsWithAnnotations(sections: PromptSection[]): string {
export function formatPromptSectionsWithAnnotations(sections: PromptSection[]): string {
return sections
.map((section) => {
const header = `[Section: ${section.label} | Source: ${section.source}]`;

View file

@ -25,6 +25,7 @@ import {
safeFetchNoThrow,
shouldUseGitHubCopilotResponsesApi,
} from "@/utils";
import { HarmBlockThreshold, HarmCategory } from "@google/generative-ai";
import { ChatAnthropic } from "@langchain/anthropic";
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
import { BaseLanguageModel } from "@langchain/core/language_models/base";
@ -41,24 +42,16 @@ import { ChatLMStudio } from "./ChatLMStudio";
import { BedrockChatModel, type BedrockChatModelFields } from "./BedrockChatModel";
import { GitHubCopilotChatModel } from "@/LLMProviders/githubCopilot/GitHubCopilotChatModel";
import { GitHubCopilotResponsesModel } from "@/LLMProviders/githubCopilot/GitHubCopilotResponsesModel";
import type { SafetySetting } from "@google/generative-ai";
const GOOGLE_SAFETY_SETTINGS_BLOCK_NONE: SafetySetting[] = [
{ category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_NONE" } as SafetySetting,
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE" } as SafetySetting,
{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_NONE" } as SafetySetting,
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" } as SafetySetting,
];
// Patch BaseLanguageModel.prototype.getNumTokens once at module load to prevent
// tiktoken CDN fetches. LangChain's default getNumTokens() downloads a ~3MB BPE
// vocabulary from tiktoken.pages.dev, which blocks all LLM calls when the CDN is
// unreachable. This char/4 estimation is the same fallback LangChain uses internally
// before tiktoken loads. Actual token usage comes from API response metadata.
(
BaseLanguageModel.prototype as { getNumTokens: (...args: unknown[]) => Promise<number> }
).getNumTokens = async (content: string | Array<{ type: string; text?: string }>) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- patching a private prototype method requires any cast
(BaseLanguageModel.prototype as any).getNumTokens = async (
content: string | Array<{ type: string; text?: string }>
) => {
const text =
typeof content === "string"
? content
@ -201,7 +194,7 @@ export default class ChatModelManager {
const modelName = customModel.name;
const modelInfo = getModelInfo(modelName);
const { isThinkingEnabled, usesAdaptiveThinking } = modelInfo;
const { isThinkingEnabled } = modelInfo;
const resolvedTemperature = this.getTemperatureForModel(modelInfo, customModel, settings);
const maxTokens = customModel.maxTokens ?? settings.maxTokens;
@ -248,15 +241,10 @@ export default class ChatModelManager {
fetch: customModel.enableCors ? safeFetch : undefined,
},
...(isThinkingEnabled && {
// Opus 4.7+ defaults thinking.display to "omitted" so thinking summaries
// never reach the UI; force "summarized" for the adaptive branch. Pre-4.7
// models default to "summarized" server-side and don't need this.
thinking: usesAdaptiveThinking
? { type: "adaptive" as const, display: "summarized" as const }
: {
type: "enabled" as const,
budget_tokens: ChatModelManager.ANTHROPIC_THINKING_BUDGET_TOKENS,
},
thinking: {
type: "enabled",
budget_tokens: ChatModelManager.ANTHROPIC_THINKING_BUDGET_TOKENS,
},
}),
},
[ChatModelProviders.AZURE_OPENAI]: await (async (): Promise<Record<string, unknown>> => {
@ -302,7 +290,24 @@ export default class ChatModelManager {
[ChatModelProviders.GOOGLE]: {
apiKey: await getDecryptedKey(customModel.apiKey || settings.googleApiKey),
model: modelName,
safetySettings: GOOGLE_SAFETY_SETTINGS_BLOCK_NONE,
safetySettings: [
{
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
{
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
{
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
{
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
],
baseUrl: customModel.baseUrl,
},
[ChatModelProviders.XAI]: {

View file

@ -17,15 +17,9 @@ jest.mock("@/logger", () => ({
logWarn: jest.fn(),
}));
// Mock safeFetchNoThrow (the requestUrl-backed wrapper used in place of fetch)
// Mock global fetch
const mockFetch = jest.fn();
jest.mock("@/utils", () => {
const actual = jest.requireActual<Record<string, unknown>>("@/utils");
return {
...actual,
safeFetchNoThrow: (url: string, options?: RequestInit): unknown => mockFetch(url, options),
};
});
window.fetch = mockFetch;
beforeEach(() => {
jest.clearAllMocks();

View file

@ -2,7 +2,6 @@ import { type Youtube4llmResponse } from "@/LLMProviders/brevilabsClient";
import { getDecryptedKey } from "@/encryptionService";
import { logError, logInfo } from "@/logger";
import { getSettings } from "@/settings/model";
import { safeFetchNoThrow } from "@/utils";
const FIRECRAWL_SEARCH_URL = "https://api.firecrawl.dev/v2/search";
const PERPLEXITY_CHAT_URL = "https://api.perplexity.ai/chat/completions";
@ -46,7 +45,7 @@ export function hasSelfHostSearchKey(): boolean {
async function firecrawlSearch(query: string, apiKey: string): Promise<SelfHostWebSearchResult> {
const startTime = Date.now();
const response = await safeFetchNoThrow(FIRECRAWL_SEARCH_URL, {
const response = await fetch(FIRECRAWL_SEARCH_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
@ -60,9 +59,7 @@ async function firecrawlSearch(query: string, apiKey: string): Promise<SelfHostW
throw new Error(`Firecrawl search failed (${response.status}): ${text}`);
}
const json = (await response.json()) as {
data?: FirecrawlSearchResult[] | { web?: FirecrawlSearchResult[] };
};
const json = await response.json();
// v2 returns { data: { web: [...] } }, older responses return { data: [...] }
const rawData = json?.data;
@ -98,7 +95,7 @@ async function perplexitySonarSearch(
query: string,
apiKey: string
): Promise<SelfHostWebSearchResult> {
const response = await safeFetchNoThrow(PERPLEXITY_CHAT_URL, {
const response = await fetch(PERPLEXITY_CHAT_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
@ -115,12 +112,9 @@ async function perplexitySonarSearch(
throw new Error(`Perplexity Sonar search failed (${response.status}): ${text}`);
}
const json = (await response.json()) as {
choices?: Array<{ message?: { content?: string } }>;
citations?: unknown;
};
const json = await response.json();
const content = json?.choices?.[0]?.message?.content ?? "";
const citations: string[] = Array.isArray(json?.citations) ? (json.citations as string[]) : [];
const citations: string[] = Array.isArray(json?.citations) ? json.citations : [];
return { content, citations };
}
@ -150,7 +144,7 @@ export async function selfHostYoutube4llm(url: string): Promise<Youtube4llmRespo
const transcriptUrl = `${SUPADATA_TRANSCRIPT_URL}?url=${encodeURIComponent(url)}&mode=auto&text=true`;
const response = await safeFetchNoThrow(transcriptUrl, {
const response = await fetch(transcriptUrl, {
method: "GET",
headers: {
"x-api-key": apiKey,
@ -159,7 +153,7 @@ export async function selfHostYoutube4llm(url: string): Promise<Youtube4llmRespo
});
if (response.status === 200) {
const json = (await response.json()) as { content?: string };
const json = await response.json();
const elapsed = Date.now() - startTime;
logInfo(`[selfHostYoutube4llm] transcript received in ${elapsed}ms`);
return {
@ -169,12 +163,12 @@ export async function selfHostYoutube4llm(url: string): Promise<Youtube4llmRespo
}
if (response.status === 201 || response.status === 202) {
const json = (await response.json()) as { job_id?: string };
const json = await response.json();
const jobId = json.job_id;
if (!jobId) {
throw new Error("Supadata returned async status but no job_id");
}
return await pollSupadataJob(jobId, apiKey, startTime);
return await pollSupadataJob(jobId as string, apiKey, startTime);
}
const text = await response.text();
@ -195,7 +189,7 @@ async function pollSupadataJob(
while (Date.now() < deadline) {
await new Promise((resolve) => window.setTimeout(resolve, SUPADATA_POLL_INTERVAL));
const pollResponse = await safeFetchNoThrow(pollUrl, {
const pollResponse = await fetch(pollUrl, {
method: "GET",
headers: {
"x-api-key": apiKey,
@ -204,7 +198,7 @@ async function pollSupadataJob(
});
if (pollResponse.status === 200) {
const json = (await pollResponse.json()) as { content?: string };
const json = await pollResponse.json();
const elapsed = Date.now() - startTime;
logInfo(`[selfHostYoutube4llm] async transcript completed in ${elapsed}ms`);
return {

View file

@ -60,7 +60,7 @@ export const projectContextLoadAtom = atom<ProjectContextLoadState>({
total: [],
});
interface IndexingProgressState {
export interface IndexingProgressState {
isActive: boolean;
isPaused: boolean;
isCancelled: boolean;
@ -70,7 +70,7 @@ interface IndexingProgressState {
completionStatus: "none" | "success" | "cancelled" | "error";
}
const indexingProgressAtom = atom<IndexingProgressState>({
export const indexingProgressAtom = atom<IndexingProgressState>({
isActive: false,
isPaused: false,
isCancelled: false,
@ -237,10 +237,26 @@ export function subscribeToProjectChange(
});
}
export function useCurrentProject() {
return useAtom(currentProjectAtom, {
store: settingsStore,
});
}
export function setProjectLoading(loading: boolean) {
settingsStore.set(projectLoadingAtom, loading);
}
export function isProjectLoading(): boolean {
return settingsStore.get(projectLoadingAtom);
}
export function subscribeToProjectLoadingChange(callback: (loading: boolean) => void): () => void {
return settingsStore.sub(projectLoadingAtom, () => {
callback(settingsStore.get(projectLoadingAtom));
});
}
export function useProjectLoading() {
return useAtom(projectLoadingAtom, {
store: settingsStore,
@ -259,6 +275,11 @@ export function getSelectedTextContexts(): SelectedTextContext[] {
return settingsStore.get(selectedTextContextsAtom);
}
export function addSelectedTextContext(context: SelectedTextContext) {
const current = getSelectedTextContexts();
setSelectedTextContexts([...current, context]);
}
export function removeSelectedTextContext(id: string) {
const current = getSelectedTextContexts();
setSelectedTextContexts(current.filter((context) => context.id !== id));
@ -274,6 +295,13 @@ export function useSelectedTextContexts() {
});
}
/**
* Gets the project context load state from the atom.
*/
export function getProjectContextLoadState(): Readonly<ProjectContextLoadState> {
return settingsStore.get(projectContextLoadAtom);
}
/**
* Sets the project context load state in the atom.
*/
@ -294,6 +322,17 @@ export function updateProjectContextLoadState<K extends keyof ProjectContextLoad
}));
}
/**
* Subscribes to changes in the project context load state.
*/
export function subscribeToProjectContextLoadChange(
callback: (state: ProjectContextLoadState) => void
): () => void {
return settingsStore.sub(projectContextLoadAtom, () => {
callback(settingsStore.get(projectContextLoadAtom));
});
}
/**
* Hook to get the project context load state from the atom.
*/

208
src/chainFactory.ts Normal file
View file

@ -0,0 +1,208 @@
// TODO(logan): This entire file is deprecated since we moved to direct chat model calls in chain runners
// Consider removing after verifying no dependencies remain
import { BaseLanguageModel } from "@langchain/core/language_models/base";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { ChatPromptTemplate, PromptTemplate } from "@langchain/core/prompts";
import { BaseRetriever } from "@langchain/core/retrievers";
import { RunnablePassthrough, RunnableSequence } from "@langchain/core/runnables";
import { BaseChatMemory } from "@langchain/classic/memory";
import { formatDocumentsAsString } from "@langchain/classic/util/document";
import { ChainType } from "./chainType";
import { logInfo } from "./logger";
import { removeErrorTags, removeThinkTags } from "./utils";
export interface LLMChainInput {
llm: BaseLanguageModel;
memory: BaseChatMemory;
prompt: ChatPromptTemplate;
abortController?: AbortController;
}
export interface RetrievalChainParams {
llm: BaseLanguageModel;
retriever: BaseRetriever;
options?: {
returnSourceDocuments?: boolean;
};
}
export interface ConversationalRetrievalChainParams {
llm: BaseLanguageModel;
retriever: BaseRetriever;
systemMessage: string;
options?: {
returnSourceDocuments?: boolean;
questionGeneratorTemplate?: string;
qaTemplate?: string;
};
}
export interface Document<T = Record<string, unknown>> {
// Structure of Document, possibly including pageContent, metadata, etc.
pageContent: string;
metadata: T;
}
type ConversationalRetrievalQAChainInput = {
question: string;
chat_history: [string, string][];
};
// Issue where conversational retrieval chain gives rephrased question
// when streaming: https://github.com/hwchase17/langchainjs/issues/754#issuecomment-1540257078
// Temp workaround triggers CORS issue 'refused to set header user-agent'
class ChainFactory {
public static instances: Map<string, RunnableSequence> = new Map();
/**
* Create a new LLM chain using the provided LLMChainInput.
*
* @param {LLMChainInput} args - the input for creating the LLM chain
* @return {RunnableSequence} the newly created LLM chain
*/
public static createNewLLMChain(args: LLMChainInput): RunnableSequence {
const { llm, memory, prompt, abortController } = args;
const model = llm.withConfig({ signal: abortController?.signal });
const instance = RunnableSequence.from([
{
input: (initialInput: { input: unknown }) => initialInput.input,
memory: () => memory.loadMemoryVariables({}),
},
{
input: (previousOutput: { input: unknown; memory: { history: unknown } }) =>
previousOutput.input,
history: (previousOutput: { input: unknown; memory: { history: unknown } }) =>
previousOutput.memory.history,
},
prompt,
model,
]);
ChainFactory.instances.set(ChainType.LLM_CHAIN, instance);
logInfo("New LLM chain created.");
return instance;
}
/**
* Gets the LLM chain singleton from the map.
*
* @param {LLMChainInput} args - the input for the LLM chain
* @return {RunnableSequence} the LLM chain instance
*/
public static getLLMChainFromMap(args: LLMChainInput): RunnableSequence {
let instance = ChainFactory.instances.get(ChainType.LLM_CHAIN);
if (!instance) {
instance = ChainFactory.createNewLLMChain(args);
}
return instance;
}
/**
* Create a conversational retrieval chain with the given parameters. Not a singleton.
*
* Example invocation:
*
* ```ts
* const conversationalRetrievalChain = ChainFactory.createConversationalRetrievalChain({
* llm: model,
* retriever: retriever
* });
*
* const response = await conversationalRetrievalChain.invoke({
* question: "What are they made out of?",
* chat_history: [
* [
* "What is the powerhouse of the cell?",
* "The powerhouse of the cell is the mitochondria.",
* ],
* ],
* });
* ```
*
* @param {ConversationalRetrievalChainParams} args - the parameters for the retrieval chain
* @return {RunnableSequence} a new conversational retrieval chain
*/
public static createConversationalRetrievalChain(
args: ConversationalRetrievalChainParams,
onDocumentsRetrieved: (documents: Document[]) => void,
debug?: boolean
): RunnableSequence {
const { llm, retriever, systemMessage } = args;
// NOTE: This is a tricky part of the Conversational RAG. Weaker models may fail this instruction
// and lose the follow up question altogether.
const condenseQuestionTemplate = `Given the following conversation and a follow up question,
summarize the conversation as context and keep the follow up question unchanged, in its original language.
If the follow up question is unrelated to its preceding messages, return this follow up question directly.
If it is related, then combine the summary and the follow up question to construct a standalone question.
Make sure to keep any [[]] wrapped note titles in the question unchanged.
Chat History:
{chat_history}
Follow Up Input: {question}
Standalone question:`;
const CONDENSE_QUESTION_PROMPT = PromptTemplate.fromTemplate(condenseQuestionTemplate);
const answerTemplate = `{system_message}
Answer the question with as detailed as possible based only on the following context:
{context}
Question: {question}
`;
const ANSWER_PROMPT = PromptTemplate.fromTemplate(answerTemplate);
const formatChatHistory = (chatHistory: [string, string][]) => {
const formattedDialogueTurns = chatHistory.map(
(dialogueTurn) => `Human: ${dialogueTurn[0]}\nAssistant: ${dialogueTurn[1]}`
);
return formattedDialogueTurns.join("\n");
};
const standaloneQuestionChain = RunnableSequence.from([
{
question: (input: ConversationalRetrievalQAChainInput) => {
if (debug) logInfo("Input Question: ", input.question);
return input.question;
},
chat_history: (input: ConversationalRetrievalQAChainInput) => {
const formattedChatHistory = formatChatHistory(input.chat_history);
if (debug) logInfo("Formatted Chat History: ", formattedChatHistory);
return formattedChatHistory;
},
},
CONDENSE_QUESTION_PROMPT,
llm,
new StringOutputParser(),
(output) => {
const thinkTagsCleaned = removeThinkTags(output);
const cleanedOutput = removeErrorTags(thinkTagsCleaned);
if (debug) logInfo("Standalone Question: ", cleanedOutput);
return cleanedOutput;
},
]);
const formatDocumentsAsStringAndStore = async (documents: Document[]) => {
// Store or log documents for debugging
onDocumentsRetrieved(documents);
return formatDocumentsAsString(documents);
};
const answerChain = RunnableSequence.from([
{
context: retriever.pipe(formatDocumentsAsStringAndStore),
question: new RunnablePassthrough(),
system_message: () => systemMessage,
},
ANSWER_PROMPT,
llm,
]);
const conversationalRetrievalQAChain = standaloneQuestionChain.pipe(answerChain);
return conversationalRetrievalQAChain as RunnableSequence;
}
}
export default ChainFactory;

View file

@ -7,7 +7,7 @@ import {
} from "@/components/command-ui/constants";
import { SelectionHighlight } from "@/editor/selectionHighlight";
import { createHighlightReplaceGuard, type ReplaceGuard } from "@/editor/replaceGuard";
import { logError } from "@/logger";
import { logError, logWarn } from "@/logger";
import { cleanMessageForCopy, findCustomModel, insertIntoEditor } from "@/utils";
import { computeVerticalPlacement } from "@/utils/panelPlacement";
import { computeSelectionAnchors } from "@/utils/selectionAnchors";
@ -16,8 +16,7 @@ import { PenLine } from "lucide-react";
import { App, Component, MarkdownRenderer, Notice, MarkdownView, Scope } from "obsidian";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { preprocessAIResponse } from "@/utils/markdownPreprocess";
import { Root } from "react-dom/client";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { createRoot, Root } from "react-dom/client";
import { CustomCommand } from "@/commands/type";
import { useSettingsValue, updateSetting } from "@/settings/model";
import {
@ -184,12 +183,12 @@ function CustomCommandChatModalContent({
return command.modelKey || globalModelKey;
}, [modelSelectionScope, settings.quickCommandModelKey, command.modelKey, globalModelKey]);
const [userSelectedModelKey, setUserSelectedModelKey] = useState(initialModelKey);
const [selectedModelKey, setSelectedModelKey] = useState(initialModelKey);
// Handle model change with scope-aware persistence
const handleModelChange = useCallback(
(newModelKey: string) => {
setUserSelectedModelKey(newModelKey);
setSelectedModelKey(newModelKey);
// Only persist for quick-command scope (shared with Quick Ask)
if (modelSelectionScope === "quick-command") {
updateSetting("quickCommandModelKey", newModelKey);
@ -210,13 +209,16 @@ function CustomCommandChatModalContent({
updateSetting("quickCommandIncludeNoteContext", checked);
}, []);
// Track if we've already shown the fallback notice to avoid repeated notices
const didShowFallbackNoticeRef = useRef(false);
// Safely resolve the selected model with fallback to first enabled model
const resolvedModel = useMemo((): CustomModel | null => {
try {
const model = findCustomModel(userSelectedModelKey, settings.activeModels);
const model = findCustomModel(selectedModelKey, settings.activeModels);
// Treat disabled models as invalid selections (ModelSelector won't present them)
if (!model.enabled) {
throw new Error(`Selected model is disabled: ${userSelectedModelKey}`);
throw new Error(`Selected model is disabled: ${selectedModelKey}`);
}
return model;
} catch {
@ -224,7 +226,7 @@ function CustomCommandChatModalContent({
// Avoid side effects during render; notify/log in the effect below.
return settings.activeModels.find((m) => m.enabled) ?? null;
}
}, [userSelectedModelKey, settings.activeModels]);
}, [selectedModelKey, settings.activeModels]);
// Compute the key for the resolved model
const resolvedModelKey = useMemo(() => {
@ -232,8 +234,23 @@ function CustomCommandChatModalContent({
return `${resolvedModel.name}|${resolvedModel.provider}`;
}, [resolvedModel]);
// Effective model key for the UI — falls back to user selection when resolution fails.
const effectiveModelKey = resolvedModelKey ?? userSelectedModelKey;
// Update selectedModelKey if we had to fall back to a different model
useEffect(() => {
if (!resolvedModelKey) return;
if (resolvedModelKey === selectedModelKey) return;
// Always keep UI selection consistent with the resolved model
setSelectedModelKey(resolvedModelKey);
// Notify only once per modal lifecycle
if (didShowFallbackNoticeRef.current) return;
didShowFallbackNoticeRef.current = true;
logWarn("Selected model is no longer available. Falling back to a default model.", {
selectedModelKey,
resolvedModelKey,
});
new Notice("Selected model is no longer available. Falling back to a default model.");
}, [resolvedModelKey, selectedModelKey]);
// Use shared streaming hook
const {
@ -260,15 +277,12 @@ function CustomCommandChatModalContent({
// Track the last input prompt for saving context on stop
const lastInputPromptRef = useRef<string>("");
// Sync editedText with finalText when finalText changes. Render-phase tracker
// preserves user edits made after the last finalText change until the next change.
const [prevFinalText, setPrevFinalText] = useState(finalText);
if (prevFinalText !== finalText) {
setPrevFinalText(finalText);
// Sync editedText with finalText when finalText changes
useEffect(() => {
if (finalText) {
setEditedText(finalText);
}
}
}, [finalText]);
// Compute content state for MenuCommandModal
const contentState: ContentState = useMemo(() => {
@ -439,7 +453,7 @@ function CustomCommandChatModalContent({
followUpValue={followUpValue}
onFollowUpChange={setFollowUpValue}
onFollowUpSubmit={handleFollowUpSubmit}
selectedModel={effectiveModelKey}
selectedModel={selectedModelKey}
onSelectModel={handleModelChange}
onStop={handleStop}
onCopy={handleCopy}
@ -651,7 +665,7 @@ export class CustomCommandChatModal {
this.container.className = "copilot-menu-command-modal-container";
doc.body.appendChild(this.container);
this.root = createPluginRoot(this.container, this.app);
this.root = createRoot(this.container);
// Capture ReplaceGuard (replaces captureReplaceSnapshot)
const { selectedText, command, systemPrompt, behaviorConfig } = this.configs;

View file

@ -5,8 +5,7 @@ import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import React, { useState } from "react";
import { Root } from "react-dom/client";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { createRoot, Root } from "react-dom/client";
import { getModelKeyFromModel, useSettingsValue } from "@/settings/model";
import { getModelDisplayText } from "@/components/ui/model-display";
import { cn } from "@/lib/utils";
@ -192,7 +191,7 @@ export class CustomCommandSettingsModal extends Modal {
onOpen() {
const { contentEl } = this;
this.root = createPluginRoot(contentEl, this.app);
this.root = createRoot(contentEl);
const handleConfirm = (command: CustomCommand) => {
void this.onUpdate(command);

View file

@ -1,6 +1,8 @@
import { CustomCommand } from "@/commands/type";
export const LEGACY_SELECTED_TEXT_PLACEHOLDER = "{copilot-selection}";
export const COMMAND_NAME_MAX_LENGTH = 50;
export const QUICK_COMMAND_CODE_BLOCK = "copilotquickcommand";
export const EMPTY_COMMAND: CustomCommand = {
title: "",
content: "",

View file

@ -66,16 +66,13 @@ export class CustomCommandManager {
commandFile = await app.vault.create(filePath, command.content);
}
await app.fileManager.processFrontMatter(
commandFile,
(frontmatter: Record<string, unknown>) => {
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] = command.showInContextMenu;
frontmatter[COPILOT_COMMAND_SLASH_ENABLED] = command.showInSlashMenu;
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ORDER] = command.order;
frontmatter[COPILOT_COMMAND_MODEL_KEY] = command.modelKey;
frontmatter[COPILOT_COMMAND_LAST_USED] = command.lastUsedMs;
}
);
await app.fileManager.processFrontMatter(commandFile, (frontmatter) => {
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] = command.showInContextMenu;
frontmatter[COPILOT_COMMAND_SLASH_ENABLED] = command.showInSlashMenu;
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ORDER] = command.order;
frontmatter[COPILOT_COMMAND_MODEL_KEY] = command.modelKey;
frontmatter[COPILOT_COMMAND_LAST_USED] = command.lastUsedMs;
});
if (!mergedOptions.skipStoreUpdate) {
updateCachedCommand(command, command.title);
@ -129,16 +126,13 @@ export class CustomCommandManager {
if (commandFile instanceof TFile) {
await app.vault.modify(commandFile, command.content);
await app.fileManager.processFrontMatter(
commandFile,
(frontmatter: Record<string, unknown>) => {
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] = command.showInContextMenu;
frontmatter[COPILOT_COMMAND_SLASH_ENABLED] = command.showInSlashMenu;
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ORDER] = command.order;
frontmatter[COPILOT_COMMAND_MODEL_KEY] = command.modelKey;
frontmatter[COPILOT_COMMAND_LAST_USED] = command.lastUsedMs;
}
);
await app.fileManager.processFrontMatter(commandFile, (frontmatter) => {
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] = command.showInContextMenu;
frontmatter[COPILOT_COMMAND_SLASH_ENABLED] = command.showInSlashMenu;
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ORDER] = command.order;
frontmatter[COPILOT_COMMAND_MODEL_KEY] = command.modelKey;
frontmatter[COPILOT_COMMAND_LAST_USED] = command.lastUsedMs;
});
}
} finally {
removePendingFileWrite(filePath);

View file

@ -6,10 +6,11 @@ import {
COPILOT_COMMAND_SLASH_ENABLED,
EMPTY_COMMAND,
LEGACY_SELECTED_TEXT_PLACEHOLDER,
QUICK_COMMAND_CODE_BLOCK,
} from "@/commands/constants";
import { CustomCommand } from "@/commands/type";
import { logWarn } from "@/logger";
import { normalizePath, Notice, TAbstractFile, TFile, Vault } from "obsidian";
import { normalizePath, Notice, TAbstractFile, TFile, Vault, Editor } from "obsidian";
import { getSettings } from "@/settings/model";
import {
updateCachedCommands,
@ -145,7 +146,7 @@ export function sortCommandsByOrder(commands: CustomCommand[]): CustomCommand[]
});
}
function sortCommandsByRecency(commands: CustomCommand[]): CustomCommand[] {
export function sortCommandsByRecency(commands: CustomCommand[]): CustomCommand[] {
return sortByStrategy(commands, "recent", {
getName: (command) => command.title,
getCreatedAtMs: () => 0,
@ -153,7 +154,7 @@ function sortCommandsByRecency(commands: CustomCommand[]): CustomCommand[] {
});
}
function sortCommandsByAlphabetical(commands: CustomCommand[]): CustomCommand[] {
export function sortCommandsByAlphabetical(commands: CustomCommand[]): CustomCommand[] {
return sortByStrategy(commands, "name", {
getName: (command) => command.title,
getCreatedAtMs: () => 0,
@ -492,7 +493,7 @@ export function getNextCustomCommandOrder(): number {
export async function ensureCommandFrontmatter(file: TFile, command: CustomCommand) {
try {
addPendingFileWrite(file.path);
await app.fileManager.processFrontMatter(file, (frontmatter: Record<string, unknown>) => {
await app.fileManager.processFrontMatter(file, (frontmatter) => {
if (frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] == null) {
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] = command.showInContextMenu;
}
@ -513,3 +514,68 @@ export async function ensureCommandFrontmatter(file: TFile, command: CustomComma
removePendingFileWrite(file.path);
}
}
/**
* Removes all quick command code blocks from the editor while preserving cursor position and selection
* @param editor - The Obsidian editor instance
* @returns true if any blocks were removed, false otherwise
*/
export function removeQuickCommandBlocks(editor: Editor): boolean {
// Store original selection positions
const originalFrom = editor.getCursor("from");
const originalTo = editor.getCursor("to");
const content = editor.getValue();
const lines = content.split("\n");
let hasExisting = false;
const newLines = [];
let removedLinesBeforeFrom = 0;
let removedLinesBeforeTo = 0;
let i = 0;
while (i < lines.length) {
if (lines[i].trim() === `\`\`\`${QUICK_COMMAND_CODE_BLOCK}`) {
hasExisting = true;
const blockStartLine = i;
// Skip the opening line
i++;
// Skip until we find the closing ```
while (i < lines.length && lines[i].trim() !== "```") {
i++;
}
// Skip the closing line
i++;
const removedLineCount = i - blockStartLine;
// Calculate how many lines were removed before the selection positions
if (blockStartLine <= originalFrom.line) {
removedLinesBeforeFrom += removedLineCount;
}
if (blockStartLine <= originalTo.line) {
removedLinesBeforeTo += removedLineCount;
}
} else {
newLines.push(lines[i]);
i++;
}
}
// Update editor content and restore selection if we removed existing blocks
if (hasExisting) {
editor.setValue(newLines.join("\n"));
// Calculate new selection positions accounting for removed lines
const newFromLine = Math.max(0, originalFrom.line - removedLinesBeforeFrom);
const newToLine = Math.max(0, originalTo.line - removedLinesBeforeTo);
// Restore the selection
editor.setSelection(
{ line: newFromLine, ch: originalFrom.ch },
{ line: newToLine, ch: originalTo.ch }
);
}
return hasExisting;
}

View file

@ -36,7 +36,11 @@ import { setSelectedTextContexts } from "@/aiParams";
/**
* Add a command to the plugin. Supports async callbacks; errors are logged.
*/
function addCommand(plugin: CopilotPlugin, id: CommandId, callback: () => void | Promise<void>) {
export function addCommand(
plugin: CopilotPlugin,
id: CommandId,
callback: () => void | Promise<void>
) {
plugin.addCommand({
id,
name: COMMAND_NAMES[id],
@ -74,7 +78,7 @@ function addEditorCommand(
/**
* Add a check command to the plugin.
*/
function addCheckCommand(
export function addCheckCommand(
plugin: CopilotPlugin,
id: CommandId,
callback: (checking: boolean) => boolean | void

View file

@ -18,6 +18,16 @@ export function isFileWritePending(filePath: string) {
return pendingFileWritesAtom.has(filePath);
}
export function createCachedCommand(command: CustomCommand): CustomCommand {
const commands = customCommandsStore.get(customCommandsAtom);
const existingCommand = commands.find((c) => c.title === command.title);
if (existingCommand) {
return existingCommand;
}
customCommandsStore.set(customCommandsAtom, [...commands, command]);
return command;
}
export function deleteCachedCommand(title: string) {
const commands = customCommandsStore.get(customCommandsAtom);
customCommandsStore.set(

View file

@ -88,6 +88,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
const [currentChain] = useChainType();
const [currentAiMessage, setCurrentAiMessage] = useState("");
const [inputMessage, setInputMessage] = useState("");
const [latestTokenCount, setLatestTokenCount] = useState<number | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
// Stable ID for streaming message, shared with final persisted message
// This allows collapsible UI state (think blocks) to persist across streaming -> history
@ -103,6 +104,12 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
const messageToAdd = shouldAttachId ? { ...message, id: streamingId } : message;
rawAddMessage(messageToAdd);
if (
messageToAdd.sender === AI_SENDER &&
messageToAdd.responseMetadata?.tokenUsage?.totalTokens
) {
setLatestTokenCount(messageToAdd.responseMetadata.tokenUsage.totalTokens);
}
},
[rawAddMessage]
);
@ -115,12 +122,8 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
const [loading, setLoading] = useState(false);
const [loadingMessage, setLoadingMessage] = useState(LOADING_MESSAGES.DEFAULT);
const [contextNotes, setContextNotes] = useState<TFile[]>([]);
const [includeActiveNote, setIncludeActiveNote] = useState(
settings.autoAddActiveContentToContext === true && currentChain !== ChainType.PROJECT_CHAIN
);
const [includeActiveWebTab, setIncludeActiveWebTab] = useState(
settings.autoAddActiveContentToContext === true && currentChain !== ChainType.PROJECT_CHAIN
);
const [includeActiveNote, setIncludeActiveNote] = useState(false);
const [includeActiveWebTab, setIncludeActiveWebTab] = useState(false);
const [selectedImages, setSelectedImages] = useState<File[]>([]);
const [showChatUI, setShowChatUI] = useState(false);
const [chatHistoryItems, setChatHistoryItems] = useState<ChatHistoryItem[]>([]);
@ -179,11 +182,10 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
return projectContextStatus === "loading" || projectContextStatus === "error";
};
const [prevProjectContextStatus, setPrevProjectContextStatus] = useState(projectContextStatus);
if (prevProjectContextStatus !== projectContextStatus) {
setPrevProjectContextStatus(projectContextStatus);
// Reset user preference when status changes to allow default behavior
useEffect(() => {
setProgressCardVisible(null);
}
}, [projectContextStatus]);
/**
* Whether to show the indexing progress card.
@ -196,22 +198,12 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
return indexingState.isActive || indexingState.completionStatus !== "none";
};
const [prevIndexingActivity, setPrevIndexingActivity] = useState({
isActive: indexingState.isActive,
completionStatus: indexingState.completionStatus,
});
if (
prevIndexingActivity.isActive !== indexingState.isActive ||
prevIndexingActivity.completionStatus !== indexingState.completionStatus
) {
setPrevIndexingActivity({
isActive: indexingState.isActive,
completionStatus: indexingState.completionStatus,
});
// Allow the card to show whenever new indexing activity or completion is detected
useEffect(() => {
if (indexingState.isActive || indexingState.completionStatus !== "none") {
setIndexingCardVisible(null);
}
}
}, [indexingState.isActive, indexingState.completionStatus]);
const handleIndexingCardClose = useCallback(() => {
setIndexingCardVisible(false);
@ -236,12 +228,11 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
await VectorStoreManager.getInstance().cancelIndexing();
}, []);
const latestTokenCount = useMemo(() => {
for (let i = chatHistory.length - 1; i >= 0; i--) {
const m = chatHistory[i];
if (m.sender === AI_SENDER) return m.responseMetadata?.tokenUsage?.totalTokens ?? null;
// Clear token count when chat is cleared or replaced (e.g., loading chat history)
useEffect(() => {
if (chatHistory.length === 0) {
setLatestTokenCount(null);
}
return null;
}, [chatHistory]);
const [previousMode, setPreviousMode] = useState<ChainType | null>(null);
@ -696,6 +687,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
// Additional UI state reset specific to this component
safeSet.setCurrentAiMessage("");
setContextNotes([]);
setLatestTokenCount(null); // Clear token count on new chat
// Capture web selection URL before clearing for suppression
const webSelectionUrl = selectedTextContexts.find((ctx) => ctx.sourceType === "web")?.url;
clearSelectedTextContexts();
@ -804,19 +796,10 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
};
}, [eventTarget, handleStopGenerating]);
const [prevAutoAddTuple, setPrevAutoAddTuple] = useState({
autoAdd: settings.autoAddActiveContentToContext,
chain: selectedChain,
});
if (
prevAutoAddTuple.autoAdd !== settings.autoAddActiveContentToContext ||
prevAutoAddTuple.chain !== selectedChain
) {
setPrevAutoAddTuple({
autoAdd: settings.autoAddActiveContentToContext,
chain: selectedChain,
});
// Use the autoAddActiveContentToContext setting
useEffect(() => {
if (settings.autoAddActiveContentToContext !== undefined) {
// Only apply the setting if not in Project mode
if (selectedChain === ChainType.PROJECT_CHAIN) {
setIncludeActiveNote(false);
setIncludeActiveWebTab(false);
@ -825,7 +808,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
setIncludeActiveWebTab(settings.autoAddActiveContentToContext);
}
}
}
}, [settings.autoAddActiveContentToContext, selectedChain]);
// Note: pendingMessages loading has been removed as ChatManager now handles
// message persistence and loading automatically based on project context

View file

@ -2,14 +2,13 @@ import ChainManager from "@/LLMProviders/chainManager";
import Chat from "@/components/Chat";
import { ChatViewLayout } from "@/components/chat-components/ChatViewLayout";
import { CHAT_VIEWTYPE } from "@/constants";
import { EventTargetContext } from "@/context";
import { AppContext, EventTargetContext } from "@/context";
import CopilotPlugin from "@/main";
import { FileParserManager } from "@/tools/FileParserManager";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import * as Tooltip from "@radix-ui/react-tooltip";
import { ItemView, Platform, WorkspaceLeaf } from "obsidian";
import * as React from "react";
import { Root } from "react-dom/client";
import { createRoot, Root } from "react-dom/client";
export default class CopilotView extends ItemView {
private get chainManager(): ChainManager {
@ -56,7 +55,7 @@ export default class CopilotView extends ItemView {
}
async onOpen(): Promise<void> {
this.root = createPluginRoot(this.containerEl.children[1], this.app);
this.root = createRoot(this.containerEl.children[1]);
const handleSaveAsNote = (saveFunction: () => Promise<void>) => {
this.handleSaveAsNote = saveFunction;
};
@ -78,7 +77,7 @@ export default class CopilotView extends ItemView {
this.windowMigrationDestroy = this.containerEl.onWindowMigrated(() => {
if (!this.root) return;
this.root.unmount();
this.root = createPluginRoot(this.containerEl.children[1], this.app);
this.root = createRoot(this.containerEl.children[1]);
this.renderView(handleSaveAsNote, updateUserMessageHistory);
});
@ -183,18 +182,20 @@ export default class CopilotView extends ItemView {
if (!this.root) return;
this.root.render(
<EventTargetContext.Provider value={this.eventTarget}>
<Tooltip.Provider delayDuration={0}>
<Chat
chainManager={this.chainManager}
updateUserMessageHistory={updateUserMessageHistory}
fileParserManager={this.fileParserManager}
plugin={this.plugin}
onSaveChat={handleSaveAsNote}
chatUIState={this.plugin.chatUIState}
/>
</Tooltip.Provider>
</EventTargetContext.Provider>
<AppContext.Provider value={this.app}>
<EventTargetContext.Provider value={this.eventTarget}>
<Tooltip.Provider delayDuration={0}>
<Chat
chainManager={this.chainManager}
updateUserMessageHistory={updateUserMessageHistory}
fileParserManager={this.fileParserManager}
plugin={this.plugin}
onSaveChat={handleSaveAsNote}
chatUIState={this.plugin.chatUIState}
/>
</Tooltip.Provider>
</EventTargetContext.Provider>
</AppContext.Provider>
);
}

View file

@ -7,7 +7,7 @@ import { type PropsWithChildren, useRef, useState } from "react";
const TOLERANCE = 2;
// detects text-overflow ellipses being used
// ref: https://stackoverflow.com/questions/7738117/html-text-overflow-ellipsis-detection
function isEllipsesActive(
export function isEllipsesActive(
textRef: React.MutableRefObject<HTMLDivElement | null>,
lineClamp?: number
): boolean {

View file

@ -2,7 +2,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { cn } from "@/lib/utils";
import { ReasoningStatus } from "@/LLMProviders/chainRunner/utils/AgentReasoningState";
import { ChevronRight } from "lucide-react";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
/**
* Props for the AgentReasoningBlock component
@ -106,16 +106,15 @@ export const AgentReasoningBlock: React.FC<AgentReasoningBlockProps> = ({
isStreaming,
}) => {
const [isExpanded, setIsExpanded] = useState(status === "reasoning");
const [prevStatus, setPrevStatus] = useState(status);
if (status !== prevStatus) {
setPrevStatus(status);
// Auto-expand when reasoning, auto-collapse when done
useEffect(() => {
if (status === "reasoning") {
setIsExpanded(true);
} else if (status === "collapsed" || status === "complete") {
setIsExpanded(false);
}
}
}, [status]);
// Don't render anything if idle
if (status === "idle") {
@ -176,3 +175,5 @@ export const AgentReasoningBlock: React.FC<AgentReasoningBlockProps> = ({
</Collapsible>
);
};
export default AgentReasoningBlock;

View file

@ -1,4 +1,4 @@
import React, { useCallback, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { TFile, TFolder } from "obsidian";
import { TypeaheadMenuPopover } from "./TypeaheadMenuPopover";
import {
@ -42,8 +42,6 @@ export function AtMentionTypeahead({
}>({
mode: "category",
});
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
const [prevResultsLength, setPrevResultsLength] = useState(0);
const availableCategoryOptions = useAtMentionCategories(isCopilotPlus);
@ -163,8 +161,8 @@ export function AtMentionTypeahead({
[selectedIndex, searchResults, handleSelect, onClose, extendedState.mode, searchQuery]
);
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen);
// Reset state when menu closes
useEffect(() => {
if (!isOpen) {
setSearchQuery("");
setSelectedIndex(0);
@ -173,12 +171,12 @@ export function AtMentionTypeahead({
selectedCategory: undefined,
});
}
}
}, [isOpen]);
if (searchResults.length !== prevResultsLength) {
setPrevResultsLength(searchResults.length);
// Reset selected index when options change
useEffect(() => {
setSelectedIndex(0);
}
}, [searchResults.length]);
if (!isOpen) {
return null;

View file

@ -52,7 +52,7 @@ interface ObsidianAppWithPlugins {
};
}
async function refreshVaultIndex() {
export async function refreshVaultIndex() {
try {
const { getSettings } = await import("@/settings/model");
const settings = getSettings();
@ -78,7 +78,7 @@ async function refreshVaultIndex() {
}
}
async function forceReindexVault() {
export async function forceReindexVault() {
try {
const { getSettings } = await import("@/settings/model");
const settings = getSettings();
@ -146,7 +146,7 @@ export async function reloadCurrentProject(app: App) {
}
}
async function forceRebuildCurrentProjectContext(app: App) {
export async function forceRebuildCurrentProjectContext(app: App) {
const currentProject = getCurrentProject();
if (!currentProject) {
new Notice("No project is currently selected to rebuild.");

View file

@ -1,5 +1,6 @@
import {
getCurrentProject,
ProjectConfig,
subscribeToProjectChange,
useChainType,
useModelKey,
@ -23,14 +24,7 @@ import { isAllowedFileForNoteContext } from "@/utils";
import { getFileIdentityKey } from "@/utils/fileListUtils";
import { CornerDownLeft, Image, Loader2, StopCircle, X } from "lucide-react";
import { App, Notice, TFile, TFolder } from "obsidian";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { $getSelection, $isRangeSelection, LexicalEditor as LexicalEditorType } from "lexical";
import { ContextControl } from "./ContextControl";
import { $removePillsByPath } from "./pills/NotePillNode";
@ -129,6 +123,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
const activeFile = app.workspace.getActiveFile();
return isAllowedFileForNoteContext(activeFile) ? activeFile : null;
});
const [selectedProject, setSelectedProject] = useState<ProjectConfig | null>(null);
const [notesFromPills, setNotesFromPills] = useState<{ path: string; basename: string }[]>([]);
const [urlsFromPills, setUrlsFromPills] = useState<string[]>([]);
const [foldersFromPills, setFoldersFromPills] = useState<string[]>([]);
@ -176,26 +171,32 @@ const ChatInput: React.FC<ChatInputProps> = ({
"If you have many files in context, this can take a while...",
];
// Render-phase reset: re-derive autonomous agent toggle whenever chain or setting changes
const autonomousAgentSourceRef = useRef<{
chain: ChainType;
enabled: boolean;
}>({ chain: currentChain, enabled: settings.enableAutonomousAgent });
if (
autonomousAgentSourceRef.current.chain !== currentChain ||
autonomousAgentSourceRef.current.enabled !== settings.enableAutonomousAgent
) {
autonomousAgentSourceRef.current = {
chain: currentChain,
enabled: settings.enableAutonomousAgent,
};
setAutonomousAgentToggle(
currentChain === ChainType.PROJECT_CHAIN ? false : settings.enableAutonomousAgent
);
}
// Sync autonomous agent toggle with settings and chain type
useEffect(() => {
if (currentChain === ChainType.PROJECT_CHAIN) {
// Force off in Projects mode
setAutonomousAgentToggle(false);
} else {
// In other modes, use the actual settings value
setAutonomousAgentToggle(settings.enableAutonomousAgent);
}
}, [settings.enableAutonomousAgent, currentChain]);
const subscribedProject = useSyncExternalStore(subscribeToProjectChange, getCurrentProject);
const selectedProject = currentChain === ChainType.PROJECT_CHAIN ? subscribedProject : null;
useEffect(() => {
if (currentChain === ChainType.PROJECT_CHAIN) {
setSelectedProject(getCurrentProject());
const unsubscribe = subscribeToProjectChange((project) => {
setSelectedProject(project);
});
return () => {
unsubscribe();
};
} else {
setSelectedProject(null);
}
}, [currentChain]);
useEffect(() => {
if (!isProjectLoading) return;
@ -321,25 +322,19 @@ const ChatInput: React.FC<ChatInputProps> = ({
});
};
// Render-phase reset: mirror tool pill presence into toggle state when inputs change.
// Users can still flip toggles independently via ChatToolControls.
const toolPillSourceRef = useRef<{
tools: string[];
isCopilotPlus: boolean;
autonomousAgentToggle: boolean;
}>({ tools: toolsFromPills, isCopilotPlus, autonomousAgentToggle });
if (
toolPillSourceRef.current.tools !== toolsFromPills ||
toolPillSourceRef.current.isCopilotPlus !== isCopilotPlus ||
toolPillSourceRef.current.autonomousAgentToggle !== autonomousAgentToggle
) {
toolPillSourceRef.current = { tools: toolsFromPills, isCopilotPlus, autonomousAgentToggle };
if (isCopilotPlus && !autonomousAgentToggle) {
setVaultToggle(toolsFromPills.includes("@vault"));
setWebToggle(toolsFromPills.includes("@websearch") || toolsFromPills.includes("@web"));
setComposerToggle(toolsFromPills.includes("@composer"));
}
}
// Sync tool button states with tool pills
useEffect(() => {
if (!isCopilotPlus || autonomousAgentToggle) return;
// Update button states based on current tool pills
const hasVault = toolsFromPills.includes("@vault");
const hasWeb = toolsFromPills.includes("@websearch") || toolsFromPills.includes("@web");
const hasComposer = toolsFromPills.includes("@composer");
setVaultToggle(hasVault);
setWebToggle(hasWeb);
setComposerToggle(hasComposer);
}, [toolsFromPills, isCopilotPlus, autonomousAgentToggle]);
// Handle when context notes are removed from the context menu
// This should remove all corresponding pills from the editor
@ -584,34 +579,43 @@ const ChatInput: React.FC<ChatInputProps> = ({
});
}, [notesFromPills, app.vault, setContextNotes]);
// Pill state is owned by the Lexical editor; absorb new entries into our context arrays
// without removing user-added ones (removal is handled in the dedicated handlers above).
// URL pill-to-context synchronization (when URL pills are added) - only for Plus chains
useEffect(() => {
if (isPlusChain(currentChain)) {
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setContextUrls((prev) => {
const contextUrlSet = new Set(prev);
const newUrlsFromPills = urlsFromPills.filter((pillUrl) => !contextUrlSet.has(pillUrl));
// Find URLs that need to be added
const newUrlsFromPills = urlsFromPills.filter((pillUrl) => {
// Only add if not already in context
return !contextUrlSet.has(pillUrl);
});
// Add completely new URLs from pills
if (newUrlsFromPills.length > 0) {
return Array.from(new Set([...prev, ...newUrlsFromPills]));
}
return prev;
});
} else {
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
// Clear URLs for non-Plus chains
setContextUrls([]);
}
}, [urlsFromPills, currentChain]);
// Pill state is owned by the Lexical editor; absorb new entries into context folders
// without removing user-added ones (removal is handled in handleFolderPillsRemoved).
// Folder-to-context synchronization (when folders are added via pills)
useEffect(() => {
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setContextFolders((prev) => {
const contextFolderPaths = new Set(prev);
const newFoldersFromPills = foldersFromPills.filter(
(pillFolder) => !contextFolderPaths.has(pillFolder)
);
// Find folders that need to be added
const newFoldersFromPills = foldersFromPills.filter((pillFolder) => {
// Only add if not already in context
return !contextFolderPaths.has(pillFolder);
});
// Add completely new folders from pills
return [...prev, ...newFoldersFromPills];
});
}, [foldersFromPills]);

View file

@ -53,7 +53,6 @@ const ChatMessages = memo(
setLoadingDots((dots) => (dots.length < 6 ? dots + "." : ""));
}, 200);
} else {
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setLoadingDots("");
}
return () => window.clearInterval(intervalId);

View file

@ -45,11 +45,6 @@ export function ChatSettingsPopover() {
// Local editing state
const [localModel, setLocalModel] = useState<CustomModel | undefined>(originalModel);
const [prevModelKey, setPrevModelKey] = useState(modelKey);
if (prevModelKey !== modelKey) {
setPrevModelKey(modelKey);
setLocalModel(originalModel);
}
// System prompt state (session-level, in-memory)
const prompts = useSystemPrompts();
@ -76,6 +71,11 @@ export function ChatSettingsPopover() {
const [showConfirmation, setShowConfirmation] = useState(false);
const confirmationRef = useRef<HTMLDivElement>(null);
// Update local state when original model changes (e.g., switching models)
useEffect(() => {
setLocalModel(originalModel);
}, [originalModel]);
// Auto-scroll to confirmation box when it appears
useEffect(() => {
if (showConfirmation && confirmationRef.current) {

View file

@ -37,7 +37,7 @@ import { ChatMessage } from "@/types/message";
import { cleanMessageForCopy, extractYoutubeVideoId, insertIntoEditor } from "@/utils";
import { preprocessAIResponse } from "@/utils/markdownPreprocess";
import { App, Component, MarkdownRenderer, MarkdownView, TFile } from "obsidian";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useSettingsValue } from "@/settings/model";
import {
buildCopilotCollapsibleDomId,
@ -91,7 +91,7 @@ const INLINE_CITATION_RE = /\[(\d+(?:\s*,\s*\d+)*)\]/g;
* to the corresponding source note. Reads the source mapping from the
* rendered .copilot-sources section in the same message.
*/
const linkInlineCitations = (root: HTMLElement): void => {
export const linkInlineCitations = (root: HTMLElement): void => {
// Build citation number -> source anchor mapping from the rendered sources section.
// We store the anchor element (not just the href) so we can copy Obsidian-specific
// attributes like data-href and class="internal-link" onto the inline citation link.
@ -314,24 +314,12 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
}) => {
const [isCopied, setIsCopied] = useState<boolean>(false);
const [isEditing, setIsEditing] = useState<boolean>(false);
const parsedReasoningBlock = useMemo(
() => parseReasoningBlock(message.message),
[message.message]
);
const reasoningData = useMemo<{
// Agent Reasoning Block state
const [reasoningData, setReasoningData] = useState<{
status: "reasoning" | "collapsed" | "complete";
elapsedSeconds: number;
steps: string[];
} | null>(() => {
if (!parsedReasoningBlock?.hasReasoning || parsedReasoningBlock.status === "idle") {
return null;
}
return {
status: parsedReasoningBlock.status,
elapsedSeconds: parsedReasoningBlock.elapsedSeconds,
steps: parsedReasoningBlock.steps,
};
}, [parsedReasoningBlock]);
} | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
const componentRef = useRef<Component | null>(null);
const isUnmountingRef = useRef<boolean>(false);
@ -673,8 +661,20 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
const originMessage = message.message;
// Parse and extract agent reasoning block if present
const reasoningBlockData = parseReasoningBlock(originMessage);
if (reasoningBlockData?.hasReasoning && reasoningBlockData.status !== "idle") {
setReasoningData({
status: reasoningBlockData.status,
elapsedSeconds: reasoningBlockData.elapsedSeconds,
steps: reasoningBlockData.steps,
});
} else {
setReasoningData(null);
}
// Use content after reasoning block (or full message if no reasoning block)
const messageContent = parsedReasoningBlock?.contentAfter ?? originMessage;
const messageContent = reasoningBlockData?.contentAfter ?? originMessage;
const processedMessage = preprocess(messageContent);
const parsedMessage = parseToolCallMarkers(processedMessage, messageId.current);
@ -747,7 +747,6 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
}
const rootRecord = ensureToolCallRoot(
app,
messageId.current,
rootsRef.current,
toolCallId,
@ -782,7 +781,6 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
// Use dedicated error block root to prevent ID collisions with tool calls
const rootRecord = ensureErrorBlockRoot(
app,
messageId.current,
errorRootsRef.current,
errorId,
@ -850,15 +848,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
return () => {
isUnmountingRef.current = true;
};
}, [
message,
app,
componentRef,
isStreaming,
preprocess,
collapsibleOpenStateMap,
parsedReasoningBlock,
]);
}, [message, app, componentRef, isStreaming, preprocess, collapsibleOpenStateMap]);
// Cleanup effect that only runs on component unmount
useEffect(() => {

View file

@ -51,35 +51,33 @@ interface FaviconOrGlobeProps {
className?: string;
}
function FaviconImage({ faviconUrl, className }: { faviconUrl: string; className: string }) {
const [failed, setFailed] = React.useState(false);
if (failed) {
return <Globe className={className} />;
}
return (
<img
src={faviconUrl}
alt=""
referrerPolicy="no-referrer"
loading="lazy"
decoding="async"
className={cn(className, "tw-rounded-sm")}
onError={() => setFailed(true)}
/>
);
}
export function FaviconOrGlobe({
faviconUrl,
isLoaded = true,
className = "tw-size-3",
}: FaviconOrGlobeProps) {
const [showFavicon, setShowFavicon] = React.useState<boolean>(Boolean(faviconUrl));
React.useEffect(() => {
setShowFavicon(Boolean(faviconUrl));
}, [faviconUrl]);
if (!isLoaded) {
return <CircleDashed className={cn(className, "tw-text-muted")} />;
}
if (faviconUrl) {
return <FaviconImage key={faviconUrl} faviconUrl={faviconUrl} className={className} />;
if (showFavicon && faviconUrl) {
return (
<img
src={faviconUrl}
alt=""
referrerPolicy="no-referrer"
loading="lazy"
decoding="async"
className={cn(className, "tw-rounded-sm")}
onError={() => setShowFavicon(false)}
/>
);
}
return <Globe className={className} />;

View file

@ -34,14 +34,7 @@ import {
} from "lucide-react";
import { getCachedProjectRecordById } from "@/projects/state";
import { App, Notice } from "obsidian";
import React, {
memo,
useCallback,
useEffect,
useMemo,
useState,
useSyncExternalStore,
} from "react";
import React, { memo, useEffect, useMemo, useState } from "react";
import { filterProjects } from "@/utils/projectUtils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -53,12 +46,22 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
function useRecentUsageManagerRevision<Key extends string>(
manager: RecentUsageManager<Key> | null | undefined
): number {
const subscribe = useCallback(
(cb: () => void) => manager?.subscribe(cb) ?? (() => {}),
[manager]
);
const getSnapshot = useCallback(() => manager?.getRevision() ?? 0, [manager]);
return useSyncExternalStore(subscribe, getSnapshot);
const [revision, setRevision] = useState(() => manager?.getRevision() ?? 0);
useEffect(() => {
if (!manager) {
setRevision(0);
return;
}
setRevision(manager.getRevision());
return manager.subscribe(() => {
setRevision(manager.getRevision());
});
}, [manager]);
return revision;
}
function ProjectItem({
@ -216,21 +219,15 @@ export const ProjectList = memo(
// Safety net: if the selected project disappears from the list (delete/move/duplicate-id),
// clear local UI selection. Don't call setCurrentProject(null) here — ProjectManager's
// records subscriber handles the atom update with save-first ordering via switchProject(null).
// Consolidated into a single effect so the local resets and the parent showChatUI(false)
// land in the same committed transition. Splitting them caused render-phase setState to
// flip the "missing" flag back to false before any effect observed the transition,
// leaving the chat UI open with no project selected.
useEffect(() => {
if (!selectedProject) return;
const stillExists = projects.some((p) => p.id === selectedProject.id);
if (stillExists) return;
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setSelectedProject(null);
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setShowChatInput(false);
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setIsOpen(true);
showChatUI(false);
if (!stillExists) {
setSelectedProject(null);
setShowChatInput(false);
setIsOpen(true);
showChatUI(false);
}
}, [projects, selectedProject, showChatUI]);
// Get the project usage manager for subscription
@ -238,14 +235,12 @@ export const ProjectList = memo(
plugin?.projectManager?.getProjectUsageTimestampsManager?.();
const projectUsageRevision = useRecentUsageManagerRevision(projectUsageTimestampsManager);
// Auto collapse when messages first appear; user can re-open manually.
const [prevHasMessages, setPrevHasMessages] = useState(hasMessages);
if (!prevHasMessages && hasMessages) {
setPrevHasMessages(true);
setIsOpen(false);
} else if (prevHasMessages && !hasMessages) {
setPrevHasMessages(false);
}
// Auto collapse when messages appear
useEffect(() => {
if (hasMessages) {
setIsOpen(false);
}
}, [hasMessages]);
// Sort projects based on sort strategy
// Note: projectUsageRevision triggers re-sort when in-memory timestamps change,

View file

@ -52,12 +52,11 @@ export function TypeaheadMenuContent({
const selectedItemRef = useRef<HTMLDivElement | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const [hoveredIndex, setHoveredIndex] = React.useState<number | null>(null);
const [prevSelectedIndex, setPrevSelectedIndex] = React.useState(selectedIndex);
if (selectedIndex !== prevSelectedIndex) {
setPrevSelectedIndex(selectedIndex);
// Clear hover state when keyboard navigation changes selection
useEffect(() => {
setHoveredIndex(null);
}
}, [selectedIndex]);
// Scroll the selected item into view when selection changes
useEffect(() => {

View file

@ -92,7 +92,6 @@ export function TypeaheadMenuPortal({
const maxLeft = win.innerWidth - containerWidth - 8;
const left = Math.min(Math.max(rect.left, minLeft), maxLeft);
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setPosition({
top,
left,

View file

@ -1,4 +1,4 @@
const COPILOT_COLLAPSIBLE_DOM_ID_PREFIX = "copilot-collapsible";
export const COPILOT_COLLAPSIBLE_DOM_ID_PREFIX = "copilot-collapsible";
declare global {
interface Window {

View file

@ -22,7 +22,7 @@ export interface CategoryOption extends TypeaheadOption {
icon: React.ReactNode;
}
const CATEGORY_OPTIONS: CategoryOption[] = [
export const CATEGORY_OPTIONS: CategoryOption[] = [
{
key: "notes",
title: "Notes",

View file

@ -173,6 +173,34 @@ export function $isActiveWebTabPillNode(
return node instanceof ActiveWebTabPillNode;
}
/**
* Check whether the editor currently contains an active web tab pill.
* Used to determine if Active Web Tab should be included at send time,
* avoiding async pill-sync race conditions.
* @returns True if at least one ActiveWebTabPillNode exists in the editor
*/
export function $hasActiveWebTabPill(): boolean {
const root = $getRoot();
function traverse(node: LexicalNode): boolean {
if ($isActiveWebTabPillNode(node)) {
return true;
}
if ("getChildren" in node && typeof node.getChildren === "function") {
const children = (node as { getChildren: () => LexicalNode[] }).getChildren();
for (const child of children) {
if (traverse(child)) {
return true;
}
}
}
return false;
}
return traverse(root);
}
/**
* Removes all active web tab pills from the editor.
* @returns The number of pills removed

View file

@ -125,7 +125,7 @@ export function $isFolderPillNode(node: LexicalNode): node is FolderPillNode {
return node instanceof FolderPillNode;
}
function $findFolderPills(): FolderPillNode[] {
export function $findFolderPills(): FolderPillNode[] {
const root = $getRoot();
const pills: FolderPillNode[] = [];

View file

@ -165,6 +165,27 @@ export function $isNotePillNode(node: LexicalNode | null | undefined): node is N
return node instanceof NotePillNode;
}
export function $findNotePills(): NotePillNode[] {
const root = $getRoot();
const pills: NotePillNode[] = [];
function traverse(node: LexicalNode) {
if (node instanceof NotePillNode) {
pills.push(node);
}
if ("getChildren" in node && typeof node.getChildren === "function") {
const children = node.getChildren() as LexicalNode[];
for (const child of children) {
traverse(child);
}
}
}
traverse(root);
return pills;
}
export function $removePillsByPath(notePath: string): number {
const root = $getRoot();
let removedCount = 0;

View file

@ -79,7 +79,7 @@ export function $isToolPillNode(node: LexicalNode): node is ToolPillNode {
return node instanceof ToolPillNode;
}
function $findToolPills(): ToolPillNode[] {
export function $findToolPills(): ToolPillNode[] {
const root = $getRoot();
const pills: ToolPillNode[] = [];

View file

@ -157,7 +157,7 @@ export function $createURLPillNode(url: string, title?: string, isActive = false
return new URLPillNode(url, title, isActive);
}
function $findURLPills(): URLPillNode[] {
export function $findURLPills(): URLPillNode[] {
const root = $getRoot();
const pills: URLPillNode[] = [];

View file

@ -208,6 +208,32 @@ export function $findWebTabPills(): WebTabPillNode[] {
return pills;
}
/**
* Check if a WebTabPillNode with the given URL exists in the editor.
* Must be called within a Lexical read/update context.
* Uses early-exit traversal for better performance.
*/
export function $hasWebTabPillWithUrl(url: string): boolean {
const root = $getRoot();
function traverse(node: LexicalNode): boolean {
if (node instanceof WebTabPillNode && node.getURL() === url) {
return true; // Early exit on match
}
if ("getChildren" in node && typeof node.getChildren === "function") {
const children = (node as { getChildren: () => LexicalNode[] }).getChildren();
for (const child of children) {
if (traverse(child)) {
return true; // Propagate early exit
}
}
}
return false;
}
return traverse(root);
}
/** Remove WebTabPillNodes by URL. */
export function $removeWebTabPillsByUrl(url: string): void {
const pills = $findWebTabPills();

View file

@ -39,11 +39,7 @@ export function AtMentionCommandPlugin({
const availableCategoryOptions = useAtMentionCategories(isCopilotPlus);
// Load note content for preview using shared utilities
const loadNoteContentForPreview = useCallback(async (file: TFile | null) => {
if (!file) {
setCurrentPreviewContent("");
return;
}
const loadNoteContentForPreview = useCallback(async (file: TFile) => {
try {
// Handle PDF and canvas files - treat as empty content (no preview)
if (file.extension === "pdf" || file.extension === "canvas") {
@ -152,8 +148,8 @@ export function AtMentionCommandPlugin({
onStateChange: onStateChangeCallback,
});
// Compute the currently selected file for preview, or null if not a note
const selectedFile = useMemo<TFile | null>(() => {
// Load preview content when selection changes
useEffect(() => {
const selectedOption = searchResults[state.selectedIndex];
if (
selectedOption &&
@ -161,15 +157,11 @@ export function AtMentionCommandPlugin({
selectedOption.category === "notes" &&
selectedOption.data instanceof TFile
) {
return selectedOption.data;
void loadNoteContentForPreview(selectedOption.data);
} else {
setCurrentPreviewContent("");
}
return null;
}, [state.selectedIndex, searchResults, isAtMentionOption]);
// Load preview content when selected file changes
useEffect(() => {
void loadNoteContentForPreview(selectedFile);
}, [selectedFile, loadNoteContentForPreview]);
}, [state.selectedIndex, searchResults, isAtMentionOption, loadNoteContentForPreview]);
// Create display options with preview content for the highlighted note
const displayOptions = useMemo(() => {

View file

@ -1,12 +1,10 @@
import React from "react";
import { Root } from "react-dom/client";
import { createRoot, Root } from "react-dom/client";
import { ErrorBlock } from "@/components/chat-components/ErrorBlock";
import { ToolCallBanner } from "@/components/chat-components/ToolCallBanner";
import type { ErrorMarker, ToolCallMarker } from "@/LLMProviders/chainRunner/utils/toolCallParser";
import { logWarn } from "@/logger";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import type { App } from "obsidian";
declare global {
interface Window {
@ -158,7 +156,6 @@ const scheduleToolCallRootDisposal = (
* Ensure a React root exists for the provided tool call container and return the root record.
*/
export const ensureToolCallRoot = (
app: App,
messageId: string,
messageRoots: Map<string, ToolCallRootRecord>,
toolCallId: string,
@ -197,7 +194,7 @@ export const ensureToolCallRoot = (
if (!record) {
record = {
root: createPluginRoot(container, app),
root: createRoot(container),
isUnmounting: false,
container,
};
@ -213,7 +210,6 @@ export const ensureToolCallRoot = (
* Uses a separate registry from tool calls to prevent ID collisions and race conditions.
*/
export const ensureErrorBlockRoot = (
app: App,
messageId: string,
messageRoots: Map<string, ToolCallRootRecord>,
errorId: string,
@ -251,7 +247,7 @@ export const ensureErrorBlockRoot = (
if (!record) {
record = {
root: createPluginRoot(container, app),
root: createRoot(container),
isUnmounting: false,
container,
};

View file

@ -49,7 +49,7 @@ export interface PillData {
/**
* Generic function to create pill nodes based on type and data
*/
function $createPillNode(pillData: PillData) {
export function $createPillNode(pillData: PillData) {
const { type, title, data } = pillData;
switch (type) {

View file

@ -11,7 +11,10 @@ declare const app: App;
* @param maxLength - Maximum length for truncated content (default: 500)
* @returns Promise resolving to processed content string
*/
async function loadNoteContentForPreview(file: TFile, maxLength: number = 500): Promise<string> {
export async function loadNoteContentForPreview(
file: TFile,
maxLength: number = 500
): Promise<string> {
try {
// Handle PDF and canvas files - treat as empty content (no preview)
if (file.extension === "pdf" || file.extension === "canvas") {

View file

@ -0,0 +1,75 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { Copy, ClipboardCopy } from "lucide-react";
import { Button } from "@/components/ui/button";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
}
interface ChatMessageProps {
message: Message;
isStreaming?: boolean;
onCopy?: (message: Message) => void;
onInsert?: (message: Message) => void;
}
/**
* Individual chat message bubble for Quick Ask modal.
* User messages align right, assistant messages align left with action buttons.
*/
export function ChatMessage({ message, isStreaming, onCopy, onInsert }: ChatMessageProps) {
const isUser = message.role === "user";
return (
<div
className={cn(
"tw-group tw-flex tw-flex-col tw-gap-1",
isUser ? "tw-items-end" : "tw-items-start"
)}
>
{/* Message bubble */}
<div
className={cn(
"tw-max-w-[80%] tw-rounded-lg tw-px-3 tw-py-2 tw-text-sm",
"tw-whitespace-pre-wrap tw-leading-relaxed",
isUser ? "tw-bg-interactive-accent tw-text-on-accent" : "tw-bg-secondary tw-text-normal"
)}
>
{message.content}
{/* Streaming cursor for assistant messages */}
{!isUser && isStreaming && (
<span className="tw-ml-0.5 tw-inline-block tw-h-4 tw-w-1.5 tw-animate-pulse tw-rounded-sm tw-bg-interactive-accent tw-align-middle" />
)}
</div>
{/* Action buttons for assistant messages */}
{!isUser && !isStreaming && (
<div className="tw-flex tw-items-center tw-gap-1 tw-opacity-0 tw-transition-opacity group-hover:tw-opacity-100">
<Button
variant="ghost2"
size="icon"
className="tw-size-5 hover:tw-bg-modifier-hover"
onClick={() => onCopy?.(message)}
aria-label="Copy message"
>
<Copy className="tw-size-3" />
</Button>
<Button
variant="ghost2"
size="icon"
className="tw-size-5 hover:tw-bg-modifier-hover"
onClick={() => onInsert?.(message)}
aria-label="Insert at cursor"
>
<ClipboardCopy className="tw-size-3" />
</Button>
</div>
)}
</div>
);
}
export type { Message };

View file

@ -62,13 +62,11 @@ export function ContentArea({
// This covers both type transitions (idle→loading) and same-type transitions
// (result→result with isStreaming flipping true for follow-up generation).
const isGenerating = state.type === "loading" || (state.type === "result" && state.isStreaming);
const [prevIsGenerating, setPrevIsGenerating] = React.useState(isGenerating);
if (isGenerating && !prevIsGenerating) {
setPrevIsGenerating(true);
setIsEditMode(false);
} else if (!isGenerating && prevIsGenerating) {
setPrevIsGenerating(false);
}
React.useEffect(() => {
if (isGenerating) {
setIsEditMode(false);
}
}, [isGenerating]);
// Auto-scroll to bottom when streaming
React.useEffect(() => {

View file

@ -111,28 +111,15 @@ export function DraggableModal({
[rawHandleMouseDown]
);
// Resize state (height and width)
const [heightPx, setHeightPx] = useState<number | null>(null);
const [widthPx, setWidthPx] = useState<number | null>(null);
// Reason: Reset transient state when the modal reopens so that stale
// drag/resize/anchor state from a previous session does not leak.
// Render-phase prev-open tracker resets the size state on false→true transition;
// ref resets and the initialPosition setter live in an effect since refs aren't
// subject to the no-direct-set-state rule and setPosition belongs to a child hook.
const [prevOpen, setPrevOpen] = useState(open);
if (open && !prevOpen) {
setPrevOpen(true);
setHeightPx(null);
setWidthPx(null);
} else if (!open && prevOpen) {
setPrevOpen(false);
}
useEffect(() => {
if (!open) return;
pendingDragCleanupRef.current?.();
pendingDragCleanupRef.current = null;
isManualPositionRef.current = false;
setHeightPx(null);
setWidthPx(null);
if (initialPosition) {
setPosition(initialPosition);
}
@ -146,6 +133,10 @@ export function DraggableModal({
};
}, []);
// Resize state (height and width)
const [heightPx, setHeightPx] = useState<number | null>(null);
const [widthPx, setWidthPx] = useState<number | null>(null);
// When resizable, lock an initial height so streaming/content won't "push" the modal taller.
useLayoutEffect(() => {
if (!open || !resizable) return;
@ -165,10 +156,13 @@ export function DraggableModal({
}
}, [open, resizable, heightPx, widthPx, minHeight, dragRef]);
// Auto-expand height when minHeight increases (e.g., ContentArea becomes visible).
// Derived: effectiveHeight clamps the user's explicit resize to the current minHeight
// without writing back into heightPx state.
const effectiveHeight = heightPx === null ? null : Math.max(heightPx, minHeight);
// Auto-expand height when minHeight increases (e.g., ContentArea becomes visible)
useEffect(() => {
if (!resizable || heightPx === null) return;
if (heightPx < minHeight) {
setHeightPx(minHeight);
}
}, [resizable, minHeight, heightPx]);
// Reason: For "above" placement, keep the panel's bottom edge anchored.
// When height increases (e.g., ContentArea appears), shift position.y up so the
@ -176,14 +170,14 @@ export function DraggableModal({
// Uses useLayoutEffect to apply before paint, preventing visual flash.
useLayoutEffect(() => {
if (anchorBottom === undefined || isManualPositionRef.current) return;
if (effectiveHeight === null) return;
if (heightPx === null) return;
const newY = Math.max(12, anchorBottom - effectiveHeight);
const newY = Math.max(12, anchorBottom - heightPx);
// Guard: only update if position actually changed (avoid infinite re-render)
if (Math.abs(position.y - newY) < 1) return;
setPosition({ x: position.x, y: newY });
}, [anchorBottom, effectiveHeight, position.x, position.y, setPosition]);
}, [anchorBottom, heightPx, position.x, position.y, setPosition]);
// Reason: Generic overflow correction for non-anchored panels.
// If content/minHeight growth pushes the modal below the viewport edge,
@ -193,15 +187,15 @@ export function DraggableModal({
// height changes.
useLayoutEffect(() => {
if (anchorBottom !== undefined || isManualPositionRef.current) return;
if (effectiveHeight === null) return;
if (heightPx === null) return;
const ownerWindow = dragRef.current?.win ?? window;
const maxY = ownerWindow.innerHeight - 12 - effectiveHeight;
const maxY = ownerWindow.innerHeight - 12 - heightPx;
const newY = Math.max(12, Math.min(position.y, maxY));
if (Math.abs(position.y - newY) < 1) return;
setPosition({ x: position.x, y: newY });
}, [anchorBottom, effectiveHeight, position.x, position.y, setPosition, dragRef]);
}, [anchorBottom, heightPx, position.x, position.y, setPosition, dragRef]);
const getResizeRect = useCallback(() => {
return dragRef.current?.getBoundingClientRect() ?? null;
@ -311,7 +305,7 @@ export function DraggableModal({
)}
style={{
width: resizable && widthPx !== null ? widthPx : width,
...(resizable && effectiveHeight !== null ? { height: effectiveHeight } : {}),
...(resizable && heightPx !== null ? { height: heightPx } : {}),
}}
>
{/* Header: drag handle + close button (flex-none) */}

View file

@ -1,2 +1,11 @@
export { type ContentState } from "./content-area";
export { DragHandle } from "./drag-handle";
export { CloseButton } from "./close-button";
export { CommandLabel } from "./command-label";
export { ContentArea, type ContentState } from "./content-area";
export { FollowUpInput } from "./follow-up-input";
export { ActionButtons } from "./action-buttons";
export { DraggableModal } from "./draggable-modal";
export { ChatMessage, type Message } from "./chat-message";
// Composed modals
export { MenuCommandModal } from "./menu-command-modal";

View file

@ -1,11 +1,11 @@
import { cn } from "@/lib/utils";
import { logError } from "@/logger";
import { getSettings, updateSetting } from "@/settings/model";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { Change, diffArrays } from "diff";
import { Check, X as XIcon } from "lucide-react";
import { App, ItemView, Notice, TFile, WorkspaceLeaf } from "obsidian";
import React, { memo, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { Button } from "../ui/button";
import { SettingSwitch } from "../ui/setting-switch";
import { getChangeBlocks } from "@/composerUtils";
@ -208,7 +208,7 @@ interface ExtendedChange extends Change {
}
export class ApplyView extends ItemView {
private root: ReturnType<typeof createPluginRoot> | null = null;
private root: ReturnType<typeof createRoot> | null = null;
private state: ApplyViewState | null = null;
private result: ApplyViewResult | null = null;
@ -252,7 +252,7 @@ export class ApplyView extends ItemView {
const rootEl = contentEl.createDiv();
if (!this.root) {
this.root = createPluginRoot(rootEl, this.app);
this.root = createRoot(rootEl);
}
// Pass a close function that takes a result

View file

@ -1,9 +1,8 @@
import { Button } from "@/components/ui/button";
import { logError } from "@/logger";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { App, Modal } from "obsidian";
import React from "react";
import { Root } from "react-dom/client";
import { createRoot, Root } from "react-dom/client";
function ConfirmModalContent({
content,
@ -58,7 +57,7 @@ export class ConfirmModal extends Modal {
onOpen() {
const { contentEl } = this;
this.root = createPluginRoot(contentEl, this.app);
this.root = createRoot(contentEl);
const handleConfirm = () => {
this.confirmed = true;

View file

@ -1,12 +1,12 @@
import React from "react";
import { App, Modal } from "obsidian";
import { createRoot } from "react-dom/client";
import { Root } from "react-dom/client";
import { Button } from "@/components/ui/button";
import { isPlusModel, navigateToPlusPage } from "@/plusUtils";
import { PLUS_UTM_MEDIUMS } from "@/constants";
import { ExternalLink } from "lucide-react";
import { getSettings } from "@/settings/model";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
function CopilotPlusExpiredModalContent({ onCancel }: { onCancel: () => void }) {
const settings = getSettings();
@ -56,7 +56,7 @@ export class CopilotPlusExpiredModal extends Modal {
onOpen() {
const { contentEl } = this;
this.root = createPluginRoot(contentEl, this.app);
this.root = createRoot(contentEl);
const handleCancel = () => {
this.close();

View file

@ -1,8 +1,8 @@
import React from "react";
import { App, Modal } from "obsidian";
import { createRoot } from "react-dom/client";
import { Root } from "react-dom/client";
import { Button } from "@/components/ui/button";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import {
DEFAULT_COPILOT_PLUS_CHAT_MODEL,
DEFAULT_COPILOT_PLUS_EMBEDDING_MODEL,
@ -77,7 +77,7 @@ export class CopilotPlusWelcomeModal extends Modal {
onOpen() {
const { contentEl } = this;
this.root = createPluginRoot(contentEl, this.app);
this.root = createRoot(contentEl);
const handleConfirm = () => {
applyPlusSettings();

View file

@ -1,9 +1,8 @@
import { App, Modal } from "obsidian";
import React, { useState } from "react";
import { Root } from "react-dom/client";
import { createRoot, Root } from "react-dom/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
function CustomPatternInputModalContent({
onConfirm,
@ -62,7 +61,7 @@ export class CustomPatternInputModal extends Modal {
onOpen() {
const { contentEl } = this;
this.root = createPluginRoot(contentEl, this.app);
this.root = createRoot(contentEl);
const handleConfirm = (extension: string) => {
this.onConfirm(extension);

View file

@ -1,9 +1,8 @@
import { App, Modal } from "obsidian";
import React, { useState } from "react";
import { Root } from "react-dom/client";
import { createRoot, Root } from "react-dom/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
function ExtensionInputModalContent({
onConfirm,
@ -73,7 +72,7 @@ export class ExtensionInputModal extends Modal {
onOpen() {
const { contentEl } = this;
this.root = createPluginRoot(contentEl, this.app);
this.root = createRoot(contentEl);
const handleConfirm = (extension: string) => {
this.onConfirm(extension);

View file

@ -0,0 +1,38 @@
import { App, FuzzySuggestModal } from "obsidian";
export class ListPromptModal extends FuzzySuggestModal<string> {
private onChoosePromptTitle: (promptTitle: string) => void;
private promptTitles: string[];
private descriptions: string[];
constructor(
app: App,
promptTitles: string[],
onChoosePromptTitle: (promptTitle: string) => void,
descriptions: string[] = []
) {
super(app);
this.promptTitles = promptTitles;
this.onChoosePromptTitle = onChoosePromptTitle;
this.descriptions = descriptions;
}
getItems(): string[] {
return this.promptTitles;
}
getItemText(promptTitle: string): string {
const index = this.promptTitles.indexOf(promptTitle);
const description = this.descriptions[index];
return description ? `${promptTitle} (${description})` : promptTitle;
}
onChooseItem(promptTitle: string, evt: MouseEvent | KeyboardEvent) {
// Find the original title by matching against the promptTitles array
const index = this.promptTitles.findIndex(
(title) => promptTitle.startsWith(title + " (") || promptTitle === title
);
const actualTitle = index >= 0 ? this.promptTitles[index] : promptTitle;
this.onChoosePromptTitle(actualTitle);
}
}

View file

@ -28,11 +28,10 @@
*/
import { Button } from "@/components/ui/button";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { Info, ShieldCheck, Smartphone } from "lucide-react";
import { App, Modal } from "obsidian";
import React, { useState } from "react";
import { Root } from "react-dom/client";
import { createRoot, Root } from "react-dom/client";
interface MigrateConfirmContentProps {
onConfirm: () => void;
@ -54,8 +53,8 @@ function MigrateConfirmContent({ onConfirm, onCancel }: MigrateConfirmContentPro
</div>
<p className="tw-m-0 tw-text-muted">
Move your API keys from <code className={"tw-text-muted tw-bg-muted/10"}>data.json</code> to
this device&apos;s{" "}
Move your API keys from <code className={"tw-text-muted tw-bg-muted/10"}>data.json</code>{" "}
to this device&apos;s{" "}
<code className={"tw-text-accent tw-bg-muted/10"}>Obsidian Keychain</code>.{" "}
<code>data.json</code> will be stripped of API keys after migration.
</p>
@ -132,7 +131,7 @@ export class MigrateConfirmModal extends Modal {
onOpen() {
const { contentEl } = this;
this.root = createPluginRoot(contentEl, this.app);
this.root = createRoot(contentEl);
const handleConfirm = () => {
this.confirmed = true;

View file

@ -0,0 +1,13 @@
import { App } from "obsidian";
import { ConfirmModal } from "./ConfirmModal";
export class NewChatConfirmModal extends ConfirmModal {
constructor(app: App, onConfirm: () => void) {
super(
app,
onConfirm,
"Starting a new chat will clear the current chat history. Any unsaved messages will be lost. Are you sure you want to continue?",
"Start New Chat"
);
}
}

View file

@ -0,0 +1,298 @@
import { App, Modal } from "obsidian";
import { Button } from "@/components/ui/button";
import { AppContext, useApp } from "@/context";
import React, { useState } from "react";
import { createRoot, Root } from "react-dom/client";
import {
categorizePatterns,
createPatternSettingsValue,
getDecodedPatterns,
getExtensionPattern,
getFilePattern,
getTagPattern,
} from "@/search/searchUtils";
import { File, FileText, Folder, Tag, Wrench, X } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { TagSearchModal } from "@/components/modals/TagSearchModal";
import { AddContextNoteModal } from "@/components/modals/AddContextNoteModal";
import { FolderSearchModal } from "@/components/modals/FolderSearchModal";
import { ExtensionInputModal } from "@/components/modals/ExtensionInputModal";
import { CustomPatternInputModal } from "@/components/modals/CustomPatternInputModal";
import { TruncatedText } from "@/components/TruncatedText";
function PatternListGroup({
title,
patterns,
onRemove,
}: {
title: string;
patterns: string[];
onRemove: (pattern: string) => void;
}) {
return (
<div className="tw-grid tw-grid-cols-4 tw-gap-2">
<div className="tw-font-bold">{title}</div>
<ul className="tw-col-span-3 tw-m-0 tw-flex tw-list-inside tw-list-disc tw-flex-col tw-gap-1 tw-pl-0">
{patterns.map((pattern) => (
<li
key={pattern}
className="tw-flex tw-gap-2 tw-rounded-md tw-pl-2 tw-pr-1 hover:tw-bg-modifier-hover"
>
<TruncatedText className="tw-flex-1">{pattern}</TruncatedText>
<Button variant="ghost2" size="fit" onClick={() => onRemove(pattern)}>
<X className="tw-size-4" />
</Button>
</li>
))}
</ul>
</div>
);
}
function PatternMatchingModalContent({
value: initialValue,
onUpdate,
container,
}: {
value: string;
onUpdate: (value: string) => void;
container: HTMLElement;
}) {
const app = useApp();
const [value, setValue] = useState(initialValue);
const patterns = getDecodedPatterns(value);
const { tagPatterns, extensionPatterns, folderPatterns, notePatterns } =
categorizePatterns(patterns);
const updateCategories = (newCategories: {
tagPatterns?: string[];
extensionPatterns?: string[];
folderPatterns?: string[];
notePatterns?: string[];
}) => {
const newValue = createPatternSettingsValue({
tagPatterns: newCategories.tagPatterns ?? tagPatterns,
extensionPatterns: newCategories.extensionPatterns ?? extensionPatterns,
folderPatterns: newCategories.folderPatterns ?? folderPatterns,
notePatterns: newCategories.notePatterns ?? notePatterns,
});
setValue(newValue);
onUpdate(newValue);
};
const hasValue =
tagPatterns.length > 0 ||
extensionPatterns.length > 0 ||
folderPatterns.length > 0 ||
notePatterns.length > 0;
return (
<div className="tw-mt-2 tw-flex tw-flex-col tw-gap-4">
<div className="tw-flex tw-max-h-[400px] tw-flex-col tw-gap-2 tw-overflow-y-auto tw-rounded-md tw-border tw-border-solid tw-border-border tw-p-4">
{!hasValue && <div className="tw-text-center tw-text-sm">No patterns specified</div>}
{tagPatterns.length > 0 && (
<PatternListGroup
title="Tags"
patterns={tagPatterns}
onRemove={(pattern) => {
const newPatterns = tagPatterns.filter((p) => p !== pattern);
updateCategories({
tagPatterns: newPatterns,
});
}}
/>
)}
{extensionPatterns.length > 0 && (
<PatternListGroup
title="Extensions"
patterns={extensionPatterns}
onRemove={(pattern) => {
const newPatterns = extensionPatterns.filter((p) => p !== pattern);
updateCategories({
extensionPatterns: newPatterns,
});
}}
/>
)}
{folderPatterns.length > 0 && (
<PatternListGroup
title="Folders"
patterns={folderPatterns}
onRemove={(pattern) => {
const newPatterns = folderPatterns.filter((p) => p !== pattern);
updateCategories({
folderPatterns: newPatterns,
});
}}
/>
)}
{notePatterns.length > 0 && (
<PatternListGroup
title="Notes"
patterns={notePatterns}
onRemove={(pattern) => {
const newPatterns = notePatterns.filter((p) => p !== pattern);
updateCategories({
notePatterns: newPatterns,
});
}}
/>
)}
</div>
<div className="tw-flex tw-justify-end tw-gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary">Add...</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" container={container}>
<DropdownMenuItem
onSelect={() => {
new TagSearchModal(app, (tag) => {
const tagPattern = getTagPattern(tag);
if (tagPatterns.includes(tagPattern)) {
return;
}
updateCategories({
tagPatterns: [...tagPatterns, tagPattern],
});
}).open();
}}
>
<div className="tw-flex tw-items-center tw-gap-2">
<Tag className="tw-size-4" />
Tag
</div>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
new FolderSearchModal(app, (folder) => {
if (folderPatterns.includes(folder)) {
return;
}
updateCategories({
folderPatterns: [...folderPatterns, folder],
});
}).open();
}}
>
<div className="tw-flex tw-items-center tw-gap-2">
<Folder className="tw-size-4" />
Folder
</div>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
new AddContextNoteModal({
app,
onNoteSelect: (note) => {
const notePattern = getFilePattern(note);
if (notePatterns.includes(notePattern)) {
return;
}
updateCategories({
notePatterns: [...notePatterns, notePattern],
});
},
excludeNotePaths: [],
titleOnly: true,
}).open();
}}
>
<div className="tw-flex tw-items-center tw-gap-2">
<FileText className="tw-size-4" />
Note
</div>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
new ExtensionInputModal(app, (extension) => {
const extensionPattern = getExtensionPattern(extension);
if (extensionPatterns.includes(extensionPattern)) {
return;
}
updateCategories({
extensionPatterns: [...extensionPatterns, extensionPattern],
});
}).open();
}}
>
<div className="tw-flex tw-items-center tw-gap-2">
<File className="tw-size-4" />
Extension
</div>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
new CustomPatternInputModal(app, (value) => {
const patterns = getDecodedPatterns(value);
const {
tagPatterns: newTagPatterns,
extensionPatterns: newExtensionPatterns,
folderPatterns: newFolderPatterns,
notePatterns: newNotePatterns,
} = categorizePatterns(patterns);
updateCategories({
tagPatterns: [...tagPatterns, ...newTagPatterns],
extensionPatterns: [...extensionPatterns, ...newExtensionPatterns],
folderPatterns: [...folderPatterns, ...newFolderPatterns],
notePatterns: [...notePatterns, ...newNotePatterns],
});
}).open();
}}
>
<div className="tw-flex tw-items-center tw-gap-2">
<Wrench className="tw-size-4" />
Custom
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
export class PatternMatchingModal extends Modal {
private root: Root;
constructor(
app: App,
private onUpdate: (value: string) => void,
/** The raw pattern matching value, separated by commas */
private value: string,
title: string
) {
super(app);
// https://docs.obsidian.md/Reference/TypeScript+API/Modal/setTitle
// @ts-ignore
this.setTitle(title);
}
onOpen() {
const { contentEl } = this;
this.root = createRoot(contentEl);
const handleUpdate = (value: string) => {
this.onUpdate(value);
};
this.root.render(
<AppContext.Provider value={this.app}>
<PatternMatchingModalContent
value={this.value}
onUpdate={handleUpdate}
container={this.contentEl}
/>
</AppContext.Provider>
);
}
onClose() {
this.root.unmount();
}
}

View file

@ -0,0 +1,297 @@
import { TruncatedText } from "@/components/TruncatedText";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
categorizePatterns,
createPatternSettingsValue,
getDecodedPatterns,
getExtensionPattern,
getFilePattern,
getTagPattern,
} from "@/search/searchUtils";
import { File, FileText, Folder, Tag, Wrench, X } from "lucide-react";
import { App, Modal, TFile } from "obsidian";
import React, { useState } from "react";
import { createRoot, Root } from "react-dom/client";
import { AppContext, useApp } from "@/context";
import { CustomPatternInputModal } from "./CustomPatternInputModal";
import { ExtensionInputModal } from "./ExtensionInputModal";
import { FolderSearchModal } from "./FolderSearchModal";
import { ProjectFileSelectModal } from "./ProjectFileSelectModal";
import { TagSearchModal } from "./TagSearchModal";
function PatternListGroup({
title,
patterns,
onRemove,
}: {
title: string;
patterns: string[];
onRemove: (pattern: string) => void;
}) {
return (
<div className="tw-grid tw-grid-cols-4 tw-gap-2">
<div className="tw-font-bold">{title}</div>
<ul className="tw-col-span-3 tw-m-0 tw-flex tw-list-inside tw-list-disc tw-flex-col tw-gap-1 tw-pl-0">
{patterns.map((pattern) => (
<li
key={pattern}
className="tw-flex tw-gap-2 tw-rounded-md tw-pl-2 tw-pr-1 hover:tw-bg-modifier-hover"
>
<TruncatedText className="tw-flex-1">{pattern}</TruncatedText>
<Button variant="ghost2" size="fit" onClick={() => onRemove(pattern)}>
<X className="tw-size-4" />
</Button>
</li>
))}
</ul>
</div>
);
}
function ProjectPatternMatchingModalContent({
value: initialValue,
onUpdate,
container,
}: {
value: string;
onUpdate: (value: string) => void;
container: HTMLElement;
}) {
const app = useApp();
const [value, setValue] = useState(initialValue);
const patterns = getDecodedPatterns(value);
const { tagPatterns, extensionPatterns, folderPatterns, notePatterns } =
categorizePatterns(patterns);
const updateCategories = (newCategories: {
tagPatterns?: string[];
extensionPatterns?: string[];
folderPatterns?: string[];
notePatterns?: string[];
}) => {
const newValue = createPatternSettingsValue({
tagPatterns: newCategories.tagPatterns ?? tagPatterns,
extensionPatterns: newCategories.extensionPatterns ?? extensionPatterns,
folderPatterns: newCategories.folderPatterns ?? folderPatterns,
notePatterns: newCategories.notePatterns ?? notePatterns,
});
setValue(newValue);
onUpdate(newValue);
};
const hasValue =
tagPatterns.length > 0 ||
extensionPatterns.length > 0 ||
folderPatterns.length > 0 ||
notePatterns.length > 0;
return (
<div className="tw-mt-2 tw-flex tw-flex-col tw-gap-4">
<div className="tw-flex tw-max-h-[400px] tw-flex-col tw-gap-2 tw-overflow-y-auto tw-rounded-md tw-border tw-border-solid tw-border-border tw-p-4">
{!hasValue && <div className="tw-text-center tw-text-sm">No patterns specified</div>}
{tagPatterns.length > 0 && (
<PatternListGroup
title="Tags"
patterns={tagPatterns}
onRemove={(pattern) => {
const newPatterns = tagPatterns.filter((p) => p !== pattern);
updateCategories({
tagPatterns: newPatterns,
});
}}
/>
)}
{extensionPatterns.length > 0 && (
<PatternListGroup
title="Extensions"
patterns={extensionPatterns}
onRemove={(pattern) => {
const newPatterns = extensionPatterns.filter((p) => p !== pattern);
updateCategories({
extensionPatterns: newPatterns,
});
}}
/>
)}
{folderPatterns.length > 0 && (
<PatternListGroup
title="Folders"
patterns={folderPatterns}
onRemove={(pattern) => {
const newPatterns = folderPatterns.filter((p) => p !== pattern);
updateCategories({
folderPatterns: newPatterns,
});
}}
/>
)}
{notePatterns.length > 0 && (
<PatternListGroup
title="Files"
patterns={notePatterns}
onRemove={(pattern) => {
const newPatterns = notePatterns.filter((p) => p !== pattern);
updateCategories({
notePatterns: newPatterns,
});
}}
/>
)}
</div>
<div className="tw-flex tw-justify-end tw-gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary">Add...</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" container={container}>
<DropdownMenuItem
onSelect={() => {
new TagSearchModal(app, (tag) => {
const tagPattern = getTagPattern(tag);
if (tagPatterns.includes(tagPattern)) {
return;
}
updateCategories({
tagPatterns: [...tagPatterns, tagPattern],
});
}).open();
}}
>
<div className="tw-flex tw-items-center tw-gap-2">
<Tag className="tw-size-4" />
Tag
</div>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
new FolderSearchModal(app, (folder) => {
if (folderPatterns.includes(folder)) {
return;
}
updateCategories({
folderPatterns: [...folderPatterns, folder],
});
}).open();
}}
>
<div className="tw-flex tw-items-center tw-gap-2">
<Folder className="tw-size-4" />
Folder
</div>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
new ProjectFileSelectModal({
app,
onFileSelect: (file: TFile) => {
const filePattern = getFilePattern(file);
if (notePatterns.includes(filePattern)) {
return;
}
updateCategories({
notePatterns: [...notePatterns, filePattern],
});
},
excludeFilePaths: [],
titleOnly: true,
}).open();
}}
>
<div className="tw-flex tw-items-center tw-gap-2">
<FileText className="tw-size-4" />
Files
</div>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
new ExtensionInputModal(app, (extension) => {
const extensionPattern = getExtensionPattern(extension);
if (extensionPatterns.includes(extensionPattern)) {
return;
}
updateCategories({
extensionPatterns: [...extensionPatterns, extensionPattern],
});
}).open();
}}
>
<div className="tw-flex tw-items-center tw-gap-2">
<File className="tw-size-4" />
Extension
</div>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
new CustomPatternInputModal(app, (value) => {
const patterns = getDecodedPatterns(value);
const {
tagPatterns: newTagPatterns,
extensionPatterns: newExtensionPatterns,
folderPatterns: newFolderPatterns,
notePatterns: newNotePatterns,
} = categorizePatterns(patterns);
updateCategories({
tagPatterns: [...tagPatterns, ...newTagPatterns],
extensionPatterns: [...extensionPatterns, ...newExtensionPatterns],
folderPatterns: [...folderPatterns, ...newFolderPatterns],
notePatterns: [...notePatterns, ...newNotePatterns],
});
}).open();
}}
>
<div className="tw-flex tw-items-center tw-gap-2">
<Wrench className="tw-size-4" />
Custom
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
export class ProjectPatternMatchingModal extends Modal {
private root: Root;
constructor(
app: App,
private onUpdate: (value: string) => void,
/** The raw pattern matching value, separated by commas */
private value: string,
title: string
) {
super(app);
// @ts-ignore
this.setTitle(title);
}
onOpen() {
const { contentEl } = this;
this.root = createRoot(contentEl);
const handleUpdate = (value: string) => {
this.onUpdate(value);
};
this.root.render(
<AppContext.Provider value={this.app}>
<ProjectPatternMatchingModalContent
value={this.value}
onUpdate={handleUpdate}
container={this.contentEl}
/>
</AppContext.Provider>
);
}
onClose() {
this.root.unmount();
}
}

View file

@ -3,10 +3,9 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { logError } from "@/logger";
import { formatYoutubeUrl, insertIntoEditor, validateYoutubeUrl } from "@/utils";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { App, Modal, Notice } from "obsidian";
import * as React from "react";
import { Root } from "react-dom/client";
import { createRoot, Root } from "react-dom/client";
interface TranscriptData {
videoId: string;
@ -215,7 +214,7 @@ export class YoutubeTranscriptModal extends Modal {
onOpen() {
const { contentEl } = this;
this.root = createPluginRoot(contentEl, this.app);
this.root = createRoot(contentEl);
const handleClose = () => {
this.close();

View file

@ -1,5 +1,5 @@
import { ProjectConfig } from "@/aiParams";
import { useApp } from "@/context";
import { AppContext, useApp } from "@/context";
import { ContextManageModal } from "@/components/modals/project/context-manage-modal";
import { openCachedItemPreview } from "@/utils/cacheFileOpener";
import type { ProcessingItem } from "@/components/project/processingAdapter";
@ -23,10 +23,9 @@ import { checkModelApiKey, err2String, randomUUID } from "@/utils";
import { Settings } from "lucide-react";
import { type UrlItem, parseProjectUrls, serializeProjectUrls } from "@/utils/urlTagUtils";
import type CopilotPlugin from "@/main";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { App, Modal, Notice } from "obsidian";
import React, { useMemo, useState } from "react";
import { Root } from "react-dom/client";
import { createRoot, Root } from "react-dom/client";
interface AddProjectModalContentProps {
initialProject?: ProjectConfig;
@ -472,7 +471,7 @@ export class AddProjectModal extends Modal {
// Reason: Ensure the modal is wide enough for card layout and tall enough for ScrollArea
modalEl.addClass("!tw-max-h-[85vh]");
this.root = createPluginRoot(contentEl, this.app);
this.root = createRoot(contentEl);
const handleSave = async (project: ProjectConfig) => {
await this.onSave(project);
@ -484,12 +483,14 @@ export class AddProjectModal extends Modal {
};
this.root.render(
<AddProjectModalContent
initialProject={this.initialProject}
onSave={handleSave}
onCancel={handleCancel}
plugin={this.plugin}
/>
<AppContext.Provider value={this.app}>
<AddProjectModalContent
initialProject={this.initialProject}
onSave={handleSave}
onCancel={handleCancel}
plugin={this.plugin}
/>
</AppContext.Provider>
);
}

View file

@ -38,9 +38,8 @@ import {
} from "lucide-react";
import { App, Modal, Notice, Platform, TFile } from "obsidian";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Root } from "react-dom/client";
import { createRoot, Root } from "react-dom/client";
import { HelpTooltip } from "@/components/ui/help-tooltip";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
function FileIcon({ extension, size = "tw-size-4" }: { extension: string; size?: string }) {
const ext = extension.toLowerCase().replace("*.", "");
@ -1381,7 +1380,7 @@ export class ContextManageModal extends Modal {
onOpen() {
const { contentEl, modalEl } = this;
this.root = createPluginRoot(contentEl, this.app);
this.root = createRoot(contentEl);
modalEl.addClass("tw-min-w-[50vw]");

View file

@ -0,0 +1,73 @@
/**
* ModeSelector - Dropdown for selecting Quick Ask mode.
* Uses DropdownMenu for consistent styling with ModelSelector.
*/
import React from "react";
import { MessageCircle, Pencil, Zap, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { QuickAskMode, QuickAskModeConfig } from "./types";
interface ModeSelectorProps {
modes: QuickAskModeConfig[];
value: QuickAskMode;
onChange: (mode: QuickAskMode) => void;
disabled?: boolean;
}
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
"message-circle": MessageCircle,
pencil: Pencil,
zap: Zap,
};
/**
* ModeSelector component for Quick Ask panel.
*/
export function ModeSelector({ modes, value, onChange, disabled }: ModeSelectorProps) {
const currentMode = modes.find((m) => m.id === value);
const IconComponent = iconMap[currentMode?.icon ?? ""] ?? MessageCircle;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={disabled}
className="tw-min-w-0 tw-gap-1 tw-px-1.5 tw-text-muted"
>
<IconComponent className="tw-size-3.5" />
<span className="tw-text-xs">{currentMode?.label ?? "Ask"}</span>
{!disabled && <ChevronDown className="tw-size-3.5" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{modes.map((mode) => {
const ModeIcon = iconMap[mode.icon] ?? MessageCircle;
return (
<DropdownMenuItem
key={mode.id}
onSelect={() => onChange(mode.id)}
disabled={!mode.implemented}
className="tw-gap-2"
>
<ModeIcon className="tw-size-4" />
<div className="tw-flex tw-flex-col">
<span>{mode.label}</span>
{!mode.implemented && <span className="tw-text-xs tw-text-muted">Coming soon</span>}
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -9,10 +9,9 @@
import { EditorView } from "@codemirror/view";
import type { Editor } from "obsidian";
import React from "react";
import { Root } from "react-dom/client";
import { createRoot, Root } from "react-dom/client";
import { updateDynamicStyleClass, clearDynamicStyleClass } from "@/utils/dom/dynamicStyleManager";
import { QuickAskPanel } from "./QuickAskPanel";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import type CopilotPlugin from "@/main";
import type { ReplaceGuard } from "@/editor/replaceGuard";
import type { ResizeDirection } from "@/hooks/use-resizable";
@ -339,7 +338,7 @@ export class QuickAskOverlay {
overlayRoot.appendChild(overlayContainer);
this.overlayContainer = overlayContainer;
this.root = createPluginRoot(overlayContainer, this.options.plugin.app);
this.root = createRoot(overlayContainer);
this.renderPanel();
// Reason: Reset side lock on scroll/resize so placement is re-evaluated

View file

@ -20,6 +20,9 @@ import { HelpTooltip } from "@/components/ui/help-tooltip";
import { useQuickAskSession } from "./useQuickAskSession";
import { QuickAskMessageComponent } from "./QuickAskMessage";
import { QuickAskInput } from "./QuickAskInput";
// TODO: Uncomment when Edit/Edit-Direct modes are implemented
// import { ModeSelector } from "./ModeSelector";
// import { modeRegistry } from "./modeRegistry";
import type { QuickAskPanelProps } from "./types";
import type { ReplaceInvalidReason } from "@/editor/replaceGuard";
import { Button } from "@/components/ui/button";
@ -40,6 +43,8 @@ export function QuickAskPanel({
}: QuickAskPanelProps) {
// UI state
const [inputText, setInputText] = useState("");
// TODO: Uncomment when Edit/Edit-Direct modes are implemented
// const [mode, setMode] = useState<QuickAskMode>("ask");
const chatAreaRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const isChatPinnedToBottomRef = useRef(true);
@ -75,6 +80,17 @@ export function QuickAskPanel({
// Previously used selectionFrom/selectionTo props that were stale and never updated.
const selectionRange = replaceGuard.getRange();
const hasSelection = !!selectionRange && selectionRange.from !== selectionRange.to;
// TODO: Uncomment when Edit/Edit-Direct modes are implemented
// const availableModes = modeRegistry.getAvailable(hasSelection);
// Keep mode valid when selection state changes
// TODO: Uncomment when Edit/Edit-Direct modes are implemented
// useEffect(() => {
// const modes = modeRegistry.getAvailable(hasSelection);
// if (!modes.some((m) => m.id === mode)) {
// setMode(modes[0]?.id ?? "ask");
// }
// }, [hasSelection, mode]);
const lastMessageId = messages[messages.length - 1]?.id;
const lastAssistantIdx = useMemo(() => {
@ -334,6 +350,15 @@ export function QuickAskPanel({
{/* Toolbar - always at bottom */}
<div className="tw-mt-auto tw-flex tw-items-center tw-justify-between tw-gap-2 tw-border-t tw-border-solid tw-border-border tw-px-3 tw-py-1.5">
<div className="tw-flex tw-items-center tw-gap-1">
{/* TODO: Uncomment when Edit/Edit-Direct modes are implemented
<ModeSelector
modes={availableModes}
value={mode}
onChange={setMode}
disabled={isStreaming}
/>
*/}
<ModelSelector
size="sm"
variant="ghost"

View file

@ -0,0 +1,17 @@
/**
* Quick Ask components exports.
*/
export { QuickAskOverlay } from "./QuickAskOverlay";
export { QuickAskPanel } from "./QuickAskPanel";
export { QuickAskMessageComponent } from "./QuickAskMessage";
export { QuickAskInput } from "./QuickAskInput";
export { useQuickAskSession } from "./useQuickAskSession";
// Reason: ModeSelector and modeRegistry are not exported because edit/edit-direct modes
// are not yet implemented. Export them when those modes are ready.
export type {
QuickAskMode,
QuickAskMessage,
QuickAskPanelProps,
QuickAskWidgetPayload,
} from "./types";

View file

@ -0,0 +1,61 @@
/**
* Mode registry for Quick Ask feature.
* Manages available modes and their configurations.
*/
import type { QuickAskMode, QuickAskModeConfig } from "./types";
import { QUICK_COMMAND_SYSTEM_PROMPT } from "@/commands/quickCommandPrompts";
import { askModeConfig, editModeConfig, editDirectModeConfig } from "./modes";
/**
* Registry class for managing Quick Ask modes.
*/
class QuickAskModeRegistry {
private modes = new Map<QuickAskMode, QuickAskModeConfig>();
constructor() {
// Register built-in modes
this.register(askModeConfig);
this.register(editModeConfig);
this.register(editDirectModeConfig);
}
/**
* Registers a mode configuration.
*/
register(config: QuickAskModeConfig): void {
this.modes.set(config.id, config);
}
/**
* Gets a mode configuration by ID.
*/
get(id: QuickAskMode): QuickAskModeConfig | undefined {
return this.modes.get(id);
}
/**
* Gets all registered modes.
*/
getAll(): QuickAskModeConfig[] {
return Array.from(this.modes.values());
}
/**
* Gets modes available based on selection state.
*/
getAvailable(hasSelection: boolean): QuickAskModeConfig[] {
return this.getAll().filter((mode) => !mode.requiresSelection || hasSelection);
}
/**
* Gets the system prompt for a mode.
*/
getSystemPrompt(id: QuickAskMode): string {
const mode = this.get(id);
return mode?.systemPrompt ?? QUICK_COMMAND_SYSTEM_PROMPT;
}
}
// Create singleton instance
export const modeRegistry = new QuickAskModeRegistry();

View file

@ -0,0 +1,16 @@
/**
* Ask mode configuration for Quick Ask.
* Multi-turn conversation mode - the default and primary mode.
*/
import type { QuickAskModeConfig } from "../types";
export const askModeConfig: QuickAskModeConfig = {
id: "ask",
label: "Ask",
icon: "message-circle",
description: "Multi-turn conversation",
requiresSelection: false,
// systemPrompt is undefined - modeRegistry.getSystemPrompt() will use default
implemented: true,
};

View file

@ -0,0 +1,15 @@
/**
* Edit-Direct mode configuration for Quick Ask.
* Direct apply edits without preview - Future implementation.
*/
import type { QuickAskModeConfig } from "../types";
export const editDirectModeConfig: QuickAskModeConfig = {
id: "edit-direct",
label: "Edit Direct",
icon: "zap",
description: "Apply edits directly",
requiresSelection: true,
implemented: false,
};

View file

@ -0,0 +1,15 @@
/**
* Edit mode configuration for Quick Ask.
* Generate edits with preview - Future implementation.
*/
import type { QuickAskModeConfig } from "../types";
export const editModeConfig: QuickAskModeConfig = {
id: "edit",
label: "Edit",
icon: "pencil",
description: "Generate edits with preview",
requiresSelection: true,
implemented: false,
};

View file

@ -0,0 +1,7 @@
/**
* Mode configurations exports.
*/
export { askModeConfig } from "./askMode";
export { editModeConfig } from "./editMode";
export { editDirectModeConfig } from "./editDirectMode";

View file

@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { Slider } from "@/components/ui/slider";
@ -23,18 +23,20 @@ export function SettingSlider({
className,
suffix,
}: SettingSliderProps) {
const [dragValue, setDragValue] = useState<number | null>(null);
const displayedValue = dragValue ?? initialValue;
// Internal state for smooth updates
const [localValue, setLocalValue] = useState(initialValue);
// Update local value when prop value changes
useEffect(() => {
setLocalValue(initialValue);
}, [initialValue]);
return (
<div className={cn("tw-flex tw-items-center tw-gap-4", className)}>
<Slider
value={[displayedValue]}
onValueChange={([value]) => setDragValue(value)}
onValueCommit={([value]) => {
setDragValue(null);
onChange?.(value);
}}
value={[localValue]}
onValueChange={([value]) => setLocalValue(value)}
onValueCommit={([value]) => onChange?.(value)}
min={min}
max={max}
step={step}
@ -42,9 +44,9 @@ export function SettingSlider({
className="tw-flex-1"
/>
<div className="tw-min-w-[60px] tw-text-right tw-text-sm tw-tabular-nums">
{displayedValue >= 1000
? `${displayedValue % 1000 === 0 ? displayedValue / 1000 : (displayedValue / 1000).toFixed(1)}k`
: displayedValue}
{localValue >= 1000
? `${localValue % 1000 === 0 ? localValue / 1000 : (localValue / 1000).toFixed(1)}k`
: localValue}
{suffix}
</div>
</div>

View file

@ -0,0 +1,55 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"tw-inline-flex tw-h-9 tw-items-center tw-justify-center tw-rounded-lg tw-p-1 tw-text-muted",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"!tw-shadow-none",
"data-[state=active]:!tw-bg-interactive-accent data-[state=active]:!tw-text-on-accent data-[state=active]:!tw-shadow-md",
"tw-inline-flex tw-items-center tw-justify-center tw-whitespace-nowrap tw-rounded-md tw-px-3 tw-py-1 tw-text-sm tw-font-medium tw-ring-offset-ring tw-transition-all focus-visible:tw-outline-none focus-visible:tw-ring-2 focus-visible:tw-ring-ring focus-visible:tw-ring-offset-2 disabled:tw-pointer-events-none disabled:tw-opacity-50",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"tw-mt-2 tw-ring-offset-ring focus-visible:tw-outline-none focus-visible:tw-ring-2 focus-visible:tw-ring-ring focus-visible:tw-ring-offset-2",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View file

@ -1,5 +1,36 @@
import { Change } from "diff";
function strikeThrough(content: string): string {
const lines = content.trim().split("\n");
return lines.map((line) => "- " + line).join("\n");
}
// Get relevant changes only and combine them into a single markdown block
export function getRelevantChangesMarkdown(blocks: Change[][]): string {
const renderedChanges = blocks
.map((block) => {
const hasAddedChanges = block.some((change) => change.added);
const hasRemovedChanges = block.some((change) => change.removed);
let blockChange = "";
if (hasAddedChanges) {
blockChange = block.map((change) => (change.added ? change.value : "")).join("\n");
} else if (hasRemovedChanges) {
blockChange = block
.map((change) => (change.removed ? strikeThrough(change.value) : ""))
.join("\n");
} else {
const content = block.map((change) => change.value).join("\n");
// Skip blocks with only whitespace.
if (content.trim().length > 0) {
blockChange = "...";
}
}
return blockChange;
})
.join("\n");
return renderedChanges;
}
// Group changes into blocks for better UI presentation
export function getChangeBlocks(changes: Change[]): Change[][] {
const blocks: Change[][] = [];

View file

@ -12,12 +12,12 @@ export const AI_SENDER = "ai";
// Default folder names
export const COPILOT_FOLDER_ROOT = "copilot";
const DEFAULT_CHAT_HISTORY_FOLDER = `${COPILOT_FOLDER_ROOT}/copilot-conversations`;
const DEFAULT_CUSTOM_PROMPTS_FOLDER = `${COPILOT_FOLDER_ROOT}/copilot-custom-prompts`;
const DEFAULT_MEMORY_FOLDER = `${COPILOT_FOLDER_ROOT}/memory`;
const DEFAULT_SYSTEM_PROMPTS_FOLDER = `${COPILOT_FOLDER_ROOT}/system-prompts`;
const DEFAULT_PROJECTS_FOLDER = `${COPILOT_FOLDER_ROOT}/projects`;
const DEFAULT_CONVERTED_DOC_OUTPUT_FOLDER = "";
export const DEFAULT_CHAT_HISTORY_FOLDER = `${COPILOT_FOLDER_ROOT}/copilot-conversations`;
export const DEFAULT_CUSTOM_PROMPTS_FOLDER = `${COPILOT_FOLDER_ROOT}/copilot-custom-prompts`;
export const DEFAULT_MEMORY_FOLDER = `${COPILOT_FOLDER_ROOT}/memory`;
export const DEFAULT_SYSTEM_PROMPTS_FOLDER = `${COPILOT_FOLDER_ROOT}/system-prompts`;
export const DEFAULT_PROJECTS_FOLDER = `${COPILOT_FOLDER_ROOT}/projects`;
export const DEFAULT_CONVERTED_DOC_OUTPUT_FOLDER = "";
export const DEFAULT_QA_EXCLUSIONS_SETTING = COPILOT_FOLDER_ROOT;
export const DEFAULT_SYSTEM_PROMPT = `You are Obsidian Copilot, a helpful assistant that integrates AI to Obsidian note-taking.
1. Never mention that you do not have access to something. Always rely on the user provided context.
@ -110,17 +110,20 @@ export const VARIABLE_NOTE_TAG = "variable_note";
export const EMBEDDED_PDF_TAG = "embedded_pdf";
export const EMBEDDED_NOTE_TAG = "embedded_note";
export const DATAVIEW_BLOCK_TAG = "dataview_block";
export const VAULT_NOTE_TAG = "vault_note";
export const RETRIEVED_DOCUMENT_TAG = "retrieved_document";
export const WEB_TAB_CONTEXT_TAG = "web_tab_context";
export const ACTIVE_WEB_TAB_CONTEXT_TAG = "active_web_tab";
export const YOUTUBE_VIDEO_CONTEXT_TAG = "youtube_video_context";
/** Marker text used as placeholder for active web tab in serialized content */
export const ACTIVE_WEB_TAB_MARKER = "{activeWebTab}";
export const EMPTY_INDEX_ERROR_MESSAGE =
"Copilot index does not exist. Please index your vault first!\n\n1. Set a working embedding model in QA settings. If it's not a local model, don't forget to set the API key. \n\n2. Click 'Refresh Index for Vault' and wait for indexing to complete. If you encounter the rate limiting error, please turn your request per second down in QA setting.";
export const CHUNK_SIZE = 6000;
export const TEXT_WEIGHT = 0.4;
export const MAX_CHARS_FOR_LOCAL_SEARCH_CONTEXT = 448000;
export const LLM_TIMEOUT_MS = 30000; // 30 seconds timeout for LLM operations
const DEFAULT_MAX_SOURCE_CHUNKS = 30; // Default max chunks for search results (with diverse top-K)
export const DEFAULT_MAX_SOURCE_CHUNKS = 30; // Default max chunks for search results (with diverse top-K)
export const AGENT_LOOP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes timeout for agent loop
export const AGENT_MAX_ITERATIONS_LIMIT = 16; // Maximum allowed value for agent iterations setting
export const LOADING_MESSAGES = {
@ -173,10 +176,13 @@ export enum ChatModels {
COPILOT_PLUS_FLASH = "copilot-plus-flash",
GPT_5_5 = "gpt-5.5",
GPT_5_4_mini = "gpt-5.4-mini",
GPT_5_4_nano = "gpt-5.4-nano",
GPT_41 = "gpt-4.1",
GPT_41_mini = "gpt-4.1-mini",
GPT_41_nano = "gpt-4.1-nano",
O4_mini = "o4-mini",
GEMINI_3_PRO_PREVIEW = "gemini-3.1-pro-preview",
GEMINI_3_5_FLASH = "gemini-3.5-flash",
GEMINI_3_FLASH_PREVIEW = "gemini-3-flash-preview",
GEMINI_3_FLASH_LITE = "gemini-3.1-flash-lite",
GEMINI_PRO = "gemini-2.5-pro",
GEMINI_FLASH = "gemini-2.5-flash",
@ -189,7 +195,7 @@ export enum ChatModels {
MISTRAL_TINY = "mistral-tiny-latest",
DEEPSEEK_REASONER = "deepseek-reasoner",
DEEPSEEK_CHAT = "deepseek-chat",
OPENROUTER_GEMINI_3_5_FLASH = "google/gemini-3.5-flash",
OPENROUTER_GEMINI_3_FLASH_PREVIEW = "google/gemini-3-flash-preview",
OPENROUTER_GEMINI_3_PRO_PREVIEW = "google/gemini-3.1-pro-preview",
OPENROUTER_GEMINI_2_5_FLASH = "google/gemini-2.5-flash",
OPENROUTER_GEMINI_2_5_PRO = "google/gemini-2.5-pro",
@ -254,14 +260,6 @@ export const BUILTIN_CHAT_MODELS: CustomModel[] = [
projectEnabled: true,
capabilities: [ModelCapability.VISION],
},
{
name: ChatModels.OPENROUTER_GEMINI_3_5_FLASH,
provider: ChatModelProviders.OPENROUTERAI,
enabled: true,
isBuiltIn: true,
projectEnabled: true,
capabilities: [ModelCapability.VISION, ModelCapability.REASONING],
},
{
name: ChatModels.GPT_5_5,
provider: ChatModelProviders.OPENAI,
@ -285,14 +283,6 @@ export const BUILTIN_CHAT_MODELS: CustomModel[] = [
isBuiltIn: true,
capabilities: [ModelCapability.VISION, ModelCapability.REASONING],
},
{
name: ChatModels.GEMINI_3_5_FLASH,
provider: ChatModelProviders.GOOGLE,
enabled: true,
isBuiltIn: true,
projectEnabled: true,
capabilities: [ModelCapability.VISION, ModelCapability.REASONING],
},
{
name: ChatModels.GEMINI_3_FLASH_LITE,
provider: ChatModelProviders.GOOGLE,
@ -310,6 +300,13 @@ export const BUILTIN_CHAT_MODELS: CustomModel[] = [
capabilities: [ModelCapability.VISION],
},
// Disabled models
{
name: ChatModels.OPENROUTER_GEMINI_3_FLASH_PREVIEW,
provider: ChatModelProviders.OPENROUTERAI,
enabled: false,
isBuiltIn: true,
capabilities: [ModelCapability.VISION, ModelCapability.REASONING],
},
{
name: ChatModels.OPENROUTER_GEMINI_3_PRO_PREVIEW,
provider: ChatModelProviders.OPENROUTERAI,
@ -394,6 +391,13 @@ export const BUILTIN_CHAT_MODELS: CustomModel[] = [
isBuiltIn: true,
capabilities: [ModelCapability.VISION, ModelCapability.REASONING],
},
{
name: ChatModels.GEMINI_3_FLASH_PREVIEW,
provider: ChatModelProviders.GOOGLE,
enabled: false,
isBuiltIn: true,
capabilities: [ModelCapability.VISION, ModelCapability.REASONING],
},
{
name: ChatModels.GEMINI_3_PRO_PREVIEW,
provider: ChatModelProviders.GOOGLE,
@ -454,6 +458,7 @@ export enum EmbeddingModelProviders {
}
export enum EmbeddingModels {
OPENAI_EMBEDDING_ADA_V2 = "text-embedding-ada-002",
OPENAI_EMBEDDING_SMALL = "text-embedding-3-small",
OPENAI_EMBEDDING_LARGE = "text-embedding-3-large",
AZURE_OPENAI = "azure-openai",

View file

@ -34,7 +34,7 @@ export { compactBySection, truncateWithEllipsis } from "./compactionUtils";
export { getSourceType as detectSourceType } from "./contextBlockRegistry";
// Re-export chat history compaction for backwards compatibility
export { compactChatHistoryContent } from "./ChatHistoryCompactor";
export { compactAssistantOutput, compactChatHistoryContent } from "./ChatHistoryCompactor";
/**
* Extract the source identifier from an XML block.

Some files were not shown because too many files have changed in this diff Show more