Compare commits

...

17 commits

Author SHA1 Message Date
Logan Yang
632c1e81b3
docs: promote Copilot v4 in the README (#2544)
* docs: promote Copilot v4 in the README

Add a "Copilot v4: Agent Mode, Reimagined" callout (with a Table of Contents
entry) introducing the v4 agent revamp — run opencode, Claude Code, or Codex
natively inside your vault — linking to the v4 promo page. Rebased onto
current master. Refs logancyang/obsidian-copilot-preview#112.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: drop the v3 callout and add a Supporter CTA to the v4 section

Remove the "Copilot V3 is a New Era" section (and its ToC entry) now that v4
is the headline, and add "Join Supporter to experience the magic of Copilot
v4 now!" to the v4 promo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:05:59 -07:00
Logan Yang
bd8829f538
release: v3.3.3 (#2494) 2026-05-20 18:27:22 -07:00
Logan Yang
05a0d03fd9
feat(models): add GA gemini-3.5-flash builtin (#2492)
* feat(models): replace gemini-3-flash-preview with GA gemini-3.5-flash

Swap the preview Gemini flash builtin for the now-GA gemini-3.5-flash
across the Google and OpenRouter providers, enable it by default, and
update the user-facing model docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(models): drop older Gemini 2.5 builtins, keep latest per family

Remove the Gemini 2.5 pro/flash builtins (Google + OpenRouter), leaving
only the latest of each family: gemini-3.1-pro-preview, gemini-3.5-flash,
and gemini-3.1-flash-lite. Reassign the default, required, and Google
test model roles from gemini-2.5-flash to gemini-3.5-flash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(models): keep gemini-2.5-pro as the GA Gemini pro builtin

gemini-3.1-pro is preview-only on the public Gemini API, so retain
gemini-2.5-pro (Google + OpenRouter) to guarantee at least one GA Gemini
pro model in the builtin list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(models): keep gemini-2.5-flash default, add gemini-3.5-flash alongside

Restore gemini-2.5-flash (Google + OpenRouter) and its default, required,
core, and Google test-model roles. gemini-3.5-flash stays as an additional
enabled builtin rather than taking over the default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:00:00 -07:00
Zero Liu
f28ced14ff
fix(chain): stop writing chainType back inside setChain (apply-Plus freeze) (#2478)
* chore(test-vault): tag deployed manifest with branch + build timestamp

`npm run test:vault` now writes a real `manifest.json` (instead of a symlink
to the worktree copy) with the current git branch and a `yyyymmdd-HHMMSS`
build timestamp appended to `name` and prepended to `description`. Surfaces
the active worktree/build directly in Obsidian's Community plugins list, so
"is my latest build actually loaded?" is a glance instead of a guess.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chain): stop writing chainType back inside setChain (apply-Plus freeze)

Applying a Copilot Plus license key froze Obsidian. Root cause: ChainManager
ran multiple concurrent `createChainWithNewModel` invocations (one from the
constructor's `initialize()`, plus one each from the settings, model-key, and
chain-type subscribers firing on `applyPlusSettings`). Each captured
`chainType = getChainType()` at its start, awaited `setChatModel(...)`, then
resumed with a now-stale local value and wrote it back to `chainTypeAtom`
via `setChain` → `setChainType`. The write re-fired the chain-type
subscriber in ProjectManager, which scheduled another stale-closure
rebuild, and the atom bounced indefinitely between `LLM_CHAIN` and
`COPILOT_PLUS_CHAIN`.

Inlined `setChain` into `createChainWithNewModel` and removed the redundant
chainType write-back. The atom is owned by the UI dropdowns and
`applyPlusSettings`; this function only reads from it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(chain): fix outdated comment reference to removed test file

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:46:21 -07:00
Zero Liu
58edadefdb
Add npm run test:vault for fast worktree-to-vault plugin reload (#2477)
Adds a one-command dev workflow: install + build + symlink main.js /
manifest.json / styles.css from the current worktree into
$COPILOT_TEST_VAULT_PATH/.obsidian/plugins/copilot/, then reload the
plugin via the Obsidian CLI. Removes the manual build/copy/reload loop
across Conductor worktrees. Docs added to CONTRIBUTING.md and AGENTS.md.
2026-05-19 12:05:49 -07:00
Logan Yang
ba378e8389
release: v3.3.2 (#2473)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 18:45:28 -07:00
Zero Liu
e03234137d
Enable unsafe member access linting (#2474) 2026-05-15 18:26:40 -07:00
Logan Yang
3f12c14444
fix(anthropic+bedrock): adaptive thinking + summarized display for claude-opus-4-7+ (#2471)
* fix(anthropic): use thinking.type=adaptive for claude-opus-4-7+

claude-opus-4-7 rejects the legacy thinking shape with a 400:
  `thinking.type.enabled` is not supported for this model.
  Use `thinking.type.adaptive` and `output_config.effort`.

Detect opus-4 minor version in getModelInfo and emit
{ type: "adaptive" } for >=7, keeping { type: "enabled", budget_tokens }
for opus-4-6 and earlier, sonnet-4, and 3-7-sonnet.

Bumps @langchain/anthropic ^1.0.0 -> ^1.3.29 to pick up the adaptive
ThinkingConfigParam variant and an updated tool-schema typing that lets
BedrockChatModel.convertTools drop its now-redundant Record cast.

Fixes #2361. Replaces stale PR #2362.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bedrock): use thinking.type=adaptive for claude-opus-4-7+

Same 400 from claude-opus-4-7+ as the direct Anthropic path (fixed in
the previous commit), but the Bedrock provider has its own payload
builder in BedrockChatModel.buildRequestBody that was unaffected.

Add the same minor-version detection inside that payload builder.
Uses an unanchored regex /claude-opus-4-(\d+)/ because Bedrock model
IDs carry a provider/profile prefix (e.g.
"global.anthropic.claude-opus-4-7-20260115-v1:0").

Fixes #2384.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: use real sonnet model IDs

claude-sonnet-4-7 doesn't exist; tests were asserting against a
fabricated ID. Switch the non-opus negative cases to a real
claude-sonnet-4-5 (and keep the existing claude-3-7-sonnet test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(thinking): set output_config.display=summarized for claude-opus-4-7+

Adaptive thinking on Opus 4.7+ alone restores the request shape but
leaves the UI empty — Anthropic flipped the default for
output_config.display from "summarized" to "omitted" on this model
line, so the API stops emitting visible thinking content blocks.
Pre-4.7 models still default to "summarized" server-side.

Set display: "summarized" alongside thinking: { type: "adaptive" } on
both the direct Anthropic and Bedrock paths so opus-4-7 renders
thinking summaries the same way sonnet 4.6 does today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(thinking): nest display=summarized inside thinking config

The Anthropic API places `display: 'summarized' | 'omitted'` on the
thinking config itself (ThinkingConfigAdaptive / ThinkingConfigEnabled),
not on a top-level `output_config`. The previous commit set
`outputConfig: { display: "summarized" }` on the ChatAnthropic config and
`output_config: { display: "summarized" }` on the Bedrock request body,
which broke the TS build (the SDK's OutputConfig only exposes
effort/format) and would not have produced the intended server behavior
for the adaptive thinking summarization toggle.

- chatModelManager.ts: emit `thinking: { type: "adaptive", display: "summarized" }`
- BedrockChatModel.ts: same, on the request payload
- BedrockChatModel.test.ts: update assertions to the new shape

* fix(thinking): constrain Opus minor regex so dated snapshots don't match

The previous `^claude-opus-4-(\d+)` (Anthropic) and unanchored
`claude-opus-4-(\d+)` (Bedrock) regex captured the date portion of
snapshot IDs like `claude-opus-4-20250514` as if it were the minor
version, so `usesAdaptiveThinking` evaluated as `true` (20250514 >= 7)
for pre-4.7 Opus models. That would send `thinking: { type: "adaptive" }`
for the legacy 4.0 snapshot and could trigger 400s on REASONING-enabled
runs.

Constrain the minor capture to 1-2 digits followed by `-`/`.` or end of
string. Real Anthropic minor versions are single/double digit; dated
snapshots have an 8-digit date that this regex now rejects.

- src/utils.ts: anchored fix for the Anthropic path
- src/LLMProviders/BedrockChatModel.ts: unanchored fix for Bedrock IDs
- regression tests for dated 4.0/4.1 snapshots in both src/utils.test.ts
  and src/LLMProviders/BedrockChatModel.test.ts

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:29:36 -07:00
Logan Yang
475d5b9c18
fix(bedrock): explain inference-profile requirement when AWS rejects on-demand model ID (#2472)
* fix(bedrock): explain inference-profile requirement when AWS rejects on-demand model ID

When users configure a bare regional model ID (e.g. anthropic.claude-sonnet-4-6) on
Bedrock, AWS returns a 400 with a cryptic JSON blob. The raw error gave no hint that
the fix is to switch to a cross-region inference profile prefix. This rewrites that
specific 400 to a one-line actionable message pointing users to Settings → Models and
listing the global./us./eu./apac. prefix options.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bedrock): generalize inference-profile sentinel and provider segment

Addresses two Codex review concerns and a CI build regression on PR #2472:

- Sentinel: match on the apostrophe-free phrases "on-demand throughput" and
  "inference profile" instead of "isn't supported", so we catch AWS error
  bodies that use the curly-apostrophe variant "isn't supported" as well.
- Guidance: derive the prefix-form provider segment (e.g. "anthropic",
  "meta", "amazon") from the bare model ID extracted from the error body,
  so non-Anthropic Bedrock models get the correct global.<provider>.<id>
  guidance instead of being told to switch to global.anthropic.<id>.
- Restore the `as Record<string, unknown>` cast (via `unknown`) on the
  Zod-to-JSON-Schema conversion. The previous reformat dropped the cast
  and broke `tsc -noEmit` with a JsonSchema7Type / Record<string,unknown>
  mismatch, which surfaced as the build (22.x) CI failure on this branch.

New tests:
- "rewrites the error even when AWS uses a curly apostrophe in 'isn't supported'"
- "uses the provider segment from the bare model ID in the prefix guidance"

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:18:00 -07:00
Zero Liu
de443ae5d9
Remove dead ChainFactory and chain validation helpers (#2469)
* Remove dead ChainFactory and chain validation helpers

ChainFactory and the chain accessors on ChainManager (getChain,
getRetrievalChain, validateChainInitialization) have been deprecated
in-source for a while — chain runners call chat models directly and
nothing reads this.chain / this.retrievalChain.

- Delete src/chainFactory.ts.
- Drop the dead chain/retrievalChain fields and accessors on
  ChainManager. The setChain switch collapses to "setChainType plus
  initializeQAChain for the QA-style chains."
- Remove isLLMChain / isRetrievalQAChain / isSupportedChain from
  utils.ts; switch utils.ts's Document import to @langchain/core/documents
  (matching what flows through the retriever pipeline already).
- Drop now-pointless `jest.mock("@/chainFactory")` blocks from five
  test files; they were short-circuiting an import tree the tests
  no longer touch.

No behavior change: 2105 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Rename initializeQAChain to refreshVaultIndex

initializeQAChain hasn't initialized a chain since the chain plumbing
was removed — its only job now is to re-index the vault when asked.
Rename to refreshVaultIndex and clean up:

- The setChain gate ("only for VAULT_QA / COPILOT_PLUS / PROJECT
  chains") is redundant. The sole caller that sets refreshIndex=true
  (projectManager on chain-type change) already enforces the same gate.
  Call refreshVaultIndex iff refreshIndex is requested.
- Drop the misleading "V3 search builds indexes on demand" inline
  comment in favor of one accurate JSDoc on the renamed function.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:02:24 -07:00
Zero Liu
5527942100
style(css): expand #ccc to 6-digit hex format for consistency (#2470)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:05:24 -07:00
Zero Liu
6a71cf8586
chore(react): centralize React root creation via createPluginRoot helper (#2467)
* chore(react): centralize React root creation via createPluginRoot helper

Every standalone React root in the plugin must wrap its tree in
AppContext.Provider so descendants can rely on useApp() — PR #2466 fixed
one missing wrap (QuickAskOverlay) but the contract was implicit and any
new createRoot callsite could silently re-introduce the same crash.
Introduce a createPluginRoot(container, app) helper that injects the
provider automatically, migrate all 17 createRoot callsites to use it,
and add a Jest guardrail that fails if any non-helper file imports
createRoot from react-dom/client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(lint): replace createRoot Jest guardrail with ESLint rule

Switch the createPluginRoot enforcement from a Jest test (walks src/
and greps imports) to an ESLint no-restricted-syntax rule. Same
invariant, faster feedback (in-editor instead of on test run), and one
fewer test file to maintain. The helper file is exempted via the
config block's `ignores`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:49:03 -07:00
Zero Liu
01a01e2951
fix(brevilabs): rewrite uploads via requestUrl multipart (W8/9) (#2399)
* fix(brevilabs): rewrite uploads via requestUrl multipart (W8/9)

Ninth of nine source workspaces splitting #2397. Replaces native
fetch(FormData) calls in brevilabsClient with Obsidian's requestUrl
plus a manually-constructed multipart body. This is required because
Obsidian's CORS-bypass path can't handle native FormData streams.

- src/LLMProviders/brevilabsClient.ts: buildMultipartFromFormData
  helper builds a Uint8Array body with explicit boundary; uploads now
  flow through requestUrl with a Content-Type header that includes
  the boundary string
- __mocks__/obsidian.js: requestUrl jest mock + __setRequestUrlImpl
  helper so tests can swap in per-case responses

Manual verification required: image upload, PDF upload, audio
transcription, error path. Plus license needed to exercise real
uploads.

W0 already merged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(network): dedupe brevilabs response parsing + bound model-fetch timeout

- Extract parseBrevilabsResponse helper so makeRequest and
  makeFormDataRequest share one parse/error path.
- Wrap fetchModelsForProvider's safeFetch in a 10s Promise.race
  (safeFetch ignores AbortSignal, so a dead base URL could hang
  the model-import dialog forever).
- Preserve real @/utils exports in selfHostServices test mock via
  jest.requireActual.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(lint): fix typescript-eslint violations in brevilabs + tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 11:42:25 -07:00
Zero Liu
19dd1aab40
fix(quick-ask): provide AppContext to overlay panel (#2466)
The Quick Ask overlay creates its own React root, so descendants that
call useApp() (AtMentionCommandPlugin → useAtMentionSearch →
useOpenWebTabs) crashed with "useApp() called outside of an
<AppContext.Provider>" when opening the panel.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:58:01 -07:00
Zero Liu
0c9ce57dd5
chore(deps): drop @langchain/community + bump openai to v6 to dedupe SDKs (#2463)
* chore(deps): drop @langchain/community, hand-roll Jina embeddings

Replaces the single use of @langchain/community (JinaEmbeddings) with a small
hand-rolled implementation that extends @langchain/core/embeddings directly and
calls the Jina /embeddings endpoint via Obsidian's requestUrl (CORS-safe). The
request shape and response handling mirror the upstream Python reference.

* chore(jina): adapt upstream community implementation with attribution

Restores batching, dimensions/normalization defaults, and the multi-modal
input type from the original @langchain/community JinaEmbeddings, with
upstream copyright notice and MIT attribution preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(deps): bump openai to ^6.10.0 to dedupe with @langchain/openai

@langchain/openai@1 pins openai@^6.10.0 while we pinned ^4.95.1, so
esbuild was shipping both copies. Aligning to v6 dedupes the bundle.
Stacks on #2461, which drops @langchain/community (and its stagehand
peer that pinned openai@^4.62.1), so npm install no longer needs
--legacy-peer-deps to resolve.

ChatOpenRouter.ts is the only direct consumer; its imports
(OpenAI default, ChatCompletionChunk/MessageParam/Role types,
chat.completions.create streaming) are all unchanged in v6.

main.js: 3,447,540 -> 3,371,972 bytes (-75 KB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:48:18 -07:00
Zero Liu
adf8c68a73
chore(deps): remove unused deps and dead code to shrink bundle (#2460)
Drop unused runtime deps (huggingface, koa, langchain, react-markdown,
react-syntax-highlighter, next-i18next, p-queue, trie-search, sse,
codemirror-companion-extension, tabler/icons-react, etc.) and demote
build-only packages to devDependencies. Delete dead source files
(quick-ask modes, ListPromptModal, NewChatConfirmModal,
PatternMatchingModal, ContainerContext, command-ui chat-message,
ui/tabs) and unused exports flagged by knip. Add `lint:dead` script
backed by knip and a knip.json config.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:25:59 -07:00
Zero Liu
a92012dc4d
chore(lint): fix no-direct-set-state-in-use-effect violations (#2454)
* chore(lint): fix no-direct-set-state-in-use-effect violations

Replaced 56 setState-in-useEffect anti-patterns across 21 files with
idiomatic React patterns: derived state via useMemo, useSyncExternalStore
for external stores, key-prop resets, render-phase prev-value trackers,
and lazy useState initializers. Deleted dead defensive syncs in
PlusSettings, ModelEditDialog, and the fallback-notice effect in
CustomCommandChatModal. Narrowly suppressed three legitimate cases
(interval-driven animation, DOM measurement, Lexical pill→context absorb).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* address codex comment

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:23:55 -07:00
153 changed files with 1901 additions and 8657 deletions

View file

@ -12,6 +12,7 @@ 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,6 +53,34 @@ 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 V3 is a New Era 🔥](#copilot-v3-is-a-new-era-)
- [Copilot v4: Agent Mode, Reimagined 🚀](#copilot-v4-agent-mode-reimagined-)
- [Why People Love It ❤️](#why-people-love-it-)
- [Get Started](#get-started)
- [Install Obsidian Copilot](#install-obsidian-copilot)
@ -78,15 +78,13 @@ This is the future we believe in. If you share this vision, please support this
- [**Copilot Plus Disclosure**](#copilot-plus-disclosure)
- [**Authors**](#authors)
## Copilot V3 is a New Era 🔥
## Copilot v4: Agent Mode, Reimagined 🚀
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!
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.
- 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!
**Join Supporter to experience the magic of Copilot v4 now!**
Read the [Changelog](https://github.com/logancyang/obsidian-copilot/releases/tag/3.0.0).
👉 **[Discover Copilot v4 →](https://www.obsidiancopilot.com/v4)**
## Why People Love It ❤️

View file

@ -1,5 +1,67 @@
# 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,10 +1,25 @@
// __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-flash-preview, gemini-3.1-pro-preview
- **Models include**: gemini-2.5-pro, gemini-2.5-flash, gemini-3.5-flash, 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,6 +121,7 @@ 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`
@ -171,14 +172,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-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 | 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 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,10 +103,8 @@ 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 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.
// no-unsafe-member-access: enabled globally; tests are exempted via the
// test-file override below.
"@typescript-eslint/no-unsafe-assignment": "off", // enabled for tests below; follow-up PR for production
"@typescript-eslint/no-unsafe-call": "off", // 107 violations
@ -121,6 +119,26 @@ 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__/**"],
@ -138,58 +156,6 @@ 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,7 +6,6 @@ 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

7
knip.json Normal file
View file

@ -0,0 +1,7 @@
{
"$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.1",
"version": "3.3.3",
"minAppVersion": "1.11.4",
"isDesktopOnly": false,
"description": "Your AI Copilot: Chat with Your Second Brain, Learn Faster, Work Smarter.",

5750
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.1",
"version": "3.3.3",
"description": "Your AI Copilot: Chat with Your Second Brain, Learn Faster, Work Smarter.",
"main": "main.js",
"scripts": {
@ -11,6 +11,7 @@
"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}'",
@ -18,7 +19,8 @@
"test": "jest --testPathIgnorePatterns=src/integration_tests/",
"test:integration": "jest src/integration_tests/",
"prepare": "husky",
"prompt:debug": "node scripts/printPromptDebug.js"
"prompt:debug": "node scripts/printPromptDebug.js",
"test:vault": "bash scripts/test-vault.sh"
},
"keywords": [],
"author": "Logan Yang",
@ -32,32 +34,30 @@
},
"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,6 +66,7 @@
"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",
@ -73,17 +74,11 @@
"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",
"@google/generative-ai": "^0.24.0",
"@huggingface/inference": "^4.11.3",
"@koa/cors": "^5.0.0",
"@langchain/anthropic": "^1.0.0",
"@langchain/anthropic": "^1.3.29",
"@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",
@ -91,10 +86,7 @@
"@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",
@ -108,42 +100,24 @@
"@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",
"next-i18next": "^13.2.2",
"openai": "^4.95.1",
"p-queue": "^8.1.0",
"prop-types": "^15.8.1",
"openai": "^6.10.0",
"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"

91
scripts/test-vault.sh Executable file
View file

@ -0,0 +1,91 @@
#!/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,7 +22,9 @@ type ImageContent = {
} | null;
type RequestBody = {
thinking?: { type: string; budget_tokens: number };
thinking?:
| { type: "enabled"; budget_tokens: number }
| { type: "adaptive"; display?: "summarized" | "omitted" };
temperature?: number;
anthropic_version?: string;
messages: Array<{
@ -77,18 +79,38 @@ const buildEventStreamChunk = (payload: string): string => {
return Buffer.from(buffer).toString("base64");
};
const createModel = (enableThinking = false): BedrockChatModel =>
const createModel = (
enableThinking = false,
modelId = "anthropic.claude-3-haiku-20240307-v1:0"
): BedrockChatModel =>
new BedrockChatModel({
modelId: "anthropic.claude-3-haiku-20240307-v1:0",
modelId,
apiKey: "test-key",
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",
endpoint: `https://example.com/model/${encodeURIComponent(modelId)}/invoke`,
streamEndpoint: `https://example.com/model/${encodeURIComponent(modelId)}/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({
@ -437,6 +459,68 @@ 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", () => {
@ -657,3 +741,94 @@ 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,6 +41,42 @@ 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.
@ -141,10 +177,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
return tools.map((tool) => {
let inputSchema: Record<string, unknown> = { type: "object", properties: {} };
if (tool.schema) {
// Use LangChain's schema conversion utilities
inputSchema = (
isInteropZodSchema(tool.schema) ? toJsonSchema(tool.schema) : tool.schema
) as Record<string, unknown>;
inputSchema = isInteropZodSchema(tool.schema) ? toJsonSchema(tool.schema) : tool.schema;
}
return {
name: tool.name,
@ -198,7 +231,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Amazon Bedrock request failed with status ${response.status}: ${errorText}`);
throw new Error(rewriteBedrockErrorMessage(response.status, errorText));
}
const data = (await response.json()) as Record<string, unknown>;
@ -280,9 +313,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Amazon Bedrock streaming request failed with status ${response.status}: ${errorText}`
);
throw new Error(rewriteBedrockErrorMessage(response.status, errorText, true));
}
if (!response.body) {
@ -1442,12 +1473,19 @@ 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) {
// Enable thinking mode for Claude models on Bedrock
// This allows the model to generate reasoning tokens
payload.thinking = {
type: "enabled",
budget_tokens: 2048,
};
// 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 };
// 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,13 +448,19 @@ export class ChatOpenRouter extends ChatOpenAI {
return undefined;
}
return toolCalls.map((call) => ({
name: call?.function?.name,
args: call?.function?.arguments,
id: call?.id,
index: call?.index,
type: "tool_call_chunk" as const,
}));
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,
};
});
}
/**

View file

@ -1,15 +1,186 @@
import { JinaEmbeddings, JinaEmbeddingsParams } from "@langchain/community/embeddings/jina";
/*
* 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
*/
export class CustomJinaEmbeddings extends JinaEmbeddings {
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.
*/
constructor(
fields?: Partial<JinaEmbeddingsParams> & {
apiKey?: string;
baseUrl?: string;
}
) {
super(fields);
if (fields?.baseUrl) {
this.baseUrl = fields.baseUrl;
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]);
}
}
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,3 +1,4 @@
import { safeFetchNoThrow } from "@/utils";
import { OpenAIEmbeddings } from "@langchain/openai";
export class CustomOpenAIEmbeddings extends OpenAIEmbeddings {
@ -35,7 +36,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 || fetch;
const fetchFn = configuration?.fetch || safeFetchNoThrow;
const response = await fetchFn(url, {
method: "POST",
@ -54,13 +55,13 @@ export class CustomOpenAIEmbeddings extends OpenAIEmbeddings {
);
}
const responseData = await response.json();
const responseData = (await response.json()) as { data?: Array<{ embedding?: unknown }> };
if (!responseData.data || !Array.isArray(responseData.data)) {
throw new Error("Invalid API response format: missing or invalid data array");
}
return (responseData.data as Array<{ embedding?: unknown }>).map((item) => {
return responseData.data.map((item) => {
if (!item.embedding || !Array.isArray(item.embedding)) {
throw new Error("Invalid API response format: missing or invalid embedding array");
}

View file

@ -4,8 +4,86 @@ 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: {
@ -123,25 +201,14 @@ export class BrevilabsClient {
if (!excludeAuthHeader) {
headers.Authorization = `Bearer ${await getDecryptedKey(getSettings().plusLicenseKey)}`;
}
const response = await safeFetchNoThrow(url.toString(), {
const response = await requestUrl({
url: url.toString(),
method,
headers,
...(method === "POST" && { body: JSON.stringify(body) }),
throw: false,
});
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 };
return parseBrevilabsResponse<T>(response, endpoint);
}
private async makeFormDataRequest<T>(
@ -159,29 +226,21 @@ export class BrevilabsClient {
const url = new URL(`${BREVILABS_API_BASE_URL}${endpoint}`);
try {
const response = await fetch(url.toString(), {
// Build multipart body manually for requestUrl (does not natively support FormData).
const { body, contentType } = await buildMultipartFromFormData(formData);
const response = await requestUrl({
url: url.toString(),
method: "POST",
headers: {
// No Content-Type header - browser will set it automatically with boundary
"Content-Type": contentType,
Authorization: `Bearer ${await getDecryptedKey(getSettings().plusLicenseKey)}`,
"X-Client-Version": this.pluginVersion,
},
body: formData,
body,
throw: false,
});
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 };
return parseBrevilabsResponse<T>(response, `${endpoint} form-data`);
} catch (error) {
return { data: null, error: error instanceof Error ? error : new Error(String(error)) };
}

View file

@ -1,11 +1,4 @@
import {
getChainType,
getCurrentProject,
getModelKey,
SetChainOptions,
setChainType,
} from "@/aiParams";
import ChainFactory, { Document } from "@/chainFactory";
import { getChainType, getCurrentProject, getModelKey, SetChainOptions } from "@/aiParams";
import { ChainType } from "@/chainType";
import { BUILTIN_CHAT_MODELS, USER_SENDER } from "@/constants";
import {
@ -20,14 +13,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, isSupportedChain } from "@/utils";
import { findCustomModel, isOSeriesModel } from "@/utils";
import { MissingModelKeyError } from "@/error";
import {
ChatPromptTemplate,
HumanMessagePromptTemplate,
MessagesPlaceholder,
} from "@langchain/core/prompts";
import { RunnableSequence } from "@langchain/core/runnables";
import { Document } from "@langchain/core/documents";
import { App, Notice } from "obsidian";
import ChatModelManager from "./chatModelManager";
import MemoryManager from "./memoryManager";
@ -35,10 +28,6 @@ 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[] {
@ -74,16 +63,6 @@ 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");
}
@ -100,17 +79,6 @@ 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;
}
@ -175,10 +143,23 @@ export default class ChainManager {
this.pendingModelError = null;
}
// 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);
// 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."
);
}
logInfo(`Setting model to ${newModelKey}`);
} catch (error) {
this.pendingModelError = error instanceof Error ? error : new Error(String(error));
@ -187,110 +168,6 @@ 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();
@ -313,17 +190,15 @@ export default class ChainManager {
}
}
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
}
/**
* 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);
}
async runChain(
@ -346,7 +221,6 @@ export default class ChainManager {
);
this.validateChatModel();
this.validateChainInitialization();
const chatModel = this.chatModelManager.getChatModel();

View file

@ -1030,9 +1030,15 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
})
);
for await (const chunk of stream) {
for await (const rawChunk 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);
const parsed = JSON.parse(toolResult.result) as { type?: unknown; documents?: unknown };
const searchResults =
parsed &&
typeof parsed === "object" &&

View file

@ -1,26 +1,7 @@
// 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 =====
export const CITATION_RULES = `CITATION RULES:
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 @@ export 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)`;
export const WEB_CITATION_RULES = `WEB CITATION RULES:
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 =====
export interface SourcesSection {
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.
*/
export function normalizeSourcesBlock(sourcesBlock: string): string {
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 @@ export function normalizeSourcesBlock(sourcesBlock: string): string {
/**
* Parses footnote definitions from sources block.
*/
export function parseFootnoteDefinitions(sourcesBlock: string): string[] {
function parseFootnoteDefinitions(sourcesBlock: string): string[] {
return sourcesBlock
.split("\n")
.map((l) => l.trim())
@ -278,10 +278,7 @@ export function parseFootnoteDefinitions(sourcesBlock: string): string[] {
/**
* Builds a citation renumbering map based on first-mention order in content.
*/
export function buildCitationMap(
mainContent: string,
footnoteLines: string[]
): Map<number, number> {
function buildCitationMap(mainContent: string, footnoteLines: string[]): Map<number, number> {
const map = new Map<number, number>();
const seen = new Set<number>();
const firstMention: number[] = [];
@ -337,7 +334,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) => {
result = result.replace(/\[\^(\d+(?:\s*,\s*\^?\d+)*)\]/g, (match, citationList: string) => {
// Split and process each number in the list
const processedNumbers = citationList
.split(",")
@ -368,10 +365,7 @@ export function normalizeCitations(content: string, map: Map<number, number>): s
/**
* Converts footnote definitions to simple display items.
*/
export function convertFootnoteDefinitions(
sourcesBlock: string,
map: Map<number, number>
): string[] {
function convertFootnoteDefinitions(sourcesBlock: string, map: Map<number, number>): string[] {
const items: string[] = [];
sourcesBlock.split("\n").forEach((line) => {
const m = line.match(/^\[\^(\d+)\]:\s*(.*)$/);
@ -409,7 +403,7 @@ export function convertFootnoteDefinitions(
/**
* Consolidates duplicate sources and returns mapping for citation updates.
*/
export function consolidateDuplicateSources(items: string[]): {
function consolidateDuplicateSources(items: string[]): {
uniqueItems: string[];
consolidationMap: Map<number, number>;
} {
@ -591,7 +585,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.
*/
export function wrapCitationPlaceholders(content: string): string {
function wrapCitationPlaceholders(content: string): string {
return content.replace(
/\[(\d+(?:\s*,\s*\d+)*)\](?!\()/g,
'<span class="copilot-citation-ref">[$1]</span>'

View file

@ -6,7 +6,6 @@
*/
import { AIMessage, ToolMessage } from "@langchain/core/messages";
import { ToolCall as LangChainToolCall } from "@langchain/core/messages/tool";
import { logError } from "@/logger";
/**
@ -27,37 +26,6 @@ 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.
*/
export interface QualitySummary {
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.
*/
export function summarizeExplanation(explanation: unknown): string {
function summarizeExplanation(explanation: unknown): string {
if (!explanation) return "";
const parts: string[] = [];

View file

@ -17,7 +17,7 @@ export interface ErrorMarker {
endIndex: number;
}
export interface ParsedMessage {
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
*/
export function decodeResultFromMarker(result: string | undefined): string | undefined {
function decodeResultFromMarker(result: string | undefined): string | undefined {
if (typeof result !== "string") return result;
if (!result.startsWith("ENC:")) return result;
try {
@ -65,38 +65,6 @@ 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>;
}
export interface ToolExecutionResult {
interface ToolExecutionResult {
toolName: string;
result: string;
success: boolean;
@ -146,7 +146,7 @@ export async function executeSequentialToolCall(
/**
* Get display name for tool (user-friendly version)
*/
export function getToolDisplayName(toolName: string): string {
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 @@ export function getToolDisplayName(toolName: string): string {
/**
* Get emoji for tool display
*/
export function getToolEmoji(toolName: string): string {
function getToolEmoji(toolName: string): string {
const emojiMap: Record<string, string> = {
localSearch: "🔍",
webSearch: "🌐",
@ -211,32 +211,6 @@ export 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.
*/
export interface BuildPromptDebugSectionsOptions {
interface BuildPromptDebugSectionsOptions {
systemSections: PromptSection[];
rawHistory?: unknown[];
adapterName: string;
@ -27,9 +27,7 @@ export interface PromptDebugReport {
* @param options - Data required to assemble annotated prompt sections.
* @returns Prompt sections with provenance metadata.
*/
export function buildPromptDebugSections(
options: BuildPromptDebugSectionsOptions
): PromptSection[] {
function buildPromptDebugSections(options: BuildPromptDebugSectionsOptions): PromptSection[] {
const { systemSections, rawHistory, adapterName, originalUserMessage, enhancedUserMessage } =
options;
const sections: PromptSection[] = [...systemSections];
@ -76,7 +74,7 @@ export function buildPromptDebugSections(
* @param sections - Prompt sections with provenance metadata.
* @returns Multiline string with section headers that identify code sources.
*/
export function formatPromptSectionsWithAnnotations(sections: PromptSection[]): string {
function formatPromptSectionsWithAnnotations(sections: PromptSection[]): string {
return sections
.map((section) => {
const header = `[Section: ${section.label} | Source: ${section.source}]`;

View file

@ -25,7 +25,6 @@ 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";
@ -42,16 +41,24 @@ 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.
// 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 }>
) => {
(
BaseLanguageModel.prototype as { getNumTokens: (...args: unknown[]) => Promise<number> }
).getNumTokens = async (content: string | Array<{ type: string; text?: string }>) => {
const text =
typeof content === "string"
? content
@ -194,7 +201,7 @@ export default class ChatModelManager {
const modelName = customModel.name;
const modelInfo = getModelInfo(modelName);
const { isThinkingEnabled } = modelInfo;
const { isThinkingEnabled, usesAdaptiveThinking } = modelInfo;
const resolvedTemperature = this.getTemperatureForModel(modelInfo, customModel, settings);
const maxTokens = customModel.maxTokens ?? settings.maxTokens;
@ -241,10 +248,15 @@ export default class ChatModelManager {
fetch: customModel.enableCors ? safeFetch : undefined,
},
...(isThinkingEnabled && {
thinking: {
type: "enabled",
budget_tokens: ChatModelManager.ANTHROPIC_THINKING_BUDGET_TOKENS,
},
// 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,
},
}),
},
[ChatModelProviders.AZURE_OPENAI]: await (async (): Promise<Record<string, unknown>> => {
@ -290,24 +302,7 @@ export default class ChatModelManager {
[ChatModelProviders.GOOGLE]: {
apiKey: await getDecryptedKey(customModel.apiKey || settings.googleApiKey),
model: modelName,
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,
},
],
safetySettings: GOOGLE_SAFETY_SETTINGS_BLOCK_NONE,
baseUrl: customModel.baseUrl,
},
[ChatModelProviders.XAI]: {

View file

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

View file

@ -2,6 +2,7 @@ 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";
@ -45,7 +46,7 @@ export function hasSelfHostSearchKey(): boolean {
async function firecrawlSearch(query: string, apiKey: string): Promise<SelfHostWebSearchResult> {
const startTime = Date.now();
const response = await fetch(FIRECRAWL_SEARCH_URL, {
const response = await safeFetchNoThrow(FIRECRAWL_SEARCH_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
@ -59,7 +60,9 @@ async function firecrawlSearch(query: string, apiKey: string): Promise<SelfHostW
throw new Error(`Firecrawl search failed (${response.status}): ${text}`);
}
const json = await response.json();
const json = (await response.json()) as {
data?: FirecrawlSearchResult[] | { web?: FirecrawlSearchResult[] };
};
// v2 returns { data: { web: [...] } }, older responses return { data: [...] }
const rawData = json?.data;
@ -95,7 +98,7 @@ async function perplexitySonarSearch(
query: string,
apiKey: string
): Promise<SelfHostWebSearchResult> {
const response = await fetch(PERPLEXITY_CHAT_URL, {
const response = await safeFetchNoThrow(PERPLEXITY_CHAT_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
@ -112,9 +115,12 @@ async function perplexitySonarSearch(
throw new Error(`Perplexity Sonar search failed (${response.status}): ${text}`);
}
const json = await response.json();
const json = (await response.json()) as {
choices?: Array<{ message?: { content?: string } }>;
citations?: unknown;
};
const content = json?.choices?.[0]?.message?.content ?? "";
const citations: string[] = Array.isArray(json?.citations) ? json.citations : [];
const citations: string[] = Array.isArray(json?.citations) ? (json.citations as string[]) : [];
return { content, citations };
}
@ -144,7 +150,7 @@ export async function selfHostYoutube4llm(url: string): Promise<Youtube4llmRespo
const transcriptUrl = `${SUPADATA_TRANSCRIPT_URL}?url=${encodeURIComponent(url)}&mode=auto&text=true`;
const response = await fetch(transcriptUrl, {
const response = await safeFetchNoThrow(transcriptUrl, {
method: "GET",
headers: {
"x-api-key": apiKey,
@ -153,7 +159,7 @@ export async function selfHostYoutube4llm(url: string): Promise<Youtube4llmRespo
});
if (response.status === 200) {
const json = await response.json();
const json = (await response.json()) as { content?: string };
const elapsed = Date.now() - startTime;
logInfo(`[selfHostYoutube4llm] transcript received in ${elapsed}ms`);
return {
@ -163,12 +169,12 @@ export async function selfHostYoutube4llm(url: string): Promise<Youtube4llmRespo
}
if (response.status === 201 || response.status === 202) {
const json = await response.json();
const json = (await response.json()) as { job_id?: string };
const jobId = json.job_id;
if (!jobId) {
throw new Error("Supadata returned async status but no job_id");
}
return await pollSupadataJob(jobId as string, apiKey, startTime);
return await pollSupadataJob(jobId, apiKey, startTime);
}
const text = await response.text();
@ -189,7 +195,7 @@ async function pollSupadataJob(
while (Date.now() < deadline) {
await new Promise((resolve) => window.setTimeout(resolve, SUPADATA_POLL_INTERVAL));
const pollResponse = await fetch(pollUrl, {
const pollResponse = await safeFetchNoThrow(pollUrl, {
method: "GET",
headers: {
"x-api-key": apiKey,
@ -198,7 +204,7 @@ async function pollSupadataJob(
});
if (pollResponse.status === 200) {
const json = await pollResponse.json();
const json = (await pollResponse.json()) as { content?: string };
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: [],
});
export interface IndexingProgressState {
interface IndexingProgressState {
isActive: boolean;
isPaused: boolean;
isCancelled: boolean;
@ -70,7 +70,7 @@ export interface IndexingProgressState {
completionStatus: "none" | "success" | "cancelled" | "error";
}
export const indexingProgressAtom = atom<IndexingProgressState>({
const indexingProgressAtom = atom<IndexingProgressState>({
isActive: false,
isPaused: false,
isCancelled: false,
@ -237,26 +237,10 @@ 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,
@ -275,11 +259,6 @@ 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));
@ -295,13 +274,6 @@ 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.
*/
@ -322,17 +294,6 @@ 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.
*/

View file

@ -1,208 +0,0 @@
// 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, logWarn } from "@/logger";
import { logError } from "@/logger";
import { cleanMessageForCopy, findCustomModel, insertIntoEditor } from "@/utils";
import { computeVerticalPlacement } from "@/utils/panelPlacement";
import { computeSelectionAnchors } from "@/utils/selectionAnchors";
@ -16,7 +16,8 @@ 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 { createRoot, Root } from "react-dom/client";
import { Root } from "react-dom/client";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { CustomCommand } from "@/commands/type";
import { useSettingsValue, updateSetting } from "@/settings/model";
import {
@ -183,12 +184,12 @@ function CustomCommandChatModalContent({
return command.modelKey || globalModelKey;
}, [modelSelectionScope, settings.quickCommandModelKey, command.modelKey, globalModelKey]);
const [selectedModelKey, setSelectedModelKey] = useState(initialModelKey);
const [userSelectedModelKey, setUserSelectedModelKey] = useState(initialModelKey);
// Handle model change with scope-aware persistence
const handleModelChange = useCallback(
(newModelKey: string) => {
setSelectedModelKey(newModelKey);
setUserSelectedModelKey(newModelKey);
// Only persist for quick-command scope (shared with Quick Ask)
if (modelSelectionScope === "quick-command") {
updateSetting("quickCommandModelKey", newModelKey);
@ -209,16 +210,13 @@ 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(selectedModelKey, settings.activeModels);
const model = findCustomModel(userSelectedModelKey, settings.activeModels);
// Treat disabled models as invalid selections (ModelSelector won't present them)
if (!model.enabled) {
throw new Error(`Selected model is disabled: ${selectedModelKey}`);
throw new Error(`Selected model is disabled: ${userSelectedModelKey}`);
}
return model;
} catch {
@ -226,7 +224,7 @@ function CustomCommandChatModalContent({
// Avoid side effects during render; notify/log in the effect below.
return settings.activeModels.find((m) => m.enabled) ?? null;
}
}, [selectedModelKey, settings.activeModels]);
}, [userSelectedModelKey, settings.activeModels]);
// Compute the key for the resolved model
const resolvedModelKey = useMemo(() => {
@ -234,23 +232,8 @@ function CustomCommandChatModalContent({
return `${resolvedModel.name}|${resolvedModel.provider}`;
}, [resolvedModel]);
// 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]);
// Effective model key for the UI — falls back to user selection when resolution fails.
const effectiveModelKey = resolvedModelKey ?? userSelectedModelKey;
// Use shared streaming hook
const {
@ -277,12 +260,15 @@ function CustomCommandChatModalContent({
// Track the last input prompt for saving context on stop
const lastInputPromptRef = useRef<string>("");
// Sync editedText with finalText when finalText changes
useEffect(() => {
// 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);
if (finalText) {
setEditedText(finalText);
}
}, [finalText]);
}
// Compute content state for MenuCommandModal
const contentState: ContentState = useMemo(() => {
@ -453,7 +439,7 @@ function CustomCommandChatModalContent({
followUpValue={followUpValue}
onFollowUpChange={setFollowUpValue}
onFollowUpSubmit={handleFollowUpSubmit}
selectedModel={selectedModelKey}
selectedModel={effectiveModelKey}
onSelectModel={handleModelChange}
onStop={handleStop}
onCopy={handleCopy}
@ -665,7 +651,7 @@ export class CustomCommandChatModal {
this.container.className = "copilot-menu-command-modal-container";
doc.body.appendChild(this.container);
this.root = createRoot(this.container);
this.root = createPluginRoot(this.container, this.app);
// Capture ReplaceGuard (replaces captureReplaceSnapshot)
const { selectedText, command, systemPrompt, behaviorConfig } = this.configs;

View file

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

View file

@ -1,8 +1,6 @@
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,13 +66,16 @@ export class CustomCommandManager {
commandFile = await app.vault.create(filePath, command.content);
}
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;
});
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;
}
);
if (!mergedOptions.skipStoreUpdate) {
updateCachedCommand(command, command.title);
@ -126,13 +129,16 @@ export class CustomCommandManager {
if (commandFile instanceof TFile) {
await app.vault.modify(commandFile, command.content);
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;
});
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;
}
);
}
} finally {
removePendingFileWrite(filePath);

View file

@ -6,11 +6,10 @@ 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, Editor } from "obsidian";
import { normalizePath, Notice, TAbstractFile, TFile, Vault } from "obsidian";
import { getSettings } from "@/settings/model";
import {
updateCachedCommands,
@ -146,7 +145,7 @@ export function sortCommandsByOrder(commands: CustomCommand[]): CustomCommand[]
});
}
export function sortCommandsByRecency(commands: CustomCommand[]): CustomCommand[] {
function sortCommandsByRecency(commands: CustomCommand[]): CustomCommand[] {
return sortByStrategy(commands, "recent", {
getName: (command) => command.title,
getCreatedAtMs: () => 0,
@ -154,7 +153,7 @@ export function sortCommandsByRecency(commands: CustomCommand[]): CustomCommand[
});
}
export function sortCommandsByAlphabetical(commands: CustomCommand[]): CustomCommand[] {
function sortCommandsByAlphabetical(commands: CustomCommand[]): CustomCommand[] {
return sortByStrategy(commands, "name", {
getName: (command) => command.title,
getCreatedAtMs: () => 0,
@ -493,7 +492,7 @@ export function getNextCustomCommandOrder(): number {
export async function ensureCommandFrontmatter(file: TFile, command: CustomCommand) {
try {
addPendingFileWrite(file.path);
await app.fileManager.processFrontMatter(file, (frontmatter) => {
await app.fileManager.processFrontMatter(file, (frontmatter: Record<string, unknown>) => {
if (frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] == null) {
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] = command.showInContextMenu;
}
@ -514,68 +513,3 @@ 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,11 +36,7 @@ import { setSelectedTextContexts } from "@/aiParams";
/**
* Add a command to the plugin. Supports async callbacks; errors are logged.
*/
export function addCommand(
plugin: CopilotPlugin,
id: CommandId,
callback: () => void | Promise<void>
) {
function addCommand(plugin: CopilotPlugin, id: CommandId, callback: () => void | Promise<void>) {
plugin.addCommand({
id,
name: COMMAND_NAMES[id],
@ -78,7 +74,7 @@ function addEditorCommand(
/**
* Add a check command to the plugin.
*/
export function addCheckCommand(
function addCheckCommand(
plugin: CopilotPlugin,
id: CommandId,
callback: (checking: boolean) => boolean | void

View file

@ -18,16 +18,6 @@ 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,7 +88,6 @@ 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
@ -104,12 +103,6 @@ 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]
);
@ -122,8 +115,12 @@ 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(false);
const [includeActiveWebTab, setIncludeActiveWebTab] = useState(false);
const [includeActiveNote, setIncludeActiveNote] = useState(
settings.autoAddActiveContentToContext === true && currentChain !== ChainType.PROJECT_CHAIN
);
const [includeActiveWebTab, setIncludeActiveWebTab] = useState(
settings.autoAddActiveContentToContext === true && currentChain !== ChainType.PROJECT_CHAIN
);
const [selectedImages, setSelectedImages] = useState<File[]>([]);
const [showChatUI, setShowChatUI] = useState(false);
const [chatHistoryItems, setChatHistoryItems] = useState<ChatHistoryItem[]>([]);
@ -182,10 +179,11 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
return projectContextStatus === "loading" || projectContextStatus === "error";
};
// Reset user preference when status changes to allow default behavior
useEffect(() => {
const [prevProjectContextStatus, setPrevProjectContextStatus] = useState(projectContextStatus);
if (prevProjectContextStatus !== projectContextStatus) {
setPrevProjectContextStatus(projectContextStatus);
setProgressCardVisible(null);
}, [projectContextStatus]);
}
/**
* Whether to show the indexing progress card.
@ -198,12 +196,22 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
return indexingState.isActive || indexingState.completionStatus !== "none";
};
// Allow the card to show whenever new indexing activity or completion is detected
useEffect(() => {
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,
});
if (indexingState.isActive || indexingState.completionStatus !== "none") {
setIndexingCardVisible(null);
}
}, [indexingState.isActive, indexingState.completionStatus]);
}
const handleIndexingCardClose = useCallback(() => {
setIndexingCardVisible(false);
@ -228,11 +236,12 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
await VectorStoreManager.getInstance().cancelIndexing();
}, []);
// Clear token count when chat is cleared or replaced (e.g., loading chat history)
useEffect(() => {
if (chatHistory.length === 0) {
setLatestTokenCount(null);
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;
}
return null;
}, [chatHistory]);
const [previousMode, setPreviousMode] = useState<ChainType | null>(null);
@ -687,7 +696,6 @@ 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();
@ -796,10 +804,19 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
};
}, [eventTarget, handleStopGenerating]);
// Use the autoAddActiveContentToContext setting
useEffect(() => {
const [prevAutoAddTuple, setPrevAutoAddTuple] = useState({
autoAdd: settings.autoAddActiveContentToContext,
chain: selectedChain,
});
if (
prevAutoAddTuple.autoAdd !== settings.autoAddActiveContentToContext ||
prevAutoAddTuple.chain !== selectedChain
) {
setPrevAutoAddTuple({
autoAdd: settings.autoAddActiveContentToContext,
chain: selectedChain,
});
if (settings.autoAddActiveContentToContext !== undefined) {
// Only apply the setting if not in Project mode
if (selectedChain === ChainType.PROJECT_CHAIN) {
setIncludeActiveNote(false);
setIncludeActiveWebTab(false);
@ -808,7 +825,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,13 +2,14 @@ import ChainManager from "@/LLMProviders/chainManager";
import Chat from "@/components/Chat";
import { ChatViewLayout } from "@/components/chat-components/ChatViewLayout";
import { CHAT_VIEWTYPE } from "@/constants";
import { AppContext, EventTargetContext } from "@/context";
import { 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 { createRoot, Root } from "react-dom/client";
import { Root } from "react-dom/client";
export default class CopilotView extends ItemView {
private get chainManager(): ChainManager {
@ -55,7 +56,7 @@ export default class CopilotView extends ItemView {
}
async onOpen(): Promise<void> {
this.root = createRoot(this.containerEl.children[1]);
this.root = createPluginRoot(this.containerEl.children[1], this.app);
const handleSaveAsNote = (saveFunction: () => Promise<void>) => {
this.handleSaveAsNote = saveFunction;
};
@ -77,7 +78,7 @@ export default class CopilotView extends ItemView {
this.windowMigrationDestroy = this.containerEl.onWindowMigrated(() => {
if (!this.root) return;
this.root.unmount();
this.root = createRoot(this.containerEl.children[1]);
this.root = createPluginRoot(this.containerEl.children[1], this.app);
this.renderView(handleSaveAsNote, updateUserMessageHistory);
});
@ -182,20 +183,18 @@ export default class CopilotView extends ItemView {
if (!this.root) return;
this.root.render(
<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>
<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>
);
}

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

View file

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

View file

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

View file

@ -1,6 +1,5 @@
import {
getCurrentProject,
ProjectConfig,
subscribeToProjectChange,
useChainType,
useModelKey,
@ -24,7 +23,14 @@ 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 } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { $getSelection, $isRangeSelection, LexicalEditor as LexicalEditorType } from "lexical";
import { ContextControl } from "./ContextControl";
import { $removePillsByPath } from "./pills/NotePillNode";
@ -123,7 +129,6 @@ 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[]>([]);
@ -171,32 +176,26 @@ const ChatInput: React.FC<ChatInputProps> = ({
"If you have many files in context, this can take a while...",
];
// 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]);
// 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
);
}
useEffect(() => {
if (currentChain === ChainType.PROJECT_CHAIN) {
setSelectedProject(getCurrentProject());
const unsubscribe = subscribeToProjectChange((project) => {
setSelectedProject(project);
});
return () => {
unsubscribe();
};
} else {
setSelectedProject(null);
}
}, [currentChain]);
const subscribedProject = useSyncExternalStore(subscribeToProjectChange, getCurrentProject);
const selectedProject = currentChain === ChainType.PROJECT_CHAIN ? subscribedProject : null;
useEffect(() => {
if (!isProjectLoading) return;
@ -322,19 +321,25 @@ const ChatInput: React.FC<ChatInputProps> = ({
});
};
// 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]);
// 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"));
}
}
// Handle when context notes are removed from the context menu
// This should remove all corresponding pills from the editor
@ -579,43 +584,34 @@ const ChatInput: React.FC<ChatInputProps> = ({
});
}, [notesFromPills, app.vault, setContextNotes]);
// URL pill-to-context synchronization (when URL pills are added) - only for Plus chains
// 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).
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);
// 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
const newUrlsFromPills = urlsFromPills.filter((pillUrl) => !contextUrlSet.has(pillUrl));
if (newUrlsFromPills.length > 0) {
return Array.from(new Set([...prev, ...newUrlsFromPills]));
}
return prev;
});
} else {
// Clear URLs for non-Plus chains
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setContextUrls([]);
}
}, [urlsFromPills, currentChain]);
// Folder-to-context synchronization (when folders are added via pills)
// Pill state is owned by the Lexical editor; absorb new entries into context folders
// without removing user-added ones (removal is handled in handleFolderPillsRemoved).
useEffect(() => {
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setContextFolders((prev) => {
const contextFolderPaths = new Set(prev);
// 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
const newFoldersFromPills = foldersFromPills.filter(
(pillFolder) => !contextFolderPaths.has(pillFolder)
);
return [...prev, ...newFoldersFromPills];
});
}, [foldersFromPills]);

View file

@ -53,6 +53,7 @@ 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,6 +45,11 @@ 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();
@ -71,11 +76,6 @@ 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, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, 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.
*/
export const linkInlineCitations = (root: HTMLElement): void => {
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,12 +314,24 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
}) => {
const [isCopied, setIsCopied] = useState<boolean>(false);
const [isEditing, setIsEditing] = useState<boolean>(false);
// Agent Reasoning Block state
const [reasoningData, setReasoningData] = useState<{
const parsedReasoningBlock = useMemo(
() => parseReasoningBlock(message.message),
[message.message]
);
const reasoningData = useMemo<{
status: "reasoning" | "collapsed" | "complete";
elapsedSeconds: number;
steps: string[];
} | null>(null);
} | null>(() => {
if (!parsedReasoningBlock?.hasReasoning || parsedReasoningBlock.status === "idle") {
return null;
}
return {
status: parsedReasoningBlock.status,
elapsedSeconds: parsedReasoningBlock.elapsedSeconds,
steps: parsedReasoningBlock.steps,
};
}, [parsedReasoningBlock]);
const contentRef = useRef<HTMLDivElement>(null);
const componentRef = useRef<Component | null>(null);
const isUnmountingRef = useRef<boolean>(false);
@ -661,20 +673,8 @@ 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 = reasoningBlockData?.contentAfter ?? originMessage;
const messageContent = parsedReasoningBlock?.contentAfter ?? originMessage;
const processedMessage = preprocess(messageContent);
const parsedMessage = parseToolCallMarkers(processedMessage, messageId.current);
@ -747,6 +747,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
}
const rootRecord = ensureToolCallRoot(
app,
messageId.current,
rootsRef.current,
toolCallId,
@ -781,6 +782,7 @@ 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,
@ -848,7 +850,15 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
return () => {
isUnmountingRef.current = true;
};
}, [message, app, componentRef, isStreaming, preprocess, collapsibleOpenStateMap]);
}, [
message,
app,
componentRef,
isStreaming,
preprocess,
collapsibleOpenStateMap,
parsedReasoningBlock,
]);
// Cleanup effect that only runs on component unmount
useEffect(() => {

View file

@ -51,33 +51,35 @@ 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 (showFavicon && faviconUrl) {
return (
<img
src={faviconUrl}
alt=""
referrerPolicy="no-referrer"
loading="lazy"
decoding="async"
className={cn(className, "tw-rounded-sm")}
onError={() => setShowFavicon(false)}
/>
);
if (faviconUrl) {
return <FaviconImage key={faviconUrl} faviconUrl={faviconUrl} className={className} />;
}
return <Globe className={className} />;

View file

@ -34,7 +34,14 @@ import {
} from "lucide-react";
import { getCachedProjectRecordById } from "@/projects/state";
import { App, Notice } from "obsidian";
import React, { memo, useEffect, useMemo, useState } from "react";
import React, {
memo,
useCallback,
useEffect,
useMemo,
useState,
useSyncExternalStore,
} from "react";
import { filterProjects } from "@/utils/projectUtils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -46,22 +53,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
function useRecentUsageManagerRevision<Key extends string>(
manager: RecentUsageManager<Key> | null | undefined
): number {
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;
const subscribe = useCallback(
(cb: () => void) => manager?.subscribe(cb) ?? (() => {}),
[manager]
);
const getSnapshot = useCallback(() => manager?.getRevision() ?? 0, [manager]);
return useSyncExternalStore(subscribe, getSnapshot);
}
function ProjectItem({
@ -219,15 +216,21 @@ 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) {
setSelectedProject(null);
setShowChatInput(false);
setIsOpen(true);
showChatUI(false);
}
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);
}, [projects, selectedProject, showChatUI]);
// Get the project usage manager for subscription
@ -235,12 +238,14 @@ export const ProjectList = memo(
plugin?.projectManager?.getProjectUsageTimestampsManager?.();
const projectUsageRevision = useRecentUsageManagerRevision(projectUsageTimestampsManager);
// Auto collapse when messages appear
useEffect(() => {
if (hasMessages) {
setIsOpen(false);
}
}, [hasMessages]);
// 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);
}
// Sort projects based on sort strategy
// Note: projectUsageRevision triggers re-sort when in-memory timestamps change,

View file

@ -52,11 +52,12 @@ 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);
// Clear hover state when keyboard navigation changes selection
useEffect(() => {
if (selectedIndex !== prevSelectedIndex) {
setPrevSelectedIndex(selectedIndex);
setHoveredIndex(null);
}, [selectedIndex]);
}
// Scroll the selected item into view when selection changes
useEffect(() => {

View file

@ -92,6 +92,7 @@ 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 @@
export const COPILOT_COLLAPSIBLE_DOM_ID_PREFIX = "copilot-collapsible";
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;
}
export const CATEGORY_OPTIONS: CategoryOption[] = [
const CATEGORY_OPTIONS: CategoryOption[] = [
{
key: "notes",
title: "Notes",

View file

@ -173,34 +173,6 @@ 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;
}
export function $findFolderPills(): FolderPillNode[] {
function $findFolderPills(): FolderPillNode[] {
const root = $getRoot();
const pills: FolderPillNode[] = [];

View file

@ -165,27 +165,6 @@ 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;
}
export function $findToolPills(): ToolPillNode[] {
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);
}
export function $findURLPills(): URLPillNode[] {
function $findURLPills(): URLPillNode[] {
const root = $getRoot();
const pills: URLPillNode[] = [];

View file

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

View file

@ -1,10 +1,12 @@
import React from "react";
import { createRoot, Root } from "react-dom/client";
import { 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 {
@ -156,6 +158,7 @@ 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,
@ -194,7 +197,7 @@ export const ensureToolCallRoot = (
if (!record) {
record = {
root: createRoot(container),
root: createPluginRoot(container, app),
isUnmounting: false,
container,
};
@ -210,6 +213,7 @@ 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,
@ -247,7 +251,7 @@ export const ensureErrorBlockRoot = (
if (!record) {
record = {
root: createRoot(container),
root: createPluginRoot(container, app),
isUnmounting: false,
container,
};

View file

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

View file

@ -11,10 +11,7 @@ declare const app: App;
* @param maxLength - Maximum length for truncated content (default: 500)
* @returns Promise resolving to processed content string
*/
export async function loadNoteContentForPreview(
file: TFile,
maxLength: number = 500
): Promise<string> {
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

@ -1,75 +0,0 @@
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,11 +62,13 @@ 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);
React.useEffect(() => {
if (isGenerating) {
setIsEditMode(false);
}
}, [isGenerating]);
const [prevIsGenerating, setPrevIsGenerating] = React.useState(isGenerating);
if (isGenerating && !prevIsGenerating) {
setPrevIsGenerating(true);
setIsEditMode(false);
} else if (!isGenerating && prevIsGenerating) {
setPrevIsGenerating(false);
}
// Auto-scroll to bottom when streaming
React.useEffect(() => {

View file

@ -111,15 +111,28 @@ 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);
}
@ -133,10 +146,6 @@ 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;
@ -156,13 +165,10 @@ export function DraggableModal({
}
}, [open, resizable, heightPx, widthPx, minHeight, dragRef]);
// 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]);
// 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);
// Reason: For "above" placement, keep the panel's bottom edge anchored.
// When height increases (e.g., ContentArea appears), shift position.y up so the
@ -170,14 +176,14 @@ export function DraggableModal({
// Uses useLayoutEffect to apply before paint, preventing visual flash.
useLayoutEffect(() => {
if (anchorBottom === undefined || isManualPositionRef.current) return;
if (heightPx === null) return;
if (effectiveHeight === null) return;
const newY = Math.max(12, anchorBottom - heightPx);
const newY = Math.max(12, anchorBottom - effectiveHeight);
// 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, heightPx, position.x, position.y, setPosition]);
}, [anchorBottom, effectiveHeight, position.x, position.y, setPosition]);
// Reason: Generic overflow correction for non-anchored panels.
// If content/minHeight growth pushes the modal below the viewport edge,
@ -187,15 +193,15 @@ export function DraggableModal({
// height changes.
useLayoutEffect(() => {
if (anchorBottom !== undefined || isManualPositionRef.current) return;
if (heightPx === null) return;
if (effectiveHeight === null) return;
const ownerWindow = dragRef.current?.win ?? window;
const maxY = ownerWindow.innerHeight - 12 - heightPx;
const maxY = ownerWindow.innerHeight - 12 - effectiveHeight;
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, heightPx, position.x, position.y, setPosition, dragRef]);
}, [anchorBottom, effectiveHeight, position.x, position.y, setPosition, dragRef]);
const getResizeRect = useCallback(() => {
return dragRef.current?.getBoundingClientRect() ?? null;
@ -305,7 +311,7 @@ export function DraggableModal({
)}
style={{
width: resizable && widthPx !== null ? widthPx : width,
...(resizable && heightPx !== null ? { height: heightPx } : {}),
...(resizable && effectiveHeight !== null ? { height: effectiveHeight } : {}),
}}
>
{/* Header: drag handle + close button (flex-none) */}

View file

@ -1,11 +1,2 @@
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 { type ContentState } from "./content-area";
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 createRoot> | null = null;
private root: ReturnType<typeof createPluginRoot> | 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 = createRoot(rootEl);
this.root = createPluginRoot(rootEl, this.app);
}
// Pass a close function that takes a result

View file

@ -1,8 +1,9 @@
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 { createRoot, Root } from "react-dom/client";
import { Root } from "react-dom/client";
function ConfirmModalContent({
content,
@ -57,7 +58,7 @@ export class ConfirmModal extends Modal {
onOpen() {
const { contentEl } = this;
this.root = createRoot(contentEl);
this.root = createPluginRoot(contentEl, this.app);
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 = createRoot(contentEl);
this.root = createPluginRoot(contentEl, this.app);
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 = createRoot(contentEl);
this.root = createPluginRoot(contentEl, this.app);
const handleConfirm = () => {
applyPlusSettings();

View file

@ -1,8 +1,9 @@
import { App, Modal } from "obsidian";
import React, { useState } from "react";
import { createRoot, Root } from "react-dom/client";
import { 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,
@ -61,7 +62,7 @@ export class CustomPatternInputModal extends Modal {
onOpen() {
const { contentEl } = this;
this.root = createRoot(contentEl);
this.root = createPluginRoot(contentEl, this.app);
const handleConfirm = (extension: string) => {
this.onConfirm(extension);

View file

@ -1,8 +1,9 @@
import { App, Modal } from "obsidian";
import React, { useState } from "react";
import { createRoot, Root } from "react-dom/client";
import { 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,
@ -72,7 +73,7 @@ export class ExtensionInputModal extends Modal {
onOpen() {
const { contentEl } = this;
this.root = createRoot(contentEl);
this.root = createPluginRoot(contentEl, this.app);
const handleConfirm = (extension: string) => {
this.onConfirm(extension);

View file

@ -1,38 +0,0 @@
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,10 +28,11 @@
*/
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 { createRoot, Root } from "react-dom/client";
import { Root } from "react-dom/client";
interface MigrateConfirmContentProps {
onConfirm: () => void;
@ -53,8 +54,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>
@ -131,7 +132,7 @@ export class MigrateConfirmModal extends Modal {
onOpen() {
const { contentEl } = this;
this.root = createRoot(contentEl);
this.root = createPluginRoot(contentEl, this.app);
const handleConfirm = () => {
this.confirmed = true;

View file

@ -1,13 +0,0 @@
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

@ -1,298 +0,0 @@
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

@ -1,297 +0,0 @@
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,9 +3,10 @@ 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 { createRoot, Root } from "react-dom/client";
import { Root } from "react-dom/client";
interface TranscriptData {
videoId: string;
@ -214,7 +215,7 @@ export class YoutubeTranscriptModal extends Modal {
onOpen() {
const { contentEl } = this;
this.root = createRoot(contentEl);
this.root = createPluginRoot(contentEl, this.app);
const handleClose = () => {
this.close();

View file

@ -1,5 +1,5 @@
import { ProjectConfig } from "@/aiParams";
import { AppContext, useApp } from "@/context";
import { 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,9 +23,10 @@ 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 { createRoot, Root } from "react-dom/client";
import { Root } from "react-dom/client";
interface AddProjectModalContentProps {
initialProject?: ProjectConfig;
@ -471,7 +472,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 = createRoot(contentEl);
this.root = createPluginRoot(contentEl, this.app);
const handleSave = async (project: ProjectConfig) => {
await this.onSave(project);
@ -483,14 +484,12 @@ export class AddProjectModal extends Modal {
};
this.root.render(
<AppContext.Provider value={this.app}>
<AddProjectModalContent
initialProject={this.initialProject}
onSave={handleSave}
onCancel={handleCancel}
plugin={this.plugin}
/>
</AppContext.Provider>
<AddProjectModalContent
initialProject={this.initialProject}
onSave={handleSave}
onCancel={handleCancel}
plugin={this.plugin}
/>
);
}

View file

@ -38,8 +38,9 @@ import {
} from "lucide-react";
import { App, Modal, Notice, Platform, TFile } from "obsidian";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createRoot, Root } from "react-dom/client";
import { 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("*.", "");
@ -1380,7 +1381,7 @@ export class ContextManageModal extends Modal {
onOpen() {
const { contentEl, modalEl } = this;
this.root = createRoot(contentEl);
this.root = createPluginRoot(contentEl, this.app);
modalEl.addClass("tw-min-w-[50vw]");

View file

@ -1,73 +0,0 @@
/**
* 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,9 +9,10 @@
import { EditorView } from "@codemirror/view";
import type { Editor } from "obsidian";
import React from "react";
import { createRoot, Root } from "react-dom/client";
import { 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";
@ -338,7 +339,7 @@ export class QuickAskOverlay {
overlayRoot.appendChild(overlayContainer);
this.overlayContainer = overlayContainer;
this.root = createRoot(overlayContainer);
this.root = createPluginRoot(overlayContainer, this.options.plugin.app);
this.renderPanel();
// Reason: Reset side lock on scroll/resize so placement is re-evaluated

View file

@ -20,9 +20,6 @@ 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";
@ -43,8 +40,6 @@ 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);
@ -80,17 +75,6 @@ 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(() => {
@ -350,15 +334,6 @@ 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

@ -1,17 +0,0 @@
/**
* 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

@ -1,61 +0,0 @@
/**
* 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

@ -1,16 +0,0 @@
/**
* 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

@ -1,15 +0,0 @@
/**
* 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

@ -1,15 +0,0 @@
/**
* 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

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

View file

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

View file

@ -1,55 +0,0 @@
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,36 +1,5 @@
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";
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 = "";
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_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,20 +110,17 @@ 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
export const DEFAULT_MAX_SOURCE_CHUNKS = 30; // Default max chunks for search results (with diverse top-K)
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 = {
@ -176,13 +173,10 @@ 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_FLASH_PREVIEW = "gemini-3-flash-preview",
GEMINI_3_5_FLASH = "gemini-3.5-flash",
GEMINI_3_FLASH_LITE = "gemini-3.1-flash-lite",
GEMINI_PRO = "gemini-2.5-pro",
GEMINI_FLASH = "gemini-2.5-flash",
@ -195,7 +189,7 @@ export enum ChatModels {
MISTRAL_TINY = "mistral-tiny-latest",
DEEPSEEK_REASONER = "deepseek-reasoner",
DEEPSEEK_CHAT = "deepseek-chat",
OPENROUTER_GEMINI_3_FLASH_PREVIEW = "google/gemini-3-flash-preview",
OPENROUTER_GEMINI_3_5_FLASH = "google/gemini-3.5-flash",
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",
@ -260,6 +254,14 @@ 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,
@ -283,6 +285,14 @@ 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,
@ -300,13 +310,6 @@ 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,
@ -391,13 +394,6 @@ 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,
@ -458,7 +454,6 @@ 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 { compactAssistantOutput, compactChatHistoryContent } from "./ChatHistoryCompactor";
export { 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