Compare commits

..

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

428 changed files with 17485 additions and 22618 deletions

View file

@ -1,258 +0,0 @@
---
name: prerelease
description: Use this agent to create a prerelease PR that triggers the automated GitHub Actions release workflow with the `--prerelease` flag set on the resulting GitHub Release. It bumps the version using a prerelease tag (e.g., `3.2.9-beta.1`), generates prerelease notes from merged PRs since the last release, updates RELEASES.md, and creates a PR whose title matches the prerelease semver pattern expected by the release workflow. Use when the user says "cut a prerelease", "create a beta", "release a release candidate", "publish an rc", or similar.
model: sonnet
color: yellow
---
You are a prerelease manager for the Copilot for Obsidian plugin. Your job is to create a prerelease PR that, when merged, publishes a GitHub Release marked as a prerelease so Obsidian's plugin browser does not offer it as a stable update to end users.
## How Prereleases Differ from Stable Releases
- The PR title is a prerelease semver: `X.Y.Z-<tag>.<N>`, e.g. `3.2.9-beta.1`, `3.3.0-rc.0`, `4.0.0-alpha.2`.
- The release workflow (`.github/workflows/release.yml`) detects the prerelease pattern and passes `--prerelease` to `gh release create`. The GitHub Release is marked as "prerelease" and Obsidian's plugin browser does not offer it as an automatic update.
- **Master's `manifest.json` is NEVER modified by a prerelease.** Obsidian's community plugin store reads `manifest.json` on master to decide which GitHub Release to serve, and it must always reflect the latest stable. The prerelease manifest lives in `manifest-beta.json` instead. `version-bump.mjs` enforces this: when `npm_package_version` is a prerelease, it writes only to `manifest-beta.json` and `versions.json`.
- `package.json` is updated by npm itself with the prerelease version. That's the source of truth the agent reads to know the current version.
- The release workflow swaps `manifest-beta.json` into `manifest.json` _inside the runner only_ before uploading release assets, so testers who download the prerelease's assets get a `manifest.json` carrying the prerelease version. The committed `manifest.json` on master stays pinned to the latest stable.
## Step-by-Step Process
### Step 0: Pre-flight Sanity Checks
Before doing any version bumping, validate the repo is releasable. Stop and surface a problem to the user rather than papering over it.
1. **Confirm clean working tree on master.**
```bash
git checkout master && git pull origin master
git status --porcelain
```
Any uncommitted state means another PR is in flight or a prior agent run left files behind. Stop and ask the user to clarify before continuing.
2. **Run the full project check.**
```bash
npm ci
npm run lint
npm run build
npm test
```
Any failure means master is broken. A prerelease published from a broken master will mislead testers about the state of the next stable release. Stop, report which step failed, and ask the user how to proceed.
3. **Inspect the built `main.js` bundle size.**
```bash
ls -lh main.js
```
If `main.js` is over 5 MB, surface the size to the user. Prereleases test the same release artifact stable users will get, so the same Sync Standard concern applies. Ask whether to ship the prerelease anyway or hold.
4. **Verify manifest integrity (both files).**
For the stable manifest:
```bash
node -p "JSON.stringify(require('./manifest.json'), null, 2)"
```
Confirm `isDesktopOnly` is declared and `minAppVersion` reflects what the code actually calls.
If `manifest-beta.json` exists (a previous prerelease is in flight), inspect it too:
```bash
[ -f manifest-beta.json ] && node -p "JSON.stringify(require('./manifest-beta.json'), null, 2)"
```
`manifest-beta.json`'s `minAppVersion` and other metadata must match `manifest.json`'s (we don't test different minimums in the prerelease channel).
5. **Assert master's `manifest.json.version` matches the latest stable GitHub Release.**
If master has drifted from the latest stable release tag, the prerelease will publish on top of a broken state. Catch the drift before doing anything else:
```bash
# Use /releases/latest which returns only the most-recent non-prerelease,
# non-draft release in a single call — works regardless of how many
# prereleases have accumulated since the last stable.
LATEST_STABLE=$(gh api repos/logancyang/obsidian-copilot/releases/latest -q .tag_name)
MASTER_VERSION=$(node -p "require('./manifest.json').version")
if [ "$LATEST_STABLE" != "$MASTER_VERSION" ]; then
echo "DRIFT: master manifest.json.version='$MASTER_VERSION' but latest stable Release='$LATEST_STABLE'. Stop." >&2
exit 1
fi
```
Stop and tell the user if this fails. Do not "fix" master's manifest.json inside a prerelease PR.
6. **Confirm there are merged PRs to prerelease.**
```bash
git describe --tags --abbrev=0
git log --oneline $(git describe --tags --abbrev=0)..HEAD | head
```
If empty, there is nothing new to test. Stop and tell the user.
Only proceed once all six checks pass.
### Step 1: Determine Prerelease Identity
Ask the user:
- **Tag** (`beta`, `rc`, `alpha`, etc.). Default `beta` if the user does not specify.
- **Base version target** (`prepatch`, `preminor`, `premajor`). What stable release is this prerelease leading up to?
- `prepatch` (most common): `3.2.8``3.2.9-beta.0`
- `preminor`: `3.2.8``3.3.0-beta.0`
- `premajor`: `3.2.8``4.0.0-beta.0`
- **Or is this an iteration on an existing prerelease line?** If the current version is already a prerelease (e.g. `3.2.9-beta.0`), use `prerelease` to bump only the prerelease counter: `3.2.9-beta.0``3.2.9-beta.1`.
### Step 2: Prepare the Branch
```bash
git checkout master
git pull origin master
```
Create a prerelease branch. Use a descriptive name that includes the prerelease identity:
```bash
git checkout -b prerelease/vX.Y.Z-<tag>.<N>
```
### Step 3: Bump the Version
Run the appropriate npm version command with `--preid` set to the chosen tag and `--no-git-tag-version` so npm does not create a tag locally (the release workflow handles tagging).
For a new prerelease line:
```bash
npm version <prepatch|preminor|premajor> --preid=<tag> --no-git-tag-version
```
For incrementing an existing prerelease:
```bash
npm version prerelease --preid=<tag> --no-git-tag-version
```
Examples:
- `3.2.8` + `npm version prepatch --preid=beta --no-git-tag-version``3.2.9-beta.0`
- `3.2.9-beta.0` + `npm version prerelease --preid=beta --no-git-tag-version``3.2.9-beta.1`
- `3.2.9-beta.5` + `npm version prerelease --preid=rc --no-git-tag-version``3.2.9-rc.0`
`version-bump.mjs` will update `manifest-beta.json` (creating it if it doesn't already exist by seeding from `manifest.json`) and `versions.json` to match. **It does NOT modify `manifest.json`.** After bumping, read the new version from `package.json` to use in subsequent steps.
### Step 4: Gather and Understand Merged PRs
Same as the stable release agent. Find the last tag (which may itself be a prerelease), list merged PRs since, and read each PR description for context.
```bash
git describe --tags --abbrev=0
gh pr list --state merged --base master --search "merged:>YYYY-MM-DD" --json number,title,author,labels --limit 500
```
If the last tag is a prerelease (e.g. `3.2.9-beta.0`), list PRs merged since that prerelease, not since the last stable. The prerelease note should reflect only what is new since the previous testing artifact.
### Step 5: Generate Prerelease Notes
Use the same `RELEASES.md` format as stable releases, with the following adjustments:
**Header format:**
```
# Copilot for Obsidian - Prerelease vX.Y.Z-<tag>.<N> 🧪
```
The `🧪` emoji signals testing intent. Other appropriate emoji: `🚧` (work in progress), `🔬` (research), `🐛` (bug-fix prerelease).
**Opening line:** State this is a prerelease intended for testers. Mention what is being tested.
> Example: _This is a beta release for testing the new Vault QA caching path before it ships in 3.2.9. Please report any indexing or query issues in Discord._
**Bullet list:** Same emoji + bold + cheerful style as stable releases, but be honest about what is unverified. If a feature has known sharp edges, say so explicitly.
**Do NOT** include the full "Improvements / Bug Fixes" PR roll-up that stable releases use unless the user asks for it. Prerelease notes should be short and testing-focused.
**Always include a "What to Test" section** with explicit bullets telling testers where to focus:
```markdown
## What to Test
- New behavior X: try Y workflow and confirm Z.
- Changed behavior W: confirm it still does what it used to do.
- Known sharp edges: list anything you suspect is unstable so testers don't waste time reporting it.
```
**Always include a "How to Install" section.** Most users don't know how to install a prerelease.
```markdown
## How to Install the Prerelease
1. Download `main.js`, `manifest.json`, and `styles.css` from this prerelease's GitHub release page.
2. Replace the same three files in your vault's `.obsidian/plugins/copilot/` folder.
3. Reload the plugin (Settings → Community Plugins → toggle Copilot off and back on, or restart Obsidian).
4. Report issues with the prerelease version number in the title so we can track them.
To return to the stable release: reinstall the plugin from Obsidian's community-plugin browser.
```
End with the same Troubleshoot footer as stable releases, and a `---` separator.
### Step 6: Update RELEASES.md
Prepend the prerelease entry at the top of `RELEASES.md`, right after the `# Release Notes` header line. Keep all existing entries intact.
When the corresponding stable release ships, that release's notes are appended above the prerelease entry. The prerelease entry stays in the file as a historical record.
### Step 7: Commit and Create PR
Stage all changed files. Note that `manifest-beta.json` is what gets touched for prereleases, NOT `manifest.json`:
```bash
git add package.json package-lock.json manifest-beta.json versions.json RELEASES.md
```
If `git status` shows `manifest.json` modified, something went wrong. `version-bump.mjs` should never touch `manifest.json` during a prerelease bump. Stop and tell the user.
Commit with message: `prerelease: vX.Y.Z-<tag>.<N>`
Push and create the PR:
```bash
git push -u origin prerelease/vX.Y.Z-<tag>.<N>
gh pr create --title "X.Y.Z-<tag>.<N>" --body "$(cat <<'EOF'
## Prerelease vX.Y.Z-<tag>.<N>
[Paste the prerelease notes content here]
---
Generated by the prerelease agent.
EOF
)"
```
**Critical**: The PR title MUST be exactly the prerelease semver string (e.g., `3.2.9-beta.1`) with no `v` prefix and nothing else. This pattern is what triggers the release workflow with `--prerelease` set.
### Step 8: Report Back
Share the PR URL with the user and summarize:
- What prerelease version was cut
- Which PRs are included (count and key features)
- The bundle size for awareness
- Reminder that the PR title is the prerelease tag and merging it publishes a prerelease GitHub Release
## Important Rules
- **Never force-push or modify existing release entries** in RELEASES.md.
- **Always start from latest master** — pull before branching.
- **The PR title must be a bare prerelease semver string** in the form `X.Y.Z-<tag>.<N>` (e.g., `3.2.9-beta.1`). No `v` prefix, no extra text. This pattern is what tells the release workflow to mark the GitHub Release as a prerelease.
- **Use the stable release agent, not this one, for stable releases.** A title like `3.2.9` (no prerelease suffix) goes to the stable agent's flow.
- **Read existing RELEASES.md entries** before writing — match the tone and format exactly. Prerelease entries should be visually distinguishable (🧪 emoji header, explicit "What to Test" section, "How to Install" section).
- **Be honest about what is unverified.** Prereleases exist to surface bugs, not to oversell stability. If you would not bet your reputation on a feature, say so in the notes.
- **Stop on any pre-flight failure.** Do not publish a prerelease from a master that fails lint/build/test or has an oversized bundle. Report and ask, do not paper over.
- **Do not silently change `manifest.minAppVersion` or `manifest.isDesktopOnly`** in a prerelease PR. Same rule as stable releases: those changes belong in dedicated PRs.
- **Never modify master's `manifest.json` from a prerelease.** It must always reflect the latest stable release. Obsidian's plugin store relies on this. Prerelease metadata goes into `manifest-beta.json` only.
- If `npm version` fails or `version-bump.mjs` doesn't run, manually update `manifest-beta.json` and `versions.json` to match the prerelease semver. Do NOT touch `manifest.json`.

View file

@ -19,78 +19,6 @@ The repository has a GitHub Actions workflow that triggers on PR merge to `maste
## Step-by-Step Process
### Step 0: Pre-flight Sanity Checks
Before doing any version bumping, validate the repo is releasable. Stop and surface a problem to the user rather than papering over it.
1. **Confirm clean working tree on master.**
```bash
git checkout master && git pull origin master
git status --porcelain
```
Any uncommitted state means another PR is in flight or a prior agent run left files behind. Stop and ask the user to clarify before continuing.
2. **Run the full project check.**
```bash
npm ci
npm run lint
npm run build
npm test
```
Any failure means master is broken and a release would publish a broken artifact. Stop, report which step failed, and ask the user how to proceed.
3. **Inspect the built `main.js` bundle size.**
```bash
ls -lh main.js
```
If `main.js` is over 5 MB, the release will trip Obsidian's Sync Standard warning and break sync for paying users. Stop, surface the exact size to the user, and ask whether to ship the release anyway or hold for a bundle-reduction PR first.
4. **Verify `manifest.json` integrity.**
```bash
node -p "JSON.stringify(require('./manifest.json'), null, 2)"
```
Confirm that:
- `isDesktopOnly` is declared (currently `false`; do not silently change this).
- `minAppVersion` matches the Obsidian APIs the code actually uses. If a commit since the last release introduced a call that needs a newer minimum, the `minAppVersion` bump belongs in its own dedicated PR with its own review window, not bundled inside this release PR. Stop and tell the user.
5. **Assert that `manifest.json.version` matches the latest stable GitHub Release.**
Obsidian's community plugin store reads `manifest.json` on master to decide which GitHub Release artifact to serve to installers. If master drifts away from the latest stable tag, installs break for everyone. Catch the drift loudly before doing anything else:
```bash
# Use /releases/latest which returns only the most-recent non-prerelease,
# non-draft release in a single call — works regardless of how many
# prereleases have accumulated since the last stable.
LATEST_STABLE=$(gh api repos/logancyang/obsidian-copilot/releases/latest -q .tag_name)
MASTER_VERSION=$(node -p "require('./manifest.json').version")
if [ "$LATEST_STABLE" != "$MASTER_VERSION" ]; then
echo "DRIFT: master manifest.json.version='$MASTER_VERSION' but latest stable Release='$LATEST_STABLE'. Stop." >&2
exit 1
fi
```
Stop and tell the user if this fails. Do not "fix" the drift by bumping `manifest.json` inside a release PR — that needs its own dedicated PR.
6. **Confirm there are merged PRs to release.**
```bash
git describe --tags --abbrev=0
git log --oneline $(git describe --tags --abbrev=0)..HEAD | head
```
If the diff is empty, there is nothing to release. Stop and tell the user.
Only proceed to Step 1 once all six checks pass.
### Step 1: Determine Release Type
Ask the user:
@ -231,12 +159,8 @@ Share the PR URL with the user and summarize what was included in the release.
- **Never force-push or modify existing release entries** in RELEASES.md
- **Always start from latest master** — pull before branching
- **The PR title must be a bare stable semver string** (e.g., `3.2.4`, not `v3.2.4` or `Release 3.2.4`). For prereleases, use the prerelease agent instead.
- **The PR title must be a bare semver string** (e.g., `3.2.4`, not `v3.2.4` or `Release 3.2.4`)
- **Include ALL merged PRs** since the last release — don't skip any
- **Attribute every change** to the correct contributor using their GitHub username
- **Read existing RELEASES.md entries** before writing — match the tone and format exactly
- If `npm version` fails or version-bump.mjs doesn't run, manually update `manifest.json` and `versions.json`
- **Do not silently change `manifest.minAppVersion` or `manifest.isDesktopOnly`** in a release PR. Those changes belong in their own dedicated PR with a separate review window so reviewers can scrutinize the compatibility impact.
- **Surface bundle-size growth in the release notes** if `main.js` grew significantly since the last release. Users notice, and reviewers do too.
- **Stop on any pre-flight failure.** Do not push a release PR for a master that fails lint/build/test, has an oversized bundle, or has an inconsistent manifest. Report and ask, do not paper over.
- **Stable releases delete `manifest-beta.json` automatically.** `version-bump.mjs` `git rm`s it when bumping to a stable version, on the rationale that the new stable supersedes any in-flight prerelease. This happens in the version-bump commit; nothing extra to do, but be aware that the diff will show the deletion.

3
.eslintignore Normal file
View file

@ -0,0 +1,3 @@
node_modules/
main.js

52
.eslintrc Normal file
View file

@ -0,0 +1,52 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": ["@typescript-eslint", "tailwindcss"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:tailwindcss/recommended",
],
"parserOptions": {
"sourceType": "module",
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-explicit-any": "off",
"react/prop-types": "off",
"react-hooks/exhaustive-deps": "error",
"tailwindcss/classnames-order": "error",
"tailwindcss/enforces-negative-arbitrary-values": "error",
"tailwindcss/enforces-shorthand": "error",
"tailwindcss/migration-from-tailwind-2": "error",
"tailwindcss/no-arbitrary-value": "off",
"tailwindcss/no-custom-classname": ["error"],
"tailwindcss/no-contradicting-classname": "error",
},
"overrides": [
{
"files": ["*.json", "*.jsonc", ".eslintrc"],
"parser": "jsonc-eslint-parser",
"rules": {
"jsonc/auto": "error",
},
},
],
"settings": {
"react": {
"version": "detect",
},
"tailwindcss": {
"callees": ["classnames", "clsx", "ctl", "cn", "cva"],
"config": "./tailwind.config.js",
"cssFiles": ["**/*.css", "!**/node_modules", "!**/.*", "!**/dist", "!**/build"],
},
},
}

View file

@ -1,9 +1,6 @@
# Release workflow: triggered when a PR targeting master is merged and its title
# is a semver string. Two formats are accepted:
# - Stable release: "X.Y.Z" (e.g. "3.2.3")
# - Prerelease: "X.Y.Z-<tag>.<N>" (e.g. "3.2.9-beta.1", "3.3.0-rc.0")
# Prerelease titles publish a GitHub Release marked as a prerelease.
# The PR title becomes the release tag and title; the PR body becomes the release notes.
# is a strict semver string (e.g. "3.2.3"). The PR title becomes the release tag
# and title; the PR body becomes the release notes.
#
# Non-semver PR titles (feature PRs, bug fixes, etc.) are silently ignored —
# the workflow runs but exits early without creating a release.
@ -24,37 +21,28 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write # required to create GitHub releases and tags
attestations: write # required to publish artifact attestations
id-token: write # required for the workflow's OIDC token (used to sign attestations)
contents: write # required to create GitHub releases and tags
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
# ── Step 1: Validate that the PR title is a semver (stable or prerelease) ──
# ── Step 1: Validate that the PR title is a strict semver ──────────────
# Exits without error when the title is not semver so non-release PRs
# pass silently. Sets outputs.version, outputs.is_release, and
# outputs.is_prerelease for downstream steps.
# pass silently. Sets outputs.version and outputs.is_release for
# downstream steps.
- name: Validate semver PR title
id: semver
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
if echo "$PR_TITLE" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "PR title '$PR_TITLE' is a stable semver — proceeding with release."
echo "PR title '$PR_TITLE' is a valid semver — proceeding with release."
echo "version=$PR_TITLE" >> "$GITHUB_OUTPUT"
echo "is_release=true" >> "$GITHUB_OUTPUT"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
elif echo "$PR_TITLE" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z-]+\.[0-9]+$'; then
echo "PR title '$PR_TITLE' is a prerelease semver — proceeding with prerelease."
echo "version=$PR_TITLE" >> "$GITHUB_OUTPUT"
echo "is_release=true" >> "$GITHUB_OUTPUT"
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "PR title '$PR_TITLE' is not semver (X.Y.Z or X.Y.Z-tag.N) — skipping release."
echo "PR title '$PR_TITLE' is not strict semver (X.Y.Z) — skipping release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
fi
# ── Step 2: Checkout the merge commit on master ─────────────────────────
@ -66,54 +54,18 @@ jobs:
# master don't cause this workflow to build the wrong code.
ref: ${{ github.event.pull_request.merge_commit_sha }}
# ── Step 3: Verify PR title version matches the source-of-truth manifest ──
# For stable releases the source of truth is manifest.json (which the
# Obsidian plugin store reads). For prereleases the source of truth is
# manifest-beta.json (manifest.json must stay pinned at the latest stable
# so the plugin store keeps serving installs correctly).
#
# For prereleases we ALSO assert that manifest.json on the merge commit
# still matches the latest stable GitHub Release tag. This defends against
# an old-style version-bump or hand edit accidentally re-poisoning master.
- name: Verify version matches manifest
# ── Step 3: Verify PR title version matches manifest.json ───────────────
- name: Verify version matches manifest.json
if: steps.semver.outputs.is_release == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.semver.outputs.version }}
IS_PRERELEASE: ${{ steps.semver.outputs.is_prerelease }}
run: |
if [ "$IS_PRERELEASE" = "true" ]; then
MANIFEST_FILE="manifest-beta.json"
# Guard: master's manifest.json must still equal the latest stable Release tag.
# Use /releases/latest which (per GitHub API) returns only the most-recent
# non-prerelease, non-draft release in one call — no pagination concerns
# even if many prereleases have accumulated since the last stable.
LATEST_STABLE=$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" -q .tag_name 2>/dev/null || true)
STABLE_MANIFEST_VERSION=$(node -p "require('./manifest.json').version")
if [ -z "$LATEST_STABLE" ]; then
echo "Could not determine latest stable GitHub Release tag; aborting to avoid poisoning manifest.json"
exit 1
fi
if [ "$STABLE_MANIFEST_VERSION" != "$LATEST_STABLE" ]; then
echo "manifest.json on merge commit ('$STABLE_MANIFEST_VERSION') drifted from latest stable Release ('$LATEST_STABLE')."
echo "A prerelease PR must not modify manifest.json. Refusing to publish."
exit 1
fi
echo "manifest.json drift check passed: $STABLE_MANIFEST_VERSION matches latest stable."
else
MANIFEST_FILE="manifest.json"
fi
if [ ! -f "$MANIFEST_FILE" ]; then
echo "Expected $MANIFEST_FILE to exist for this release type but it does not."
exit 1
fi
MANIFEST_VERSION=$(node -p "require('./$MANIFEST_FILE').version")
MANIFEST_VERSION=$(node -p "require('./manifest.json').version")
if [ "$VERSION" != "$MANIFEST_VERSION" ]; then
echo "Version mismatch: PR title='$VERSION', $MANIFEST_FILE='$MANIFEST_VERSION'"
echo "Version mismatch: PR title='$VERSION', manifest.json='$MANIFEST_VERSION'"
exit 1
fi
echo "Version check passed: $VERSION (verified against $MANIFEST_FILE)"
echo "Version check passed: $VERSION"
# ── Step 4: Set up Node.js ───────────────────────────────────────────────
- name: Setup Node.js 22.x
@ -157,60 +109,20 @@ jobs:
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
# ── Step 9: Prepare release-asset manifest ──────────────────────────────
# For a prerelease, the manifest.json asset uploaded to the GitHub
# Release must carry the prerelease version (so testers who sideload
# the assets get a manifest matching what they downloaded). We swap
# manifest-beta.json into manifest.json's place IN THE RUNNER only —
# this never gets pushed back to master, so the committed manifest.json
# stays pinned to the latest stable.
- name: Prepare release-asset manifest
if: steps.semver.outputs.is_release == 'true' && steps.semver.outputs.is_prerelease == 'true'
run: |
cp manifest-beta.json manifest.json
echo "Release-asset manifest.json (prerelease):"
cat manifest.json
# ── Step 10: Generate build provenance attestation ──────────────────────
# Cryptographically signs main.js / manifest.json / styles.css with the
# workflow's OIDC identity and publishes the attestation to Sigstore's
# public transparency log. Anyone can later verify a downloaded asset
# was actually built by this workflow on this commit with:
# gh attestation verify main.js --owner logancyang --repo logancyang/obsidian-copilot
#
# Runs AFTER the prerelease manifest swap so the attested manifest.json
# exactly matches the file uploaded to the GitHub Release.
- name: Generate artifact attestation
if: steps.semver.outputs.is_release == 'true' && steps.check_existing.outputs.exists != 'true'
uses: actions/attest-build-provenance@v2
with:
subject-path: |
main.js
manifest.json
styles.css
# ── Step 11: Create GitHub Release ───────────────────────────────────────
# ── Step 9: Create GitHub Release ────────────────────────────────────────
# --target pins the tag to the exact merge commit SHA so concurrent
# merges cannot cause the release tag to point at a different commit.
# When the PR title is a prerelease semver (X.Y.Z-tag.N), pass --prerelease
# so Obsidian's plugin browser does not offer it as a stable update.
# Artifacts: main.js, manifest.json, styles.css
- name: Create GitHub Release
if: steps.semver.outputs.is_release == 'true' && steps.check_existing.outputs.exists != 'true'
env:
VERSION: ${{ steps.semver.outputs.version }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
IS_PRERELEASE: ${{ steps.semver.outputs.is_prerelease }}
run: |
PRERELEASE_FLAG=""
if [ "$IS_PRERELEASE" = "true" ]; then
PRERELEASE_FLAG="--prerelease"
fi
gh release create "$VERSION" \
--target "$MERGE_SHA" \
--title "$VERSION" \
--notes-file /tmp/release-notes.md \
$PRERELEASE_FLAG \
main.js \
manifest.json \
styles.css

View file

@ -1 +1 @@
npx nano-staged
npx lint-staged

112
AGENTS.md
View file

@ -12,7 +12,6 @@ Copilot for Obsidian is an AI-powered assistant plugin that integrates various L
- **NEVER RUN `npm run dev`** - The user will handle all builds manually
- `npm run build` - Production build (TypeScript check + minified output)
- `npm run test:vault` - macOS only. Installs deps, builds, symlinks `main.js` / `manifest.json` / `styles.css` from the current worktree into `$COPILOT_TEST_VAULT_PATH/.obsidian/plugins/copilot/`, then reloads the plugin via the Obsidian CLI. Requires the user-level env var `COPILOT_TEST_VAULT_PATH` to be set to a vault that has been opened in Obsidian at least once. Use this when the user asks you to load the plugin into their test vault — it replaces manual build + copy + reload.
### Code Quality
@ -28,38 +27,6 @@ Copilot for Obsidian is an AI-powered assistant plugin that integrates various L
- `npm run test:integration` - Run integration tests (requires API keys)
- Run single test: `npm test -- -t "test name"`
### Obsidian CLI (Live Testing)
The Obsidian desktop app includes a CLI for plugin development. Use the full path:
```bash
/Applications/Obsidian.app/Contents/MacOS/obsidian <command>
```
**Plugin reload** (after `npm run build`):
```bash
/Applications/Obsidian.app/Contents/MacOS/obsidian plugin:reload id=copilot
```
**Console debugging** (requires attaching debugger first):
```bash
/Applications/Obsidian.app/Contents/MacOS/obsidian dev:debug on
/Applications/Obsidian.app/Contents/MacOS/obsidian dev:console limit=30
/Applications/Obsidian.app/Contents/MacOS/obsidian dev:console level=error limit=10
/Applications/Obsidian.app/Contents/MacOS/obsidian dev:errors
```
**Other useful dev commands**:
- `dev:dom selector=<css>` — Query DOM elements
- `dev:screenshot path=<file>` — Take a screenshot
- `eval code=<js>` — Execute JS in the app context
- `plugin:disable id=copilot` / `plugin:enable id=copilot`
Run `obsidian help` for the full command list.
## High-Level Architecture
### Core Systems
@ -224,15 +191,7 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE
- `logWarn()` for warnings
- `logError()` for errors
- Import from logger: `import { logInfo, logWarn, logError } from "@/logger"`
### CSS & Styling
- **NEVER edit `styles.css` directly** - This is a generated file
- **Source file**: `src/styles/tailwind.css` - Edit this file for custom CSS
- **Build process**: `npm run build:tailwind` compiles `src/styles/tailwind.css``styles.css`
- **Tailwind classes**: Use Tailwind utility classes in components (see `tailwind.config.js` for available classes)
- **Custom CSS**: Add custom styles to `src/styles/tailwind.css` after the `@import` statements
- After editing CSS, always run `npm run build` to regenerate `styles.css`
- These utilities already respect the debug flag internally — never wrap them in `if (getSettings().debug)`
## Testing Guidelines
@ -242,17 +201,6 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE
- Test files adjacent to implementation (`.test.ts`)
- Use `@testing-library/react` for component testing
### Avoiding Deep Dependency Chains in Tests
This codebase has deep transitive import chains (e.g. a utility → cache → searchUtils → embeddingManager → brevilabsClient → plusUtils → Modal). Importing any module in this chain from a test requires mocking the entire tree, which is brittle and verbose.
**Rules for new code:**
1. **Pass data, not services** — If a function only needs a string (like `outputFolder`), accept it as a parameter. Don't give it access to the entire settings singleton.
2. **Singletons at the edges only**`getSettings()`, `PDFCache.getInstance()`, `BrevilabsClient.getInstance()` should only be called in top-level orchestration (constructors, main entry points). Inner functions receive what they need as parameters.
3. **Pure logic in leaf modules** — Extract testable logic into small files with minimal imports. The orchestration file (which has heavy imports) calls the leaf function and passes in the dependencies. See `src/tools/convertedDocOutput.ts` as an example.
4. **Litmus test before writing a function** — "Can I test this by calling it directly with plain arguments?" If the answer is no because of an import, that dependency should be a parameter instead.
## Development Session Planning
### Using TODO.md for Session Management
@ -314,67 +262,10 @@ The TODO.md should be:
- For technical debt and known issues, see [`TECHDEBT.md`](./designdocs/todo/TECHDEBT.md)
- For current development session planning, see [`TODO.md`](./TODO.md)
## User-Facing Documentation
- **When modifying user-facing behavior** (new features, changed settings, removed functionality), **update the corresponding doc in `docs/`**. The doc filenames match their topics (e.g., `llm-providers.md` for provider changes, `agent-mode-and-tools.md` for tool changes).
- Docs are written for non-technical users — no source code references, explain behavior and concepts.
- If a change affects multiple docs, update all of them.
- If you're unsure which doc to update, check `docs/index.md` for the full list with descriptions.
### AWS Bedrock Usage
**IMPORTANT**: When using AWS Bedrock, always use **cross-region inference profile IDs** for better reliability and availability:
- **Global** (recommended): `global.anthropic.claude-sonnet-4-5-20250929-v1:0`
- Routes to any commercial AWS region automatically
- Best for reliability and performance
- **US**: `us.anthropic.claude-sonnet-4-5-20250929-v1:0`
- **EU**: `eu.anthropic.claude-sonnet-4-5-20250929-v1:0`
- **APAC**: `apac.anthropic.claude-sonnet-4-5-20250929-v1:0`
**Avoid regional model IDs** (without prefix): `anthropic.claude-sonnet-4-5-20250929-v1:0`
- These only work in specific regions and often fail
- Not recommended for production use
**References:**
- [AWS Bedrock Cross-Region Inference](https://docs.aws.amazon.com/bedrock/latest/userguide/cross-region-inference.html)
- [Supported Inference Profiles](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html)
### Obsidian Plugin Environment
- **Global `app` variable**: In Obsidian plugins, `app` is a globally available variable that provides access to the Obsidian API. It's automatically available in all files without needing to import or declare it.
### Picking the right `document` / `window` (popout-window safety)
Obsidian supports pop-out windows. The plugin loads in the main window but views can live in any window. Picking the wrong `Document` / `Window` produces stale references, off-screen popovers, listeners on the wrong window, or DOM nodes that never render. Use this decision order:
1. **`element.doc` / `element.win`** — preferred. Obsidian augments every `Node` with `.doc: Document` and `.win: Window` that always reflect the element's current owner. Use whenever you have any DOM node in scope (a ref, an event target, a component's container, a `Range`'s `startContainer`).
- `containerRef.current?.doc.addEventListener(...)`
- `range.startContainer.win.innerWidth`
- `editor.getRootElement()?.doc`
2. **`global activeDocument` / `activeWindow`** — fallback only. These point to whichever window is _focused right now_. Correct semantics for actions that follow user focus (e.g., the AddImageModal file picker; selectionchange registration at plugin load), but wrong when the action belongs to a specific view (a chat in a popout while the user clicks back to the main window).
3. **`document` / `window` globals** — almost always wrong. They are aliases for the main window even when the user is interacting with a popout. Avoid in new code. If you find yourself reaching for them, it's a sign the surrounding code should be taking a `Document`/`Window` parameter or deriving from a DOM ref.
4. **`element.ownerDocument`** — works (standard DOM), but prefer `.doc` for consistency with the codebase. They return the same `Document` for any mounted `HTMLElement`. `.doc` is shorter and typed non-nullable.
**Listeners that may outlive a window migration:** capture the `Document` / `Window` at registration and remove on the same one:
```ts
const doc = containerRef.current?.doc;
if (!doc) return;
doc.addEventListener("keydown", handler);
return () => doc.removeEventListener("keydown", handler);
```
Do **not** rely on `activeDocument` at registration _and_ removal — it can shift between the two calls if focus moves.
**View migrated to a new window:** for a view that owns React or other long-lived renderers, register `this.containerEl.onWindowMigrated((win) => { ... })` in `onOpen`. The callback fires when Obsidian reparents the element into a different window's document. Tear down and rebuild the renderer there so it captures the new window. Save the returned destroy function and call it in `onClose` to avoid leaks. `CopilotView` is the canonical example — it unmounts and recreates the React root on migration so Lexical re-binds to the popout's window.
**Cross-realm `instanceof`:** popout windows have their own `Element`, `MouseEvent`, etc., so standard `instanceof` checks fail across windows. Use Obsidian's `element.instanceOf(HTMLElement)` and `event.instanceOf(MouseEvent)` when checking type across realms.
**Tests (jsdom):** `jest.setup.js` polyfills `Node.doc` / `Node.win` so plugin code using these properties works under jsdom. Don't add `instanceof` guards that depend on the Obsidian-augmented globals without considering the test environment.
### Architecture Migration Notes
- **SharedState Removed**: The legacy `src/sharedState.ts` has been completely removed
@ -388,4 +279,3 @@ Do **not** rely on `activeDocument` at registration _and_ removal — it can shi
- Non-project chats stored in default repository
- Backwards compatible - loads existing messages from ProjectManager cache
- Zero configuration required - works automatically
- Check @tailwind.config.js to understand what tailwind css classnames are available

360
CLAUDE.md
View file

@ -1,3 +1,361 @@
# CLAUDE.md
@AGENTS.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
Copilot for Obsidian is an AI-powered assistant plugin that integrates various LLM providers (OpenAI, Anthropic, Google, etc.) with Obsidian. It provides chat interfaces, autocomplete, semantic search, and various AI-powered commands for note-taking and knowledge management.
## Development Commands
### Build & Development
- **NEVER RUN `npm run dev`** - The user will handle all builds manually
- `npm run build` - Production build (TypeScript check + minified output)
### Code Quality
- `npm run lint` - Run ESLint checks
- `npm run lint:fix` - Auto-fix ESLint issues
- `npm run format` - Format code with Prettier
- `npm run format:check` - Check formatting without changing files
- **Before PR:** Always run `npm run format && npm run lint`
### Testing
- `npm run test` - Run unit tests (excludes integration tests)
- `npm run test:integration` - Run integration tests (requires API keys)
- Run single test: `npm test -- -t "test name"`
### Obsidian CLI (Live Testing)
The Obsidian desktop app includes a CLI for plugin development. Use the full path:
```bash
/Applications/Obsidian.app/Contents/MacOS/obsidian <command>
```
**Plugin reload** (after `npm run build`):
```bash
/Applications/Obsidian.app/Contents/MacOS/obsidian plugin:reload id=copilot
```
**Console debugging** (requires attaching debugger first):
```bash
/Applications/Obsidian.app/Contents/MacOS/obsidian dev:debug on
/Applications/Obsidian.app/Contents/MacOS/obsidian dev:console limit=30
/Applications/Obsidian.app/Contents/MacOS/obsidian dev:console level=error limit=10
/Applications/Obsidian.app/Contents/MacOS/obsidian dev:errors
```
**Other useful dev commands**:
- `dev:dom selector=<css>` — Query DOM elements
- `dev:screenshot path=<file>` — Take a screenshot
- `eval code=<js>` — Execute JS in the app context
- `plugin:disable id=copilot` / `plugin:enable id=copilot`
Run `obsidian help` for the full command list.
## High-Level Architecture
### Core Systems
1. **LLM Provider System** (`src/LLMProviders/`)
- Provider implementations for OpenAI, Anthropic, Google, Azure, local models
- `LLMProviderManager` handles provider lifecycle and switching
- Stream-based responses with error handling and rate limiting
- Custom model configuration support
2. **Chain Factory Pattern** (`src/chainFactory.ts`)
- Different chain types for various AI operations (chat, copilot, adhoc prompts)
- LangChain integration for complex workflows
- Memory management for conversation context
- Tool integration (search, file operations, time queries)
3. **Vector Store & Search** (`src/search/`)
- `VectorStoreManager` manages embeddings and semantic search
- `ChunkedStorage` for efficient large document handling
- Event-driven index updates via `IndexManager`
- Multiple embedding providers support
4. **UI Component System** (`src/components/`)
- React functional components with Radix UI primitives
- Tailwind CSS with class variance authority (CVA)
- Modal system for user interactions
- Chat interface with streaming support
- Settings UI with versioned components
5. **Message Management Architecture** (`src/core/`, `src/state/`)
- **MessageRepository** (`src/core/MessageRepository.ts`): Single source of truth for all messages
- Stores each message once with both `displayText` and `processedText`
- Provides computed views for UI display and LLM processing
- No complex dual-array synchronization
- **ChatManager** (`src/core/ChatManager.ts`): Central business logic coordinator
- Orchestrates MessageRepository, ContextManager, and LLM operations
- Handles message sending, editing, regeneration, and deletion
- Manages context processing and chain memory synchronization
- **Project Chat Isolation**: Maintains separate MessageRepository per project
- Automatically detects project switches via `getCurrentMessageRepo()`
- Each project has its own isolated message history
- Non-project chats use `defaultProjectKey` repository
- **ChatUIState** (`src/state/ChatUIState.ts`): Clean UI-only state manager
- Delegates all business logic to ChatManager
- Provides React integration with subscription mechanism
- Replaces legacy SharedState with minimal, focused approach
- **ContextManager** (`src/core/ContextManager.ts`): Handles context processing
- Processes message context (notes, URLs, selected text)
- Reprocesses context when messages are edited
6. **Settings Management**
- Jotai for atomic settings state management
- React contexts for feature-specific state
7. **Plugin Integration**
- Main entry: `src/main.ts` extends Obsidian Plugin
- Command registration system
- Event handling for Obsidian lifecycle
- Settings persistence and migration
- Chat history loading via pending message mechanism
### Key Patterns
- **Single Source of Truth**: MessageRepository stores each message once with computed views
- **Clean Architecture**: Repository → Manager → UIState → React Components
- **Context Reprocessing**: Automatic context updates when messages are edited
- **Computed Views**: Display messages for UI, LLM messages for AI processing
- **Project Isolation**: Each project maintains its own MessageRepository instance
- **Error Handling**: Custom error types with detailed interfaces
- **Async Operations**: Consistent async/await pattern with proper error boundaries
- **Caching**: Multi-layer caching for files, PDFs, and API responses
- **Streaming**: Real-time streaming for LLM responses
- **Testing**: Unit tests adjacent to implementation, integration tests for API calls
## Message Management Architecture
For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE.md`](./designdocs/MESSAGE_ARCHITECTURE.md).
### Core Classes and Flow
1. **MessageRepository** (`src/core/MessageRepository.ts`)
- Single source of truth for all messages
- Stores `StoredMessage` objects with both `displayText` and `processedText`
- Provides computed views via `getDisplayMessages()` and `getLLMMessages()`
- No complex dual-array synchronization or ID matching
2. **ChatManager** (`src/core/ChatManager.ts`)
- Central business logic coordinator
- Orchestrates MessageRepository, ContextManager, and LLM operations
- Handles all message CRUD operations with proper error handling
- Synchronizes with chain memory for conversation history
- **Project Chat Isolation Implementation**:
- Maintains `projectMessageRepos: Map<string, MessageRepository>` for project-specific storage
- `getCurrentMessageRepo()` automatically detects current project and returns correct repository
- Seamlessly switches between project repositories when project changes
- Creates new empty repository for each project (no message caching)
3. **ChatUIState** (`src/state/ChatUIState.ts`)
- Clean UI-only state manager
- Delegates all business logic to ChatManager
- Provides React integration with subscription mechanism
- Replaces legacy SharedState with minimal, focused approach
4. **ContextManager** (`src/core/ContextManager.ts`)
- Handles context processing (notes, URLs, selected text)
- Reprocesses context when messages are edited
- Ensures fresh context for LLM processing
5. **ChatPersistenceManager** (`src/core/ChatPersistenceManager.ts`)
- Handles saving and loading chat history to/from markdown files
- Project-aware file naming (prefixes with project ID)
- Parses and formats chat content for storage
- Integrated with ChatManager for seamless persistence
## Code Style Guidelines
### MAJOR PRINCIPLES
- **ALWAYS WRITE GENERALIZABLE SOLUTIONS**: Never add edge-case handling or hardcoded logic for specific scenarios (like "piano notes" or "daily notes"). Solutions must work for all cases.
- **NEVER MODIFY AI PROMPT CONTENT**: Do not update, edit, or change any AI prompts, system prompts, or model adapter prompts unless explicitly asked to do so by the user
- **Avoid hardcoding**: No hardcoded folder names, file patterns, or special-case logic
- **Configuration over convention**: If behavior needs to vary, make it configurable, not hardcoded
- **Universal patterns**: Solutions should work equally well for any folder structure, naming convention, or content type
### TypeScript
- Strict mode enabled (no implicit any, strict null checks)
- Use absolute imports with `@/` prefix: `import { ChainType } from "@/chainFactory"`
- Prefer const assertions and type inference where appropriate
- Use interface for object shapes, type for unions/aliases
### React
- Functional components only (no class components)
- Custom hooks for reusable logic
- Props interfaces defined above components
- Avoid inline styles, use Tailwind classes
### General
- File naming: PascalCase for components, camelCase for utilities
- Async/await over promises
- Early returns for error conditions
- **Always add JSDoc comments** for all functions and methods
- Organize imports: React → external → internal
- **Avoid language-specific lists** (like stopwords or action verbs) - use language-agnostic approaches instead
### Logging
- **NEVER use console.log** - Use the logging utilities instead:
- `logInfo()` for informational messages
- `logWarn()` for warnings
- `logError()` for errors
- Import from logger: `import { logInfo, logWarn, logError } from "@/logger"`
### CSS & Styling
- **NEVER edit `styles.css` directly** - This is a generated file
- **Source file**: `src/styles/tailwind.css` - Edit this file for custom CSS
- **Build process**: `npm run build:tailwind` compiles `src/styles/tailwind.css``styles.css`
- **Tailwind classes**: Use Tailwind utility classes in components (see `tailwind.config.js` for available classes)
- **Custom CSS**: Add custom styles to `src/styles/tailwind.css` after the `@import` statements
- After editing CSS, always run `npm run build` to regenerate `styles.css`
## Testing Guidelines
- Unit tests use Jest with TypeScript support
- Mock Obsidian API for plugin testing
- Integration tests require API keys in `.env.test`
- Test files adjacent to implementation (`.test.ts`)
- Use `@testing-library/react` for component testing
### Avoiding Deep Dependency Chains in Tests
This codebase has deep transitive import chains (e.g. a utility → cache → searchUtils → embeddingManager → brevilabsClient → plusUtils → Modal). Importing any module in this chain from a test requires mocking the entire tree, which is brittle and verbose.
**Rules for new code:**
1. **Pass data, not services** — If a function only needs a string (like `outputFolder`), accept it as a parameter. Don't give it access to the entire settings singleton.
2. **Singletons at the edges only**`getSettings()`, `PDFCache.getInstance()`, `BrevilabsClient.getInstance()` should only be called in top-level orchestration (constructors, main entry points). Inner functions receive what they need as parameters.
3. **Pure logic in leaf modules** — Extract testable logic into small files with minimal imports. The orchestration file (which has heavy imports) calls the leaf function and passes in the dependencies. See `src/tools/convertedDocOutput.ts` as an example.
4. **Litmus test before writing a function** — "Can I test this by calling it directly with plain arguments?" If the answer is no because of an import, that dependency should be a parameter instead.
## Development Session Planning
### Using TODO.md for Session Management
**IMPORTANT**: When working on a development session, maintain a comprehensive `TODO.md` file that serves as the central plan and tracker:
1. **Session Goal**: Define the high-level objective at the start
2. **Task Tracking**:
- List all completed tasks with [x] checkboxes
- Track pending tasks with [ ] checkboxes
- Group related tasks into logical sections
3. **Architecture Decisions**: Document key design choices and rationale
4. **Progress Updates**: Keep the TODO.md updated as tasks complete
5. **Testing Checklist**: Include verification steps for the session
The TODO.md should be:
- The single source of truth for session progress
- Updated frequently as work progresses
- Clear enough that another developer can understand what was done
- Comprehensive enough to serve as a migration guide
### Structure Example:
```markdown
# Development Session TODO
## Session Goal
[Clear statement of what this session aims to achieve]
## Completed Tasks ✅
- [x] Task description with key details
- [x] Another completed task
## Pending Tasks 📋
- [ ] Next task to work on
- [ ] Future enhancement
## Architecture Summary
[Key design decisions and rationale]
## Testing Checklist
- [ ] Functionality verification
- [ ] Performance checks
```
## Important Notes
- The plugin supports multiple LLM providers with custom endpoints
- Vector store requires rebuilding when switching embedding providers
- Settings are versioned - migrations may be needed
- Local model support available via Ollama/LM Studio
- Rate limiting is implemented for all API calls
- For technical debt and known issues, see [`TECHDEBT.md`](./designdocs/todo/TECHDEBT.md)
- For current development session planning, see [`TODO.md`](./TODO.md)
## User-Facing Documentation
- **When modifying user-facing behavior** (new features, changed settings, removed functionality), **update the corresponding doc in `docs/`**. The doc filenames match their topics (e.g., `llm-providers.md` for provider changes, `agent-mode-and-tools.md` for tool changes).
- Docs are written for non-technical users — no source code references, explain behavior and concepts.
- If a change affects multiple docs, update all of them.
- If you're unsure which doc to update, check `docs/index.md` for the full list with descriptions.
### AWS Bedrock Usage
**IMPORTANT**: When using AWS Bedrock, always use **cross-region inference profile IDs** for better reliability and availability:
- **Global** (recommended): `global.anthropic.claude-sonnet-4-5-20250929-v1:0`
- Routes to any commercial AWS region automatically
- Best for reliability and performance
- **US**: `us.anthropic.claude-sonnet-4-5-20250929-v1:0`
- **EU**: `eu.anthropic.claude-sonnet-4-5-20250929-v1:0`
- **APAC**: `apac.anthropic.claude-sonnet-4-5-20250929-v1:0`
**Avoid regional model IDs** (without prefix): `anthropic.claude-sonnet-4-5-20250929-v1:0`
- These only work in specific regions and often fail
- Not recommended for production use
**References:**
- [AWS Bedrock Cross-Region Inference](https://docs.aws.amazon.com/bedrock/latest/userguide/cross-region-inference.html)
- [Supported Inference Profiles](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html)
### Obsidian Plugin Environment
- **Global `app` variable**: In Obsidian plugins, `app` is a globally available variable that provides access to the Obsidian API. It's automatically available in all files without needing to import or declare it.
### Architecture Migration Notes
- **SharedState Removed**: The legacy `src/sharedState.ts` has been completely removed
- **Clean Architecture**: New architecture follows Repository → Manager → UIState → UI pattern
- **Single Source of Truth**: All messages stored once in MessageRepository with computed views
- **Context Always Fresh**: Context is reprocessed when messages are edited to ensure accuracy
- **Chat History Loading**: Uses pending message mechanism through CopilotView → Chat component props
- **Project Chat Isolation**: Each project now has completely isolated chat history
- Automatic detection of project switches via `ProjectManager.getCurrentProjectId()`
- Separate MessageRepository instances per project ID
- Non-project chats stored in default repository
- Backwards compatible - loads existing messages from ProjectManager cache
- Zero configuration required - works automatically
- Check @tailwind.config.js to understand what tailwind css classnames are available

View file

@ -53,63 +53,6 @@ In the case of Copilot for Obsidian, you will need to:
Try to be descriptive in your branch names and pull requests. Happy coding!
#### Fast Iteration with `npm run test:vault` (macOS)
If you work across multiple worktrees or just want one command to build and load the plugin into a test vault, use `npm run test:vault`. It runs `npm install`, builds, symlinks `main.js` / `manifest.json` / `styles.css` from the worktree into the vault's `.obsidian/plugins/copilot/` folder, and reloads the plugin in Obsidian via its CLI.
**One-time setup:**
1. Create or pick a vault dedicated to plugin testing and open it in Obsidian at least once so `.obsidian/` is created.
2. Enable community plugins in that vault (Settings → Community plugins → Turn on).
3. Set an env var pointing at the vault path. Add this to `~/.zshrc`, `~/.bashrc`, or `~/.config/fish/config.fish`:
```bash
export COPILOT_TEST_VAULT_PATH="$HOME/Obsidian/CopilotTestVault"
```
**Per change:**
From any worktree, run:
```bash
npm run test:vault
```
The script installs deps, builds the plugin, symlinks the build artifacts into the vault, then calls `plugin:enable` and `plugin:reload` on the Obsidian CLI. If Obsidian isn't running, the symlinks are still in place — start Obsidian and the new build will load.
Because the script symlinks files (not the worktree root), the vault's plugin `data.json` (settings, chat history) stays vault-local and is preserved across worktrees and rebuilds.
Requires macOS with Obsidian installed at `/Applications/Obsidian.app`.
## Commit Signing
Commits to `master` must be signed and verified by GitHub. The easiest path is SSH signing using your existing SSH key.
1. Configure git to sign with your SSH key:
```bash
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
```
Replace `id_ed25519.pub` with the path to your own public key if different.
2. Register the same key as a **Signing Key** on GitHub at https://github.com/settings/ssh/new. Set "Key type" to `Signing Key` (this is separate from an Authentication Key, even if it's the same key).
3. Confirm your commit email matches a verified email on your GitHub account at https://github.com/settings/emails. Otherwise commits show as Unverified even when signed.
4. Verify locally and on GitHub:
```bash
git commit --allow-empty -m "test signing"
git log --show-signature -1
```
After pushing, the commit on github.com should display a green **Verified** badge.
If you already use GPG, set `gpg.format openpgp` instead and register the GPG public key at https://github.com/settings/gpg/new. Commits merged via the GitHub web UI are auto-signed by GitHub and don't need this setup.
## Prompt Testing
If you are making prompt changes, make sure to run the integration tests using the following steps:

View file

@ -53,38 +53,24 @@ This is the future we believe in. If you share this vision, please support this
## Table of Contents
- [The What](#the-what)
- [The Why](#the-why)
- [Key Features](#key-features)
- [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)
- [Set API Keys](#set-api-keys)
- [Usage](#usage)
- [Free User](#free-user)
- [**Chat Mode: reference notes and discuss ideas with Copilot**](#chat-mode-reference-notes-and-discuss-ideas-with-copilot)
- [**Vault QA Mode: chat with your entire vault**](#vault-qa-mode-chat-with-your-entire-vault)
- [Copilot's Command Palette](#copilots-command-palette)
- [**Relevant Notes: notes suggestions based on semantic similarity and links**](#relevant-notes-notes-suggestions-based-on-semantic-similarity-and-links)
- [Copilot Plus/Believer](#copilot-plusbeliever)
- [**Get Precision Insights From a Specific Time Window**](#get-precision-insights-from-a-specific-time-window)
- [**Agent Mode: Autonomous Tool Calling**](#agent-mode-autonomous-tool-calling)
- [**Understand Images in Your Notes**](#understand-images-in-your-notes)
- [**One Prompt, Every Source—Instant Summaries from PDFs, Videos, and Web**](#one-prompt-every-sourceinstant-summaries-from-pdfs-videos-and-web)
- [**Need Help?**](#need-help)
- [**FAQ**](#faq)
- [**🙏 Thank You**](#-thank-you)
- [**Copilot Plus Disclosure**](#copilot-plus-disclosure)
- [**Authors**](#authors)
- [Need Help?](#need-help)
- [FAQ](#faq)
## Copilot v4: Agent Mode, Reimagined 🚀
## Copilot V3 is a New Era 🔥
Our biggest leap yet. **Copilot v4** lets you run the most capable coding agents available — **opencode**, **Claude Code**, or **Codex** — natively inside your vault, tuned for knowledge work and entirely on your terms. Bring your own agent, keep every note on your device, and let it plan, search, and act across your Second Brain. No lock-in, no compromise.
After months of hard work, we have revamped the codebase and adopted a new paradigm for our agentic infrastructure. It opens the door for easier addition of agentic tools (MCP support coming). We will provide a new version of the documentation soon. Here is a couple of new things that you cannot miss!
**Join Supporter to experience the magic of Copilot v4 now!**
- FOR ALL USERS: You can do vault search out-of-the-box **without building an index first** (Indexing is still available but optional behind the "Semantic Search" toggle in QA settings).
- FOR FREE USERS: Image support and chat context menu are available to all users starting from v3.0.0!
- FOR PLUS USERS: **Autonomous agent** is available with vault search, web search, youtube, composer and soon a lot other tools! **Long-term memory** is also a tool the agent can use by itself starting from 3.1.0!
👉 **[Discover Copilot v4 →](https://www.obsidiancopilot.com/v4)**
Read the [Changelog](https://github.com/logancyang/obsidian-copilot/releases/tag/3.0.0).
## Why People Love It ❤️
@ -115,6 +101,35 @@ Our biggest leap yet. **Copilot v4** lets you run the most capable coding agents
## Usage
### Table of Contents
- [The What](#the-what)
- [The Why](#the-why)
- [Key Features](#key-features)
- [Table of Contents](#table-of-contents)
- [Copilot V3 is a New Era 🔥](#copilot-v3-is-a-new-era-)
- [Why People Love It ❤️](#why-people-love-it-)
- [Get Started](#get-started)
- [Install Obsidian Copilot](#install-obsidian-copilot)
- [Set API Keys](#set-api-keys)
- [Usage](#usage)
- [Table of Contents](#table-of-contents-1)
- [Free User](#free-user)
- [**Chat Mode: reference notes and discuss ideas with Copilot**](#chat-mode-reference-notes-and-discuss-ideas-with-copilot)
- [**Vault QA Mode: chat with your entire vault**](#vault-qa-mode-chat-with-your-entire-vault)
- [Copilot's Command Palette](#copilots-command-palette)
- [**Relevant Notes: notes suggestions based on semantic similarity and links**](#relevant-notes-notes-suggestions-based-on-semantic-similarity-and-links)
- [Copilot Plus/Believer](#copilot-plusbeliever)
- [**Get Precision Insights From a Specific Time Window**](#get-precision-insights-from-a-specific-time-window)
- [**Agent Mode: Autonomous Tool Calling**](#agent-mode-autonomous-tool-calling)
- [**Understand Images in Your Notes**](#understand-images-in-your-notes)
- [**One Prompt, Every Source—Instant Summaries from PDFs, Videos, and Web**](#one-prompt-every-sourceinstant-summaries-from-pdfs-videos-and-web)
- [**Need Help?**](#need-help)
- [**FAQ**](#faq)
- [**🙏 Thank You**](#-thank-you)
- [**Copilot Plus Disclosure**](#copilot-plus-disclosure)
- [**Authors**](#authors)
### Free User
#### **Chat Mode: reference notes and discuss ideas with Copilot**

View file

@ -1,220 +1,5 @@
# Release Notes
# Copilot for Obsidian - Release v3.3.3 🛠️
This patch brings a fresh Gemini model, squashes a nasty Copilot Plus freeze, and makes it much easier to verify your dev build is actually loaded in Obsidian. Thanks to @logancyang and @zeroliu for the quick turnaround!
- 💡 **Gemini 3.5 Flash is now a built-in model** — Google's latest generally-available Gemini model (`gemini-3.5-flash`) is now available out of the box, enabled by default with Vision and Reasoning support. It replaces the old `gemini-3-flash-preview` entry. Your existing default model (`google/gemini-2.5-flash` on OpenRouter) is unchanged — this is an additional option. (@logancyang)
- 🛠️ **Copilot Plus no longer freezes when you apply your license key** — Applying a Plus license key was causing Obsidian to freeze indefinitely. The root cause was a chain-rebuild loop: multiple concurrent initializations were each capturing the chain type, then writing it back after awaiting the model switch, bouncing the state between `LLM_CHAIN` and `COPILOT_PLUS_CHAIN` and triggering endless rebuilds. This is now fixed — applying your key fires a single clean rebuild and the UI stays responsive. (@zeroliu)
- 🔧 **New `npm run test:vault` command for developers** — A single command now builds the plugin and hot-reloads it directly into your test vault via the Obsidian CLI. It also stamps the loaded build's name with the current git branch and timestamp, so you can glance at Obsidian's Community plugins list to confirm you're running the build you think you are. (@zeroliu)
More details in the changelog:
### Improvements
- #2477 Add npm run test:vault for fast worktree-to-vault plugin reload @zeroliu
- #2492 feat(models): add GA gemini-3.5-flash builtin @logancyang
### Bug Fixes
- #2478 fix(chain): stop writing chainType back inside setChain (apply-Plus freeze) @zeroliu
## Troubleshoot
- If models are missing, navigate to Copilot settings -> Models tab and click "Refresh Built-in Models".
- Please report any issue you see in the member channel!
---
# Copilot for Obsidian - Release v3.3.2 🛠️
This patch is all about stability and speed. **Claude Opus 4.7+ now works correctly with adaptive thinking** on both direct Anthropic and AWS Bedrock, project-mode file ingestion is fixed, Quick Ask no longer crashes on `@`-mention, and the plugin is 75 KB lighter thanks to a clean dependency deduplication. Kudos to @logancyang and @zeroliu for the focused clean-up!
- 🧠 **Claude Opus 4.7+ adaptive thinking is fixed** — Adding `claude-opus-4-7` (or any Opus 4.7+) as a model and enabling reasoning used to return a 400 error because the plugin was sending the legacy `thinking.type=enabled` shape, which Opus 4.7+ no longer accepts. It now correctly sends `thinking.type=adaptive` and requests a summarized display so the thinking block appears in chat. Both the direct Anthropic and AWS Bedrock code paths are fixed. (@logancyang)
- 📂 **Project-mode file ingestion is fixed** — PDFs, images, audio files, and Office docs added to a project's source files were silently failing to upload because Obsidian's CORS-bypass path can't handle native `FormData` streams. The upload path now uses `requestUrl` with a manually-constructed multipart body, matching how the rest of the plugin talks to Brevilabs. (@zeroliu)
- 💬 **Quick Ask no longer crashes on `@`-mention** — Opening the Quick Ask panel and typing `@` to mention a note was crashing with "useApp() called outside of AppContext.Provider". Fixed by wrapping the panel's React root in the same `AppContext` that the main chat view uses. (@zeroliu)
- ⚡ **75 KB smaller bundle**`@langchain/community` is dropped (Jina embeddings now run against a hand-rolled Obsidian-native client), and `openai` is bumped to v6 to eliminate a duplicate copy of the SDK that was sneaking in alongside `@langchain/openai`. The plugin is now around 3.3 MB. (@zeroliu)
- 🔴 **Clearer error when a Bedrock model ID is missing its inference-profile prefix** — If you configure a bare regional Bedrock model ID (e.g. `anthropic.claude-sonnet-4-6` without a `global.`, `us.`, etc. prefix), the error used to surface as raw JSON. It now shows a plain-English message pointing you to Settings → Models and listing the four valid prefix forms. (@logancyang)
- 🧹 **React state management cleanup** — 56 `setState`-in-`useEffect` anti-patterns were replaced with idiomatic React patterns across 21 files: derived state via `useMemo`, `useSyncExternalStore` for external stores, and `key`-prop resets where appropriate. Side effects include: the token-count badge in chat now updates correctly after regeneration, chain/setting toggles in ChatInput no longer flash stale state, and the draggable quick-command modal no longer resets your resize when content grows. (@zeroliu)
More details in the changelog:
### Improvements
- #2460 chore(deps): remove unused deps and dead code to shrink bundle @zeroliu
- #2463 chore(deps): drop @langchain/community + bump openai to v6 to dedupe SDKs @zeroliu
- #2467 chore(react): centralize React root creation via createPluginRoot helper @zeroliu
- #2469 Remove dead ChainFactory and chain validation helpers @zeroliu
- #2470 style(css): expand #ccc to 6-digit hex format for consistency @zeroliu
### Bug Fixes
- #2399 fix(brevilabs): rewrite uploads via requestUrl multipart (W8/9) @zeroliu
- #2454 chore(lint): fix no-direct-set-state-in-use-effect violations @zeroliu
- #2466 fix(quick-ask): provide AppContext to overlay panel @zeroliu
- #2471 fix(anthropic+bedrock): adaptive thinking + summarized display for claude-opus-4-7+ @logancyang
- #2472 fix(bedrock): explain inference-profile requirement when AWS rejects on-demand model ID @logancyang
## Troubleshoot
- If models are missing, navigate to Copilot settings -> Models tab and click "Refresh Built-in Models".
- Please report any issue you see in the member channel!
---
# Copilot for Obsidian - Release v3.3.1 🔐
This release is all about keeping your API keys safe and your plugin running smoothly everywhere. **API keys can now be stored in Obsidian's built-in Keychain** so they never touch `data.json`, the encryption toggle is removed (it was more trouble than it was worth), and a wave of reliability fixes ensures chat works correctly on mobile, in popout windows, and with Plus mode. Underneath, **@zeroliu landed nineteen back-to-back PRs of code-quality work** — tightening ESLint, eliminating ~395 `any` types, swapping out unmaintained dependencies, and modernizing the React layer. Huge thanks to both @Emt-lin (Keychain) and @zeroliu (codebase hardening) for carrying this release. 🙌
> ⚠️ **Requires Obsidian 1.11.4+.** The `minAppVersion` is bumped from 1.7.2 to 1.11.4 because the Keychain feature relies on Obsidian's `app.secretStorage` API, which only exists in 1.11.4 and later.
- 🔐 **API keys now live in the Obsidian Keychain** — Fresh installs automatically store all API keys in Obsidian's vault-scoped Keychain instead of `data.json`. Existing users can opt in at any time via the new "API Key Storage" section in Advanced Settings. A guided migration wizard walks you through the move, and keys are stripped from `data.json` the moment migration completes. Each device has its own Keychain, so re-entering keys on a new device is expected. (@Emt-lin)
- The Advanced Settings panel shows your current storage mode and surfaces a clear warning if a synced vault arrives on a device whose Keychain is empty (so you know exactly why chat isn't working yet).
- A "Delete All Keys" option in Advanced Settings purges secrets from both the Keychain and `data.json` in one go.
- The Reset Settings button no longer touches API keys. Use "Delete All Keys" if you want them gone.
- 🚫 **Encryption toggle removed in favor of the Keychain** — With API keys moving to the Obsidian Keychain, the standalone "Enable Encryption" toggle is no longer needed and has been retired. It also didn't survive a sync to mobile cleanly, which caused intermittent chat failures for some users. Migrate to the Keychain for proper secret storage; existing encrypted blobs are still decrypted transparently on read so nothing breaks. (@logancyang)
- Mobile users whose vault contained desktop-encrypted keys now see a startup notice listing exactly which fields need to be re-entered, rather than a confusing 401 error mid-chat.
- 📱 **Image send on mobile is fixed** — Sending images was broken for all mobile users since 3.3.0 due to a missing Buffer polyfill. Fixed. (@logancyang)
- ⌨️ **Quick Command shortcuts and icons restored** — The Cmd+Enter (Replace) and Cmd+Shift+Enter (Insert) shortcuts in the Quick Command result modal were broken if any global Obsidian hotkey was bound to Cmd+Enter. Fixed, and the inline shortcut glyphs next to the Insert/Replace buttons are back too. (@zeroliu)
- 🧠 **Plus mode: no more surprise getFileTree calls** — When you have a note attached in context and ask "what's this note about?", the planner no longer fires a `getFileTree` tool call first. It now knows the content is already in context and answers directly. Explicit "list folders" or "find other notes" requests still trigger the tool as expected. (@logancyang)
- 🏷️ **Settings version chip and Reset button are visible again** — Obsidian's own CSS was hiding the version chip and Reset Settings button at the top of the settings panel. Fixed by swapping the `<h1>` for a semantically equivalent `<div>` that Obsidian's styles don't suppress. (@logancyang)
- 🛡️ **Supply-chain attestations for release assets** — Release artifacts (`main.js`, `manifest.json`, `styles.css`) are now cryptographically signed and published to Sigstore's transparency log. You can verify any release asset with `gh attestation verify`. (@logancyang)
- 🧹 **Major codebase hardening pass**@zeroliu landed a sustained ESLint and type-safety campaign across the repo: ~395 `any` types removed, dozens of type-aware rules turned on, unsafe assignments/returns/member-access/arguments locked down, floating promises explicitly handled, deprecated dependencies swapped, dead CSS removed, the global `app` reference replaced with a `useApp()` hook, and a long tail of smaller wins. See the full PR list below.
More details in the changelog:
### Improvements
- #2364 feat(keychain): migrate API key storage to Obsidian Keychain @Emt-lin
- #2431 ci(release): publish artifact attestations for release assets @logancyang
- #2448 refactor(react): replace global `app` with useApp() hook @zeroliu
### Bug Fixes
- #2457 fix(plus): planner skips getFileTree when active note is attached @logancyang
- #2455 fix(keychain): empty-keychain banner, reset modal copy, lifecycle reset @logancyang
- #2446 fix(encryption): deprecate encryption toggle; fix mobile chat failure on desktop-encrypted keys @logancyang
- #2445 fix(settings): restore version chip + reset button hidden by Obsidian h1 CSS @logancyang
- #2443 fix(mobile): import Buffer from the polyfill so image send works on mobile @logancyang
- #2442 fix(quick-command): restore Cmd+Enter shortcut and shortcut hint icons @zeroliu
### Code Quality (@zeroliu)
- #2453 chore(lint): fix no-array-index-key and other React lint warnings
- #2452 chore(eslint): enable no-explicit-any; fix ~395 violations
- #2451 chore(styles): drop dead CSS, reduce !important, fix duplicate selectors
- #2450 chore(lint): fix deprecation and template-literal lint warnings
- #2449 chore(icons): drop deprecated lucide Youtube icon; reuse Globe
- #2447 chore(deps): swap unmaintained/legacy deps per e18e module-replacements
- #2444 chore(types): tighten any types and fix langchain getType deprecation
- #2441 chore(eslint): enable no-unnecessary-type-assertion and no-misused-promises
- #2440 chore(eslint): enable @typescript-eslint/no-unsafe-argument and fix violations
- #2439 chore(eslint): enable @typescript-eslint/unbound-method
- #2438 chore(eslint): enable no-unsafe-member-access; fix 124 violations
- #2437 chore(eslint): enable @typescript-eslint/no-floating-promises
- #2436 chore(eslint): enable @typescript-eslint/no-unsafe-return
- #2435 chore(test): type test mocks to satisfy @typescript-eslint/no-unsafe-call
- #2434 chore(eslint): enable no-unsafe-assignment for tests
- #2433 chore(eslint): remove redundant non-TS rule overrides
- #2424 chore(lint): enable 4 type-aware quick-win rules and fix violations
## 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.0 🚀
The headline of this release is a big one: **your projects now live as notes in your vault**, not buried in `data.json`. Update and your existing projects migrate automatically. Everything else in this release is quality and polish: a 1.8 MB bundle reduction, mobile support declared official, fresh built-in models, and a wave of reliability fixes for Korean/CJK input, Miyo, Ollama, popout windows, and more!
- 📁 **Projects are now vault notes** — Project configurations are migrated out of the plugin's `data.json` and into your vault as regular markdown files. On first launch after the update, Copilot reads your existing projects and writes them to vault files automatically. You can see, edit, and version-control your project configs like any other note! Any projects that can't be migrated are backed up to an `unsupported/` subfolder so nothing is lost. (@Emt-lin)
- 📱 **Mobile support is now official** — Copilot is no longer desktop-only in the plugin manifest. Chat, Vault QA, and Plus modes all work on Obsidian Mobile, and the plugin now properly declares `minAppVersion: 1.7.2` so users on older Obsidian builds get a clear message rather than a cryptic runtime error. (@logancyang)
- ⚡ **Bundle shrinks by 1.8 MB** — Cohere and Mistral models now route through OpenAI-compatible endpoints instead of their own SDKs, trimming the plugin from ~5 MB down to ~3.3 MB. Faster loads, especially on mobile! Note: if you had a custom `baseUrl` saved for a Cohere model, clear it in the model settings so Cohere falls back to the correct compatibility endpoint. (@logancyang)
- 💡 **Latest built-in models (May 2026)** — Built-in model list updated: GPT-5.5, GPT-5.4-mini, Claude Opus 4.7, Gemini 3.1 Flash-Lite (now GA), and Grok 4.3 are all available out of the box. Click "Refresh Built-in Models" in settings if you don't see them yet. (@logancyang)
- 🔧 **GitHub Copilot codex models use the Responses API** — Codex-family models accessed through GitHub Copilot now correctly route to the `/responses` endpoint. Previously they were hitting `/chat/completions` and failing with HTTP 400. (@Keryer)
- ⌨️ **Enter key delay fixed for Korean/CJK input** — A 100ms timeout in the IME composition handler was making Enter feel sluggish when confirming Korean, Japanese, or Chinese input. Removed. (@octo-patch)
- 🗑️ **Deleted files respect your trash preference** — When Copilot deletes a chat history file, project file, system prompt, or custom command, it now uses Obsidian's trash setting (system trash / vault `.trash` / permanent) instead of always deleting permanently. Recoverable! (@zeroliu)
- 🛤️ **Miyo path fixes for cross-vault and remote setups** — Two Miyo fixes ship together: vault-folder-prefixed paths are now sent to the related-notes endpoint (fixing cross-device disambiguation), and the vault folder-name prefix is stripped from indexed paths (fixing broken links in "List Indexed Files"). (@wenzhengjiang)
- 🌐 **Ollama respects the CORS setting** — Ollama requests now route through `safeFetch` when "Enable CORS" is toggled, matching what every other OpenAI-compatible provider does. Fixes mobile (WKWebView) requests to `http://` Ollama hosts. (@zeroliu)
- 🪟 **Popout window reliability** — Chat in a popout window now creates DOM nodes in the correct window. Typing into the chat input after dragging the leaf between windows works without reopening the view. Pills, typeahead menus, the Quick Ask overlay, and inline citation links all render correctly in popouts. (@zeroliu)
- 🧠 **Think-section rendering fix** — A bug where indented code blocks inside `<think>` sections consumed the closing `</div>` tag and displayed it as literal text has been fixed. Affects models like `google/gemma-4-31b-it` that use indented bullet-point reasoning. (@trulyshelton)
More details in the changelog:
### Improvements
- #2324 feat: migrate project storage from data.json to vault files @Emt-lin
- #2402 perf(deps): route Cohere & Mistral through OpenAI-compat (-1.8 MB bundle) @logancyang
- #2396 chore(models): bump built-in models to latest (May 2026) @logancyang
- #2425 chore(manifest): bump minAppVersion to 1.7.2 and declare mobile support @logancyang
- #2406 fix(popout): document ownership and selection listener fixes (W4/9) @zeroliu
- #2405 fix(vault): respect user trash preference via FileManager.trashFile (W7/9) @zeroliu
- #2404 chore(providers): adopt non-deprecated LangChain APIs + type cleanup @zeroliu
- #2403 chore(types): tighten types and replace TFile/TFolder casts @zeroliu
- #2401 chore(popout): window-global API swaps for timers, globalThis, base64 @zeroliu
- #2400 chore(styles): replace element.style with Tailwind + CSS vars @zeroliu
- #2398 chore(deps): bump deps ahead of scorecard cleanup @zeroliu
- #2358 docs(ai): unify AGENTS.md and CLAUDE.md @capyBearista
### Bug Fixes
- #2393 fix(miyo): send portable folder-prefixed path to related-notes endpoint @wenzhengjiang
- #2390 fix(miyo): strip vault folder-name prefix from indexed paths @wenzhengjiang
- #2382 fix(ollama): route through safeFetch when enableCors is set @zeroliu
- #2367 fix: remove 100ms IME timeout that delayed Enter key for Korean/CJK input @octo-patch
- #2343 fix: prevent </div> from being consumed by indented code blocks in think sections @trulyshelton
- #2320 fix: use /responses for GitHub Copilot codex models @Keryer
- #2408 fix: broken tests @zeroliu
- #2407 chore(promises): explicit handling of floating promises @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 - Prerelease v3.2.9-beta.0 🧪
This is a beta release for testing the project storage migration, mobile support declaration, and a wave of reliability fixes before they ship in 3.2.9. Please report any issues in the Discord member channel with the version number in your report title.
- 📁 **Projects now live in your vault, not data.json** — Project configurations are migrated from the plugin's `data.json` into your vault as regular markdown files. Your projects and their settings transfer automatically on first load. This is a significant storage change — testers please verify your projects come through intact and project chat history loads correctly. (@Emt-lin)
- 📱 **Mobile support is now officially declared** — Copilot is no longer desktop-only. The plugin now works on Obsidian Mobile (minAppVersion 1.7.2). If you're a mobile user, now is the time to test! (@logancyang)
- ⚡ **Bundle shrinks by 1.8 MB** — Cohere and Mistral models now route through the OpenAI-compatible endpoint instead of their own SDKs, cutting the plugin bundle from ~5 MB down to ~3.3 MB. Faster loads, especially on mobile. (@logancyang)
- 🔧 **GitHub Copilot codex models use the Responses API** — Models like `gpt-4.1` and `o4-mini` accessed through GitHub Copilot now use the `/responses` endpoint as required. (@Keryer)
- ⌨️ **Enter key delay fixed for Korean/CJK input** — A 100ms IME timeout was causing a noticeable delay when confirming input with Enter in Korean, Japanese, and Chinese. Removed. (@octo-patch)
- 🛤️ **Miyo path portability fixes** — Two Miyo fixes land together: vault-folder-prefixed paths are now sent to the related-notes endpoint (fixing cross-vault disambiguation), and the vault folder-name prefix is stripped from indexed paths (fixing search result links). (@wenzhengjiang)
- 🌐 **Ollama respects CORS setting for all requests** — Ollama requests now go through `safeFetch` when `enableCors` is set, matching the intended behavior for custom Ollama setups. (@zeroliu)
- 🧠 **Think-section `</div>` parsing fix** — A bug where `</div>` tags were consumed by indented code blocks inside think sections has been fixed. (@trulyshelton)
- 🛡️ **Codebase quality hardening** — A large wave of ESLint rule enablements (Obsidian API rules, security rules, type-aware rules) and corresponding fixes landed across the W0-W9 scorecard series. No behavior changes for users, but the codebase is now significantly more defensively typed and free of unsafe patterns. (@zeroliu)
## What to Test
- **Project migration**: Open the plugin after updating. Confirm all your projects appear in the Projects list and project chat history loads. Check that project files appear as notes in your vault.
- **Mobile**: If you use Obsidian Mobile, install the prerelease and confirm basic chat, model switching, and search all work.
- **Korean/CJK input**: Confirm Enter key no longer has a 100ms lag when confirming IME input in the chat box.
- **Miyo**: If you use Miyo, test related-notes and search to confirm paths resolve correctly.
- **Ollama with CORS**: If you run Ollama with `enableCors: true`, confirm requests still go through correctly.
- **Bundle size**: Confirm Cohere and Mistral models still work correctly after the SDK routing change.
## How to Install the Prerelease
1. Download `main.js`, `manifest.json`, and `styles.css` from this prerelease's GitHub release page.
2. Replace the same three files in your vault's `.obsidian/plugins/copilot/` folder.
3. Reload the plugin (Settings → Community Plugins → toggle Copilot off and back on, or restart Obsidian).
4. Report issues with the prerelease version number `3.2.9-beta.0` in the title so we can track them.
To return to the stable release: reinstall the plugin from Obsidian's community-plugin browser.
## 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 with `3.2.9-beta.0` in the title!
---
# Copilot for Obsidian - Release v3.2.8 🔍
A small but handy patch release adding a **global search toggle for Miyo** so you can search across everything you've indexed, not just your current vault!

View file

@ -1,25 +1,8 @@
// __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: {},
});
/* eslint-disable no-undef */
import yaml from "js-yaml";
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(() => {
@ -47,7 +30,7 @@ module.exports = {
isDesktop: true,
},
parseYaml: jest.fn().mockImplementation((content) => {
return parseYamlString(content);
return yaml.load(content);
}),
Modal: class Modal {
constructor() {
@ -66,7 +49,7 @@ module.exports = {
},
})),
ItemView: jest.fn().mockImplementation(function () {
this.containerEl = window.document.createElement("div");
this.containerEl = document.createElement("div");
this.onOpen = jest.fn();
this.onClose = jest.fn();
this.getDisplayText = jest.fn().mockReturnValue("Mock View");
@ -75,7 +58,7 @@ module.exports = {
}),
Notice: jest.fn().mockImplementation(function (message) {
this.message = message;
this.noticeEl = window.document.createElement("div");
this.noticeEl = document.createElement("div");
this.hide = jest.fn();
}),
TFile: jest.fn().mockImplementation(function (path) {
@ -97,7 +80,7 @@ module.exports = {
};
// Mock the global app object
window.app = {
global.app = {
vault: {
getAbstractFileByPath: jest.fn().mockReturnValue({
name: "test-file.md",
@ -118,7 +101,4 @@ window.app = {
getFirstLinkpathDest: jest.fn().mockReturnValue(null),
getFileCache: jest.fn().mockReturnValue(null),
},
fileManager: {
trashFile: jest.fn().mockResolvedValue(undefined),
},
};

View file

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

View file

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

View file

@ -189,16 +189,15 @@ If your settings get into a bad state, you can reset:
1. Go to **Settings → Copilot** → find the reset option
2. Or delete the `data.json` file from the plugin folder: `.obsidian/plugins/copilot/data.json`
⚠️ Resetting clears all your settings. API keys kept in `data.json` (standard storage) are removed, but keys stored in the Obsidian Keychain are **not** — to erase those, use **Settings → Copilot → Advanced → API Key Storage → Delete All Keys**. Back up your keys first.
⚠️ Resetting will delete all your settings including API keys. Back them up first.
### API Key Storage
### API Key Encryption
Copilot has two ways to store API keys:
Copilot can encrypt your API keys at rest for added security.
- **Standard storage**: API keys are saved in `data.json` in plain text. Existing vaults stay in this mode until you choose to migrate.
- **Obsidian Keychain**: New installs use this by default. You can also switch an existing vault by going to **Settings → Copilot → Advanced → API Key Storage** and clicking **Migrate to Obsidian Keychain**. After migration, `data.json` no longer contains your API keys.
**Enable**: **Settings → Copilot → Advanced → Enable Encryption**
The Obsidian Keychain is per device. If you sync your vault to another device, you may need to re-enter API keys there.
If you see strange authentication errors after enabling this, try disabling encryption and re-entering your keys.
### Debug Mode and Logs

View file

@ -1,279 +0,0 @@
import obsidianmd from "eslint-plugin-obsidianmd";
import eslintReact from "@eslint-react/eslint-plugin";
import reactHooks from "eslint-plugin-react-hooks";
import tailwind from "eslint-plugin-tailwindcss";
import globals from "globals";
export default [
{
ignores: [
"node_modules/**",
"main.js",
"styles.css",
"data.json",
"designdocs/**",
"docs/**",
],
},
// obsidianmd recommended brings:
// - eslint:recommended
// - typescript-eslint recommendedTypeChecked on .ts/.tsx (recommended on .js/.jsx)
// - obsidianmd plugin + all obsidianmd-namespaced rules
// - import / @microsoft/sdl / depend / no-unsanitized
// - Obsidian-injected globals (activeDocument, createDiv, etc.)
...obsidianmd.configs.recommended,
// React + tailwind plugins ship flat configs with no `files` filter, so
// they'd cascade onto package.json (which uses the JSON parser) and crash.
// Constrain them to JSX/TSX sources where React/JSX rules actually apply.
{
files: ["**/*.{jsx,tsx}"],
...eslintReact.configs.recommended,
},
{
files: ["**/*.{jsx,tsx}"],
rules: {
// Deferred to follow-up PRs — these flag legitimate anti-patterns but
// each fix requires per-component intent analysis, and they're surfaced
// as warnings (not errors) so they don't block CI.
//
// no-direct-set-state-in-use-effect: ~50 violations. Common pattern is
// "sync local state with prop", which has no one-size-fits-all fix —
// some cases want render-time derivation, others want a `key` prop reset
// or `useSyncExternalStore`. Refactoring blindly risks behavior regressions
// in the chat UI's stateful components.
"@eslint-react/hooks-extra/no-direct-set-state-in-use-effect": "warn",
},
},
{
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx}"],
plugins: { "react-hooks": reactHooks },
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
},
},
...tailwind.configs["flat/recommended"].map((cfg) => ({
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx}"],
...cfg,
})),
{
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx}"],
languageOptions: {
globals: {
// Obsidian plugin runtime injects `app` as a global (see CLAUDE.md).
app: "readonly",
},
},
settings: {
"react-x": { version: "detect" },
tailwindcss: {
callees: ["classnames", "clsx", "ctl", "cn", "cva"],
config: "./tailwind.config.js",
cssFiles: ["**/*.css", "!**/node_modules", "!**/.*", "!**/dist", "!**/build"],
// Obsidian-provided utility classes used in JSX but not defined in our CSS.
whitelist: ["clickable-icon"],
},
},
rules: {
// Carry-over from legacy .eslintrc
"no-prototype-builtins": "off",
"tailwindcss/classnames-order": "error",
"tailwindcss/enforces-negative-arbitrary-values": "error",
"tailwindcss/enforces-shorthand": "error",
"tailwindcss/migration-from-tailwind-2": "error",
"tailwindcss/no-arbitrary-value": "off",
"tailwindcss/no-custom-classname": "error",
"tailwindcss/no-contradicting-classname": "error",
// obsidianmd: defer to follow-up PRs
"obsidianmd/ui/sentence-case": "off",
// obsidianmd: disabled intentionally — Platform.isMacOS branching is on-purpose
"obsidianmd/platform": "off",
// Bundled by obsidianmd/recommended via tseslint.configs.recommendedTypeChecked.
// Disabled here because the codebase intentionally uses `any` / dynamic typing
// around Obsidian's untyped APIs and LangChain message shapes — flipping these
// on would require refactoring thousands of call sites with no functional gain.
//
// Violation counts (src/**/*.{ts,tsx}) are noted inline. Rules with low counts
// are candidates to enable in small follow-up PRs.
// --- Heavy: any-flow through Obsidian/LangChain APIs ---
// no-unsafe-member-access: enabled globally; tests are exempted via the
// test-file override below.
"@typescript-eslint/no-unsafe-assignment": "off", // enabled for tests below; follow-up PR for production
"@typescript-eslint/no-unsafe-call": "off", // 107 violations
// --- Medium: promise / method ergonomics ---
// Enabled in the TS-only block below.
// no-deprecated: defer — surface the warnings, but don't fail CI yet
"@typescript-eslint/no-deprecated": "off",
// SDL / import / no-unsanitized / depend: defer — review separately
"no-restricted-globals": "off",
},
},
// 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__/**"],
languageOptions: {
globals: {
...globals.jest,
...globals.node,
},
},
rules: {
"import/no-nodejs-modules": "off",
// Tests use intentional `any` mocks; disable type-safety rules that flood
// the test suite without adding signal.
"@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.
{
files: ["**/*.test.{ts,tsx}"],
rules: {
"@typescript-eslint/no-unsafe-assignment": "error",
},
},
// Integration tests bootstrap jsdom fetch via `node-fetch` polyfill —
// allow the otherwise-banned import here only.
{
files: ["src/integration_tests/**"],
rules: {
"no-restricted-imports": "off",
},
},
// Node-context files (build configs, scripts)
{
files: [
"*.{js,mjs,cjs}",
"scripts/**",
"esbuild.config.mjs",
"version-bump.mjs",
"wasmPlugin.mjs",
"nodeModuleShim.mjs",
"jest.config.js",
"tailwind.config.js",
],
languageOptions: {
globals: {
...globals.node,
},
},
rules: {
"import/no-nodejs-modules": "off",
},
},
// TypeScript-specific overrides (the @typescript-eslint plugin is registered
// by obsidianmd's recommended config only for .ts/.tsx files).
{
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-unused-vars": ["error", { args: "none" }],
// checksVoidReturn relaxed for:
// - attributes: async event handlers in JSX (onClick={async () => ...}) are
// the standard React pattern; React already handles them correctly.
// - inheritedMethods: Obsidian's Plugin.onload/onunload are commonly async.
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: { attributes: false, inheritedMethods: false } },
],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-unsafe-return": "error",
"@typescript-eslint/unbound-method": "error",
// TypeScript handles undefined-identifier detection (and does so cross-realm
// correctly); per typescript-eslint's own guidance, disable no-undef on TS.
"no-undef": "off",
},
},
// Non-TS files aren't in tsconfig.json — disable type-aware rules that
// obsidianmd's recommended config enables globally. Most typed obsidianmd
// rules are already gated to **/*.ts(x); only no-plugin-as-component leaks
// out via recommendedPluginRulesConfig, and @typescript-eslint/no-deprecated
// is enabled globally.
{
files: ["**/*.js", "**/*.mjs", "**/*.cjs", "**/*.jsx", "**/package.json"],
rules: {
"@typescript-eslint/no-deprecated": "off",
"obsidianmd/no-plugin-as-component": "off",
},
},
// package.json: keep depend/ban-dependencies enabled (from obsidianmd
// recommended) but allow the deps we deliberately keep.
{
files: ["**/package.json"],
rules: {
"depend/ban-dependencies": [
"error",
{
presets: ["native", "microutilities", "preferred"],
allowed: [],
},
],
},
},
// logger.ts is the central logging utility and must call console.* directly.
// scripts/** are CLI tools that print to stdout.
{
files: ["src/logger.ts", "scripts/**"],
rules: {
"obsidianmd/rule-custom-message": "off",
},
},
// Jest assertions like `expect(mock.method).toHaveBeenCalled()` reference
// methods unbound by design. The rule has no clean workaround for jest
// patterns (binding changes the reference identity and breaks the assertion),
// so disable it in tests. Scoped to .ts/.tsx because the @typescript-eslint
// plugin is only registered for those files. Placed last so it overrides the
// TS-only block above.
{
files: ["**/*.test.{ts,tsx}"],
rules: {
"@typescript-eslint/unbound-method": "off",
},
},
];

View file

@ -6,12 +6,9 @@ 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
// jsdom; Jest can't parse ESM without extra config, so point at the CJS
// build it ships under dist/.
"^yaml$": "<rootDir>/node_modules/yaml/dist/index.js",
},
testRegex: ".*\\.test\\.(jsx?|tsx?)$",
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],

View file

@ -1,24 +1,5 @@
import "web-streams-polyfill/dist/polyfill.min.js";
import { TextEncoder, TextDecoder } from "util";
window.TextEncoder = TextEncoder;
window.TextDecoder = TextDecoder;
// Polyfill Obsidian's Node.doc / Node.win augmentation so plugin code that
// reads `element.doc` / `element.win` works under jsdom.
if (typeof Node !== "undefined" && !Object.prototype.hasOwnProperty.call(Node.prototype, "doc")) {
Object.defineProperty(Node.prototype, "doc", {
get() {
return this.ownerDocument ?? window.document;
},
configurable: true,
});
}
if (typeof Node !== "undefined" && !Object.prototype.hasOwnProperty.call(Node.prototype, "win")) {
Object.defineProperty(Node.prototype, "win", {
get() {
return this.ownerDocument?.defaultView ?? window;
},
configurable: true,
});
}
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;

View file

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

View file

@ -1,9 +1,8 @@
{
"id": "copilot",
"name": "Copilot",
"version": "3.3.3",
"minAppVersion": "1.11.4",
"isDesktopOnly": false,
"version": "3.2.8",
"minAppVersion": "0.15.0",
"description": "Your AI Copilot: Chat with Your Second Brain, Learn Faster, Work Smarter.",
"author": "Logan Yang",
"authorUrl": "https://twitter.com/logancyang",

11820
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,30 +1,28 @@
{
"name": "obsidian-copilot",
"version": "3.3.3",
"version": "3.2.8",
"description": "Your AI Copilot: Chat with Your Second Brain, Learn Faster, Work Smarter.",
"main": "main.js",
"scripts": {
"dev": "run-p dev:*",
"dev": "npm-run-all --parallel dev:*",
"dev:tailwind": "npx tailwindcss -i src/styles/tailwind.css -o styles.css --watch",
"dev:esbuild": "node esbuild.config.mjs",
"build": "npm run build:tailwind && npm run build:esbuild || exit 1",
"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",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"format": "prettier --write 'src/**/*.{js,ts,tsx,md}'",
"format:check": "prettier --check 'src/**/*.{js,ts,tsx,md}'",
"version": "node version-bump.mjs",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"test": "jest --testPathIgnorePatterns=src/integration_tests/",
"test:integration": "jest src/integration_tests/",
"prepare": "husky",
"prompt:debug": "node scripts/printPromptDebug.js",
"test:vault": "bash scripts/test-vault.sh"
"prompt:debug": "node scripts/printPromptDebug.js"
},
"keywords": [],
"author": "Logan Yang",
"nano-staged": {
"lint-staged": {
"*.{js,jsx,ts,tsx,json,css,md}": [
"prettier --write"
],
@ -34,59 +32,70 @@
},
"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/crypto-js": "^4.1.1",
"@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/lodash.debounce": "^4.0.9",
"@types/luxon": "^3.4.2",
"@types/node": "^16.11.6",
"@types/node-fetch": "^2.6.12",
"@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",
"builtin-modules": "3.3.0",
"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": "^8.57.0",
"eslint-plugin-json": "^4.0.1",
"eslint-plugin-react": "^7.37.3",
"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",
"nano-staged": "^0.8.0",
"npm-run-all2": "^7.0.2",
"lint-staged": "^15.2.9",
"node-fetch": "^2.7.0",
"npm-run-all": "^4.1.5",
"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",
"web-streams-polyfill": "^3.3.2",
"yaml": "^2.6.1"
"web-streams-polyfill": "^3.3.2"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@langchain/anthropic": "^1.3.29",
"@langchain/classic": "^1.0.9",
"@google/generative-ai": "^0.24.0",
"@huggingface/inference": "^4.11.3",
"@koa/cors": "^5.0.0",
"@langchain/anthropic": "^1.0.0",
"@langchain/cohere": "^1.0.0",
"@langchain/community": "^1.0.0",
"@langchain/core": "^1.1.29",
"@langchain/deepseek": "^1.0.0",
"@langchain/google-genai": "^2.1.23",
"@langchain/groq": "^1.0.0",
"@langchain/mistralai": "^1.0.0",
"@langchain/openai": "^1.0.0",
"@langchain/textsplitters": "^1.0.0",
"@langchain/xai": "^1.0.0",
"@lexical/plain-text": "^0.34.0",
"@lexical/react": "^0.34.0",
"@lexical/selection": "^0.34.0",
"@lexical/utils": "^0.34.0",
"@orama/orama": "^3.0.0-rc-2",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.2",
@ -100,26 +109,45 @@
"@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",
"axios": "^1.3.4",
"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",
"cohere-ai": "^7.13.0",
"crypto-js": "^4.1.1",
"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",
"lodash.debounce": "^4.0.8",
"lucide-react": "^0.462.0",
"luxon": "^3.5.0",
"minisearch": "^7.2.0",
"openai": "^6.10.0",
"next-i18next": "^13.2.2",
"p-queue": "^8.1.0",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.3.5",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^3.0.2",
"react-syntax-highlighter": "^15.5.0",
"sse": "github:mpetazzoni/sse.js",
"tailwind-merge": "^2.5.5",
"turndown": "^7.2.2",
"uuid": "^11.1.0",
"zod": "^3.25.76"
"tailwindcss-animate": "^1.0.7",
"trie-search": "^2.2.0",
"turndown": "^7.2.2"
}
}

View file

@ -46,7 +46,6 @@ async function main() {
});
try {
// eslint-disable-next-line no-unsanitized/method -- outfile is a controlled path under os.tmpdir(), produced by the esbuild step above.
const module = await import(url.pathToFileURL(outfile).href);
await module.run(process.argv.slice(2));
} finally {

View file

@ -1,4 +1,3 @@
import type ChainManager from "@/LLMProviders/chainManager";
import MemoryManager from "@/LLMProviders/memoryManager";
import { ModelAdapterFactory } from "@/LLMProviders/chainRunner/utils/modelAdapter";
import { buildAgentPromptDebugReport } from "@/LLMProviders/chainRunner/utils/promptDebugService";
@ -7,8 +6,6 @@ import { ChatMessage } from "@/types/message";
import { initializeBuiltinTools } from "@/tools/builtinTools";
import { getSettings } from "@/settings/model";
import { UserMemoryManager } from "@/memory/UserMemoryManager";
import type { App } from "obsidian";
import type { BaseChatModel } from "@langchain/core/language_models/chat_models";
interface HeadlessApp {
vault: {
@ -97,8 +94,7 @@ export async function run(args: string[]): Promise<void> {
}
const app = createHeadlessApp();
// eslint-disable-next-line obsidianmd/no-global-this -- node-only debug script, no window available
(global as unknown as { app: unknown }).app = app;
(globalThis as any).app = app;
initializeBuiltinTools();
@ -113,15 +109,13 @@ export async function run(args: string[]): Promise<void> {
.join("\n");
const memoryManager = MemoryManager.getInstance();
const userMemoryManager = new UserMemoryManager(app as unknown as App);
const userMemoryManager = new UserMemoryManager(app as any);
const chainContext = {
memoryManager,
userMemoryManager,
} as unknown as ChainManager;
} as any;
const adapter = ModelAdapterFactory.createAdapter({
modelName: "gpt-4",
} as unknown as BaseChatModel);
const adapter = ModelAdapterFactory.createAdapter({ modelName: "gpt-4" } as any);
const report = await buildAgentPromptDebugReport({
chainManager: chainContext,
adapter,

View file

@ -88,6 +88,6 @@ export class Modal {
}
}
export function parseYaml(_: string): unknown {
export function parseYaml(_: string): any {
return {};
}

View file

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

View file

@ -1,61 +1,5 @@
import { BedrockChatModel } from "./BedrockChatModel";
type ProcessStreamResult = {
deltaChunks: Array<{
text?: string;
message: {
content: unknown;
additional_kwargs?: { delta?: { reasoning?: string } };
};
}>;
usage?: Record<string, unknown>;
stopReason?: string;
hasText: boolean;
debugSummaries: string[];
};
type ContentItem = { type: string; text?: string; thinking?: string };
type ImageContent = {
type: "image";
source: { type: "base64"; media_type: string; data: string };
} | null;
type RequestBody = {
thinking?:
| { type: "enabled"; budget_tokens: number }
| { type: "adaptive"; display?: "summarized" | "omitted" };
temperature?: number;
anthropic_version?: string;
messages: Array<{
role: string;
content: Array<{
type: string;
text?: string;
source?: { type: string; media_type: string; data: string };
}>;
}>;
};
type BedrockInternal = {
decodeChunkBytes: (encoded: string) => string[];
processStreamEvent: (
event: unknown,
runManager: unknown,
currentUsage: unknown,
currentStopReason: unknown
) => Promise<ProcessStreamResult>;
buildContentItemsFromDelta: (event: unknown) => ContentItem[] | null;
extractStreamText: (event: unknown) => string | null;
buildRequestBody: (messages: unknown[], options?: unknown) => RequestBody;
convertImageContent: (imageUrl: string) => ImageContent;
normaliseMessageContent: (
message: unknown
) => string | Array<{ type: string; [key: string]: unknown }>;
};
const asInternal = (m: BedrockChatModel): BedrockInternal => m as unknown as BedrockInternal;
/**
* Builds a minimal Amazon EventStream message containing the provided UTF-8 payload.
* This helper keeps CRC fields at zero because the decoder ignores them.
@ -79,38 +23,18 @@ const buildEventStreamChunk = (payload: string): string => {
return Buffer.from(buffer).toString("base64");
};
const createModel = (
enableThinking = false,
modelId = "anthropic.claude-3-haiku-20240307-v1:0"
): BedrockChatModel =>
const createModel = (enableThinking = false): BedrockChatModel =>
new BedrockChatModel({
modelId,
modelId: "anthropic.claude-3-haiku-20240307-v1:0",
apiKey: "test-key",
endpoint: `https://example.com/model/${encodeURIComponent(modelId)}/invoke`,
streamEndpoint: `https://example.com/model/${encodeURIComponent(modelId)}/invoke-with-response-stream`,
endpoint: "https://example.com/model/anthropic.claude-3-haiku-20240307-v1%3A0/invoke",
streamEndpoint:
"https://example.com/model/anthropic.claude-3-haiku-20240307-v1%3A0/invoke-with-response-stream",
anthropicVersion: "bedrock-2023-05-31",
enableThinking,
fetchImplementation: jest.fn(),
});
const createModelWithFetch = (
fetchMock: jest.Mock,
opts?: { modelId?: string; noStream?: boolean }
): BedrockChatModel =>
new BedrockChatModel({
modelId: opts?.modelId ?? "anthropic.claude-sonnet-4-5-20250929-v1:0",
apiKey: "test-key",
endpoint: "https://example.com/model/anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke",
...(opts?.noStream
? {}
: {
streamEndpoint:
"https://example.com/model/anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke-with-response-stream",
}),
anthropicVersion: "bedrock-2023-05-31",
fetchImplementation: fetchMock,
});
describe("BedrockChatModel streaming decode", () => {
it("decodes simple base64 JSON payloads", () => {
const payload = JSON.stringify({
@ -124,7 +48,7 @@ describe("BedrockChatModel streaming decode", () => {
const base64 = Buffer.from(payload, "utf-8").toString("base64");
const model = createModel();
const decoded = asInternal(model).decodeChunkBytes(base64);
const decoded = (model as any).decodeChunkBytes(base64);
expect(decoded).toEqual([payload]);
});
@ -140,7 +64,7 @@ describe("BedrockChatModel streaming decode", () => {
const base64 = buildEventStreamChunk(payload);
const model = createModel();
const decoded = asInternal(model).decodeChunkBytes(base64);
const decoded = (model as any).decodeChunkBytes(base64);
expect(decoded).toEqual([payload]);
});
@ -161,7 +85,7 @@ describe("BedrockChatModel streaming decode", () => {
};
const model = createModel();
const processed = await asInternal(model).processStreamEvent(
const processed = await (model as any).processStreamEvent(
event,
undefined,
undefined,
@ -187,10 +111,10 @@ describe("BedrockChatModel streaming decode", () => {
};
const model = createModel();
const contentItems = asInternal(model).buildContentItemsFromDelta(event);
const contentItems = (model as any).buildContentItemsFromDelta(event);
expect(contentItems).toHaveLength(1);
expect(contentItems![0]).toEqual({
expect(contentItems[0]).toEqual({
type: "thinking",
thinking: "Let me analyze this problem...",
});
@ -209,10 +133,10 @@ describe("BedrockChatModel streaming decode", () => {
};
const model = createModel();
const contentItems = asInternal(model).buildContentItemsFromDelta(event);
const contentItems = (model as any).buildContentItemsFromDelta(event);
expect(contentItems).toHaveLength(1);
expect(contentItems![0]).toEqual({
expect(contentItems[0]).toEqual({
type: "text",
text: "Based on my analysis, the answer is...",
});
@ -237,7 +161,7 @@ describe("BedrockChatModel streaming decode", () => {
};
const model = createModel();
const processed = await asInternal(model).processStreamEvent(
const processed = await (model as any).processStreamEvent(
event,
undefined,
undefined,
@ -251,7 +175,7 @@ describe("BedrockChatModel streaming decode", () => {
// Check that content is an array with thinking type
expect(Array.isArray(chunk?.message.content)).toBe(true);
const content = chunk?.message.content as unknown[];
const content = chunk?.message.content as any[];
expect(content).toHaveLength(1);
expect(content[0]).toEqual({
type: "thinking",
@ -284,7 +208,7 @@ describe("BedrockChatModel streaming decode", () => {
};
const model = createModel();
const processed = await asInternal(model).processStreamEvent(
const processed = await (model as any).processStreamEvent(
event,
undefined,
undefined,
@ -298,7 +222,7 @@ describe("BedrockChatModel streaming decode", () => {
// Check that content is an array with text type
expect(Array.isArray(chunk?.message.content)).toBe(true);
const content = chunk?.message.content as unknown[];
const content = chunk?.message.content as any[];
expect(content).toHaveLength(1);
expect(content[0]).toEqual({
type: "text",
@ -330,7 +254,7 @@ describe("BedrockChatModel streaming decode", () => {
chunk: { bytes: thinkingBase64 },
};
const thinkingResult = await asInternal(model).processStreamEvent(
const thinkingResult = await (model as any).processStreamEvent(
thinkingEvent,
undefined,
undefined,
@ -339,7 +263,7 @@ describe("BedrockChatModel streaming decode", () => {
expect(thinkingResult.deltaChunks).toHaveLength(1);
const thinkingChunk = thinkingResult.deltaChunks[0];
expect((thinkingChunk?.message.content as Array<{ type: string }>)[0]?.type).toBe("thinking");
expect((thinkingChunk?.message.content as any[])[0]?.type).toBe("thinking");
// Second chunk: text
const textPayload = JSON.stringify({
@ -359,7 +283,7 @@ describe("BedrockChatModel streaming decode", () => {
chunk: { bytes: textBase64 },
};
const textResult = await asInternal(model).processStreamEvent(
const textResult = await (model as any).processStreamEvent(
textEvent,
undefined,
undefined,
@ -368,7 +292,7 @@ describe("BedrockChatModel streaming decode", () => {
expect(textResult.deltaChunks).toHaveLength(1);
const textChunk = textResult.deltaChunks[0];
expect((textChunk?.message.content as Array<{ type: string }>)[0]?.type).toBe("text");
expect((textChunk?.message.content as any[])[0]?.type).toBe("text");
});
it("extractStreamText can fallback to extract thinking content", () => {
@ -383,7 +307,7 @@ describe("BedrockChatModel streaming decode", () => {
};
const model = createModel();
const extracted = asInternal(model).extractStreamText(event);
const extracted = (model as any).extractStreamText(event);
expect(extracted).toBe("Fallback thinking extraction");
});
@ -400,10 +324,10 @@ describe("BedrockChatModel streaming decode", () => {
};
const model = createModel();
const contentItems = asInternal(model).buildContentItemsFromDelta(event);
const contentItems = (model as any).buildContentItemsFromDelta(event);
expect(contentItems).toHaveLength(1);
expect(contentItems![0]).toEqual({
expect(contentItems[0]).toEqual({
type: "thinking",
thinking: "",
});
@ -413,8 +337,8 @@ describe("BedrockChatModel streaming decode", () => {
describe("thinking mode enablement", () => {
it("includes thinking parameter when enableThinking is true", () => {
const model = createModel(true);
const requestBody = asInternal(model).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
const requestBody = (model as any).buildRequestBody([
{ role: "user", content: "test", _getType: () => "human" },
]);
expect(requestBody.thinking).toEqual({
@ -427,8 +351,8 @@ describe("BedrockChatModel streaming decode", () => {
it("does not include thinking parameter when enableThinking is false", () => {
const model = createModel(false);
const requestBody = asInternal(model).buildRequestBody(
[{ role: "user", content: "test", getType: () => "human" }],
const requestBody = (model as any).buildRequestBody(
[{ role: "user", content: "test", _getType: () => "human" }],
{ temperature: 0.7 }
);
@ -440,8 +364,8 @@ describe("BedrockChatModel streaming decode", () => {
it("respects user temperature when thinking is disabled", () => {
const model = createModel(false);
const requestBody = asInternal(model).buildRequestBody(
[{ role: "user", content: "test", getType: () => "human" }],
const requestBody = (model as any).buildRequestBody(
[{ role: "user", content: "test", _getType: () => "human" }],
{ temperature: 0.5 }
);
@ -451,76 +375,14 @@ describe("BedrockChatModel streaming decode", () => {
it("forces temperature to 1 when thinking is enabled", () => {
const model = createModel(true);
const requestBody = asInternal(model).buildRequestBody(
[{ role: "user", content: "test", getType: () => "human" }],
const requestBody = (model as any).buildRequestBody(
[{ role: "user", content: "test", _getType: () => "human" }],
{ temperature: 0.5 } // User tries to set 0.5, should be overridden to 1
);
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", () => {
@ -528,7 +390,7 @@ describe("BedrockChatModel streaming decode", () => {
it("converts valid data URL to Claude image format", () => {
const model = createModel();
const dataUrl = "data:image/jpeg;base64,/9j/4AAQSkZJRg==";
const result = asInternal(model).convertImageContent(dataUrl);
const result = (model as any).convertImageContent(dataUrl);
expect(result).toEqual({
type: "image",
@ -543,7 +405,7 @@ describe("BedrockChatModel streaming decode", () => {
it("handles PNG images", () => {
const model = createModel();
const dataUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
const result = asInternal(model).convertImageContent(dataUrl);
const result = (model as any).convertImageContent(dataUrl);
expect(result).toEqual({
type: "image",
@ -558,7 +420,7 @@ describe("BedrockChatModel streaming decode", () => {
it("returns null for invalid data URL format", () => {
const model = createModel();
const invalidUrl = "not-a-data-url";
const result = asInternal(model).convertImageContent(invalidUrl);
const result = (model as any).convertImageContent(invalidUrl);
expect(result).toBeNull();
});
@ -566,7 +428,7 @@ describe("BedrockChatModel streaming decode", () => {
it("returns null for non-image media type", () => {
const model = createModel();
const dataUrl = "data:text/plain;base64,SGVsbG8gV29ybGQ=";
const result = asInternal(model).convertImageContent(dataUrl);
const result = (model as any).convertImageContent(dataUrl);
expect(result).toBeNull();
});
@ -583,10 +445,10 @@ describe("BedrockChatModel streaming decode", () => {
image_url: { url: "data:image/jpeg;base64,/9j/4AAQSkZJRg==" },
},
],
getType: () => "human",
_getType: () => "human",
};
const result = asInternal(model).normaliseMessageContent(message);
const result = (model as any).normaliseMessageContent(message);
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
@ -604,10 +466,10 @@ describe("BedrockChatModel streaming decode", () => {
{ type: "text", text: "Hello " },
{ type: "text", text: "world!" },
],
getType: () => "human",
_getType: () => "human",
};
const result = asInternal(model).normaliseMessageContent(message);
const result = (model as any).normaliseMessageContent(message);
expect(typeof result).toBe("string");
expect(result).toBe("Hello world!");
@ -617,10 +479,10 @@ describe("BedrockChatModel streaming decode", () => {
const model = createModel();
const message = {
content: "Simple text message",
getType: () => "human",
_getType: () => "human",
};
const result = asInternal(model).normaliseMessageContent(message);
const result = (model as any).normaliseMessageContent(message);
expect(result).toBe("Simple text message");
});
@ -638,11 +500,11 @@ describe("BedrockChatModel streaming decode", () => {
image_url: { url: "data:image/jpeg;base64,/9j/4AAQSkZJRg==" },
},
],
getType: () => "human",
_getType: () => "human",
},
];
const requestBody = asInternal(model).buildRequestBody(messages);
const requestBody = (model as any).buildRequestBody(messages);
expect(requestBody.messages).toHaveLength(1);
expect(requestBody.messages[0].content).toHaveLength(2);
@ -679,18 +541,18 @@ describe("BedrockChatModel streaming decode", () => {
image_url: { url: "data:image/png;base64,IMAGE2DATA" },
},
],
getType: () => "human",
_getType: () => "human",
},
];
const requestBody = asInternal(model).buildRequestBody(messages);
const requestBody = (model as any).buildRequestBody(messages);
expect(requestBody.messages[0].content).toHaveLength(3);
expect(requestBody.messages[0].content[0].type).toBe("text");
expect(requestBody.messages[0].content[1].type).toBe("image");
expect(requestBody.messages[0].content[1].source!.media_type).toBe("image/jpeg");
expect(requestBody.messages[0].content[1].source.media_type).toBe("image/jpeg");
expect(requestBody.messages[0].content[2].type).toBe("image");
expect(requestBody.messages[0].content[2].source!.media_type).toBe("image/png");
expect(requestBody.messages[0].content[2].source.media_type).toBe("image/png");
});
it("handles text-only messages correctly", () => {
@ -698,11 +560,11 @@ describe("BedrockChatModel streaming decode", () => {
const messages = [
{
content: "Just text, no images",
getType: () => "human",
_getType: () => "human",
},
];
const requestBody = asInternal(model).buildRequestBody(messages);
const requestBody = (model as any).buildRequestBody(messages);
expect(requestBody.messages).toHaveLength(1);
expect(requestBody.messages[0].content).toHaveLength(1);
@ -727,11 +589,11 @@ describe("BedrockChatModel streaming decode", () => {
image_url: { url: "data:image/jpeg;base64,VALIDDATA" }, // Valid
},
],
getType: () => "human",
_getType: () => "human",
},
];
const requestBody = asInternal(model).buildRequestBody(messages);
const requestBody = (model as any).buildRequestBody(messages);
// Should have text + 1 valid image (invalid one skipped)
expect(requestBody.messages[0].content).toHaveLength(2);
@ -741,94 +603,3 @@ describe("BedrockChatModel streaming decode", () => {
});
});
});
describe("BedrockChatModel inference-profile error rewriting", () => {
const awsInferenceProfileError = JSON.stringify({
message:
"Invocation of model ID anthropic.claude-sonnet-4-5 with on-demand throughput isn't supported. Retry your request with the ID or ARN of an inference profile that contains this model.",
});
const makeErrorResponse = (status: number, body: string): Response =>
({
ok: false,
status,
text: () => Promise.resolve(body),
}) as unknown as Response;
it("rewrites 400 inference-profile error in non-streaming path to actionable message", async () => {
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, awsInferenceProfileError));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(
/cross-region inference profile ID/
);
});
it("rewrites 400 inference-profile error in streaming path to actionable message", async () => {
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, awsInferenceProfileError));
const model = createModelWithFetch(fetchMock);
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
const gen = model._streamResponseChunks(messages as never, {});
await expect(gen.next()).rejects.toThrow(/cross-region inference profile ID/);
});
it("includes the bare model ID in the rewritten message", async () => {
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, awsInferenceProfileError));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(
/anthropic\.claude-sonnet-4-5/
);
});
it("does not rewrite a 400 error that is unrelated to inference profiles", async () => {
const genericBody = JSON.stringify({ message: "ValidationException: bad request" });
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, genericBody));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(
/Amazon Bedrock request failed with status 400/
);
});
it("does not rewrite non-400 errors", async () => {
const body = JSON.stringify({ message: "Internal Server Error" });
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(500, body));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(
/Amazon Bedrock request failed with status 500/
);
});
it("rewrites the error even when AWS uses a curly apostrophe in 'isnt supported'", async () => {
const curlyApostropheBody = JSON.stringify({
message:
"Invocation of model ID anthropic.claude-sonnet-4-5 with on-demand throughput isnt supported. Retry your request with the ID or ARN of an inference profile that contains this model.",
});
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, curlyApostropheBody));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(
/cross-region inference profile ID/
);
});
it("uses the provider segment from the bare model ID in the prefix guidance", async () => {
const nonAnthropicBody = JSON.stringify({
message:
"Invocation of model ID meta.llama4-maverick-17b with on-demand throughput isn't supported. Retry your request with the ID or ARN of an inference profile that contains this model.",
});
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, nonAnthropicBody));
const model = createModelWithFetch(fetchMock, { noStream: true });
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
await expect(model._generate(messages as never, {})).rejects.toThrow(/global\.meta\.<id>/);
});
});

View file

@ -1,8 +1,3 @@
// Reason: `buffer` is the npm polyfill (browser-compatible), bundled by esbuild
// so the same Buffer code path works on desktop (Electron) and mobile (WebView).
// eslint-disable-next-line import/no-nodejs-modules
import { Buffer } from "buffer";
import {
BaseChatModel,
type BaseChatModelCallOptions,
@ -41,42 +36,6 @@ export interface BedrockChatModelFields extends BaseChatModelParams {
streaming?: boolean;
}
/**
* Rewrites Bedrock HTTP error messages into actionable text when possible.
* Falls back to the original "Amazon Bedrock ... failed with status N: body" form.
*
* The detection uses two stable substrings from the AWS ValidationException body
* ("on-demand throughput" and "inference profile") rather than the apostrophe-bearing
* phrase "isn't supported", because AWS has been observed serving both straight (')
* and curly () apostrophe variants. False positives are harmless: the rewritten
* message still names the bare model ID from the original body.
*/
function rewriteBedrockErrorMessage(status: number, body: string, streaming = false): string {
const prefix = streaming
? "Amazon Bedrock streaming request failed with status"
: "Amazon Bedrock request failed with status";
if (
status === 400 &&
body.includes("on-demand throughput") &&
body.includes("inference profile")
) {
const modelIdMatch = body.match(/model ID ([^\s]+) with/);
const bareId = modelIdMatch?.[1] ?? "<model-id>";
// Provider segment of the model ID (e.g. "anthropic" from "anthropic.claude-...").
// Falls back to a generic placeholder when the ID isn't in <provider>.<model> form.
const providerSegment = bareId.includes(".") ? bareId.split(".")[0] : "<provider>";
return (
`This Bedrock model requires a cross-region inference profile ID, not a bare regional model ID. ` +
`Update the model name in Settings → Models to use one of the prefixed forms: ` +
`global.${providerSegment}.<id> (recommended), us.${providerSegment}.<id>, eu.${providerSegment}.<id>, or apac.${providerSegment}.<id>. ` +
`The current value "${bareId}" is not accepted by AWS on-demand throughput.`
);
}
return `${prefix} ${status}: ${body}`;
}
/**
* Lightweight ChatModel integration for Amazon Bedrock using a simple API key header.
* This implementation issues JSON requests against the public Bedrock runtime endpoint.
@ -125,8 +84,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
super(baseParams);
// scorecard: streaming requires fetch — cannot use requestUrl
const globalFetch = typeof fetch !== "undefined" ? fetch.bind(window) : undefined;
const globalFetch = typeof fetch !== "undefined" ? fetch.bind(globalThis) : undefined;
this.fetchImpl = fetchImplementation ?? globalFetch;
if (!this.fetchImpl) {
@ -169,14 +127,11 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
/**
* Convert LangChain tools to Claude's tool format for Bedrock.
*/
private convertToolsToClaude(tools: StructuredToolInterface[]): Array<{
name: string;
description: string;
input_schema: Record<string, unknown>;
}> {
private convertToolsToClaude(tools: StructuredToolInterface[]): any[] {
return tools.map((tool) => {
let inputSchema: Record<string, unknown> = { type: "object", properties: {} };
let inputSchema: any = { type: "object", properties: {} };
if (tool.schema) {
// Use LangChain's schema conversion utilities
inputSchema = isInteropZodSchema(tool.schema) ? toJsonSchema(tool.schema) : tool.schema;
}
return {
@ -190,24 +145,17 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
/**
* Extract tool calls from Claude's response format.
*/
private extractToolCalls(
data: unknown
):
| Array<{ id: string; name: string; args: Record<string, unknown>; type: "tool_call" }>
| undefined {
const dataObj = data as Record<string, unknown> | null | undefined;
if (!Array.isArray(dataObj?.content)) return undefined;
private extractToolCalls(data: any): any[] | undefined {
if (!Array.isArray(data?.content)) return undefined;
const toolUseBlocks = (dataObj.content as Record<string, unknown>[]).filter(
(block) => block.type === "tool_use"
);
const toolUseBlocks = data.content.filter((block: any) => block.type === "tool_use");
if (toolUseBlocks.length === 0) return undefined;
return toolUseBlocks.map((block) => ({
id: block.id as string,
name: block.name as string,
args: (block.input || {}) as Record<string, unknown>,
return toolUseBlocks.map((block: any) => ({
id: block.id,
name: block.name,
args: block.input || {},
type: "tool_call" as const,
}));
}
@ -231,10 +179,10 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
if (!response.ok) {
const errorText = await response.text();
throw new Error(rewriteBedrockErrorMessage(response.status, errorText));
throw new Error(`Amazon Bedrock request failed with status ${response.status}: ${errorText}`);
}
const data = (await response.json()) as Record<string, unknown>;
const data = await response.json();
const text = this.extractText(data);
const toolCalls = this.extractToolCalls(data);
@ -313,7 +261,9 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
if (!response.ok) {
const errorText = await response.text();
throw new Error(rewriteBedrockErrorMessage(response.status, errorText, true));
throw new Error(
`Amazon Bedrock streaming request failed with status ${response.status}: ${errorText}`
);
}
if (!response.body) {
@ -350,14 +300,13 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
byteBuffer = new Uint8Array(remainingBytes);
for (const messagePayload of messages) {
const parsedOuter = this.safeJsonParse(messagePayload);
if (!parsedOuter || typeof parsedOuter !== "object") {
const outerEvent = this.safeJsonParse(messagePayload);
if (!outerEvent) {
logWarn(
`[${requestId}] Failed to parse event JSON: ${messagePayload.slice(0, 100)}...`
);
continue;
}
const outerEvent = parsedOuter as Record<string, unknown>;
// Handle AWS EventStream format where bytes field is at top level
let eventToProcess = outerEvent;
@ -436,20 +385,19 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
const fallback = await this._generate(messages, options, runManager);
const fallbackText = fallback.generations[0]?.text ?? "";
if (fallbackText) {
const fallbackMetadata: Record<string, unknown> = fallback.llmOutput ?? {};
yield new ChatGenerationChunk({
message: new AIMessageChunk({
content: fallbackText,
response_metadata: fallbackMetadata,
response_metadata: fallback.llmOutput ?? {},
}),
text: fallbackText,
generationInfo: fallbackMetadata,
generationInfo: fallback.llmOutput ?? {},
});
}
}
}
private safeJsonParse(value: string): unknown {
private safeJsonParse(value: string): any | null {
try {
return JSON.parse(value);
} catch {
@ -465,21 +413,18 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
* @returns Claude-style content array or null if not classifiable
*/
private buildContentItemsFromDelta(
event: Record<string, unknown>
event: any
): Array<{ type: string; text?: string; thinking?: string }> | null {
if (!event || typeof event !== "object") {
return null;
}
// Check for content_block_delta format (nested delta)
const contentBlockDelta = event.content_block_delta as Record<string, unknown> | undefined;
const contentBlockDeltaCamel = event.contentBlockDelta as Record<string, unknown> | undefined;
const deltaUnknown = contentBlockDelta?.delta ?? contentBlockDeltaCamel?.delta ?? event.delta;
const delta = event.content_block_delta?.delta || event.contentBlockDelta?.delta || event.delta;
if (!deltaUnknown || typeof deltaUnknown !== "object") {
if (!delta || typeof delta !== "object") {
return null;
}
const delta = deltaUnknown as Record<string, unknown>;
const deltaType = delta.type;
@ -509,29 +454,27 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
* Returns tool_call_chunks format that LangChain can concatenate.
*/
private extractToolCallChunk(
event: Record<string, unknown>
event: any
): { id?: string; index: number; name?: string; args?: string } | null {
if (!event || typeof event !== "object") {
return null;
}
// Handle content_block_start with tool_use - initial tool call info
const contentBlock = event.content_block as Record<string, unknown> | undefined;
if (event.type === "content_block_start" && contentBlock?.type === "tool_use") {
if (event.type === "content_block_start" && event.content_block?.type === "tool_use") {
return {
id: contentBlock.id as string | undefined,
index: typeof event.index === "number" ? event.index : 0,
name: contentBlock.name as string | undefined,
id: event.content_block.id,
index: event.index ?? 0,
name: event.content_block.name,
args: "",
};
}
// Handle content_block_delta with input_json_delta - partial tool args
const eventDelta = event.delta as Record<string, unknown> | undefined;
if (event.type === "content_block_delta" && eventDelta?.type === "input_json_delta") {
if (event.type === "content_block_delta" && event.delta?.type === "input_json_delta") {
return {
index: typeof event.index === "number" ? event.index : 0,
args: typeof eventDelta.partial_json === "string" ? eventDelta.partial_json : "",
index: event.index ?? 0,
args: event.delta.partial_json || "",
};
}
@ -539,7 +482,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
}
private async processStreamEvent(
event: Record<string, unknown>,
event: any,
runManager: CallbackManagerForLLMRun | undefined,
currentUsage?: Record<string, unknown>,
currentStopReason?: string
@ -556,17 +499,15 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
let hasText = false;
const debugSummaries: string[] = [];
const eventChunk = event.chunk as Record<string, unknown> | undefined;
if (event?.type === "chunk" && typeof eventChunk?.bytes === "string") {
const decodedPayloads = this.decodeChunkBytes(eventChunk.bytes);
if (event?.type === "chunk" && typeof event.chunk?.bytes === "string") {
const decodedPayloads = this.decodeChunkBytes(event.chunk.bytes);
for (const payload of decodedPayloads) {
const parsedInner = this.safeJsonParse(payload);
if (!parsedInner || typeof parsedInner !== "object") {
const innerEvent = this.safeJsonParse(payload);
if (!innerEvent) {
debugSummaries.push(`Failed to parse inner payload: ${this.describePayload(payload)}`);
continue;
}
const innerEvent = parsedInner as Record<string, unknown>;
const chunkMetadata = this.buildChunkMetadata(innerEvent);
@ -647,10 +588,9 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
} else {
// Only log if it's an unexpected event type that should have had content
// But don't warn for tool-related events which are handled above
const innerDelta = innerEvent.delta as Record<string, unknown> | undefined;
if (
innerEvent.type === "content_block_delta" &&
innerDelta?.type !== "input_json_delta"
innerEvent.delta?.type !== "input_json_delta"
) {
const summary = `No content in content_block_delta event: ${this.describeEvent(innerEvent)}`;
debugSummaries.push(summary);
@ -764,15 +704,14 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
};
}
private describeEvent(event: unknown): string {
if (!event || typeof event !== "object") {
private describeEvent(event: Record<string, unknown>): string {
if (!event) {
return "<empty event>";
}
const e = event as Record<string, unknown>;
const type = typeof e.type === "string" ? e.type : "unknown";
const keys = Object.keys(e).slice(0, 6).join(",");
const summary = this.stringifyForLog(e);
const type = typeof event.type === "string" ? event.type : "unknown";
const keys = Object.keys(event).slice(0, 6).join(",");
const summary = this.stringifyForLog(event);
return `${type} {${keys}} -> ${summary}`;
}
@ -852,9 +791,20 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
private decodeBase64ToUint8Array(encoded: string): Uint8Array | null {
try {
// Reason: Buffer (from the `buffer` polyfill imported above) is the
// cross-platform path — bare global Buffer is undefined in mobile WebView.
return new Uint8Array(Buffer.from(encoded, "base64"));
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(encoded, "base64"));
}
if (typeof atob === "function") {
const binary = atob(encoded);
const output = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
output[i] = binary.charCodeAt(i);
}
return output;
}
return null;
} catch {
return null;
}
@ -995,28 +945,18 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
return metadata;
}
private extractStreamText(event: Record<string, unknown>): string | null {
private extractStreamText(event: any): string | null {
if (!event || typeof event !== "object") {
return null;
}
const evDelta = event.delta as Record<string, unknown> | undefined;
const evContentBlockDelta = event.content_block_delta as Record<string, unknown> | undefined;
const evContentBlockDeltaCamel = event.contentBlockDelta as Record<string, unknown> | undefined;
const evContentBlockDeltaInner = evContentBlockDelta?.delta as
| Record<string, unknown>
| undefined;
const evContentBlockDeltaCamelInner = evContentBlockDeltaCamel?.delta as
| Record<string, unknown>
| undefined;
// Check for thinking/reasoning fields first (fallback for non-streaming or debugging)
// This ensures thinking content is never silently dropped
const thinkingCandidates: Array<unknown> = [
evDelta?.thinking,
evContentBlockDeltaInner?.thinking,
evContentBlockDeltaCamelInner?.thinking,
evDelta?.reasoning_content, // Deepseek compatibility
event.delta?.thinking,
event.content_block_delta?.delta?.thinking,
event.contentBlockDelta?.delta?.thinking,
event.delta?.reasoning_content, // Deepseek compatibility
event.reasoning_content,
];
@ -1040,27 +980,19 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
}
}
const evMessage = event.message as Record<string, unknown> | undefined;
const evMessageStop = event.messageStop as Record<string, unknown> | undefined;
const evMessageStopSnake = event.message_stop as Record<string, unknown> | undefined;
const evMessageStopMsg = evMessageStop?.message as Record<string, unknown> | undefined;
const evMessageStopSnakeMsg = evMessageStopSnake?.message as
| Record<string, unknown>
| undefined;
const nestedCandidates: Array<unknown> = [
evDelta?.text,
evDelta?.output_text,
evDelta?.content,
evContentBlockDeltaCamelInner?.text,
evContentBlockDeltaCamelInner?.output_text,
evContentBlockDeltaCamelInner?.content,
evContentBlockDeltaInner?.text,
evContentBlockDeltaInner?.output_text,
evContentBlockDeltaInner?.content,
evMessage?.content,
evMessageStopMsg?.content,
evMessageStopSnakeMsg?.content,
event.delta?.text,
event.delta?.output_text,
event.delta?.content,
event.contentBlockDelta?.delta?.text,
event.contentBlockDelta?.delta?.output_text,
event.contentBlockDelta?.delta?.content,
event.content_block_delta?.delta?.text,
event.content_block_delta?.delta?.output_text,
event.content_block_delta?.delta?.content,
event.message?.content,
event.messageStop?.message?.content,
event.message_stop?.message?.content,
event.content,
];
@ -1085,24 +1017,20 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
if (Array.isArray(candidate)) {
const combined = candidate
.map((part: unknown): string => {
.map((part) => {
if (typeof part === "string") {
return part;
}
if (part && typeof part === "object") {
const partObj = part as Record<string, unknown>;
if (typeof partObj.text === "string") {
return partObj.text;
if (typeof (part as any).text === "string") {
return (part as any).text;
}
if (typeof partObj.value === "string") {
return partObj.value;
if (typeof (part as any).value === "string") {
return (part as any).value;
}
if (Array.isArray(partObj.content)) {
return (partObj.content as unknown[])
.map((sub: unknown) => {
const subObj = sub as Record<string, unknown> | null | undefined;
return typeof subObj?.text === "string" ? subObj.text : "";
})
if (Array.isArray((part as any).content)) {
return (part as any).content
.map((sub: any) => (typeof sub?.text === "string" ? sub.text : ""))
.join("");
}
}
@ -1146,7 +1074,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
return null;
}
private extractUsage(event: Record<string, unknown>): Record<string, unknown> | undefined {
private extractUsage(event: any): Record<string, unknown> | undefined {
if (!event || typeof event !== "object") {
return undefined;
}
@ -1168,31 +1096,29 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
}
if (event.messageStop && typeof event.messageStop === "object") {
return this.extractUsage(event.messageStop as Record<string, unknown>);
return this.extractUsage(event.messageStop);
}
if (event.message_stop && typeof event.message_stop === "object") {
return this.extractUsage(event.message_stop as Record<string, unknown>);
return this.extractUsage(event.message_stop);
}
return undefined;
}
private extractStopReason(event: Record<string, unknown>): string | undefined {
private extractStopReason(event: any): string | undefined {
if (!event || typeof event !== "object") {
return undefined;
}
const evMessageStop = event.messageStop as Record<string, unknown> | undefined;
const evMessageStopSnake = event.message_stop as Record<string, unknown> | undefined;
const stopReason =
event.stop_reason ||
event.stopReason ||
event.completionReason ||
event.completion_reason ||
event.reason ||
evMessageStop?.stopReason ||
evMessageStopSnake?.stop_reason ||
event.messageStop?.stopReason ||
event.message_stop?.stop_reason ||
(event.type === "message_stop" ? event.reason : undefined);
return typeof stopReason === "string" ? stopReason : undefined;
@ -1329,7 +1255,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
const systemPrompts: string[] = [];
messages.forEach((message) => {
const messageType = message.type;
const messageType = message._getType();
// Handle system messages (always text-only)
if (messageType === "system") {
@ -1419,13 +1345,9 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
type: "text",
text: block.text,
});
} else if (
block.type === "image_url" &&
(block.image_url as Record<string, unknown> | undefined)?.url
) {
} else if (block.type === "image_url" && block.image_url?.url) {
// Image block in OpenAI format - convert to Claude format
const imageUrlBlock = block.image_url as Record<string, unknown>;
const claudeImage = this.convertImageContent(imageUrlBlock.url as string);
const claudeImage = this.convertImageContent(block.image_url.url);
if (claudeImage) {
contentBlocks.push(claudeImage);
}
@ -1473,19 +1395,12 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
// Handle thinking mode for Claude models
// Only enable if user has explicitly enabled REASONING capability for this model
if (this.enableThinking) {
// claude-opus-4-7+ rejects { type: "enabled", budget_tokens } with a 400 and requires
// { type: "adaptive" }. Unanchored match because Bedrock IDs include provider/profile
// prefixes (e.g. "global.anthropic.claude-opus-4-7-20260115-v1:0"). Constrain the minor
// to 1-2 digits followed by a delimiter so dated snapshot IDs like
// "claude-opus-4-20250514-v1:0" aren't misread as Opus 4.20250514.
const opusMinorMatch = this.modelName.match(/claude-opus-4-(\d{1,2})(?:[-.]|$)/);
const usesAdaptiveThinking = opusMinorMatch ? parseInt(opusMinorMatch[1], 10) >= 7 : false;
// Opus 4.7+ defaults thinking.display to "omitted" so thinking summaries
// never reach the UI; force "summarized" for the adaptive branch. Pre-4.7
// models default to "summarized" server-side.
payload.thinking = usesAdaptiveThinking
? { type: "adaptive", display: "summarized" }
: { type: "enabled", budget_tokens: 2048 };
// Enable thinking mode for Claude models on Bedrock
// This allows the model to generate reasoning tokens
payload.thinking = {
type: "enabled",
budget_tokens: 2048,
};
// When thinking is enabled, temperature must be 1
// https://docs.claude.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
payload.temperature = 1;
@ -1511,7 +1426,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
*/
private normaliseMessageContent(
message: BaseMessage
): string | Array<{ type: string; [key: string]: unknown }> {
): string | Array<{ type: string; [key: string]: any }> {
const { content } = message;
// Handle string content (simple text message)
@ -1551,7 +1466,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
}
return null;
})
.filter((part): part is { type: string; [key: string]: unknown } => part !== null);
.filter((part): part is { type: string; [key: string]: any } => part !== null);
}
// No images, flatten to string
@ -1582,21 +1497,20 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
return "";
}
private extractText(data: Record<string, unknown>): string {
private extractText(data: any): string {
if (typeof data?.outputText === "string") {
return data.outputText;
}
if (Array.isArray(data?.content)) {
return (data.content as unknown[])
.map((item: unknown): string => {
return data.content
.map((item: any) => {
if (!item) return "";
if (typeof item === "string") return item;
if (typeof item === "object") {
const itemObj = item as Record<string, unknown>;
if (typeof itemObj.text === "string") return itemObj.text;
if (itemObj.text && typeof itemObj.text === "object" && "text" in itemObj.text) {
return ((itemObj.text as Record<string, unknown>).text as string | undefined) ?? "";
if (typeof item.text === "string") return item.text;
if (item.text && typeof item.text === "object" && "text" in item.text) {
return item.text.text ?? "";
}
}
return "";

View file

@ -11,14 +11,14 @@ import { ChatOpenAI } from "@langchain/openai";
export interface ChatLMStudioInput {
modelName?: string;
apiKey?: string;
configuration?: Record<string, unknown>;
configuration?: any;
temperature?: number;
maxTokens?: number;
topP?: number;
frequencyPenalty?: number;
streaming?: boolean;
streamUsage?: boolean;
[key: string]: unknown;
[key: string]: any;
}
/**
@ -27,13 +27,13 @@ export interface ChatLMStudioInput {
* stop before the request is sent, guaranteeing all null values in
* tools are stripped regardless of which LangChain code path produced them.
*/
function createLMStudioFetch(baseFetch?: typeof window.fetch): typeof window.fetch {
const underlyingFetch = baseFetch || window.fetch;
function createLMStudioFetch(baseFetch?: typeof globalThis.fetch): typeof globalThis.fetch {
const underlyingFetch = baseFetch || globalThis.fetch;
return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
if (init?.body && typeof init.body === "string") {
try {
const body = JSON.parse(init.body) as { tools?: unknown };
const body = JSON.parse(init.body);
let modified = false;
// Strip null/undefined values from tool definitions
@ -63,8 +63,7 @@ function createLMStudioFetch(baseFetch?: typeof window.fetch): typeof window.fet
export class ChatLMStudio extends ChatOpenAI {
constructor(fields: ChatLMStudioInput) {
const configuration = fields.configuration as { fetch?: typeof window.fetch } | undefined;
const originalFetch = configuration?.fetch;
const originalFetch = fields.configuration?.fetch;
super({
...fields,
@ -79,7 +78,7 @@ export class ChatLMStudio extends ChatOpenAI {
// `text: { format: undefined }` (serializes to `text: {}`) which LM Studio
// rejects with "Required: text.format".
modelKwargs: {
...(fields.modelKwargs as Record<string, unknown> | undefined),
...fields.modelKwargs,
text: { format: { type: "text" } },
},
});

View file

@ -1,5 +1,5 @@
import { BaseChatModelParams } from "@langchain/core/language_models/chat_models";
import { AIMessageChunk, BaseMessage } from "@langchain/core/messages";
import { AIMessageChunk } from "@langchain/core/messages";
import type { UsageMetadata } from "@langchain/core/messages";
import { ChatGenerationChunk } from "@langchain/core/outputs";
import { ChatOpenAI } from "@langchain/openai";
@ -44,12 +44,7 @@ export interface ChatOpenRouterInput extends BaseChatModelParams {
// All other ChatOpenAI parameters
modelName?: string;
apiKey?: string;
configuration?: {
baseURL?: string;
defaultHeaders?: Record<string, string>;
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
[key: string]: unknown;
};
configuration?: any;
temperature?: number;
maxTokens?: number;
topP?: number;
@ -57,7 +52,7 @@ export interface ChatOpenRouterInput extends BaseChatModelParams {
streaming?: boolean;
maxRetries?: number;
maxConcurrency?: number;
[key: string]: unknown;
[key: string]: any;
}
export class ChatOpenRouter extends ChatOpenAI {
@ -106,7 +101,7 @@ export class ChatOpenRouter extends ChatOpenAI {
*
* @see https://openrouter.ai/docs/features/prompt-caching
*/
override invocationParams(options?: this["ParsedCallOptions"]): Record<string, unknown> {
override invocationParams(options?: this["ParsedCallOptions"]): any {
const baseParams = super.invocationParams(options);
// Only inject cache_control for OpenRouter endpoints. LM Studio, Copilot Plus, and
@ -155,9 +150,9 @@ export class ChatOpenRouter extends ChatOpenAI {
* LangChain filters out reasoning_details, so we bypass it completely
*/
override async *_streamResponseChunks(
messages: BaseMessage[],
messages: any[],
options: this["ParsedCallOptions"],
_runManager?: { handleLLMNewToken: (token: string) => Promise<void> }
_runManager?: any
): AsyncGenerator<ChatGenerationChunk> {
const params = this.invocationParams(options);
const openaiMessages = this.toOpenRouterMessages(messages);
@ -170,13 +165,11 @@ export class ChatOpenRouter extends ChatOpenAI {
...(params.stream_options ?? {}),
include_usage: true,
},
} as Parameters<
typeof this.openaiClient.chat.completions.create
>[0])) as unknown as AsyncIterable<OpenRouterChatChunk>;
})) as unknown as AsyncIterable<OpenRouterChatChunk>;
let usageSummary: OpenRouterUsage | undefined;
for await (const rawChunk of stream) {
for await (const rawChunk of stream as AsyncIterable<OpenRouterChatChunk>) {
if (rawChunk.usage) {
usageSummary = rawChunk.usage;
}
@ -195,7 +188,7 @@ export class ChatOpenRouter extends ChatOpenAI {
const messageChunk = this.buildMessageChunk({
rawChunk,
delta: delta as unknown as Record<string, unknown>,
delta,
content,
finishReason: choice.finish_reason,
reasoningDetails,
@ -207,9 +200,7 @@ export class ChatOpenRouter extends ChatOpenAI {
text: typeof messageChunk.content === "string" ? messageChunk.content : "",
generationInfo: {
finish_reason: choice.finish_reason,
// Reason: system_fingerprint is marked deprecated by some scorecards but is still
// returned by OpenAI-style streaming APIs and is useful for telemetry.
system_fingerprint: rawChunk["system_fingerprint"],
system_fingerprint: rawChunk.system_fingerprint,
model: rawChunk.model,
},
});
@ -235,13 +226,9 @@ export class ChatOpenRouter extends ChatOpenAI {
* @param messages LangChain messages passed into the model
* @returns Messages formatted for the OpenRouter API
*/
private toOpenRouterMessages(messages: BaseMessage[]): OpenRouterMessageParam[] {
private toOpenRouterMessages(messages: any[]): OpenRouterMessageParam[] {
return messages.map((msg) => {
const msgRecord = msg as unknown as Record<string, unknown>;
const role =
typeof msg._getType === "function"
? msg._getType()
: ((msgRecord.role as string) ?? "user");
const role = typeof msg._getType === "function" ? msg._getType() : (msg.role ?? "user");
const mappedRole =
role === "human"
? "user"
@ -249,11 +236,11 @@ export class ChatOpenRouter extends ChatOpenAI {
? "assistant"
: (role as OpenAI.ChatCompletionRole);
if (msgRecord.tool_call_id) {
if (msg.tool_call_id) {
return {
role: "tool",
content: msg.content,
tool_call_id: msgRecord.tool_call_id as string,
tool_call_id: msg.tool_call_id,
} as OpenRouterMessageParam;
}
@ -289,7 +276,7 @@ export class ChatOpenRouter extends ChatOpenAI {
*/
private buildMessageChunk(config: {
rawChunk: OpenRouterChatChunk;
delta: Record<string, unknown>;
delta: Record<string, any>;
content: string;
finishReason: string | null | undefined;
reasoningText?: string;
@ -385,20 +372,17 @@ export class ChatOpenRouter extends ChatOpenAI {
* @param choice Chunk choice object from the OpenRouter stream
* @returns Array of reasoning detail entries, if present
*/
private extractReasoningDetails(
choice: OpenAI.ChatCompletionChunk.Choice
): unknown[] | undefined {
const choiceRecord = choice as unknown as Record<string, Record<string, unknown>>;
private extractReasoningDetails(choice: any): unknown[] | undefined {
const candidate =
choiceRecord?.delta?.reasoning_details ??
choiceRecord?.message?.reasoning_details ??
(choice as unknown as Record<string, unknown>)?.reasoning_details;
choice?.delta?.reasoning_details ??
choice?.message?.reasoning_details ??
choice?.reasoning_details;
if (!Array.isArray(candidate)) {
return undefined;
}
return (candidate as unknown[]).filter((detail) => detail !== undefined && detail !== null);
return candidate.filter((detail) => detail !== undefined && detail !== null);
}
/**
@ -418,12 +402,8 @@ export class ChatOpenRouter extends ChatOpenAI {
if (typeof part === "string") {
return part;
}
if (
part &&
typeof part === "object" &&
typeof (part as { text?: unknown }).text === "string"
) {
return (part as { text: string }).text;
if (part && typeof part === "object" && typeof part.text === "string") {
return part.text;
}
return "";
})
@ -440,7 +420,7 @@ export class ChatOpenRouter extends ChatOpenAI {
* @returns Tool call chunk array compatible with LangChain
*/
private extractToolCallChunks(
toolCalls: unknown
toolCalls: any
):
| Array<{ name?: string; args?: string; id?: string; index?: number; type: "tool_call_chunk" }>
| undefined {
@ -448,19 +428,13 @@ export class ChatOpenRouter extends ChatOpenAI {
return undefined;
}
return toolCalls.map((rawCall) => {
const call = rawCall as
| { function?: { name?: string; arguments?: string }; id?: string; index?: number }
| null
| undefined;
return {
name: call?.function?.name,
args: call?.function?.arguments,
id: call?.id,
index: call?.index,
type: "tool_call_chunk" as const,
};
});
return toolCalls.map((call) => ({
name: call?.function?.name,
args: call?.function?.arguments,
id: call?.id,
index: call?.index,
type: "tool_call_chunk" as const,
}));
}
/**
@ -486,12 +460,8 @@ export class ChatOpenRouter extends ChatOpenAI {
metadata.model = rawChunk.model;
}
// Reason: system_fingerprint is marked deprecated by some scorecards but is still
// returned by OpenAI-style streaming APIs and is useful for telemetry. Use bracket
// access to bypass JSDoc deprecation warnings.
const fingerprint = rawChunk["system_fingerprint"];
if (fingerprint) {
metadata.system_fingerprint = fingerprint;
if (rawChunk.system_fingerprint) {
metadata.system_fingerprint = rawChunk.system_fingerprint;
}
if (rawChunk.usage) {

View file

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

View file

@ -1,10 +1,9 @@
import { safeFetchNoThrow } from "@/utils";
import { OpenAIEmbeddings } from "@langchain/openai";
export class CustomOpenAIEmbeddings extends OpenAIEmbeddings {
private customConfig: Record<string, unknown>;
private customConfig: any;
constructor(config: Record<string, unknown>) {
constructor(config: any) {
super(config);
// Store the config for our custom methods
this.customConfig = config;
@ -30,20 +29,17 @@ export class CustomOpenAIEmbeddings extends OpenAIEmbeddings {
};
// Get the correct baseURL, apiKey, and fetch function from the configuration
const configuration = this.customConfig.configuration as
| { baseURL?: string; fetch?: typeof fetch }
| undefined;
const baseURL = configuration?.baseURL || "https://api.openai.com/v1";
const baseURL = this.customConfig.configuration?.baseURL || "https://api.openai.com/v1";
const url = `${baseURL}/embeddings`;
const apiKey = this.customConfig.apiKey as string;
const fetchFn = configuration?.fetch || safeFetchNoThrow;
const apiKey = this.customConfig.apiKey;
const fetchFn = this.customConfig.configuration?.fetch || fetch;
const response = await fetchFn(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...((this.customConfig.headers as Record<string, string>) || {}),
...(this.customConfig.headers || {}),
},
body: JSON.stringify(requestBody),
});
@ -55,17 +51,17 @@ export class CustomOpenAIEmbeddings extends OpenAIEmbeddings {
);
}
const responseData = (await response.json()) as { data?: Array<{ embedding?: unknown }> };
const responseData = await response.json();
if (!responseData.data || !Array.isArray(responseData.data)) {
throw new Error("Invalid API response format: missing or invalid data array");
}
return responseData.data.map((item) => {
return responseData.data.map((item: any) => {
if (!item.embedding || !Array.isArray(item.embedding)) {
throw new Error("Invalid API response format: missing or invalid embedding array");
}
return item.embedding as number[];
return item.embedding;
});
}
}

View file

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

View file

@ -1,5 +1,11 @@
import { getChainType, getCurrentProject, getModelKey, SetChainOptions } from "@/aiParams";
import { ChainType } from "@/chainType";
import {
getChainType,
getCurrentProject,
getModelKey,
SetChainOptions,
setChainType,
} from "@/aiParams";
import ChainFactory, { ChainType, Document } from "@/chainFactory";
import { BUILTIN_CHAT_MODELS, USER_SENDER } from "@/constants";
import {
AutonomousAgentChainRunner,
@ -13,14 +19,14 @@ import { logError, logInfo } from "@/logger";
import { getSettings, subscribeToSettingsChange } from "@/settings/model";
import { getSystemPrompt } from "@/system-prompts/systemPromptBuilder";
import { ChatMessage } from "@/types/message";
import { findCustomModel, isOSeriesModel } from "@/utils";
import { findCustomModel, isOSeriesModel, isSupportedChain } from "@/utils";
import { MissingModelKeyError } from "@/error";
import {
ChatPromptTemplate,
HumanMessagePromptTemplate,
MessagesPlaceholder,
} from "@langchain/core/prompts";
import { Document } from "@langchain/core/documents";
import { RunnableSequence } from "@langchain/core/runnables";
import { App, Notice } from "obsidian";
import ChatModelManager from "./chatModelManager";
import MemoryManager from "./memoryManager";
@ -28,6 +34,10 @@ import PromptManager from "./promptManager";
import { UserMemoryManager } from "@/memory/UserMemoryManager";
export default class ChainManager {
// TODO: These chains are deprecated since we now use direct chat model calls in chain runners
// Consider removing after verifying no dependencies remain
private chain: RunnableSequence;
private retrievalChain: RunnableSequence;
private retrievedDocuments: Document[] = [];
public getRetrievedDocuments(): Document[] {
@ -50,12 +60,10 @@ export default class ChainManager {
this.userMemoryManager = new UserMemoryManager(app);
// Initialize async operations
void this.initialize().catch((err) => logError("ChainManager initialize failed", err));
this.initialize();
subscribeToSettingsChange(() => {
void this.createChainWithNewModel().catch((err) =>
logError("createChainWithNewModel failed", err)
);
subscribeToSettingsChange(async () => {
await this.createChainWithNewModel();
});
}
@ -63,6 +71,16 @@ export default class ChainManager {
await this.createChainWithNewModel();
}
// TODO: These methods are deprecated - chain runners now use direct chat model calls
// Remove after confirming no usage remains
public getChain(): RunnableSequence {
return this.chain;
}
public getRetrievalChain(): RunnableSequence {
return this.retrievalChain;
}
private validateChainType(chainType: ChainType): void {
if (chainType === undefined || chainType === null) throw new Error("No chain type set");
}
@ -79,6 +97,15 @@ 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.");
this.createChainWithNewModel({}, false);
}
}
public storeRetrieverDocuments(documents: Document[]) {
this.retrievedDocuments = documents;
}
@ -143,23 +170,10 @@ export default class ChainManager {
this.pendingModelError = null;
}
// Chain-type housekeeping. Do NOT write `chainType` back to the atom —
// the atom is owned by the UI dropdowns and `applyPlusSettings`. The
// captured local `chainType` may already be stale by the time we reach
// here (we just awaited `setChatModel(...)`), and writing it back used
// to create a self-sustaining `setChainType` → ProjectManager
// subscriber → `createChainWithNewModel` loop that froze Obsidian on
// apply-Plus-key.
if (this.chatModelManager.validateChatModel(this.chatModelManager.getChatModel())) {
this.validateChainType(chainType);
if (options.refreshIndex) {
await this.refreshVaultIndex();
}
} else {
console.error(
"createChainWithNewModel: skipping chain-type housekeeping — no chat model set."
);
}
// Must update the chatModel for chain because ChainFactory always
// retrieves the old chain without the chatModel change if it exists!
// Create a new chain with the new chatModel
this.setChain(chainType, options);
logInfo(`Setting model to ${newModelKey}`);
} catch (error) {
this.pendingModelError = error instanceof Error ? error : new Error(String(error));
@ -168,6 +182,108 @@ 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,
}) as RunnableSequence;
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),
getSettings().debug
);
setChainType(ChainType.VAULT_QA_CHAIN);
if (getSettings().debug) {
console.log("New Vault QA chain with hybrid retriever created for entire vault");
console.log("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,
}) as RunnableSequence;
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,
}) as RunnableSequence;
setChainType(ChainType.PROJECT_CHAIN);
break;
}
default:
this.validateChainType(chainType);
break;
}
}
private getChainRunner(): ChainRunner {
const chainType = getChainType();
const settings = getSettings();
@ -186,19 +302,21 @@ export default class ChainManager {
case ChainType.PROJECT_CHAIN:
return new ProjectChainRunner(this);
default:
throw new Error(`Unsupported chain type: ${String(chainType)}`);
throw new Error(`Unsupported chain type: ${chainType}`);
}
}
/**
* Re-index the vault into the Orama vector store. No-op when legacy
* semantic search is disabled v3 lexical search builds its index on
* demand and doesn't need a precomputed store.
*/
private async refreshVaultIndex() {
if (!getSettings().enableSemanticSearchV3) return;
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
await VectorStoreManager.getInstance().indexVaultToVectorStore(false);
private async initializeQAChain(options: SetChainOptions) {
// Handle index refresh if needed
if (options.refreshIndex) {
const settings = getSettings();
if (settings.enableSemanticSearchV3) {
// Use VectorStoreManager for Orama indexing
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
await VectorStoreManager.getInstance().indexVaultToVectorStore(false);
}
// V3 search builds indexes on demand, no action needed
}
}
async runChain(
@ -221,6 +339,7 @@ export default class ChainManager {
);
this.validateChatModel();
this.validateChainInitialization();
const chatModel = this.chatModelManager.getChatModel();
@ -240,9 +359,7 @@ export default class ChainManager {
]);
}
void this.createChainWithNewModel({ prompt: effectivePrompt }, false).catch((err) =>
logError("createChainWithNewModel failed", err)
);
this.createChainWithNewModel({ prompt: effectivePrompt }, false);
/*this.setChain(getChainType(), {
prompt: effectivePrompt,
});*/

View file

@ -9,7 +9,6 @@ import { initializeBuiltinTools } from "@/tools/builtinTools";
import { ToolRegistry } from "@/tools/ToolRegistry";
import { StructuredTool } from "@langchain/core/tools";
import { Runnable } from "@langchain/core/runnables";
import type { BaseChatModel } from "@langchain/core/language_models/chat_models";
import { ChatMessage, ResponseMetadata, StreamingResult } from "@/types/message";
import { err2String, withSuppressedTokenWarnings } from "@/utils";
import { AIMessage, BaseMessage, HumanMessage, SystemMessage } from "@langchain/core/messages";
@ -29,7 +28,6 @@ import {
buildToolCallsFromChunks,
accumulateToolCallChunk,
ToolCallChunk,
type RawToolCallChunk,
} from "./utils/nativeToolCalling";
import { ensureCiCOrderingWithQuestion } from "./utils/cicPromptUtils";
@ -58,7 +56,7 @@ type AgentSource = {
title: string;
path: string;
score: number;
explanation?: unknown;
explanation?: any;
};
/**
@ -122,7 +120,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
// Agent Reasoning Block state
private reasoningState: AgentReasoningState = createInitialReasoningState();
private reasoningTimerInterval: number | null = null;
private reasoningTimerInterval: ReturnType<typeof setInterval> | null = null;
private accumulatedContent = ""; // Track content to include in timer updates
private allReasoningSteps: Array<{ timestamp: number; summary: string; toolName?: string }> = []; // Full history of all steps
private abortHandledByTimer = false; // Flag to prevent duplicate interrupted messages
@ -197,7 +195,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
this.addReasoningStep(randomStep);
// Update every 100ms for smooth timer - always includes accumulated content
this.reasoningTimerInterval = window.setInterval(() => {
this.reasoningTimerInterval = setInterval(() => {
// Check for abort and show interrupted message immediately
if (abortController?.signal.aborted && this.reasoningState.status === "reasoning") {
this.stopReasoningTimer();
@ -261,7 +259,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
*/
private stopReasoningTimer(): void {
if (this.reasoningTimerInterval) {
window.clearInterval(this.reasoningTimerInterval);
clearInterval(this.reasoningTimerInterval);
this.reasoningTimerInterval = null;
}
this.reasoningState.status = "collapsed";
@ -398,7 +396,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
if (!isPlusUser) {
await this.handleError(
new Error("Invalid license key"),
thinkStreamer.processErrorChunk.bind(thinkStreamer) as (message: string) => void
thinkStreamer.processErrorChunk.bind(thinkStreamer)
);
const errorResponse = thinkStreamer.close().content;
return this.handleResponse(
@ -476,11 +474,11 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
this.lastDisplayedContent = "";
return loopResult.finalResponse;
} catch (error: unknown) {
} catch (error: any) {
// Always stop the reasoning timer on error
this.stopReasoningTimer();
if ((error as { name?: string }).name === "AbortError" || abortController.signal.aborted) {
if (error.name === "AbortError" || abortController.signal.aborted) {
logInfo("Autonomous agent stream aborted by user", {
reason: abortController.signal.reason,
});
@ -510,7 +508,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
await this.handleError(
new Error(autonomousAgentErrorMsg + fallbackErrorMsg),
thinkStreamer.processErrorChunk.bind(thinkStreamer) as (message: string) => void
thinkStreamer.processErrorChunk.bind(thinkStreamer)
);
const fullAIResponse = thinkStreamer.close().content;
@ -538,18 +536,14 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
*/
private async prepareAgentConversation(
userMessage: ChatMessage,
chatModel: BaseChatModel & {
modelName?: string;
model?: string;
bindTools?: (tools: unknown[]) => unknown;
},
chatModel: any,
_updateLoadingMessage?: (message: string) => void // Unused, kept for potential future use
): Promise<AgentRunContext> {
const messages: BaseMessage[] = [];
const availableTools = this.getAvailableTools();
// Bind tools to the model for native function calling
const modelName = chatModel.modelName || chatModel.model || "unknown";
const modelName = (chatModel as any).modelName || (chatModel as any).model || "unknown";
if (typeof chatModel.bindTools !== "function") {
throw new Error(
`Model ${modelName} does not support native tool calling (bindTools not available). ` +
@ -717,8 +711,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
// 2. Model returned only thinking/reasoning content that gets filtered
let finalContent = content;
if (!finalContent || finalContent.trim() === "") {
const rawToolCallChunks =
(aiMessage as { tool_call_chunks?: unknown[] }).tool_call_chunks ?? [];
const rawToolCallChunks = (aiMessage as any).tool_call_chunks ?? [];
logWarn(
`[Agent] Empty response detected (iteration ${iteration}). ` +
`Content length: ${content?.length ?? 0}, ` +
@ -745,7 +738,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
: displayedContent;
updateCurrentAiMessage(currentResponse);
if (i + STREAM_CHUNK_SIZE < finalContent.length) {
await new Promise((resolve) => window.setTimeout(resolve, STREAM_DELAY_MS));
await new Promise((resolve) => setTimeout(resolve, STREAM_DELAY_MS));
}
}
@ -1030,15 +1023,9 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
})
);
for await (const rawChunk of stream) {
for await (const chunk of stream) {
if (abortController.signal.aborted) break;
const chunk = rawChunk as {
response_metadata?: { finish_reason?: string };
tool_call_chunks?: unknown;
content?: unknown;
};
// Check for MALFORMED_FUNCTION_CALL error - throw to trigger fallback
const finishReason = chunk.response_metadata?.finish_reason;
if (finishReason === "MALFORMED_FUNCTION_CALL") {
@ -1050,7 +1037,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
const tcChunks = chunk.tool_call_chunks;
if (tcChunks && Array.isArray(tcChunks)) {
for (const tc of tcChunks) {
accumulateToolCallChunk(toolCallChunks, tc as RawToolCallChunk);
accumulateToolCallChunk(toolCallChunks, tc);
}
}
@ -1059,7 +1046,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
if (chunkContent) rawContent += chunkContent;
// Process chunk through ThinkBlockStreamer to strip thinking content
thinkStreamer.processChunk(chunk as Parameters<typeof thinkStreamer.processChunk>[0]);
thinkStreamer.processChunk(chunk);
}
// Close the streamer to finalize content (handles unclosed think blocks, etc.)
@ -1094,9 +1081,9 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
aiMessage,
streamingResult,
};
} catch (error: unknown) {
logError(`Stream error: ${(error as Error).message}`);
if ((error as { name?: string }).name === "AbortError" || abortController.signal.aborted) {
} catch (error: any) {
logError(`Stream error: ${error.message}`);
if (error.name === "AbortError" || abortController.signal.aborted) {
const streamingResult = thinkStreamer.close();
return {
content: streamingResult.content,

View file

@ -110,9 +110,8 @@ export abstract class BaseChainRunner implements ChainRunner {
updateCurrentAiMessage("");
}
// Log compact memory summary and a truncated final response (~300 chars)
const historyMessages = (
this.chainManager.memoryManager.getMemory().chatHistory as { messages?: unknown[] }
).messages;
const historyMessages = (this.chainManager.memoryManager.getMemory().chatHistory as any)
.messages;
logInfo("Chat memory updated:\n", {
turns: Array.isArray(historyMessages) ? historyMessages.length : 0,
});
@ -121,8 +120,8 @@ export abstract class BaseChainRunner implements ChainRunner {
try {
const { parseToolCallMarkers } = await import("./utils/toolCallParser");
const parsed = parseToolCallMarkers(fullAIResponse);
let textOnly = (parsed.segments as { type: string; content: string }[])
.map((seg) => (seg.type === "text" ? seg.content : ""))
let textOnly = parsed.segments
.map((seg: any) => (seg.type === "text" ? seg.content : ""))
.join("")
.trim();
if (!textOnly) textOnly = fullAIResponse || "";
@ -147,16 +146,15 @@ export abstract class BaseChainRunner implements ChainRunner {
* @param error - Raw provider error object.
* @param processErrorChunk - Callback used to stream error text to the UI.
*/
protected async handleError(error: unknown, processErrorChunk: (message: string) => void) {
protected async handleError(error: any, processErrorChunk: (message: string) => void) {
const msg = err2String(error);
logError("Error during LLM invocation:", msg);
const errorData =
(error as { response?: { data?: { error?: unknown } } })?.response?.data?.error || msg;
const errorCode = (errorData as { code?: string })?.code || msg;
const errorData = error?.response?.data?.error || msg;
const errorCode = errorData?.code || msg;
let errorMessage = "";
// Check for specific error messages
if ((error as { message?: string })?.message?.includes("Invalid license key")) {
if (error?.message?.includes("Invalid license key")) {
errorMessage = "Invalid Copilot Plus license key. Please check your license key in settings.";
} else if (errorCode === "model_not_found") {
errorMessage =
@ -202,19 +200,13 @@ export abstract class BaseChainRunner implements ChainRunner {
* @returns True if the error indicates an authentication problem.
*/
private isAuthenticationError(error: unknown, normalizedMessage: string): boolean {
const responseError = (
error as {
response?: {
status?: number;
data?: {
error?: { status?: number | string; code?: string; message?: string; type?: string };
};
};
}
)?.response;
const responseError = (error as { response?: { status?: number; data?: any } })?.response;
const errorData = responseError?.data?.error ?? (error as { error?: unknown })?.error;
const rawStatus = responseError?.status ?? (errorData as { status?: number | string })?.status;
const statusCode = typeof rawStatus === "string" ? Number.parseInt(rawStatus, 10) : rawStatus;
const statusCode =
typeof rawStatus === "string"
? Number.parseInt(rawStatus, 10)
: (rawStatus as number | undefined);
const errorObject =
typeof errorData === "object" && errorData !== null
? (errorData as Record<string, unknown>)

View file

@ -50,7 +50,6 @@ import {
isFilterOnlyResults,
isTimeDominantResults,
logSearchResultsDebugTable,
type SearchDoc,
} from "./utils/searchResultUtils";
import {
buildLocalSearchInnerContent,
@ -69,8 +68,8 @@ import ProjectManager from "@/LLMProviders/projectManager";
import { isProjectMode } from "@/aiParams";
type ToolCallWithExecutor = {
tool: StructuredTool;
args: Record<string, unknown>;
tool: any;
args: any;
};
export class CopilotPlusChainRunner extends BaseChainRunner {
@ -109,16 +108,12 @@ export class CopilotPlusChainRunner extends BaseChainRunner {
*/
private async planToolCalls(
userMessage: string,
chatModel: BaseChatModel,
hasActiveContextNote: boolean = false
chatModel: BaseChatModel
): Promise<{ toolCalls: ToolCallWithExecutor[]; salientTerms: string[] }> {
const availableTools = this.getAvailableToolsForPlanning();
// Check if model supports native tool calling
const modelWithTools = chatModel as BaseChatModel & {
bindTools?: (tools: StructuredTool[]) => unknown;
};
if (typeof modelWithTools.bindTools !== "function") {
if (typeof (chatModel as any).bindTools !== "function") {
logWarn("[CopilotPlus] Model does not support native tool calling, skipping tool planning");
return {
toolCalls: [],
@ -127,22 +122,15 @@ export class CopilotPlusChainRunner extends BaseChainRunner {
}
// Bind tools to the model for native function calling
const boundModel = modelWithTools.bindTools(availableTools);
const boundModel = (chatModel as any).bindTools(availableTools);
// Build a lightweight planning prompt (no XML format instructions needed)
// Reason: when an active note is attached in this turn, tell the planner not
// to call getFileTree just because the user says "this note" / "this file" —
// the note's content is already available via context, so getFileTree is
// wasteful unless the user explicitly wants to discover OTHER notes.
const activeContextHint = hasActiveContextNote
? "\n- The user has an active note attached in this turn. Its content is already available; do NOT call getFileTree merely because the user says 'this note' or 'this file'. Only call getFileTree when the user explicitly wants to discover OTHER notes, list folders, or verify paths."
: "";
const planningPrompt = `You are a helpful AI assistant. Analyze the user's message and determine if any tools should be called.
Guidelines:
- Use tools when the user's request requires external information or computation
- For time-related queries, use getTimeRangeMs to convert time expressions to timestamps
- Use getFileTree ONLY when the user wants to discover or list notes/folders in the vault not to read content already in context${activeContextHint}
- For file structure queries, use getFileTree to explore the vault
- If no tools are needed, respond with your analysis
After analyzing, extract key search terms from the user's message that would be useful for searching notes:
@ -172,11 +160,9 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
// token estimation entirely. Actual token usage comes from API response metadata.
let response: AIMessage;
{
const stream: AsyncIterable<AIMessageChunk> = await withSuppressedTokenWarnings(() =>
(
boundModel as { stream: (msgs: unknown) => Promise<AsyncIterable<AIMessageChunk>> }
).stream(planningMessages)
);
const stream = (await withSuppressedTokenWarnings(() =>
boundModel.stream(planningMessages)
)) as AsyncIterable<AIMessageChunk>;
let aggregated: AIMessageChunk | undefined;
for await (const chunk of stream) {
aggregated = aggregated ? aggregated.concat(chunk) : chunk;
@ -194,7 +180,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
// Extract tool calls from native response
const nativeToolCalls = response.tool_calls || [];
const responseText =
typeof response.content === "string" ? response.content : JSON.stringify(response.content);
typeof response.content === "string" ? response.content : String(response.content);
logInfo("[CopilotPlus] Native tool calls:", nativeToolCalls.length);
@ -262,7 +248,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
private async processAtCommands(
userMessage: string,
existingToolCalls: ToolCallWithExecutor[],
context: { salientTerms: string[]; timeRange?: unknown }
context: { salientTerms: string[]; timeRange?: any }
): Promise<ToolCallWithExecutor[]> {
const message = userMessage.toLowerCase();
const cleanQuery = this.removeAtCommands(userMessage);
@ -531,7 +517,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
}
// Process existing chat content images if present
const existingContent = userMessage.content as MessageContent[] | undefined;
const existingContent = userMessage.content;
if (existingContent && existingContent.length > 0) {
const result = await this.processChatInputImages(existingContent);
successfulImages.push(...result.successfulImages);
@ -560,8 +546,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
}
protected hasCapability(model: BaseChatModel, capability: ModelCapability): boolean {
const modelWithName = model as BaseChatModel & { modelName?: string; model?: string };
const modelName: string = modelWithName.modelName || modelWithName.model || "";
const modelName = (model as any).modelName || (model as any).model || "";
const customModel = this.chainManager.chatModelManager.findModelByName(modelName);
return customModel?.capabilities?.includes(capability) ?? false;
}
@ -585,7 +570,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
private async streamMultimodalResponse(
textContent: string,
userMessage: ChatMessage,
allToolOutputs: { tool: string; output: unknown }[],
allToolOutputs: any[],
abortController: AbortController,
thinkStreamer: ThinkBlockStreamer,
originalUserQuestion: string,
@ -599,7 +584,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
const isMultimodalCurrent = this.isMultimodalModel(chatModel);
// Create messages array
const messages: { role: string; content: string | MessageContent[] }[] = [];
const messages: any[] = [];
// Envelope-based context construction (required)
const envelope = userMessage.contextEnvelope;
@ -728,9 +713,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
});
break;
}
for await (const processedChunk of actionStreamer.processChunk(
chunk as unknown as Record<string, unknown>
)) {
for await (const processedChunk of actionStreamer.processChunk(chunk)) {
thinkStreamer.processChunk(processedChunk);
}
}
@ -756,7 +739,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
const excludeThinking = !hasReasoning;
const thinkStreamer = new ThinkBlockStreamer(updateCurrentAiMessage, excludeThinking);
let sources: { title: string; path: string; score: number; explanation?: unknown }[] = [];
let sources: { title: string; path: string; score: number; explanation?: any }[] = [];
const isPlusUser = await checkIsPlusUser({
isCopilotPlus: true,
@ -764,7 +747,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
if (!isPlusUser) {
await this.handleError(
new Error("Invalid license key"),
thinkStreamer.processErrorChunk.bind(thinkStreamer) as (message: string) => void
thinkStreamer.processErrorChunk.bind(thinkStreamer)
);
const errorResponse = thinkStreamer.close().content;
@ -795,26 +778,11 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
try {
// Use model-based planning instead of Broca
const chatModel = this.chainManager.chatModelManager.getChatModel();
// Reason: detect active-note attachment from the envelope so the planner
// can avoid calling getFileTree just to read a note that's already in
// context. See issue #2456. Scope to current-turn segments only: in
// compacted envelopes L3_TURN merges prior-turn context into a single
// "compacted_context" segment (metadata.source === "compacted"), so an
// <active_note> tag from an earlier turn could otherwise falsely trigger
// the optimization.
const l3TurnForPlanning = envelope.layers.find((l) => l.id === "L3_TURN");
const hasActiveContextNote = !!l3TurnForPlanning?.segments?.some(
(seg) => seg.metadata?.source === "current_turn" && /<active_note[\s>]/.test(seg.content)
);
const planningResult = await this.planToolCalls(
messageForAnalysis,
chatModel,
hasActiveContextNote
);
const planningResult = await this.planToolCalls(messageForAnalysis, chatModel);
// Execute getTimeRangeMs immediately if present (needed for localSearch timeRange)
// We execute it once here and remove it from toolCalls to avoid double execution
let timeRange: unknown = undefined;
let timeRange: any = undefined;
const timeRangeCall = planningResult.toolCalls.find(
(tc) => tc.tool.name === "getTimeRangeMs"
);
@ -825,12 +793,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
);
// Parse result if it's a JSON string (LangChain tools return strings)
// Extract epoch values from TimeInfo objects - localSearch expects {startTime: number, endTime: number}
type TimeInfoResult = {
startTime?: { epoch?: number };
endTime?: { epoch?: number };
error?: unknown;
};
const extractEpochValues = (result: TimeInfoResult): unknown => {
const extractEpochValues = (result: any) => {
if (result?.startTime?.epoch !== undefined && result?.endTime?.epoch !== undefined) {
return {
startTime: result.startTime.epoch,
@ -842,7 +805,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
if (typeof timeRangeResult === "string") {
try {
const parsed = JSON.parse(timeRangeResult) as TimeInfoResult;
const parsed = JSON.parse(timeRangeResult);
// Only use result if it's not an error
if (!parsed.error) {
timeRange = extractEpochValues(parsed);
@ -850,11 +813,8 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
} catch {
logWarn("[CopilotPlus] Failed to parse getTimeRangeMs result:", timeRangeResult);
}
} else if (timeRangeResult) {
const typedResult = timeRangeResult as TimeInfoResult;
if (!typedResult.error) {
timeRange = extractEpochValues(typedResult);
}
} else if (timeRangeResult && !timeRangeResult.error) {
timeRange = extractEpochValues(timeRangeResult);
}
logInfo("[CopilotPlus] Executed getTimeRangeMs, result:", timeRange);
}
@ -878,7 +838,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
salientTerms: planningResult.salientTerms,
timeRange,
});
} catch (error: unknown) {
} catch (error: any) {
return this.handleResponse(
getApiErrorMessage(error),
userMessage,
@ -926,22 +886,16 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
cleanedUserMessage,
updateLoadingMessage
);
} catch (error: unknown) {
} catch (error: any) {
// Reset loading message to default
updateLoadingMessage?.(LOADING_MESSAGES.DEFAULT);
// Check if the error is due to abort signal
if (
(error instanceof Error && error.name === "AbortError") ||
abortController.signal.aborted
) {
if (error.name === "AbortError" || abortController.signal.aborted) {
logInfo("CopilotPlus stream aborted by user", { reason: abortController.signal.reason });
// Don't show error message for user-initiated aborts
} else {
await this.handleError(
error,
thinkStreamer.processErrorChunk.bind(thinkStreamer) as (message: string) => void
);
await this.handleError(error, thinkStreamer.processErrorChunk.bind(thinkStreamer));
}
}
@ -966,7 +920,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
const fallbackSources =
this.lastCitationSources && this.lastCitationSources.length > 0
? this.lastCitationSources
: (sources || []).map((source) => ({ title: source.title, path: source.path }));
: ((sources as any[]) || []).map((source) => ({ title: source.title, path: source.path }));
fullAIResponse = addFallbackSources(
fullAIResponse,
@ -989,14 +943,14 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
}
private async executeToolCalls(
toolCalls: ToolCallWithExecutor[],
toolCalls: any[],
updateLoadingMessage?: (message: string) => void
): Promise<{
toolOutputs: { tool: string; output: unknown }[];
sources: { title: string; path: string; score: number; explanation?: unknown }[];
toolOutputs: { tool: string; output: any }[];
sources: { title: string; path: string; score: number; explanation?: any }[];
}> {
const toolOutputs: { tool: string; output: unknown }[] = [];
const allSources: { title: string; path: string; score: number; explanation?: unknown }[] = [];
const toolOutputs = [];
const allSources: { title: string; path: string; score: number; explanation?: any }[] = [];
// TODO: remove this hack until better solution in place (logan, wenzheng)
// Skip getFileTree if localSearch is already being called to avoid redundant work
@ -1044,32 +998,17 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
// Persist citation lines built for this turn to reuse in fallback
private lastCitationSources: { title?: string; path?: string }[] | null = null;
protected getTimeExpression(toolCalls: ToolCallWithExecutor[]): string {
protected getTimeExpression(toolCalls: any[]): string {
const timeRangeCall = toolCalls.find((call) => call.tool.name === "getTimeRangeMs");
return timeRangeCall ? (timeRangeCall.args.timeExpression as string) : "";
return timeRangeCall ? timeRangeCall.args.timeExpression : "";
}
private prepareLocalSearchResult(documents: unknown[], timeExpression: string): string {
private prepareLocalSearchResult(documents: any[], timeExpression: string): string {
const settings = getSettings();
// Type alias for the shape we need from search result documents
type SearchDoc = {
includeInContext?: boolean;
mtime?: number;
content?: string;
source?: string;
isFilterResult?: boolean;
title?: string;
path?: string;
__sourceId?: number;
};
// Cast once to a typed array we can work with throughout this method
const typedDocs = documents as SearchDoc[];
// Filter documents that should be included in context
// Use !== false to be consistent with formatSearchResultsForLLM and logSearchResultsDebugTable
const includedDocs = typedDocs.filter((doc) => doc.includeInContext !== false);
const includedDocs = documents.filter((doc) => doc.includeInContext !== false);
// Generate quality summary across all docs combined
const qualitySummary = generateQualitySummary(includedDocs);
@ -1083,8 +1022,8 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
const timeDominant = isTimeDominantResults(includedDocs);
// Determine tier split based on result type
let tier1Docs: SearchDoc[];
let tier2Docs: SearchDoc[];
let tier1Docs: any[];
let tier2Docs: any[];
if (timeDominant) {
// Sort by mtime desc; most recent get full content, older get metadata-only
const sorted = [...includedDocs].sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
@ -1106,39 +1045,33 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
}
// Calculate total content length for tier 1 only (safety net truncation)
const totalContentLength = tier1Docs.reduce<number>((sum, doc) => {
return sum + (doc.content ? doc.content.length : 0);
}, 0);
const totalContentLength = tier1Docs.reduce((sum, doc) => sum + (doc.content?.length || 0), 0);
// If total content length exceeds threshold, truncate content proportionally
let processedDocs: SearchDoc[] = tier1Docs;
let processedDocs = tier1Docs;
if (totalContentLength > MAX_CHARS_FOR_LOCAL_SEARCH_CONTEXT) {
const truncationRatio = MAX_CHARS_FOR_LOCAL_SEARCH_CONTEXT / totalContentLength;
logInfo(
"Truncating document contents to fit context length. Truncation ratio:",
truncationRatio
);
processedDocs = tier1Docs.map(
(doc): SearchDoc => ({
...doc,
content:
doc.content?.slice(0, Math.floor((doc.content?.length || 0) * truncationRatio)) || "",
})
);
processedDocs = tier1Docs.map((doc) => ({
...doc,
content:
doc.content?.slice(0, Math.floor((doc.content?.length || 0) * truncationRatio)) || "",
}));
}
// Assign stable source ids (continuous across both groups) and sanitize content
const withIds: SearchDoc[] = processedDocs.map(
(doc, idx): SearchDoc => ({
...doc,
__sourceId: idx + 1,
content: sanitizeContentForCitations((doc.content as string) || ""),
})
);
const withIds = processedDocs.map((doc, idx) => ({
...doc,
__sourceId: idx + 1,
content: sanitizeContentForCitations(doc.content || ""),
}));
// Split into filter and search docs by isFilterResult flag
const filterDocs = withIds.filter((d) => d.isFilterResult === true);
const searchDocs = withIds.filter((d) => d.isFilterResult !== true);
const filterDocs = withIds.filter((d: any) => d.isFilterResult === true);
const searchDocs = withIds.filter((d: any) => d.isFilterResult !== true);
// Use split formatter if there are filter results, otherwise fall back to unified format.
// For tag-only queries (tier1 empty), output metadata-only directly.
@ -1168,14 +1101,14 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
// Build a compact, unnumbered source catalog to avoid bias
const sourceEntries: SourceCatalogEntry[] = withIds
.slice(0, Math.min(20, withIds.length))
.map((d) => ({
.map((d: any) => ({
title: d.title || d.path || "Untitled",
path: d.path || d.title || "",
}));
const catalogLines = formatSourceCatalog(sourceEntries);
// Also keep a numbered mapping for fallback use only (if model emits footnotes but forgets Sources)
this.lastCitationSources = withIds.slice(0, Math.min(20, withIds.length)).map((d) => {
this.lastCitationSources = withIds.slice(0, Math.min(20, withIds.length)).map((d: any) => {
const title = d.title || d.path || "Untitled";
return {
title,
@ -1211,9 +1144,9 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
): {
formattedForLLM: string;
formattedForDisplay: string;
sources: { title: string; path: string; score: number; explanation?: unknown }[];
sources: { title: string; path: string; score: number; explanation?: any }[];
} {
let sources: { title: string; path: string; score: number; explanation?: unknown }[] = [];
let sources: { title: string; path: string; score: number; explanation?: any }[] = [];
let formattedForLLM: string;
let formattedForDisplay: string;
@ -1224,7 +1157,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
}
try {
const parsed = JSON.parse(toolResult.result) as { type?: unknown; documents?: unknown };
const parsed = JSON.parse(toolResult.result);
const searchResults =
parsed &&
typeof parsed === "object" &&
@ -1239,7 +1172,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
}
// Log a concise debug table of results with explanations (title, ctime, mtime)
logSearchResultsDebugTable(searchResults as SearchDoc[]);
logSearchResultsDebugTable(searchResults);
// Extract sources with explanation for UI display
sources = extractSourcesFromSearchResults(searchResults);
@ -1268,13 +1201,15 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
* Formats all tool outputs uniformly for user message.
* All tools (localSearch, webSearch, getFileTree, etc.) are treated the same.
*/
private formatAllToolOutputs(toolOutputs: { tool: string; output: unknown }[]): string {
private formatAllToolOutputs(toolOutputs: any[]): string {
if (toolOutputs.length === 0) return "";
const formattedOutputs = toolOutputs
.map((output) => {
const content: string =
typeof output.output === "string" ? output.output : JSON.stringify(output.output);
let content = output.output;
if (typeof content !== "string") {
content = JSON.stringify(content);
}
return `<${output.tool}>\n${content}\n</${output.tool}>`;
})
.join("\n\n");

View file

@ -15,9 +15,7 @@ export class LLMChainRunner extends BaseChainRunner {
* Construct messages array using envelope-based context (L1-L5 layers)
* Requires context envelope - throws error if unavailable
*/
private async constructMessages(
userMessage: ChatMessage
): Promise<{ role: string; content: string | unknown[] }[]> {
private async constructMessages(userMessage: ChatMessage): Promise<any[]> {
// Require envelope for LLM chain
if (!userMessage.contextEnvelope) {
throw new Error(
@ -34,7 +32,7 @@ export class LLMChainRunner extends BaseChainRunner {
debug: false,
});
const messages: { role: string; content: string | unknown[] }[] = [];
const messages: any[] = [];
// Add system message (L1)
const systemMessage = baseMessages.find((m) => m.role === "system");
@ -52,7 +50,7 @@ export class LLMChainRunner extends BaseChainRunner {
// Handle multimodal content if present
if (userMessage.content && Array.isArray(userMessage.content)) {
// Merge envelope text with multimodal content (images)
const updatedContent = userMessage.content.map((item: { type?: string }) => {
const updatedContent = userMessage.content.map((item: any) => {
if (item.type === "text") {
return { ...item, text: userMessageContent.content };
}
@ -117,13 +115,9 @@ export class LLMChainRunner extends BaseChainRunner {
// Stream with abort signal
const chatStream = await withSuppressedTokenWarnings(() =>
this.chainManager.chatModelManager.getChatModel().stream(
// ProviderMessage[] format matches what getChatModel().stream() accepts at runtime
messages as never,
{
signal: abortController.signal,
}
)
this.chainManager.chatModelManager.getChatModel().stream(messages, {
signal: abortController.signal,
})
);
for await (const chunk of chatStream) {
@ -131,19 +125,15 @@ export class LLMChainRunner extends BaseChainRunner {
logInfo("Stream iteration aborted", { reason: abortController.signal.reason });
break;
}
streamer.processChunk(chunk as Parameters<typeof streamer.processChunk>[0]);
streamer.processChunk(chunk);
}
} catch (error: unknown) {
} catch (error: any) {
// Check if the error is due to abort signal
const errorName = error instanceof Error ? error.name : "";
if (errorName === "AbortError" || abortController.signal.aborted) {
if (error.name === "AbortError" || abortController.signal.aborted) {
logInfo("Stream aborted by user", { reason: abortController.signal.reason });
// Don't show error message for user-initiated aborts
} else {
await this.handleError(
error,
streamer.processErrorChunk.bind(streamer) as (message: string) => void
);
await this.handleError(error, streamer.processErrorChunk.bind(streamer));
}
}

View file

@ -138,26 +138,22 @@ export class VaultQAChainRunner extends BaseChainRunner {
// Format documents as context with XML tags
// Sanitize content to remove pre-existing citation markers
const context = (
retrievedDocs as { metadata?: { title?: string; path?: string }; pageContent?: string }[]
)
.map((doc) => {
const context = retrievedDocs
.map((doc: any) => {
const title = doc.metadata?.title || "Untitled";
const path = doc.metadata?.path || title;
return `<${RETRIEVED_DOCUMENT_TAG}>\n<title>${title}</title>\n<path>${path}</path>\n<content>\n${sanitizeContentForCitations(doc.pageContent ?? "")}\n</content>\n</${RETRIEVED_DOCUMENT_TAG}>`;
return `<${RETRIEVED_DOCUMENT_TAG}>\n<title>${title}</title>\n<path>${path}</path>\n<content>\n${sanitizeContentForCitations(doc.pageContent)}\n</content>\n</${RETRIEVED_DOCUMENT_TAG}>`;
})
.join("\n\n");
// Step 6: Build messages array with envelope-aware logic
const messages: { role: string; content: string | unknown[] }[] = [];
const messages: any[] = [];
const chatModel = this.chainManager.chatModelManager.getChatModel();
// Prepare RAG context and citation instructions
const sourceEntries: SourceCatalogEntry[] = (
retrievedDocs as { metadata?: { title?: string; path?: string } }[]
)
const sourceEntries: SourceCatalogEntry[] = retrievedDocs
.slice(0, Math.max(5, Math.min(20, retrievedDocs.length)))
.map((d) => ({
.map((d: any) => ({
title: d.metadata?.title || d.metadata?.path || "Untitled",
path: d.metadata?.path || d.metadata?.title || "",
}));
@ -208,14 +204,12 @@ export class VaultQAChainRunner extends BaseChainRunner {
// Handle multimodal content if present
if (userMessage.content && Array.isArray(userMessage.content)) {
const updatedContent = userMessage.content.map(
(item: { type?: string }): { type?: string; [key: string]: unknown } => {
if (item.type === "text") {
return { ...item, text: enhancedUserContent };
}
return { ...item };
const updatedContent = userMessage.content.map((item: any) => {
if (item.type === "text") {
return { ...item, text: enhancedUserContent };
}
);
return item;
});
messages.push({
role: "user",
content: updatedContent,
@ -240,13 +234,9 @@ export class VaultQAChainRunner extends BaseChainRunner {
// Stream with abort signal
const chatStream = await withSuppressedTokenWarnings(() =>
this.chainManager.chatModelManager.getChatModel().stream(
// ProviderMessage[] format matches what getChatModel().stream() accepts at runtime
messages as never,
{
signal: abortController.signal,
}
)
this.chainManager.chatModelManager.getChatModel().stream(messages, {
signal: abortController.signal,
})
);
for await (const chunk of chatStream) {
@ -254,18 +244,15 @@ export class VaultQAChainRunner extends BaseChainRunner {
logInfo("VaultQA stream iteration aborted", { reason: abortController.signal.reason });
break;
}
streamer.processChunk(chunk as Parameters<typeof streamer.processChunk>[0]);
streamer.processChunk(chunk);
}
} catch (error: unknown) {
} catch (error: any) {
// Check if the error is due to abort signal
if ((error as { name?: string }).name === "AbortError" || abortController.signal.aborted) {
if (error.name === "AbortError" || abortController.signal.aborted) {
logInfo("VaultQA stream aborted by user", { reason: abortController.signal.reason });
// Don't show error message for user-initiated aborts
} else {
await this.handleError(
error,
streamer.processErrorChunk.bind(streamer) as (message: string) => void
);
await this.handleError(error, streamer.processErrorChunk.bind(streamer));
}
}

View file

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

View file

@ -10,7 +10,7 @@ const MockedToolManager = ToolManager as jest.Mocked<typeof ToolManager>;
const MockedToolResultFormatter = ToolResultFormatter as jest.Mocked<typeof ToolResultFormatter>;
describe("ActionBlockStreamer", () => {
let writeFileTool: unknown;
let writeFileTool: any;
let streamer: ActionBlockStreamer;
beforeEach(() => {
@ -24,8 +24,8 @@ describe("ActionBlockStreamer", () => {
});
// Helper function to process chunks and collect results
async function processChunks(chunks: { content: string | null }[]): Promise<unknown[]> {
const outputContents: unknown[] = [];
async function processChunks(chunks: { content: string | null }[]) {
const outputContents: any[] = [];
for (const chunk of chunks) {
for await (const result of streamer.processChunk(chunk)) {
// Always push the content, even if it's null, undefined, or empty string
@ -180,7 +180,7 @@ describe("ActionBlockStreamer", () => {
});
it("should handle chunks with different content types", async () => {
const chunks: { content: string | null }[] = [
const chunks: any[] = [
{ content: "Hello" },
{ content: null },
{ content: "" },

View file

@ -14,7 +14,7 @@ export class ActionBlockStreamer {
constructor(
private toolManager: typeof ToolManager,
private writeFileTool: unknown
private writeFileTool: any
) {}
private findCompleteBlock(str: string) {
@ -32,23 +32,21 @@ export class ActionBlockStreamer {
};
}
async *processChunk(
chunk: Record<string, unknown>
): AsyncGenerator<Record<string, unknown>, void, unknown> {
async *processChunk(chunk: any): AsyncGenerator<any, void, unknown> {
// Handle different chunk formats
let chunkContent = "";
// Handle Claude thinking model array-based content
if (Array.isArray(chunk.content)) {
for (const item of chunk.content as Array<{ type?: string; text?: unknown }>) {
for (const item of chunk.content) {
if (item.type === "text" && item.text != null) {
chunkContent += typeof item.text === "string" ? item.text : "";
chunkContent += item.text;
}
}
}
// Handle standard string content
else if (chunk.content != null) {
chunkContent = typeof chunk.content === "string" ? chunk.content : "";
chunkContent = chunk.content;
}
// Add to buffer
@ -79,10 +77,10 @@ export class ActionBlockStreamer {
});
// Format tool result using ToolResultFormatter for consistency with agent mode
const formattedResult = ToolResultFormatter.format("writeFile", result as string);
const formattedResult = ToolResultFormatter.format("writeFile", result);
yield { ...chunk, content: `\n${formattedResult}\n` };
} catch (err: unknown) {
yield { ...chunk, content: `\nError: ${(err as Error)?.message ?? String(err)}\n` };
} catch (err: any) {
yield { ...chunk, content: `\nError: ${err?.message || err}\n` };
}
// Remove processed block from buffer

View file

@ -120,7 +120,7 @@ export class ThinkBlockStreamer {
}
}
private handleClaudeChunk(content: Array<{ type?: string; text?: string; thinking?: string }>) {
private handleClaudeChunk(content: any[]) {
let textContent = "";
let hasThinkingContent = false;
for (const item of content) {
@ -157,10 +157,7 @@ export class ThinkBlockStreamer {
return hasThinkingContent;
}
private handleDeepseekChunk(chunk: {
content?: string;
additional_kwargs?: { reasoning_content?: string };
}) {
private handleDeepseekChunk(chunk: any) {
// Handle standard string content
if (typeof chunk.content === "string") {
this.fullResponse += stripSpecialTokens(chunk.content);
@ -203,13 +200,7 @@ export class ThinkBlockStreamer {
* - Models that only populate reasoning_details (without delta.reasoning) won't show thinking
* - This is acceptable for now as most models use delta.reasoning for streaming
*/
private handleOpenRouterChunk(chunk: {
content?: string;
additional_kwargs?: {
delta?: { reasoning?: string };
reasoning_details?: unknown[];
};
}) {
private handleOpenRouterChunk(chunk: any) {
// Only process delta.reasoning (streaming), ignore reasoning_details entirely
if (chunk.additional_kwargs?.delta?.reasoning) {
// Skip thinking content if excludeThinking is enabled
@ -242,14 +233,7 @@ export class ThinkBlockStreamer {
* Accumulate native tool call chunks during streaming.
* LangChain providers send tool_call_chunks with incremental data.
*/
private handleToolCallChunks(chunk: {
tool_call_chunks?: Array<{
index?: number;
id?: string;
name?: string;
args?: string;
}>;
}) {
private handleToolCallChunks(chunk: any) {
// Check for tool_call_chunks in the chunk (LangChain streaming format)
const toolCallChunks = chunk.tool_call_chunks;
if (!toolCallChunks || !Array.isArray(toolCallChunks)) {
@ -257,7 +241,7 @@ export class ThinkBlockStreamer {
}
for (const tc of toolCallChunks) {
const idx: number = (tc.index as number) ?? 0;
const idx = tc.index ?? 0;
const existing = this.toolCallChunks.get(idx) || { name: "", args: "" };
// Accumulate data from chunk
@ -269,17 +253,7 @@ export class ThinkBlockStreamer {
}
}
processChunk(chunk: {
response_metadata?: Record<string, unknown>;
usage_metadata?: { input_tokens?: number; output_tokens?: number; total_tokens?: number };
tool_call_chunks?: Array<{ index?: number; id?: string; name?: string; args?: string }>;
content?: string | Array<{ type?: string; text?: string; thinking?: string }>;
additional_kwargs?: {
reasoning_content?: string;
delta?: { reasoning?: string };
reasoning_details?: unknown[];
};
}) {
processChunk(chunk: any) {
// Detect truncation using multi-provider detector
const truncationResult = detectTruncation(chunk);
if (truncationResult.wasTruncated) {
@ -316,17 +290,16 @@ export class ThinkBlockStreamer {
// Route based on the actual chunk format
if (Array.isArray(chunk.content)) {
// Claude format with content array
// chunk.content is Array<...> in this branch (checked by Array.isArray guard above)
this.handleClaudeChunk(chunk.content);
} else if (chunk.additional_kwargs?.reasoning_content) {
// Deepseek format with reasoning_content
this.handleDeepseekChunk(chunk as Parameters<typeof this.handleDeepseekChunk>[0]);
this.handleDeepseekChunk(chunk);
} else if (isThinkingChunk) {
// OpenRouter format with delta.reasoning or reasoning_details
this.handleOpenRouterChunk(chunk as Parameters<typeof this.handleOpenRouterChunk>[0]);
this.handleOpenRouterChunk(chunk);
} else {
// Default case: regular content or other formats
this.handleDeepseekChunk(chunk as Parameters<typeof this.handleDeepseekChunk>[0]);
this.handleDeepseekChunk(chunk);
}
// Handle text-level think tags (e.g., from nvidia/nemotron models)

View file

@ -148,7 +148,7 @@ describe("chatHistoryUtils", () => {
},
];
const messages: { role: string; content: string | unknown[] }[] = [];
const messages: any[] = [];
addChatHistoryToMessages(rawHistory, messages);
expect(messages).toEqual([

View file

@ -6,7 +6,7 @@ import { logInfo } from "@/logger";
export interface ProcessedMessage {
role: "user" | "assistant";
content: string | unknown[]; // string or MessageContent[]
content: any; // string or MessageContent[]
}
/**
@ -16,29 +16,28 @@ export interface ProcessedMessage {
* @param rawHistory Array of messages from memory.loadMemoryVariables()
* @returns Array of processed messages safe for LLM consumption
*/
export function processRawChatHistory(rawHistory: unknown[]): ProcessedMessage[] {
export function processRawChatHistory(rawHistory: any[]): ProcessedMessage[] {
const messages: ProcessedMessage[] = [];
for (const message of rawHistory) {
if (!message) continue;
const msg = message as Record<string, unknown>;
// Check if this is a BaseMessage with _getType method
if (typeof msg._getType === "function") {
const messageType = (msg._getType as () => string)();
if (typeof message._getType === "function") {
const messageType = message._getType();
// Only process human and AI messages
if (messageType === "human") {
messages.push({ role: "user", content: msg.content as string | unknown[] });
messages.push({ role: "user", content: message.content });
} else if (messageType === "ai") {
messages.push({ role: "assistant", content: msg.content as string | unknown[] });
messages.push({ role: "assistant", content: message.content });
}
// Skip system messages and unknown types
} else if (msg.content !== undefined) {
} else if (message.content !== undefined) {
// Fallback for other message formats - try to infer role
const role = inferMessageRole(msg);
const role = inferMessageRole(message);
if (role) {
messages.push({ role, content: msg.content as string | unknown[] });
messages.push({ role, content: message.content });
}
}
}
@ -50,7 +49,7 @@ export function processRawChatHistory(rawHistory: unknown[]): ProcessedMessage[]
* Try to infer the role from various message format properties
* @returns 'user' | 'assistant' | null
*/
function inferMessageRole(message: Record<string, unknown>): "user" | "assistant" | null {
function inferMessageRole(message: any): "user" | "assistant" | null {
// Check various properties that might indicate the role
if (message.role === "human" || message.role === "user" || message.sender === "user") {
return "user";
@ -70,8 +69,8 @@ function inferMessageRole(message: Record<string, unknown>): "user" | "assistant
* @param messages Target messages array to add to
*/
export function addChatHistoryToMessages(
rawHistory: unknown[],
messages: Array<{ role: string; content: string | unknown[] }>
rawHistory: any[],
messages: Array<{ role: string; content: any }>
): void {
const processedHistory = processRawChatHistory(rawHistory);
for (const msg of processedHistory) {
@ -88,17 +87,14 @@ export interface ChatHistoryEntry {
* Extract text content from potentially multimodal message content.
* Replaces non-text content (images) with placeholder.
*/
function extractTextContent(content: string | unknown[]): string {
function extractTextContent(content: any): string {
if (typeof content === "string") {
return content;
} else if (Array.isArray(content)) {
// Extract text from multimodal content, skip image_url payloads
const textParts: string = content
.filter(
(item): item is { type: string; text?: string } =>
typeof item === "object" && item !== null && (item as { type?: unknown }).type === "text"
)
.map((item): string => item.text || "")
const textParts = content
.filter((item: any) => item.type === "text")
.map((item: any) => item.text || "")
.join(" ");
return textParts || "[Image content]";
}
@ -233,10 +229,8 @@ export function extractConversationTurns(processedHistory: ProcessedMessage[]):
* @returns The processed history that was added
*/
export async function loadAndAddChatHistory(
memory: {
loadMemoryVariables: (vars: Record<string, unknown>) => Promise<{ history?: unknown[] }>;
},
messages: Array<{ role: string; content: string | unknown[] }>
memory: any,
messages: Array<{ role: string; content: any }>
): Promise<ProcessedMessage[]> {
const memoryVariables = await memory.loadMemoryVariables({});
const rawHistory = memoryVariables.history || [];

View file

@ -51,8 +51,8 @@ More content
it("should handle edge cases", () => {
expect(sanitizeContentForCitations("")).toBe("");
expect(sanitizeContentForCitations(null)).toBe("");
expect(sanitizeContentForCitations(undefined)).toBe("");
expect(sanitizeContentForCitations(null as any)).toBe("");
expect(sanitizeContentForCitations(undefined as any)).toBe("");
});
});
@ -142,8 +142,8 @@ More content
it("should handle edge cases", () => {
expect(hasExistingCitations("")).toBe(false);
expect(hasExistingCitations(null)).toBe(false);
expect(hasExistingCitations(undefined)).toBe(false);
expect(hasExistingCitations(null as any)).toBe(false);
expect(hasExistingCitations(undefined as any)).toBe(false);
});
it("should handle the exact format from user's example", () => {
@ -191,8 +191,8 @@ More content
it("should handle edge cases", () => {
expect(hasInlineCitations("")).toBe(false);
expect(hasInlineCitations(null)).toBe(false);
expect(hasInlineCitations(undefined)).toBe(false);
expect(hasInlineCitations(null as any)).toBe(false);
expect(hasInlineCitations(undefined as any)).toBe(false);
});
it("should NOT confuse markdown links with citations", () => {
@ -424,7 +424,7 @@ More content
it("should handle empty sources array", () => {
const response = "Some content";
const sources: { title?: string; path?: string }[] = [];
const sources: any[] = [];
const result = addFallbackSources(response, sources);
expect(result).toBe(response);
@ -432,8 +432,8 @@ More content
it("should handle invalid inputs gracefully", () => {
expect(addFallbackSources("", [{ title: "Doc" }])).toBe("");
expect(addFallbackSources(null, [{ title: "Doc" }])).toBe("");
expect(addFallbackSources(undefined, [{ title: "Doc" }])).toBe("");
expect(addFallbackSources(null as any, [{ title: "Doc" }])).toBe("");
expect(addFallbackSources(undefined as any, [{ title: "Doc" }])).toBe("");
});
});

View file

@ -5,7 +5,7 @@
// ===== CITATION RULES =====
const CITATION_RULES = `CITATION RULES:
export const CITATION_RULES = `CITATION RULES:
1. START with [^1] and increment sequentially ([^1], [^2], [^3], etc.) with NO gaps
2. BE SELECTIVE: ONLY cite when introducing NEW factual claims, specific data, or direct quotes from sources
3. IMPORTANT: Do NOT cite every sentence or bullet point. This creates clutter and poor readability.
@ -21,7 +21,7 @@ const CITATION_RULES = `CITATION RULES:
9. If multiple source chunks come from the same document, cite each relevant chunk separately (e.g., [^1] and [^2] can both be from the same document title)
10. End with '#### Sources' section containing: [^n]: [[Title]] (one per line, matching citation order)`;
const WEB_CITATION_RULES = `WEB CITATION RULES:
export const WEB_CITATION_RULES = `WEB CITATION RULES:
1. START with [^1] and increment sequentially ([^1], [^2], [^3], etc.) with NO gaps
2. Cite ONLY when introducing new factual claims, statistics, or direct quotes from the search results
3. After every cited claim, place the corresponding footnote immediately after the sentence ("The study found X [^1]")
@ -106,7 +106,7 @@ const MAX_FALLBACK_SOURCES = 20;
* Adds fallback sources to response if citations are missing.
*/
export function addFallbackSources(
response: string | null | undefined,
response: string,
sources: { title?: string; path?: string }[],
enableInlineCitations: boolean = true
): string {
@ -141,7 +141,7 @@ export function addFallbackSources(
/**
* Sanitizes content to remove pre-existing citation markers to prevent number leakage.
*/
export function sanitizeContentForCitations(text: string | null | undefined): string {
export function sanitizeContentForCitations(text: string): string {
if (!text) return "";
// Remove inline footnote refs like [^12]
@ -159,7 +159,7 @@ export function sanitizeContentForCitations(text: string | null | undefined): st
/**
* Detects if response already has sources section or footnote definitions.
*/
export function hasExistingCitations(response: string | null | undefined): boolean {
export function hasExistingCitations(response: string): boolean {
const content = response || "";
const hasMarkdownHeading = /(^|\n)\s*#{1,6}\s*Sources\b/i.test(content);
const hasPlainLabel = /(^|\n)\s*Sources\s*(?:[:-]\s*)?(\n|$)/i.test(content);
@ -173,7 +173,7 @@ export function hasExistingCitations(response: string | null | undefined): boole
* Detects if response contains inline citation markers in the body text (like [^1], [^2], etc.).
* This is different from hasExistingCitations which checks for the Sources section.
*/
export function hasInlineCitations(response: string | null | undefined): boolean {
export function hasInlineCitations(response: string): boolean {
const content = response || "";
// Look for [^digits] patterns in the text (inline citations)
// This should match [^1], [^2], etc. used in the body text
@ -193,7 +193,7 @@ export function getWebSearchCitationInstructions(enableInlineCitations: boolean
// ===== CITATION PROCESSING UTILITIES =====
interface SourcesSection {
export interface SourcesSection {
mainContent: string;
sourcesBlock: string;
}
@ -254,7 +254,7 @@ export function extractSourcesSection(content: string): SourcesSection | null {
/**
* Normalizes sources block by adding line breaks if everything is on one line.
*/
function normalizeSourcesBlock(sourcesBlock: string): string {
export function normalizeSourcesBlock(sourcesBlock: string): string {
if (!sourcesBlock.includes("\n")) {
// Ensure a break before every [n]
sourcesBlock = sourcesBlock.replace(/\s*\[(\d+)\]\s*/g, "\n[$1] ");
@ -268,7 +268,7 @@ function normalizeSourcesBlock(sourcesBlock: string): string {
/**
* Parses footnote definitions from sources block.
*/
function parseFootnoteDefinitions(sourcesBlock: string): string[] {
export function parseFootnoteDefinitions(sourcesBlock: string): string[] {
return sourcesBlock
.split("\n")
.map((l) => l.trim())
@ -278,7 +278,10 @@ function parseFootnoteDefinitions(sourcesBlock: string): string[] {
/**
* Builds a citation renumbering map based on first-mention order in content.
*/
function buildCitationMap(mainContent: string, footnoteLines: string[]): Map<number, number> {
export function buildCitationMap(
mainContent: string,
footnoteLines: string[]
): Map<number, number> {
const map = new Map<number, number>();
const seen = new Set<number>();
const firstMention: number[] = [];
@ -323,7 +326,7 @@ export function normalizeCitations(content: string, map: Map<number, number>): s
changed = false;
// Handle single citations: [^n] -> [n]
result = result.replace(/\[\^(\d+)\]/g, (match, n: string) => {
result = result.replace(/\[\^(\d+)\]/g, (match, n) => {
const oldN = parseInt(n, 10);
const newN = map.get(oldN) ?? oldN;
const replacement = `[${newN}]`;
@ -334,7 +337,7 @@ export function normalizeCitations(content: string, map: Map<number, number>): s
});
// Handle multiple citations: [^n, ^m] -> [n, m]
result = result.replace(/\[\^(\d+(?:\s*,\s*\^?\d+)*)\]/g, (match, citationList: string) => {
result = result.replace(/\[\^(\d+(?:\s*,\s*\^?\d+)*)\]/g, (match, citationList) => {
// Split and process each number in the list
const processedNumbers = citationList
.split(",")
@ -365,7 +368,10 @@ export function normalizeCitations(content: string, map: Map<number, number>): s
/**
* Converts footnote definitions to simple display items.
*/
function convertFootnoteDefinitions(sourcesBlock: string, map: Map<number, number>): string[] {
export function convertFootnoteDefinitions(
sourcesBlock: string,
map: Map<number, number>
): string[] {
const items: string[] = [];
sourcesBlock.split("\n").forEach((line) => {
const m = line.match(/^\[\^(\d+)\]:\s*(.*)$/);
@ -403,7 +409,7 @@ function convertFootnoteDefinitions(sourcesBlock: string, map: Map<number, numbe
/**
* Consolidates duplicate sources and returns mapping for citation updates.
*/
function consolidateDuplicateSources(items: string[]): {
export function consolidateDuplicateSources(items: string[]): {
uniqueItems: string[];
consolidationMap: Map<number, number>;
} {
@ -450,7 +456,7 @@ export function updateCitationsForConsolidation(
): string {
if (consolidationMap.size === 0) return content;
return content.replace(/\[(\d+(?:\s*,\s*\d+)*)\]/g, (_match, nums: string) => {
return content.replace(/\[(\d+(?:\s*,\s*\d+)*)\]/g, (_match, nums) => {
const parts = nums.split(/\s*,\s*/);
const seen = new Set<number>();
const unique: number[] = [];
@ -479,7 +485,7 @@ export function deduplicateAdjacentCitations(content: string): string {
// Match citation brackets separated by optional whitespace or connectors (" and ", ", ")
result = result.replace(
/\[(\d+(?:\s*,\s*\d+)*)\](?:\s*(?:and|,)\s*|\s*)\[(\d+(?:\s*,\s*\d+)*)\]/g,
(match, first: string, second: string) => {
(match, first, second) => {
const firstNums = new Set(first.split(/\s*,\s*/).map((s: string) => s.trim()));
const secondNums = second.split(/\s*,\s*/).map((s: string) => s.trim());
if (secondNums.every((n: string) => firstNums.has(n))) {
@ -585,7 +591,7 @@ function buildSourcesDetails(mainContent: string, items: SourcesDisplayItem[]):
* These spans provide visual feedback during streaming (styled as pending links)
* and are replaced by linkInlineCitations with actual clickable anchors after streaming.
*/
function wrapCitationPlaceholders(content: string): string {
export function wrapCitationPlaceholders(content: string): string {
return content.replace(
/\[(\d+(?:\s*,\s*\d+)*)\](?!\()/g,
'<span class="copilot-citation-ref">[$1]</span>'

View file

@ -28,9 +28,7 @@ export interface FinishReasonResult {
* @param chunk The streaming chunk from the LLM (AIMessageChunk)
* @returns FinishReasonResult with truncation status and details
*/
export function detectTruncation(chunk: {
response_metadata?: Record<string, unknown>;
}): FinishReasonResult {
export function detectTruncation(chunk: any): FinishReasonResult {
const metadata = chunk.response_metadata || {};
// OpenAI, DeepSeek, Mistral, Groq use "length"
@ -71,10 +69,7 @@ export function detectTruncation(chunk: {
* @param chunk The streaming chunk from the LLM
* @returns Token usage object or null if not available
*/
export function extractTokenUsage(chunk: {
response_metadata?: Record<string, unknown>;
usage_metadata?: { input_tokens?: number; output_tokens?: number; total_tokens?: number };
}): {
export function extractTokenUsage(chunk: any): {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
@ -83,47 +78,31 @@ export function extractTokenUsage(chunk: {
// OpenAI format: tokenUsage with camelCase
if (metadata.tokenUsage) {
const tu = metadata.tokenUsage as {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
};
return {
inputTokens: tu.promptTokens,
outputTokens: tu.completionTokens,
totalTokens: tu.totalTokens,
inputTokens: metadata.tokenUsage.promptTokens,
outputTokens: metadata.tokenUsage.completionTokens,
totalTokens: metadata.tokenUsage.totalTokens,
};
}
// Anthropic/Bedrock/others format: usage with snake_case or camelCase
if (metadata.usage) {
const u = metadata.usage as {
input_tokens?: number;
inputTokens?: number;
inputTokenCount?: number;
prompt_tokens?: number;
output_tokens?: number;
outputTokens?: number;
outputTokenCount?: number;
completion_tokens?: number;
total_tokens?: number;
totalTokens?: number;
};
return {
inputTokens:
u.input_tokens ||
u.inputTokens || // Bedrock camelCase
u.inputTokenCount || // Bedrock invocationMetrics
u.prompt_tokens,
metadata.usage.input_tokens ||
metadata.usage.inputTokens || // Bedrock camelCase
metadata.usage.inputTokenCount || // Bedrock invocationMetrics
metadata.usage.prompt_tokens,
outputTokens:
u.output_tokens ||
u.outputTokens || // Bedrock camelCase
u.outputTokenCount || // Bedrock invocationMetrics
u.completion_tokens,
metadata.usage.output_tokens ||
metadata.usage.outputTokens || // Bedrock camelCase
metadata.usage.outputTokenCount || // Bedrock invocationMetrics
metadata.usage.completion_tokens,
totalTokens:
u.total_tokens ||
u.totalTokens || // Bedrock camelCase
(u.input_tokens || u.inputTokenCount || 0) + (u.output_tokens || u.outputTokenCount || 0),
metadata.usage.total_tokens ||
metadata.usage.totalTokens || // Bedrock camelCase
(metadata.usage.input_tokens || metadata.usage.inputTokenCount || 0) +
(metadata.usage.output_tokens || metadata.usage.outputTokenCount || 0),
};
}

View file

@ -5,11 +5,11 @@ describe("Image extraction from content", () => {
// Mock the global app object
const mockApp = {
metadataCache: {
getFirstLinkpathDest: jest.fn() as jest.Mock<{ path: string } | null, [string, string]>,
getFirstLinkpathDest: jest.fn(),
},
};
(window as unknown as { app: typeof mockApp }).app = mockApp;
(global as any).app = mockApp;
beforeEach(() => {
jest.clearAllMocks();

View file

@ -1,6 +1,5 @@
import { ModelAdapterFactory, joinPromptSections } from "./modelAdapter";
import { ToolMetadata } from "@/tools/ToolRegistry";
import type { BaseChatModel } from "@langchain/core/language_models/chat_models";
describe("ModelAdapter", () => {
describe("enhanceSystemPrompt", () => {
@ -16,7 +15,7 @@ describe("ModelAdapter", () => {
});
it("should include tool instructions when tools are enabled", () => {
const mockModel = { modelName: "gpt-4" } as unknown as BaseChatModel;
const mockModel = { modelName: "gpt-4" } as any;
const adapter = ModelAdapterFactory.createAdapter(mockModel);
const toolMetadata: ToolMetadata[] = [
@ -39,7 +38,7 @@ describe("ModelAdapter", () => {
});
it("should only include instructions for enabled tools", () => {
const mockModel = { modelName: "gpt-4" } as unknown as BaseChatModel;
const mockModel = { modelName: "gpt-4" } as any;
const adapter = ModelAdapterFactory.createAdapter(mockModel);
const toolMetadata: ToolMetadata[] = [
@ -65,7 +64,7 @@ describe("ModelAdapter", () => {
});
it("should include base structure elements", () => {
const mockModel = { modelName: "gpt-4" } as unknown as BaseChatModel;
const mockModel = { modelName: "gpt-4" } as any;
const adapter = ModelAdapterFactory.createAdapter(mockModel);
const enhancedPrompt = adapter.enhanceSystemPrompt(basePrompt, toolDescriptions, [], []);
@ -77,7 +76,7 @@ describe("ModelAdapter", () => {
});
it("should handle GPT-specific enhancements", () => {
const mockModel = { modelName: "gpt-4" } as unknown as BaseChatModel;
const mockModel = { modelName: "gpt-4" } as any;
const adapter = ModelAdapterFactory.createAdapter(mockModel);
const enhancedPrompt = adapter.enhanceSystemPrompt(basePrompt, toolDescriptions, [], []);
@ -88,7 +87,7 @@ describe("ModelAdapter", () => {
});
it("should handle Claude-specific enhancements", () => {
const mockModel = { modelName: "claude-3-7-sonnet" } as unknown as BaseChatModel;
const mockModel = { modelName: "claude-3-7-sonnet" } as any;
const adapter = ModelAdapterFactory.createAdapter(mockModel);
const enhancedPrompt = adapter.enhanceSystemPrompt(basePrompt, toolDescriptions, [], []);
@ -98,7 +97,7 @@ describe("ModelAdapter", () => {
});
it("should handle Gemini-specific enhancements", () => {
const mockModel = { modelName: "gemini-pro" } as unknown as BaseChatModel;
const mockModel = { modelName: "gemini-pro" } as any;
const adapter = ModelAdapterFactory.createAdapter(mockModel);
const enhancedPrompt = adapter.enhanceSystemPrompt(basePrompt, toolDescriptions, [], []);
@ -108,7 +107,7 @@ describe("ModelAdapter", () => {
});
it("should exclude instructions when no metadata provided", () => {
const mockModel = { modelName: "gpt-4" } as unknown as BaseChatModel;
const mockModel = { modelName: "gpt-4" } as any;
const adapter = ModelAdapterFactory.createAdapter(mockModel);
const enhancedPrompt = adapter.enhanceSystemPrompt(
@ -124,7 +123,7 @@ describe("ModelAdapter", () => {
});
it("should include composer-specific examples for GPT when file tools are enabled", () => {
const mockModel = { modelName: "gpt-4" } as unknown as BaseChatModel;
const mockModel = { modelName: "gpt-4" } as any;
const adapter = ModelAdapterFactory.createAdapter(mockModel);
const enhancedPrompt = adapter.enhanceSystemPrompt(
@ -142,7 +141,7 @@ describe("ModelAdapter", () => {
});
it("should rebuild enhanceSystemPrompt output from section metadata", () => {
const mockModel = { modelName: "gpt-4" } as unknown as BaseChatModel;
const mockModel = { modelName: "gpt-4" } as any;
const adapter = ModelAdapterFactory.createAdapter(mockModel);
const sections = adapter.buildSystemPromptSections(basePrompt, toolDescriptions, [], []);
@ -158,7 +157,7 @@ describe("ModelAdapter", () => {
});
it("should enhance file editing messages for GPT", () => {
const mockModel = { modelName: "gpt-4" } as unknown as BaseChatModel;
const mockModel = { modelName: "gpt-4" } as any;
const adapter = ModelAdapterFactory.createAdapter(mockModel);
const editMessage = "fix the typo in my note";

View file

@ -75,7 +75,7 @@ export interface ModelAdapter {
* @param response - The model's response text containing tool calls
* @returns Array of parsed tool calls
*/
parseToolCalls?(response: string): unknown[];
parseToolCalls?(response: string): any[];
/**
* Check if model needs special handling
@ -288,7 +288,7 @@ class GPTModelAdapter extends BaseModelAdapter {
* Check if this is a GPT-5 model
* @returns True if the model is in the GPT-5 family
*/
isGPT5Model(): boolean {
protected isGPT5Model(): boolean {
return this.modelName.includes("gpt-5") || this.modelName.includes("gpt5");
}
buildSystemPromptSections(
@ -674,11 +674,7 @@ REMEMBER: It is better to say "I only searched your notes, not the web" than to
*/
export class ModelAdapterFactory {
static createAdapter(model: BaseChatModel): ModelAdapter {
const modelName: string = (
(model as { modelName?: string }).modelName ||
(model as { model?: string }).model ||
""
).toLowerCase();
const modelName = ((model as any).modelName || (model as any).model || "").toLowerCase();
logInfo(`Creating model adapter for: ${modelName}`);
@ -686,7 +682,7 @@ export class ModelAdapterFactory {
if (modelName.includes("gpt")) {
const adapter = new GPTModelAdapter(modelName);
// Log if it's a GPT-5 model for debugging
if (adapter.isGPT5Model()) {
if ((adapter as any).isGPT5Model()) {
logInfo("Using GPTModelAdapter with GPT-5 specific enhancements");
} else {
logInfo("Using GPTModelAdapter");

View file

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

View file

@ -1,6 +1,5 @@
import { generatePromptDebugReportForAgent, resolveBasePrompt } from "./promptDebugService";
import type ChainManager from "@/LLMProviders/chainManager";
import { ModelAdapter, PromptSection } from "./modelAdapter";
import { PromptSection } from "./modelAdapter";
import { PromptDebugReport } from "./toolPromptDebugger";
const createAdapter = () => ({
@ -9,7 +8,7 @@ const createAdapter = () => ({
basePrompt: string,
toolDescriptions: string,
toolNames?: string[],
toolMetadata?: unknown[]
toolMetadata?: any[]
): PromptSection[] => [
{
id: "system",
@ -25,7 +24,7 @@ const createAdapter = () => ({
constructor: { name: "TestAdapter" },
});
const createChainContext = (history: unknown[] = []): ChainManager => {
const createChainContext = (history: any[] = []) => {
const memory = {
loadMemoryVariables: jest.fn().mockResolvedValue({ history }),
};
@ -37,7 +36,7 @@ const createChainContext = (history: unknown[] = []): ChainManager => {
userMemoryManager: {
getUserMemoryPrompt: jest.fn().mockResolvedValue(null),
},
} as unknown as ChainManager;
} as any;
};
describe("promptDebugService", () => {
@ -47,7 +46,7 @@ describe("promptDebugService", () => {
const report: PromptDebugReport = await generatePromptDebugReportForAgent({
chainManager,
adapter: adapter as unknown as ModelAdapter,
adapter: adapter as any,
basePrompt: "BasePrompt",
toolDescriptions: "<tool></tool>",
toolNames: ["localSearch"],
@ -91,7 +90,7 @@ describe("promptDebugService", () => {
userMemoryManager: {
getUserMemoryPrompt: jest.fn().mockResolvedValue(memoryPrompt),
},
} as unknown as ChainManager;
} as any;
const prompt = await resolveBasePrompt(chainManager);
expect(prompt).toContain(memoryPrompt);

View file

@ -58,7 +58,7 @@ describe("promptPayloadRecorder", () => {
});
it("serializes circular message graphs without throwing", async () => {
const circular: { role: string; self?: unknown } = { role: "system" };
const circular: any = { role: "system" };
circular.self = circular;
expect(() => recordPromptPayload({ messages: [circular] })).not.toThrow();

View file

@ -23,7 +23,7 @@ function safeSerialize(value: unknown): string {
return JSON.stringify(
value,
(key, val: unknown): unknown => {
(key, val) => {
if (typeof val === "object" && val !== null) {
if (seen.has(val)) {
return "[Circular]";
@ -85,14 +85,14 @@ function buildLayeredViewFromMessages(
};
// Helper to extract text content from message
const getTextContent = (msg: { role?: unknown; content?: unknown }): string => {
const getTextContent = (msg: any): string => {
if (typeof msg.content === "string") {
return msg.content;
}
if (Array.isArray(msg.content)) {
const textParts: string[] = (msg.content as Array<{ type?: string; text?: string }>)
.filter((item) => item.type === "text")
.map((item): string => item.text ?? "");
const textParts = msg.content
.filter((item: any) => item.type === "text")
.map((item: any) => item.text);
return textParts.join("\n");
}
return "";
@ -103,7 +103,7 @@ function buildLayeredViewFromMessages(
let historyCount = 0;
for (let i = 0; i < messageArray.length; i++) {
const msg = messageArray[i] as { role?: unknown; content?: unknown };
const msg: any = messageArray[i];
const content = getTextContent(msg);
if (msg.role === "system") {
@ -194,9 +194,7 @@ function buildLayeredViewFromMessages(
}
// Last user message
const lastMsg = messageArray[messageArray.length - 1] as
| { role?: unknown; content?: unknown }
| undefined;
const lastMsg: any = messageArray[messageArray.length - 1];
if (lastMsg && lastMsg.role === "user") {
lines.push("━━━ USER MESSAGE ━━━");
lines.push("");

View file

@ -10,10 +10,10 @@ import {
describe("searchResultUtils", () => {
describe("formatSearchResultsForLLM", () => {
it("should return empty string for non-array input", () => {
expect(formatSearchResultsForLLM(null)).toBe("");
expect(formatSearchResultsForLLM(undefined)).toBe("");
expect(formatSearchResultsForLLM("string")).toBe("");
expect(formatSearchResultsForLLM({})).toBe("");
expect(formatSearchResultsForLLM(null as any)).toBe("");
expect(formatSearchResultsForLLM(undefined as any)).toBe("");
expect(formatSearchResultsForLLM("string" as any)).toBe("");
expect(formatSearchResultsForLLM({} as any)).toBe("");
});
it("should return 'No relevant documents found.' for empty array", () => {
@ -153,9 +153,9 @@ describe("searchResultUtils", () => {
describe("extractSourcesFromSearchResults", () => {
it("should return empty array for non-array input", () => {
expect(extractSourcesFromSearchResults(null)).toEqual([]);
expect(extractSourcesFromSearchResults(undefined)).toEqual([]);
expect(extractSourcesFromSearchResults("string")).toEqual([]);
expect(extractSourcesFromSearchResults(null as any)).toEqual([]);
expect(extractSourcesFromSearchResults(undefined as any)).toEqual([]);
expect(extractSourcesFromSearchResults("string" as any)).toEqual([]);
});
it("should extract sources with all fields", () => {
@ -283,8 +283,8 @@ describe("searchResultUtils", () => {
});
it("should return empty string for non-array input", () => {
expect(formatMetadataOnlyDocuments(null)).toBe("");
expect(formatMetadataOnlyDocuments(undefined)).toBe("");
expect(formatMetadataOnlyDocuments(null as any)).toBe("");
expect(formatMetadataOnlyDocuments(undefined as any)).toBe("");
});
it("should include correct count attribute", () => {
@ -385,8 +385,8 @@ describe("searchResultUtils", () => {
});
it("should return false for non-array input", () => {
expect(isFilterOnlyResults(null as unknown as Array<{ source?: string }>)).toBe(false);
expect(isFilterOnlyResults(undefined as unknown as Array<{ source?: string }>)).toBe(false);
expect(isFilterOnlyResults(null as any)).toBe(false);
expect(isFilterOnlyResults(undefined as any)).toBe(false);
});
it("should return true when all docs have filter sources", () => {
@ -425,8 +425,8 @@ describe("searchResultUtils", () => {
});
it("should return false for non-array input", () => {
expect(isTimeDominantResults(null as unknown as Array<{ source?: string }>)).toBe(false);
expect(isTimeDominantResults(undefined as unknown as Array<{ source?: string }>)).toBe(false);
expect(isTimeDominantResults(null as any)).toBe(false);
expect(isTimeDominantResults(undefined as any)).toBe(false);
});
it("should return true when at least one doc has source time-filtered", () => {

View file

@ -1,32 +1,11 @@
import { logInfo, logWarn, logMarkdownBlock, logTable } from "@/logger";
import { sanitizeContentForCitations } from "@/LLMProviders/chainRunner/utils/citationUtils";
/**
* Raw document shape returned by search/retrieval tools.
* Fields are optional because different providers may omit some.
*/
export interface SearchDoc {
title?: string;
path?: string;
content?: string;
mtime?: string | number;
rerank_score?: number;
score?: number;
explanation?: unknown;
includeInContext?: boolean;
__sourceId?: string | number;
collection_name?: string;
source_id?: string | number;
matchType?: string;
source?: string;
chunkId?: string;
}
/**
* Quality summary for search results.
* Helps the LLM evaluate whether results are adequate or if re-search is needed.
*/
interface QualitySummary {
export interface QualitySummary {
high: number; // Count of results with score >= 0.7
medium: number; // Count of results with score >= 0.3 and < 0.7
low: number; // Count of results with score < 0.3
@ -41,7 +20,7 @@ interface QualitySummary {
* @param searchResults - Array of search results with scores
* @returns Quality summary with counts by relevance tier
*/
export function generateQualitySummary(searchResults: SearchDoc[]): QualitySummary {
export function generateQualitySummary(searchResults: any[]): QualitySummary {
if (!Array.isArray(searchResults) || searchResults.length === 0) {
return { high: 0, medium: 0, low: 0, total: 0, averageScore: 0 };
}
@ -98,15 +77,13 @@ export function formatQualitySummary(summary: QualitySummary): string {
* @param searchResults - The raw search results from localSearch tool
* @returns Formatted text string for LLM
*/
export function formatSearchResultsForLLM(searchResults: unknown): string {
export function formatSearchResultsForLLM(searchResults: any[]): string {
if (!Array.isArray(searchResults)) {
return "";
}
// Filter documents that should be included in context
const includedDocs = (searchResults as SearchDoc[]).filter(
(doc) => doc.includeInContext !== false
);
const includedDocs = searchResults.filter((doc) => doc.includeInContext !== false);
if (includedDocs.length === 0) {
return "No relevant documents found.";
@ -114,11 +91,15 @@ export function formatSearchResultsForLLM(searchResults: unknown): string {
// Format each document with essential metadata
const formattedDocs = includedDocs
.map((doc, idx: number) => {
.map((doc: any, idx: number) => {
const title = doc.title || "Untitled";
const path = doc.path || "";
// Optional stable source id if provided by caller; fallback to order
const sourceId = doc.__sourceId || doc.collection_name || doc.source_id || idx + 1;
const sourceId =
(doc as any).__sourceId ||
(doc as any).collection_name ||
(doc as any).source_id ||
idx + 1;
// Safely handle mtime - check validity before converting
let modified: string | null = null;
@ -178,13 +159,13 @@ export function formatSearchResultStringForLLM(resultString: string): string {
* @returns Sources array with explanation preserved for UI
*/
export function extractSourcesFromSearchResults(
searchResults: unknown
): { title: string; path: string; score: number; explanation?: unknown }[] {
searchResults: any[]
): { title: string; path: string; score: number; explanation?: any }[] {
if (!Array.isArray(searchResults)) {
return [];
}
return (searchResults as SearchDoc[]).map((doc) => ({
return searchResults.map((doc: any) => ({
title: doc.title || doc.path || "Untitled",
path: doc.path || doc.title || "",
score: doc.rerank_score || doc.score || 0,
@ -212,20 +193,19 @@ function toIsoString(ts: unknown): string {
* Create a concise, single-line summary of an explanation object.
* Includes lexical matches, semantic score, folder/graph boosts, and score adjustments.
*/
function summarizeExplanation(explanation: unknown): string {
export function summarizeExplanation(explanation: any): string {
if (!explanation) return "";
const parts: string[] = [];
const exp = explanation as Record<string, unknown>;
try {
// Lexical matches summary
if (Array.isArray(exp.lexicalMatches) && exp.lexicalMatches.length > 0) {
if (Array.isArray(explanation.lexicalMatches) && explanation.lexicalMatches.length > 0) {
const fields = new Set<string>();
const terms = new Set<string>();
for (const m of exp.lexicalMatches as Array<{ field?: unknown; query?: unknown }>) {
if (typeof m?.field === "string") fields.add(m.field);
if (typeof m?.query === "string") terms.add(m.query);
for (const m of explanation.lexicalMatches) {
if (m?.field) fields.add(String(m.field));
if (m?.query) terms.add(String(m.query));
}
const fieldsStr = Array.from(fields).join("/");
const termsStr = Array.from(terms).slice(0, 3).join(", ");
@ -233,32 +213,24 @@ function summarizeExplanation(explanation: unknown): string {
}
// Semantic score
if (typeof exp.semanticScore === "number" && exp.semanticScore > 0) {
parts.push(`Semantic: ${(exp.semanticScore * 100).toFixed(1)}%`);
if (typeof explanation.semanticScore === "number" && explanation.semanticScore > 0) {
parts.push(`Semantic: ${(explanation.semanticScore * 100).toFixed(1)}%`);
}
// Folder boost
if (
exp.folderBoost &&
typeof (exp.folderBoost as { boostFactor?: number }).boostFactor === "number"
) {
const fb = exp.folderBoost as { boostFactor: number; folder?: string };
if (explanation.folderBoost && typeof explanation.folderBoost.boostFactor === "number") {
const fb = explanation.folderBoost;
const folder = fb.folder || "root";
parts.push(`Folder +${fb.boostFactor.toFixed(2)} (${folder})`);
}
// Graph connections (query-aware boost)
if (exp.graphConnections && typeof exp.graphConnections === "object") {
const gc = exp.graphConnections as {
backlinks?: number;
coCitations?: number;
sharedTags?: number;
score?: number;
};
if (explanation.graphConnections && typeof explanation.graphConnections === "object") {
const gc = explanation.graphConnections;
const bits: string[] = [];
if ((gc.backlinks ?? 0) > 0) bits.push(`${gc.backlinks} backlinks`);
if ((gc.coCitations ?? 0) > 0) bits.push(`${gc.coCitations} co-cites`);
if ((gc.sharedTags ?? 0) > 0) bits.push(`${gc.sharedTags} tags`);
if (gc.backlinks > 0) bits.push(`${gc.backlinks} backlinks`);
if (gc.coCitations > 0) bits.push(`${gc.coCitations} co-cites`);
if (gc.sharedTags > 0) bits.push(`${gc.sharedTags} tags`);
if (typeof gc.score === "number") {
parts.push(`Graph ${gc.score.toFixed(1)}${bits.length ? ` (${bits.join(", ")})` : ""}`);
} else if (bits.length) {
@ -268,21 +240,21 @@ function summarizeExplanation(explanation: unknown): string {
// Legacy graph boost
if (
exp.graphBoost &&
typeof (exp.graphBoost as { boostFactor?: number }).boostFactor === "number" &&
!exp.graphConnections
explanation.graphBoost &&
typeof explanation.graphBoost.boostFactor === "number" &&
!explanation.graphConnections
) {
const gb = exp.graphBoost as { boostFactor: number; connections?: number };
const gb = explanation.graphBoost;
parts.push(`Graph +${gb.boostFactor.toFixed(2)} (${gb.connections} connections)`);
}
// Score adjustment
if (
typeof exp.baseScore === "number" &&
typeof exp.finalScore === "number" &&
exp.baseScore !== exp.finalScore
typeof explanation.baseScore === "number" &&
typeof explanation.finalScore === "number" &&
explanation.baseScore !== explanation.finalScore
) {
parts.push(`Score: ${exp.baseScore.toFixed(4)}${exp.finalScore.toFixed(4)}`);
parts.push(`Score: ${explanation.baseScore.toFixed(4)}${explanation.finalScore.toFixed(4)}`);
}
} catch {
// Ignore explanation parsing errors, leave parts as-is
@ -310,8 +282,8 @@ function summarizeExplanation(explanation: unknown): string {
* @returns Formatted XML string with `<filterResults>` and `<searchResults>` sections
*/
export function formatSplitSearchResultsForLLM(
filterDocs: SearchDoc[],
searchDocs: SearchDoc[],
filterDocs: any[],
searchDocs: any[],
startId = 1
): string {
let currentId = startId;
@ -320,8 +292,8 @@ export function formatSplitSearchResultsForLLM(
// Format filter results
if (filterDocs.length > 0) {
const filterXml = filterDocs
.map((doc) => {
const id = doc.__sourceId || currentId++;
.map((doc: any) => {
const id = (doc as any).__sourceId || currentId++;
const title = doc.title || "Untitled";
const path = doc.path || "";
const matchType = doc.matchType || doc.source || "filter";
@ -345,8 +317,8 @@ ${doc.content || ""}
// Format search results
if (searchDocs.length > 0) {
const searchXml = searchDocs
.map((doc) => {
const id = doc.__sourceId || currentId++;
.map((doc: any) => {
const id = (doc as any).__sourceId || currentId++;
const title = doc.title || "Untitled";
const path = doc.path || "";
const modified = toIsoString(doc.mtime);
@ -421,13 +393,16 @@ export function isTimeDominantResults(docs: Array<{ source?: string }>): boolean
* @param snippetLength - Maximum characters for the content snippet (default 300)
* @returns Formatted XML string with `<additionalMatches>` wrapper, or empty string if no docs
*/
export function formatMetadataOnlyDocuments(docs: unknown, snippetLength = 300): string {
export function formatMetadataOnlyDocuments(
docs: Array<{ title?: string; path?: string; mtime?: number | null; content?: string }>,
snippetLength = 300
): string {
if (!Array.isArray(docs) || docs.length === 0) {
return "";
}
const fileElements = (docs as SearchDoc[])
.map((doc) => {
const fileElements = docs
.map((doc: any) => {
const title = doc.title || "Untitled";
const path = doc.path || "";
const modified = toIsoString(doc.mtime);
@ -445,7 +420,7 @@ export function formatMetadataOnlyDocuments(docs: unknown, snippetLength = 300):
return `<additionalMatches count="${docs.length}" note="These results contain titles and metadata only. To read the full content of a note, call the readNote tool with its path.">\n${fileElements}\n</additionalMatches>`;
}
export function logSearchResultsDebugTable(searchResults: SearchDoc[]): void {
export function logSearchResultsDebugTable(searchResults: any[]): void {
if (!Array.isArray(searchResults) || searchResults.length === 0) {
logInfo("Search Results: (none)");
return;
@ -460,7 +435,7 @@ export function logSearchResultsDebugTable(searchResults: SearchDoc[]): void {
};
let includedCount = 0;
const rows: Row[] = searchResults.map((doc) => {
const rows: Row[] = searchResults.map((doc: any) => {
const mtime = toIsoString(doc.mtime);
const scoreNum = typeof doc.rerank_score === "number" ? doc.rerank_score : doc.score || 0;
const score = (Number.isFinite(scoreNum) ? scoreNum : 0).toFixed(4);

View file

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

View file

@ -1,4 +1,4 @@
import { executeSequentialToolCall, type ToolCall } from "./toolExecution";
import { executeSequentialToolCall } from "./toolExecution";
import { createLangChainTool } from "@/tools/createLangChainTool";
import { ToolRegistry } from "@/tools/ToolRegistry";
import { z } from "zod";
@ -147,7 +147,7 @@ describe("toolExecution", () => {
});
it("should handle invalid tool call", async () => {
const result = await executeSequentialToolCall(null as unknown as ToolCall, []);
const result = await executeSequentialToolCall(null as any, []);
expect(result).toEqual({
toolName: "unknown",

View file

@ -1,4 +1,3 @@
import { StructuredTool } from "@langchain/core/tools";
import { logError, logInfo, logWarn } from "@/logger";
import { checkIsPlusUser, isSelfHostModeValid } from "@/plusUtils";
import { getSettings } from "@/settings/model";
@ -15,7 +14,7 @@ export interface ToolCall {
args: Record<string, unknown>;
}
interface ToolExecutionResult {
export interface ToolExecutionResult {
toolName: string;
result: string;
success: boolean;
@ -31,7 +30,7 @@ interface ToolExecutionResult {
*/
export async function executeSequentialToolCall(
toolCall: ToolCall,
availableTools: Pick<StructuredTool, "name" | "invoke">[],
availableTools: any[],
originalUserMessage?: string
): Promise<ToolExecutionResult> {
const DEFAULT_TOOL_TIMEOUT = 120000; // 120 seconds timeout per tool
@ -50,7 +49,7 @@ export async function executeSequentialToolCall(
const tool = availableTools.find((t) => t.name === toolCall.name);
if (!tool) {
const availableToolNames = availableTools.map((t): string => t.name).join(", ");
const availableToolNames = availableTools.map((t) => t.name).join(", ");
return {
toolName: toolCall.name,
result: `Error: Tool '${toolCall.name}' not found. Available tools: ${availableToolNames}. Make sure you have the tool enabled in the Agent settings.`,
@ -97,7 +96,7 @@ export async function executeSequentialToolCall(
result = await Promise.race([
ToolManager.callTool(tool, toolArgs),
new Promise((_, reject) =>
window.setTimeout(
setTimeout(
() => reject(new Error(`Tool execution timed out after ${timeout}ms`)),
timeout
)
@ -146,7 +145,7 @@ export async function executeSequentialToolCall(
/**
* Get display name for tool (user-friendly version)
*/
function getToolDisplayName(toolName: string): string {
export function getToolDisplayName(toolName: string): string {
// Special handling for localSearch to show the actual search type being used
if (toolName === "localSearch") {
const settings = getSettings();
@ -184,7 +183,7 @@ function getToolDisplayName(toolName: string): string {
/**
* Get emoji for tool display
*/
function getToolEmoji(toolName: string): string {
export function getToolEmoji(toolName: string): string {
const emojiMap: Record<string, string> = {
localSearch: "🔍",
webSearch: "🌐",
@ -211,6 +210,29 @@ function getToolEmoji(toolName: string): string {
return emojiMap[toolName] || "🔧";
}
/**
* Get user confirmation message for tool call
*/
export function getToolConfirmtionMessage(toolName: string, toolArgs?: any): 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
*/
@ -261,11 +283,11 @@ export function logToolResult(toolName: string, result: ToolExecutionResult): vo
* If path is not available, falls back to title
*/
export function deduplicateSources(
sources: { title: string; path: string; score: number; explanation?: unknown }[]
): { title: string; path: string; score: number; explanation?: unknown }[] {
sources: { title: string; path: string; score: number; explanation?: any }[]
): { title: string; path: string; score: number; explanation?: any }[] {
const uniqueSources = new Map<
string,
{ title: string; path: string; score: number; explanation?: unknown }
{ title: string; path: string; score: number; explanation?: any }
>();
for (const source of sources) {

View file

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

View file

@ -42,9 +42,9 @@ describe("escapeXml", () => {
});
it("should handle non-string inputs", () => {
expect(escapeXml(null)).toBe("");
expect(escapeXml(undefined)).toBe("");
expect(escapeXml(123)).toBe("");
expect(escapeXml(null as any)).toBe("");
expect(escapeXml(undefined as any)).toBe("");
expect(escapeXml(123 as any)).toBe("");
});
it("should escape XML entity references", () => {
@ -90,9 +90,9 @@ describe("unescapeXml", () => {
});
it("should handle non-string inputs", () => {
expect(unescapeXml(null)).toBe("");
expect(unescapeXml(undefined)).toBe("");
expect(unescapeXml(123)).toBe("");
expect(unescapeXml(null as any)).toBe("");
expect(unescapeXml(undefined as any)).toBe("");
expect(unescapeXml(123 as any)).toBe("");
});
it("should handle double-escaped ampersand correctly", () => {

View file

@ -9,7 +9,7 @@
* @param str - The string to escape
* @returns The escaped string safe for XML content
*/
export function escapeXml(str: unknown): string {
export function escapeXml(str: string): string {
if (typeof str !== "string") {
return "";
}
@ -28,7 +28,7 @@ export function escapeXml(str: unknown): string {
* @param str - The XML-escaped string to unescape
* @returns The unescaped string with original characters restored
*/
export function unescapeXml(str: unknown): string {
export function unescapeXml(str: string): string {
if (typeof str !== "string") {
return "";
}
@ -47,6 +47,6 @@ export function unescapeXml(str: unknown): string {
* @param str - The string to escape for attribute use
* @returns The escaped string safe for XML attributes
*/
export function escapeXmlAttribute(str: unknown): string {
export function escapeXmlAttribute(str: string): string {
return escapeXml(str);
}

View file

@ -23,14 +23,16 @@ import {
ModelInfo,
safeFetch,
safeFetchNoThrow,
shouldUseGitHubCopilotResponsesApi,
} from "@/utils";
import { HarmBlockThreshold, HarmCategory } from "@google/generative-ai";
import { ChatAnthropic } from "@langchain/anthropic";
import { ChatCohere } from "@langchain/cohere";
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
import { BaseLanguageModel } from "@langchain/core/language_models/base";
import { ChatDeepSeek } from "@langchain/deepseek";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { ChatGroq } from "@langchain/groq";
import { ChatMistralAI } from "@langchain/mistralai";
import { ChatOllama } from "@langchain/ollama";
import { ChatOpenAI } from "@langchain/openai";
import { ChatXAI } from "@langchain/xai";
@ -40,41 +42,31 @@ import { ChatOpenRouter } from "./ChatOpenRouter";
import { ChatLMStudio } from "./ChatLMStudio";
import { BedrockChatModel, type BedrockChatModelFields } from "./BedrockChatModel";
import { GitHubCopilotChatModel } from "@/LLMProviders/githubCopilot/GitHubCopilotChatModel";
import { GitHubCopilotResponsesModel } from "@/LLMProviders/githubCopilot/GitHubCopilotResponsesModel";
import type { SafetySetting } from "@google/generative-ai";
const GOOGLE_SAFETY_SETTINGS_BLOCK_NONE: SafetySetting[] = [
{ category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_NONE" } as SafetySetting,
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE" } as SafetySetting,
{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_NONE" } as SafetySetting,
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" } as SafetySetting,
];
// Patch BaseLanguageModel.prototype.getNumTokens once at module load to prevent
// tiktoken CDN fetches. LangChain's default getNumTokens() downloads a ~3MB BPE
// vocabulary from tiktoken.pages.dev, which blocks all LLM calls when the CDN is
// unreachable. This char/4 estimation is the same fallback LangChain uses internally
// before tiktoken loads. Actual token usage comes from API response metadata.
(
BaseLanguageModel.prototype as { getNumTokens: (...args: unknown[]) => Promise<number> }
).getNumTokens = async (content: string | Array<{ type: string; text?: string }>) => {
(BaseLanguageModel.prototype as any).getNumTokens = async (
content: string | Array<{ type: string; text?: string }>
) => {
const text =
typeof content === "string"
? content
: content.map((item: { type: string; text?: string }): string => item.text ?? "").join("");
: content.map((item: any) => (typeof item === "string" ? item : (item.text ?? ""))).join("");
return Math.ceil(text.length / 4);
};
type ChatConstructorType = {
new (config: Record<string, unknown>): BaseChatModel;
new (config: any): any;
};
const CHAT_PROVIDER_CONSTRUCTORS = {
[ChatModelProviders.OPENAI]: ChatOpenAI,
[ChatModelProviders.AZURE_OPENAI]: ChatOpenAI,
[ChatModelProviders.ANTHROPIC]: ChatAnthropic,
[ChatModelProviders.COHEREAI]: ChatOpenAI,
[ChatModelProviders.COHEREAI]: ChatCohere,
[ChatModelProviders.GOOGLE]: ChatGoogleGenerativeAI,
[ChatModelProviders.XAI]: ChatXAI,
[ChatModelProviders.OPENROUTERAI]: ChatOpenRouter,
@ -84,7 +76,7 @@ const CHAT_PROVIDER_CONSTRUCTORS = {
[ChatModelProviders.OPENAI_FORMAT]: ChatOpenAI,
[ChatModelProviders.SILICONFLOW]: ChatOpenAI,
[ChatModelProviders.COPILOT_PLUS]: ChatOpenRouter,
[ChatModelProviders.MISTRAL]: ChatOpenAI,
[ChatModelProviders.MISTRAL]: ChatMistralAI,
[ChatModelProviders.DEEPSEEK]: ChatDeepSeek,
[ChatModelProviders.AMAZON_BEDROCK]: BedrockChatModel,
[ChatModelProviders.GITHUB_COPILOT]: GitHubCopilotChatModel,
@ -201,7 +193,7 @@ export default class ChatModelManager {
const modelName = customModel.name;
const modelInfo = getModelInfo(modelName);
const { isThinkingEnabled, usesAdaptiveThinking } = modelInfo;
const { isThinkingEnabled } = modelInfo;
const resolvedTemperature = this.getTemperatureForModel(modelInfo, customModel, settings);
const maxTokens = customModel.maxTokens ?? settings.maxTokens;
@ -248,18 +240,13 @@ export default class ChatModelManager {
fetch: customModel.enableCors ? safeFetch : undefined,
},
...(isThinkingEnabled && {
// Opus 4.7+ defaults thinking.display to "omitted" so thinking summaries
// never reach the UI; force "summarized" for the adaptive branch. Pre-4.7
// models default to "summarized" server-side and don't need this.
thinking: usesAdaptiveThinking
? { type: "adaptive" as const, display: "summarized" as const }
: {
type: "enabled" as const,
budget_tokens: ChatModelManager.ANTHROPIC_THINKING_BUDGET_TOKENS,
},
thinking: {
type: "enabled",
budget_tokens: ChatModelManager.ANTHROPIC_THINKING_BUDGET_TOKENS,
},
}),
},
[ChatModelProviders.AZURE_OPENAI]: await (async (): Promise<Record<string, unknown>> => {
[ChatModelProviders.AZURE_OPENAI]: await (async () => {
const azureUrl = normalizeAzureUrl(customModel.baseUrl);
return {
modelName: customModel.baseUrl
@ -292,17 +279,30 @@ export default class ChatModelManager {
};
})(),
[ChatModelProviders.COHEREAI]: {
modelName,
apiKey: await getDecryptedKey(customModel.apiKey || settings.cohereApiKey),
configuration: {
baseURL: customModel.baseUrl || ProviderInfo[ChatModelProviders.COHEREAI].host,
fetch: customModel.enableCors ? safeFetch : undefined,
},
model: modelName,
},
[ChatModelProviders.GOOGLE]: {
apiKey: await getDecryptedKey(customModel.apiKey || settings.googleApiKey),
model: modelName,
safetySettings: GOOGLE_SAFETY_SETTINGS_BLOCK_NONE,
safetySettings: [
{
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
{
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
{
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
{
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
],
baseUrl: customModel.baseUrl,
},
[ChatModelProviders.XAI]: {
@ -344,9 +344,6 @@ export default class ChatModelManager {
headers: {
Authorization: `Bearer ${await getDecryptedKey(customModel.apiKey || "default-key")}`,
},
// Route through Obsidian's requestUrl (safeFetch) to bypass CORS / mixed-content
// restrictions — required on mobile (WKWebView) when calling http:// Ollama hosts.
fetch: customModel.enableCors ? safeFetch : undefined,
// Enable thinking for models with REASONING capability (e.g., qwen3, deepseek-r1)
// Thinking content goes to additional_kwargs.reasoning_content
think: customModel.capabilities?.includes(ModelCapability.REASONING) ?? false,
@ -410,12 +407,9 @@ export default class ChatModelManager {
},
},
[ChatModelProviders.MISTRAL]: {
modelName,
model: modelName,
apiKey: await getDecryptedKey(customModel.apiKey || settings.mistralApiKey),
configuration: {
baseURL: customModel.baseUrl || ProviderInfo[ChatModelProviders.MISTRAL].host,
fetch: customModel.enableCors ? safeFetch : undefined,
},
serverURL: customModel.baseUrl,
},
[ChatModelProviders.DEEPSEEK]: {
modelName: modelName,
@ -441,7 +435,7 @@ export default class ChatModelManager {
let selectedProviderConfig =
providerConfig[customModel.provider as keyof typeof providerConfig] || {};
if ((customModel.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK) {
if (customModel.provider === ChatModelProviders.AMAZON_BEDROCK) {
selectedProviderConfig = await this.buildBedrockConfig(
customModel,
modelName,
@ -484,7 +478,7 @@ export default class ChatModelManager {
maxTokens: number,
_temperature: number | undefined,
customModel?: CustomModel
): Record<string, unknown> {
) {
const settings = getSettings();
const modelInfo = getModelInfo(modelName);
const resolvedTemperature = this.getTemperatureForModel(
@ -493,7 +487,7 @@ export default class ChatModelManager {
settings
);
const config: Record<string, unknown> = {
const config: any = {
maxTokens,
temperature: resolvedTemperature,
};
@ -511,7 +505,7 @@ export default class ChatModelManager {
if (
modelInfo.isGPT5 &&
customModel?.verbosity &&
(customModel?.provider as ChatModelProviders) !== ChatModelProviders.AZURE_OPENAI
customModel?.provider !== ChatModelProviders.AZURE_OPENAI
) {
const verbosityValue = customModel.verbosity;
// For Responses API, verbosity is nested under 'text' parameter
@ -586,7 +580,7 @@ export default class ChatModelManager {
* This prevents passing undefined values to providers that don't support them
*/
private getProviderSpecificParams(provider: ChatModelProviders, customModel: CustomModel) {
const params: Record<string, unknown> = {};
const params: Record<string, any> = {};
// Add topP only if defined
if (customModel.topP !== undefined) {
@ -666,7 +660,7 @@ export default class ChatModelManager {
* @returns True when the provider requirements are satisfied, otherwise false.
*/
private hasProviderCredentials(model: CustomModel): boolean {
if ((model.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK) {
if (model.provider === ChatModelProviders.AMAZON_BEDROCK) {
const settings = getSettings();
const apiKey = model.apiKey || settings.amazonBedrockApiKey;
// Region defaults to us-east-1 if not specified, so API key is the only requirement
@ -682,9 +676,8 @@ export default class ChatModelManager {
}
getProviderConstructor(model: CustomModel): ChatConstructorType {
const constructor: ChatConstructorType = CHAT_PROVIDER_CONSTRUCTORS[
model.provider as ChatModelProviders
] as unknown as ChatConstructorType;
const constructor: ChatConstructorType =
CHAT_PROVIDER_CONSTRUCTORS[model.provider as ChatModelProviders];
if (!constructor) {
console.warn(`Unknown provider: ${model.provider} for model: ${model.name}`);
throw new Error(`Unknown provider: ${model.provider} for model: ${model.name}`);
@ -786,8 +779,8 @@ export default class ChatModelManager {
const modelInfo = getModelInfo(model.name);
if (
modelInfo.isGPT5 &&
((model.provider as ChatModelProviders) === ChatModelProviders.OPENAI ||
(model.provider as ChatModelProviders) === ChatModelProviders.OPENAI_FORMAT)
(model.provider === ChatModelProviders.OPENAI ||
model.provider === ChatModelProviders.OPENAI_FORMAT)
) {
logInfo(`Chat model set with Responses API for GPT-5: ${model.name}`);
}
@ -806,7 +799,7 @@ export default class ChatModelManager {
}
if (!selectedModel.hasApiKey) {
const errorMessage = `API key is not provided for the model: ${modelKey}.`;
if ((model.provider as ChatModelProviders) === ChatModelProviders.COPILOT_PLUS) {
if (model.provider === ChatModelProviders.COPILOT_PLUS) {
throw new MissingPlusLicenseError(
"Copilot Plus license key is not configured. Please enter your license key in the Copilot Plus section at the top of Basic Settings."
);
@ -818,37 +811,24 @@ export default class ChatModelManager {
const modelInfo = getModelInfo(model.name);
// For GPT-5 models, automatically use Responses API for proper verbosity support
const constructorConfig: Record<string, unknown> = { ...modelConfig };
const useCopilotResponses = shouldUseGitHubCopilotResponsesApi(model);
const constructorConfig: any = { ...modelConfig };
if (
modelInfo.isGPT5 &&
((selectedModel.vendor as ChatModelProviders) === ChatModelProviders.OPENAI ||
(selectedModel.vendor as ChatModelProviders) === ChatModelProviders.OPENAI_FORMAT)
(selectedModel.vendor === ChatModelProviders.OPENAI ||
selectedModel.vendor === ChatModelProviders.OPENAI_FORMAT)
) {
constructorConfig.useResponsesApi = true;
logInfo(`Enabling Responses API for GPT-5 model: ${model.name} (${selectedModel.vendor})`);
}
if (useCopilotResponses) {
constructorConfig.useResponsesApi = true;
logInfo(`Enabling Responses API for GitHub Copilot model: ${model.name}`);
}
// For LM Studio, use ChatLMStudio by default for Responses API compatibility.
// Opt out by setting useResponsesApi to false.
if (
(model.provider as ChatModelProviders) === ChatModelProviders.LM_STUDIO &&
model.useResponsesApi !== false
) {
if (model.provider === ChatModelProviders.LM_STUDIO && model.useResponsesApi !== false) {
const lmStudioInstance = new ChatLMStudio(constructorConfig);
logInfo(`[ChatModelManager] Using Responses API for LM Studio model: ${model.name}`);
return lmStudioInstance;
}
if (useCopilotResponses) {
return new GitHubCopilotResponsesModel(constructorConfig);
}
const newModelInstance = new selectedModel.AIConstructor(constructorConfig);
return newModelInstance;
@ -905,33 +885,25 @@ export default class ChatModelManager {
const pingMaxTokens = modelInfo.isThinkingEnabled ? 4096 : 30;
const tokenConfig = { maxTokens: pingMaxTokens };
const constructorConfig: Record<string, unknown> = {
const constructorConfig: any = {
...pingConfig,
...tokenConfig,
};
const useCopilotResponses = shouldUseGitHubCopilotResponsesApi(model);
if (
modelInfo.isGPT5 &&
((model.provider as ChatModelProviders) === ChatModelProviders.OPENAI ||
(model.provider as ChatModelProviders) === ChatModelProviders.OPENAI_FORMAT)
(model.provider === ChatModelProviders.OPENAI ||
model.provider === ChatModelProviders.OPENAI_FORMAT)
) {
constructorConfig.useResponsesApi = true;
}
if (useCopilotResponses) {
constructorConfig.useResponsesApi = true;
}
// For LM Studio with Responses API, ping via ChatLMStudio so the
// connectivity check hits the same /v1/responses endpoint used in chats.
const testModel =
(model.provider as ChatModelProviders) === ChatModelProviders.LM_STUDIO &&
model.useResponsesApi !== false
model.provider === ChatModelProviders.LM_STUDIO && model.useResponsesApi !== false
? new ChatLMStudio(constructorConfig)
: useCopilotResponses
? new GitHubCopilotResponsesModel(constructorConfig)
: new (this.getProviderConstructor(modelToTest))(constructorConfig);
: new (this.getProviderConstructor(modelToTest))(constructorConfig);
await testModel.invoke([{ role: "user", content: "hello" }], {
timeout: 8000,
});
@ -942,7 +914,7 @@ export default class ChatModelManager {
await tryPing(false);
return true;
} catch (firstError) {
logInfo("First ping attempt failed, retrying with CORS enabled.");
console.log("First ping attempt failed, trying with CORS...");
try {
// Second try with CORS
await tryPing(true);

View file

@ -1,10 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { CustomModel } from "@/aiParams";
import { BREVILABS_MODELS_BASE_URL, EmbeddingModelProviders, ProviderInfo } from "@/constants";
import { getDecryptedKey } from "@/encryptionService";
import { CustomError } from "@/error";
import { logInfo } from "@/logger";
import { getModelKeyFromModel, getSettings, subscribeToSettingsChange } from "@/settings/model";
import { err2String, safeFetch } from "@/utils";
import { CohereEmbeddings } from "@langchain/cohere";
import { Embeddings } from "@langchain/core/embeddings";
import { GoogleGenerativeAIEmbeddings } from "@langchain/google-genai";
import { OllamaEmbeddings } from "@langchain/ollama";
@ -14,13 +15,13 @@ import { BrevilabsClient } from "./brevilabsClient";
import { CustomJinaEmbeddings } from "./CustomJinaEmbeddings";
import { CustomOpenAIEmbeddings } from "./CustomOpenAIEmbeddings";
type EmbeddingConstructorType = new (config: Record<string, unknown>) => Embeddings;
type EmbeddingConstructorType = new (config: any) => Embeddings;
const EMBEDDING_PROVIDER_CONSTRUCTORS = {
[EmbeddingModelProviders.COPILOT_PLUS]: CustomOpenAIEmbeddings,
[EmbeddingModelProviders.COPILOT_PLUS_JINA]: CustomJinaEmbeddings,
[EmbeddingModelProviders.OPENAI]: OpenAIEmbeddings,
[EmbeddingModelProviders.COHEREAI]: OpenAIEmbeddings,
[EmbeddingModelProviders.COHEREAI]: CohereEmbeddings,
[EmbeddingModelProviders.GOOGLE]: GoogleGenerativeAIEmbeddings,
[EmbeddingModelProviders.AZURE_OPENAI]: AzureOpenAIEmbeddings,
[EmbeddingModelProviders.OLLAMA]: OllamaEmbeddings,
@ -116,14 +117,14 @@ export default class EmbeddingManager {
}
static getModelName(embeddingsInstance: Embeddings): string {
const emb = embeddingsInstance as { model?: string; modelName?: string };
if (emb.model) {
return emb.model;
} else if (emb.modelName) {
return emb.modelName;
const emb = embeddingsInstance as any;
if ("model" in emb && emb.model) {
return emb.model as string;
} else if ("modelName" in emb && emb.modelName) {
return emb.modelName as string;
} else {
throw new Error(
`Embeddings instance missing model or modelName properties: ${JSON.stringify(embeddingsInstance)}`
`Embeddings instance missing model or modelName properties: ${embeddingsInstance}`
);
}
}
@ -140,7 +141,7 @@ export default class EmbeddingManager {
const settings = getSettings();
const embeddingModelKey = settings.embeddingModelKey;
if (!Object.prototype.hasOwnProperty.call(EmbeddingManager.modelMap, embeddingModelKey)) {
if (!EmbeddingManager.modelMap.hasOwnProperty(embeddingModelKey)) {
throw new CustomError(`No embedding model found for: ${embeddingModelKey}`);
}
@ -175,12 +176,13 @@ export default class EmbeddingManager {
EmbeddingManager.embeddingModel = new selectedModel.EmbeddingConstructor(config);
return EmbeddingManager.embeddingModel;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new CustomError(`Error creating embedding model: ${embeddingModelKey}. ${message}`);
throw new CustomError(
`Error creating embedding model: ${embeddingModelKey}. ${error.message}`
);
}
}
private async getEmbeddingConfig(customModel: CustomModel): Promise<Record<string, unknown>> {
private async getEmbeddingConfig(customModel: CustomModel): Promise<any> {
const settings = getSettings();
const modelName = customModel.name;
@ -239,14 +241,8 @@ export default class EmbeddingManager {
},
},
[EmbeddingModelProviders.COHEREAI]: {
modelName,
model: modelName,
apiKey: await getDecryptedKey(customModel.apiKey || settings.cohereApiKey),
timeout: 10000,
batchSize: getSettings().embeddingBatchSize,
configuration: {
baseURL: customModel.baseUrl || ProviderInfo[EmbeddingModelProviders.COHEREAI].host,
fetch: customModel.enableCors ? safeFetch : undefined,
},
},
[EmbeddingModelProviders.GOOGLE]: {
modelName: modelName,
@ -327,7 +323,7 @@ export default class EmbeddingManager {
await tryPing(false);
return true;
} catch (firstError) {
logInfo("First ping attempt failed, trying with CORS...");
console.log("First ping attempt failed, trying with CORS...");
try {
// Second try with CORS
await tryPing(true);

View file

@ -1,7 +1,6 @@
import type { BaseMessageChunk, MessageContent } from "@langchain/core/messages";
import { ChatOpenAICompletions } from "@langchain/openai";
import { COPILOT_API_BASE, GitHubCopilotProvider } from "./GitHubCopilotProvider";
import { buildGitHubCopilotAuthedFetch } from "./GitHubCopilotResponsesModel";
import type { FetchImplementation } from "@/utils";
import { extractTextFromChunk } from "@/utils";
@ -17,37 +16,29 @@ const CHARS_PER_TOKEN = 4;
* ChatOpenAICompletions' `_streamResponseChunks` skips chunks with non-string
* content, causing all text to be silently dropped. This normalizer ensures
* content is always a string before it reaches that check.
* @param content - Raw delta content from the transport layer.
* @returns Normalized plain-text content.
*/
function normalizeDeltaContent(content: unknown): string {
if (typeof content === "string") return content;
if (content == null) return "";
if (Array.isArray(content)) {
return content
.map((part: unknown): string => {
.map((part) => {
if (typeof part === "string") return part;
if (
part &&
typeof part === "object" &&
typeof (part as { text?: unknown }).text === "string"
) {
return (part as { text: string }).text;
if (part && typeof part === "object" && typeof part.text === "string") {
return part.text;
}
return "";
})
.join("");
}
if (typeof content === "object" && typeof (content as { text?: unknown }).text === "string") {
return (content as { text: string }).text;
if (typeof content === "object" && typeof (content as any).text === "string") {
return (content as any).text;
}
return "";
}
/** Extract the constructor fields type from ChatOpenAICompletions. */
type ChatOpenAICompletionsFields = NonNullable<
ConstructorParameters<typeof ChatOpenAICompletions>[0]
>;
type ChatOpenAICompletionsFields = NonNullable<ConstructorParameters<typeof ChatOpenAICompletions>[0]>;
/**
* Constructor params for GitHubCopilotChatModel.
@ -62,9 +53,6 @@ export type GitHubCopilotChatModelParams = ChatOpenAICompletionsFields & {
/**
* GitHub Copilot ChatModel built on top of ChatOpenAICompletions.
*
* This class is kept for Copilot models that still speak the Chat Completions API.
* Codex models are routed separately through GitHubCopilotResponsesModel.
*
* Reason: We extend ChatOpenAICompletions instead of ChatOpenAI because:
* 1. ChatOpenAI routes between Completions API and Responses API internally.
* GitHub Copilot only supports the Chat Completions API endpoint.
@ -84,28 +72,73 @@ export class GitHubCopilotChatModel extends ChatOpenAICompletions {
/**
* Build a fetch wrapper that injects a valid Copilot token and custom headers
* on every request, with automatic 401 retry after token refresh.
* @param provider - GitHubCopilotProvider singleton for token management.
* @param baseFetch - Underlying fetch implementation (native or CORS-safe).
* @returns A fetch-compatible function with Copilot auth injected.
*
* @param provider - GitHubCopilotProvider singleton for token management
* @param baseFetch - Underlying fetch implementation (native or CORS-safe)
* @returns A fetch-compatible function with Copilot auth injected
*/
private static buildAuthedFetch(
provider: GitHubCopilotProvider,
baseFetch: FetchImplementation
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
return buildGitHubCopilotAuthedFetch(provider, baseFetch);
return async (input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> => {
// Reason: OpenAI SDK v6 always calls fetch(urlString, init), so we only need
// to handle string and URL inputs. Request objects are not used by the SDK,
// but we extract the URL defensively to avoid silent failures.
// Note: If a future SDK version passes Request objects, this wrapper would
// need to clone the Request to preserve method/body/headers and support retry.
// Reason: Guard `typeof Request` to avoid ReferenceError in environments
// where the Request global may not exist (e.g., some Obsidian mobile runtimes).
const url =
typeof input === "string"
? input
: typeof Request !== "undefined" && input instanceof Request
? input.url
: input.toString();
const doRequest = async (token: string): Promise<Response> => {
const copilotHeaders = provider.buildCopilotRequestHeaders(token);
const mergedHeaders = new Headers(init.headers);
// Copilot headers take precedence (especially Authorization)
for (const [key, value] of Object.entries(copilotHeaders)) {
mergedHeaders.set(key, value);
}
return baseFetch(url, { ...init, headers: mergedHeaders });
};
let token = await provider.getValidCopilotToken();
let response = await doRequest(token);
// 401: invalidate cached token and retry once with a fresh one.
// Reason: Only retry on 401 (Unauthorized / expired token). 403 means
// "Forbidden" (e.g., no Copilot subscription) — a permanent condition
// where token refresh won't help. This matches GitHubCopilotProvider's
// own retry logic which also limits retries to 401.
if (response.status === 401) {
try {
await response.body?.cancel();
} catch {
// Ignore cancellation errors — body may already be closed
}
provider.invalidateCopilotToken();
token = await provider.getValidCopilotToken();
response = await doRequest(token);
}
return response;
};
}
/**
* Create a Copilot-backed ChatOpenAICompletions instance.
* Wires up dynamic token refresh and Copilot headers via a custom fetch wrapper.
* @param fields - LangChain/OpenAI constructor fields with Copilot fetch options.
*/
constructor(fields: GitHubCopilotChatModelParams) {
const { fetchImplementation, configuration, apiKey, ...rest } = fields;
const provider = GitHubCopilotProvider.getInstance();
// scorecard: streaming requires fetch — cannot use requestUrl
const baseFetch = fetchImplementation ?? configuration?.fetch ?? fetch;
const baseFetch = fetchImplementation ?? (configuration?.fetch as FetchImplementation) ?? fetch;
const authedFetch = GitHubCopilotChatModel.buildAuthedFetch(provider, baseFetch);
super({
@ -119,7 +152,7 @@ export class GitHubCopilotChatModel extends ChatOpenAICompletions {
configuration: {
...(configuration ?? {}),
// Reason: OpenAI SDK appends "/chat/completions" to baseURL automatically
baseURL: configuration?.baseURL ?? COPILOT_API_BASE,
baseURL: (configuration?.baseURL as string) ?? COPILOT_API_BASE,
fetch: authedFetch,
},
});
@ -142,17 +175,13 @@ export class GitHubCopilotChatModel extends ChatOpenAICompletions {
* 2. Non-string content: Claude models may return delta.content as an array of
* content parts. The parent's _streamResponseChunks skips chunks where
* `typeof content !== "string"`, silently dropping all text.
* @param delta - Streaming delta payload.
* @param rawResponse - Raw transport response chunk.
* @param defaultRole - Fallback role inferred by LangChain.
* @returns A normalized LangChain message chunk.
*/
protected override _convertCompletionsDeltaToBaseMessageChunk(
delta: Record<string, unknown>,
rawResponse: unknown,
delta: Record<string, any>,
rawResponse: any,
// Reason: Parent expects OpenAI's ChatCompletionRole type, but we accept any string
// to avoid coupling to the exact OpenAI SDK type. Cast is safe because we pass through.
defaultRole?: string
defaultRole?: any
): BaseMessageChunk {
// Reason: Copilot API omits delta.role when proxying Claude models.
// The parent converter uses `delta.role ?? defaultRole` to determine message type.
@ -169,22 +198,12 @@ export class GitHubCopilotChatModel extends ChatOpenAICompletions {
// each delta is a single-use streaming chunk.
delta.content = normalizeDeltaContent(delta.content);
// Reason: rawResponse and defaultRole are intentionally `any` here — the parent's
// signature uses tight OpenAI SDK types we don't want to couple to.
type SuperFn =
(typeof ChatOpenAICompletions.prototype)["_convertCompletionsDeltaToBaseMessageChunk"];
return super._convertCompletionsDeltaToBaseMessageChunk(
delta,
rawResponse as Parameters<SuperFn>[1],
defaultRole as Parameters<SuperFn>[2]
);
return super._convertCompletionsDeltaToBaseMessageChunk(delta, rawResponse, defaultRole);
}
/**
* Simple token estimation based on character count.
* Kept as a safe fallback for direct usage outside ChatModelManager.
* @param content - Message content to estimate.
* @returns Approximate token count.
*/
override async getNumTokens(content: MessageContent): Promise<number> {
const text = extractTextFromChunk(content);

View file

@ -68,8 +68,8 @@ export interface CopilotAuthState {
* - Token management (access token + copilot token)
* - Model listing
*
* Chat requests are handled by GitHubCopilotChatModel / GitHubCopilotResponsesModel,
* which use this provider for token lifecycle via buildCopilotRequestHeaders/getValidCopilotToken.
* Chat completions are handled by GitHubCopilotChatModel (extends ChatOpenAICompletions),
* which uses this provider for token lifecycle via buildCopilotRequestHeaders/getValidCopilotToken.
*
* WARNING: This uses GitHub Copilot's internal API which is not officially
* supported for third-party applications. Use at your own risk.
@ -651,7 +651,7 @@ export class GitHubCopilotProvider {
*/
private delay(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
return new Promise((resolve) => setTimeout(resolve, ms));
}
if (signal.aborted) {
@ -660,11 +660,11 @@ export class GitHubCopilotProvider {
return new Promise((resolve, reject) => {
const onAbort = () => {
window.clearTimeout(timeoutId);
clearTimeout(timeoutId);
reject(new AuthCancelledError());
};
const timeoutId = window.setTimeout(() => {
const timeoutId = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);

View file

@ -1,115 +0,0 @@
import { ChatOpenAI } from "@langchain/openai";
import { COPILOT_API_BASE, GitHubCopilotProvider } from "./GitHubCopilotProvider";
import type { FetchImplementation } from "@/utils";
/** Extract the constructor fields type from ChatOpenAI. */
type ChatOpenAIFields = NonNullable<ConstructorParameters<typeof ChatOpenAI>[0]>;
/**
* Constructor params for GitHubCopilotResponsesModel.
* Inherits all ChatOpenAI fields and adds Copilot-specific fetch injection.
*/
export type GitHubCopilotResponsesModelParams = ChatOpenAIFields & {
/** Custom fetch implementation for CORS bypass (e.g., safeFetchNoThrow on mobile) */
fetchImplementation?: FetchImplementation;
};
/**
* Builds a fetch wrapper that injects a valid Copilot token and custom headers
* on every request, with automatic 401 retry after token refresh.
* @param provider - GitHubCopilotProvider singleton for token management.
* @param baseFetch - Underlying fetch implementation (native or CORS-safe).
* @returns A fetch-compatible function with Copilot auth injected.
*/
export function buildGitHubCopilotAuthedFetch(
provider: GitHubCopilotProvider,
baseFetch: FetchImplementation
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
return async (input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> => {
// Reason: OpenAI SDK v6 always calls fetch(urlString, init), so we only need
// to handle string and URL inputs. Request objects are not used by the SDK,
// but we extract the URL defensively to avoid silent failures.
// Note: If a future SDK version passes Request objects, this wrapper would
// need to clone the Request to preserve method/body/headers and support retry.
// Reason: Guard `typeof Request` to avoid ReferenceError in environments
// where the Request global may not exist (e.g., some Obsidian mobile runtimes).
const url =
typeof input === "string"
? input
: typeof Request !== "undefined" && input instanceof Request
? input.url
: input instanceof URL
? input.href
: input.url;
const doRequest = async (token: string): Promise<Response> => {
const copilotHeaders = provider.buildCopilotRequestHeaders(token);
const mergedHeaders = new Headers(init.headers);
// Copilot headers take precedence (especially Authorization)
for (const [key, value] of Object.entries(copilotHeaders)) {
mergedHeaders.set(key, value);
}
return baseFetch(url, { ...init, headers: mergedHeaders });
};
let token = await provider.getValidCopilotToken();
let response = await doRequest(token);
// 401: invalidate cached token and retry once with a fresh one.
// Reason: Only retry on 401 (Unauthorized / expired token). 403 means
// "Forbidden" (e.g., no Copilot subscription) — a permanent condition
// where token refresh won't help. This matches GitHubCopilotProvider's
// own retry logic which also limits retries to 401.
if (response.status === 401) {
try {
await response.body?.cancel();
} catch {
// Ignore cancellation errors — body may already be closed
}
provider.invalidateCopilotToken();
token = await provider.getValidCopilotToken();
response = await doRequest(token);
}
return response;
};
}
/**
* GitHub Copilot model that routes requests through the Responses API.
* Used for Copilot Codex models, which reject the Chat Completions endpoint.
*/
export class GitHubCopilotResponsesModel extends ChatOpenAI {
/**
* Create a Copilot-backed ChatOpenAI instance configured for `/responses`.
* Wires up dynamic token refresh and Copilot headers via a custom fetch wrapper.
* @param fields - LangChain/OpenAI constructor fields with Copilot fetch options.
*/
constructor(fields: GitHubCopilotResponsesModelParams) {
const { fetchImplementation, configuration, apiKey, ...rest } = fields;
const provider = GitHubCopilotProvider.getInstance();
const baseFetch = fetchImplementation ?? configuration?.fetch ?? fetch;
const authedFetch = buildGitHubCopilotAuthedFetch(provider, baseFetch);
super({
...rest,
apiKey: apiKey || "copilot-dynamic-token",
useResponsesApi: true,
streamUsage: false,
configuration: {
...(configuration ?? {}),
baseURL: configuration?.baseURL ?? COPILOT_API_BASE,
fetch: authedFetch,
},
});
}
/** LangChain model type identifier. */
override _llmType(): string {
return "github-copilot";
}
}

View file

@ -1,5 +1,4 @@
import { compactAssistantOutput } from "@/context/ChatHistoryCompactor";
import { logInfo } from "@/logger";
import { getSettings, subscribeToSettingsChange } from "@/settings/model";
import { BaseChatMemory, BufferWindowMemory } from "@langchain/classic/memory";
import { BaseChatMessageHistory } from "@langchain/core/chat_history";
@ -35,7 +34,7 @@ export default class MemoryManager {
chatHistory: chatHistory,
});
if (this.debug) {
logInfo("Memory initialized with context turns:", chatContextTurns);
console.log("Memory initialized with context turns:", chatContextTurns);
}
}
@ -44,13 +43,13 @@ export default class MemoryManager {
}
async clearChatMemory(): Promise<void> {
if (this.debug) logInfo("Clearing chat memory");
if (this.debug) console.log("Clearing chat memory");
await this.memory.clear();
}
async loadMemoryVariables(): Promise<Record<string, unknown>> {
async loadMemoryVariables(): Promise<any> {
const variables = await this.memory.loadMemoryVariables({});
if (this.debug) logInfo("Loaded memory variables:", variables);
if (this.debug) console.log("Loaded memory variables:", variables);
return variables;
}
@ -59,25 +58,16 @@ export default class MemoryManager {
* The output (assistant response) is compacted to reduce memory bloat from
* accumulated tool results (localSearch, readNote, etc.).
*/
async saveContext(
input: Record<string, unknown>,
output: Record<string, unknown> | string
): Promise<void> {
async saveContext(input: any, output: any): Promise<void> {
// Compact the output to prevent memory bloat from tool results
const compactedOutput =
typeof output === "string"
? compactAssistantOutput(output)
: {
...output,
output: compactAssistantOutput((output as { output: string | unknown[] }).output),
};
: { ...output, output: compactAssistantOutput(output.output) };
if (this.debug) {
logInfo("Saving to memory - Input:", input, "Output (compacted):", compactedOutput);
console.log("Saving to memory - Input:", input, "Output (compacted):", compactedOutput);
}
await this.memory.saveContext(
input,
compactedOutput as Parameters<typeof this.memory.saveContext>[1]
);
await this.memory.saveContext(input, compactedOutput);
}
}

View file

@ -1,27 +1,22 @@
import {
FailedItem,
getChainType,
getCurrentProject,
isProjectMode,
ProjectConfig,
setCurrentProject,
setProjectLoading,
subscribeToChainTypeChange,
subscribeToModelKeyChange,
subscribeToProjectChange,
} from "@/aiParams";
import { ContextCache, ProjectContextCache } from "@/cache/projectContextCache";
import { ChainType } from "@/chainType";
import { ChainType } from "@/chainFactory";
import CopilotView from "@/components/CopilotView";
import { CHAT_VIEWTYPE, VAULT_VECTOR_STORE_STRATEGY } from "@/constants";
import { logError, logInfo, logWarn } from "@/logger";
import CopilotPlugin from "@/main";
import { Mention } from "@/mentions/Mention";
import { getMatchingPatterns, shouldIndexFile } from "@/search/searchUtils";
import { ProjectFileManager } from "@/projects/ProjectFileManager";
import { getCachedProjects, subscribeToProjectRecords } from "@/projects/state";
import { ProjectFileRecord } from "@/projects/type";
import { getSettings } from "@/settings/model";
import { getSettings, subscribeToSettingsChange, updateSetting } from "@/settings/model";
import { FileParserManager, saveConvertedDocOutput } from "@/tools/FileParserManager";
import { err2String } from "@/utils";
import { isRateLimitError } from "@/utils/rateLimitUtils";
@ -40,7 +35,7 @@ export default class ProjectManager {
private readonly projectContextCache: ProjectContextCache;
private fileParserManager: FileParserManager;
private loadTracker: ProjectLoadTracker;
private projectRecordsUnsubscriber?: () => void;
private readonly projectUsageTimestampsManager = new RecentUsageManager<string>();
private constructor(app: App, plugin: CopilotPlugin) {
this.app = app;
@ -57,11 +52,11 @@ export default class ProjectManager {
this.loadTracker = ProjectLoadTracker.getInstance(this.app);
// Set up subscriptions
subscribeToModelKeyChange(() => {
void this.getCurrentChainManager().createChainWithNewModel();
subscribeToModelKeyChange(async () => {
await this.getCurrentChainManager().createChainWithNewModel();
});
subscribeToChainTypeChange(() => {
subscribeToChainTypeChange(async () => {
// When switching from other modes to project mode, no need to update the chain.
if (isProjectMode()) {
return;
@ -69,52 +64,29 @@ export default class ProjectManager {
const settings = getSettings();
const shouldAutoIndex =
settings.enableSemanticSearchV3 &&
(settings.indexVaultToVectorStore as VAULT_VECTOR_STORE_STRATEGY) ===
VAULT_VECTOR_STORE_STRATEGY.ON_MODE_SWITCH &&
settings.indexVaultToVectorStore === VAULT_VECTOR_STORE_STRATEGY.ON_MODE_SWITCH &&
(getChainType() === ChainType.VAULT_QA_CHAIN ||
getChainType() === ChainType.COPILOT_PLUS_CHAIN);
void this.getCurrentChainManager().createChainWithNewModel({
await this.getCurrentChainManager().createChainWithNewModel({
refreshIndex: shouldAutoIndex,
});
});
// Subscribe to Project changes
subscribeToProjectChange((project) => {
void this.switchProject(project);
subscribeToProjectChange(async (project) => {
await this.switchProject(project);
});
// Subscribe to project cache changes to monitor project modifications
// Subscribe to settings changes to monitor projectList changes
this.setupProjectListChangeMonitor();
}
private previousProjectRecords: ProjectFileRecord[] = [];
private setupProjectListChangeMonitor() {
this.previousProjectRecords = [];
this.projectRecordsUnsubscriber?.();
this.projectRecordsUnsubscriber = subscribeToProjectRecords((nextRecords) => {
const prevProjects = this.previousProjectRecords.map((r) => r.project);
const nextProjects = nextRecords.map((r) => r.project);
this.previousProjectRecords = nextRecords;
subscribeToSettingsChange(async (prev, next) => {
if (!prev || !next) return;
// Reason: if the active project's id disappeared (e.g. manual frontmatter id edit),
// clear selection so state doesn't point at a phantom project.
// Reason: if the active project disappeared externally (file deleted/moved/invalidated),
// use switchProject(null) which saves the current chat first, then clears the atom.
// Calling setCurrentProject(null) directly would cause saveChat() to misclassify
// the project chat as non-project because it reads the atom at save time.
if (
this.currentProjectId &&
!nextProjects.some((p) => p.id === this.currentProjectId) &&
getCurrentProject()?.id === this.currentProjectId
) {
logWarn(
`[ProjectManager] Active project id="${this.currentProjectId}" no longer exists, clearing selection`
);
void this.switchProject(null).catch((err) =>
logError("[ProjectManager] Failed to switch away from removed project", err)
);
}
const prevProjects = prev.projectList || [];
const nextProjects = next.projectList || [];
// Find modified projects
for (const nextProject of nextProjects) {
@ -123,31 +95,15 @@ export default class ProjectManager {
// Check if project configuration has changed (ignoring UsageTimestamps)
if (this.hasMeaningfulProjectConfigChange(prevProject, nextProject)) {
// Compare project configuration changes and selectively update cache
void this.compareAndUpdateCache(prevProject, nextProject).catch((err) =>
logError("[ProjectManager] compareAndUpdateCache failed", err)
);
await this.compareAndUpdateCache(prevProject, nextProject);
// If this is the current project, reload its context and recreate chain
// Reason: also check getCurrentProject()?.id to avoid overwriting the atom
// during a switchProject() transition (where currentProjectId is still the old id
// but the atom has already been updated to the new project).
if (
this.currentProjectId === nextProject.id &&
getCurrentProject()?.id === nextProject.id
) {
// Reason: keep aiParams.getCurrentProject() fresh so PromptManager/ChainManager
// observe updated systemPrompt and modelConfigs when vault files change.
setCurrentProject(nextProject);
void Promise.all([
if (this.currentProjectId === nextProject.id) {
await Promise.all([
this.loadProjectContext(nextProject, true),
// Recreate chain to pick up new system prompt
this.getCurrentChainManager().createChainWithNewModel(),
]).catch((error) => {
logError(
`[Projects] Failed to refresh current project after config change id=${nextProject.id}`,
error
);
});
]);
}
}
}
@ -184,12 +140,38 @@ export default class ProjectManager {
}
/**
* Touch the project's usage timestamp with throttled persistence.
* Delegates to ProjectFileManager for vault file writes.
* Touch the project's usage timestamp in settings with throttled persistence.
* Memory is always updated immediately (for UI sorting), but settings writes are throttled.
*/
private touchProjectUsageTimestamps(project: ProjectConfig): void {
const manager = ProjectFileManager.getInstance(this.app);
void manager.touchProjectLastUsed(project.id);
// Always update memory for immediate UI feedback
this.projectUsageTimestampsManager.touch(project.id);
// Check if we should persist to settings (throttled)
const currentProjects = getSettings().projectList || [];
const persistedProject = currentProjects.find((p) => p.id === project.id);
if (!persistedProject) {
return;
}
const timestampToPersist = this.projectUsageTimestampsManager.shouldPersist(
project.id,
persistedProject.UsageTimestamps
);
if (timestampToPersist === null) {
return;
}
updateSetting(
"projectList",
currentProjects.map((p) =>
p.id === project.id ? { ...p, UsageTimestamps: timestampToPersist } : p
)
);
// Mark persistence successful for throttling purposes
this.projectUsageTimestampsManager.markPersisted(project.id, timestampToPersist);
}
/**
@ -197,19 +179,10 @@ export default class ProjectManager {
* This allows UI components to use in-memory values for immediate feedback.
*/
public getProjectUsageTimestampsManager(): RecentUsageManager<string> {
return ProjectFileManager.getInstance(this.app).getProjectUsageTimestampsManager();
return this.projectUsageTimestampsManager;
}
public async switchProject(project: ProjectConfig | null): Promise<void> {
// Reason: setCurrentProject(updatedConfig) fires this callback even for same-id updates
// (e.g. when vault file content changes). Skip early to avoid loading-state churn.
if (project && this.currentProjectId === project.id) {
return;
}
if (!project && this.currentProjectId === null) {
return;
}
try {
// Clear all project context loading states
this.loadTracker.clearAllLoadStates();
@ -223,12 +196,6 @@ export default class ProjectManager {
await this.saveCurrentProjectMessage();
this.currentProjectId = null; // ensure set currentProjectId
// Reason: update the atom AFTER saving so saveChat() uses the correct project context.
// The guard at the top of switchProject prevents re-entry when this fires the subscriber.
if (getCurrentProject() !== null) {
setCurrentProject(null);
}
await this.loadNextProjectMessage();
this.refreshChatView();
return;
@ -236,6 +203,9 @@ export default class ProjectManager {
// else
const projectId = project.id;
if (this.currentProjectId === projectId) {
return;
}
await this.saveCurrentProjectMessage();
this.currentProjectId = projectId; // ensure set currentProjectId
@ -317,7 +287,7 @@ export default class ProjectManager {
await this.projectContextCache.setCacheSafely(project, updatedContextCacheAfterSources);
// After other contexts are processed, ensure all referenced non-markdown files are parsed and cached
await this.processNonMarkdownFiles(project, projectAllFiles, updatedContextCacheAfterSources);
await this.processNonMarkdownFiles(project, projectAllFiles);
logInfo(`[loadProjectContext] Completed for project: ${project.name}.`);
return updatedContextCacheAfterSources;
@ -399,7 +369,7 @@ export default class ProjectManager {
}
public async getProjectContext(projectId: string): Promise<string | null> {
const project = getCachedProjects().find((p) => p.id === projectId);
const project = getSettings().projectList.find((p) => p.id === projectId);
if (!project) {
logWarn(`[getProjectContext] Project not found for ID: ${projectId}`);
return null;
@ -819,8 +789,7 @@ modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`;
private async processNonMarkdownFiles(
project: ProjectConfig,
projectAllFiles: TFile[],
contextCache: ContextCache
projectAllFiles: TFile[]
): Promise<void> {
const nonMarkdownFiles = projectAllFiles.filter((file) => file.extension !== "md");
@ -839,28 +808,12 @@ modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`;
project
);
// Reason: reorder so files with existing cache references are processed first.
// This is a heuristic — fileContexts[path] is a cacheKey reference, not a guarantee
// that content exists — but in practice, referenced files almost always have valid
// cached content, so they complete near-instantly via getOrReuseFileContext().
// This prevents slow/failing uncached items from blocking already-cached ones.
const likelyCachedFiles: TFile[] = [];
const remainingFiles: TFile[] = [];
for (const file of nonMarkdownFiles) {
if (contextCache.fileContexts[file.path]?.cacheKey) {
likelyCachedFiles.push(file);
} else {
remainingFiles.push(file);
}
}
const orderedFiles = [...likelyCachedFiles, ...remainingFiles];
let processedNonMdCount = 0;
// TODO: Add batch progress feedback (e.g. Notice or status bar update) so users
// know how many files remain. Consider bounded concurrency (e.g. p-limit) instead
// of sequential processing for faster throughput on large vaults.
for (const file of orderedFiles) {
for (const file of nonMarkdownFiles) {
const filePath = file.path;
if (this.fileParserManager.supportsExtension(file.extension)) {
try {
@ -914,7 +867,7 @@ modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`;
return;
}
const project = getCachedProjects().find((p) => p.id === this.currentProjectId);
const project = getSettings().projectList.find((p) => p.id === this.currentProjectId);
if (!project) {
logError(`[retryFailedItem] Current project not found: ${this.currentProjectId}`);
return;
@ -937,7 +890,7 @@ modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`;
await this.retryNonMarkdownFile(project, failedItem.path);
break;
default:
logWarn("[retryFailedItem] Unknown item type:", failedItem.type);
logWarn(`[retryFailedItem] Unknown item type: ${failedItem.type}`);
return;
}
@ -1038,8 +991,6 @@ modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`;
}
public onunload(): void {
this.projectRecordsUnsubscriber?.();
this.projectRecordsUnsubscriber = undefined;
this.projectContextCache.cleanup();
}
}

View file

@ -4,7 +4,7 @@ import { hasSelfHostSearchKey, selfHostWebSearch } from "./selfHostServices";
const mockGetSettings = jest.fn();
jest.mock("@/settings/model", () => ({
getSettings: (): unknown => mockGetSettings(),
getSettings: () => mockGetSettings(),
}));
jest.mock("@/encryptionService", () => ({
@ -17,15 +17,9 @@ jest.mock("@/logger", () => ({
logWarn: jest.fn(),
}));
// Mock safeFetchNoThrow (the requestUrl-backed wrapper used in place of fetch)
// Mock global fetch
const mockFetch = jest.fn();
jest.mock("@/utils", () => {
const actual = jest.requireActual<Record<string, unknown>>("@/utils");
return {
...actual,
safeFetchNoThrow: (url: string, options?: RequestInit): unknown => mockFetch(url, options),
};
});
global.fetch = mockFetch;
beforeEach(() => {
jest.clearAllMocks();

View file

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

View file

@ -1,19 +0,0 @@
import { TFile, TFolder } from "obsidian";
/**
* Construct a TFile-typed mock from a partial spec. Uses the real TFile prototype
* so `instanceof TFile` returns true. Test helper keeps `as TFile` casts out of
* test files so the `no-restricted-syntax` rule stays clean.
*/
export function mockTFile<T extends Partial<TFile>>(props: T): TFile {
const file: TFile = Object.create(TFile.prototype);
Object.assign(file, props);
return file;
}
/** TFolder counterpart to mockTFile. */
export function mockTFolder<T extends Partial<TFolder>>(props: T): TFolder {
const folder: TFolder = Object.create(TFolder.prototype);
Object.assign(folder, props);
return folder;
}

View file

@ -1,4 +1,4 @@
import { ChainType } from "@/chainType";
import { ChainType } from "@/chainFactory";
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
import { ChatPromptTemplate } from "@langchain/core/prompts";
@ -6,7 +6,6 @@ import { ModelCapability, ReasoningEffort, Verbosity } from "@/constants";
import { settingsAtom, settingsStore } from "@/settings/model";
import { SelectedTextContext } from "@/types/message";
import { atom, useAtom } from "jotai";
import { TFile } from "obsidian";
const userModelKeyAtom = atom<string | null>(null);
const modelKeyAtom = atom(
@ -60,7 +59,7 @@ export const projectContextLoadAtom = atom<ProjectContextLoadState>({
total: [],
});
interface IndexingProgressState {
export interface IndexingProgressState {
isActive: boolean;
isPaused: boolean;
isCancelled: boolean;
@ -70,7 +69,7 @@ interface IndexingProgressState {
completionStatus: "none" | "success" | "cancelled" | "error";
}
const indexingProgressAtom = atom<IndexingProgressState>({
export const indexingProgressAtom = atom<IndexingProgressState>({
isActive: false,
isPaused: false,
isCancelled: false,
@ -129,7 +128,7 @@ export interface ModelConfig {
export interface SetChainOptions {
prompt?: ChatPromptTemplate;
chatModel?: BaseChatModel;
noteFile?: TFile;
noteFile?: any;
abortController?: AbortController;
refreshIndex?: boolean;
}
@ -237,10 +236,26 @@ export function subscribeToProjectChange(
});
}
export function useCurrentProject() {
return useAtom(currentProjectAtom, {
store: settingsStore,
});
}
export function setProjectLoading(loading: boolean) {
settingsStore.set(projectLoadingAtom, loading);
}
export function isProjectLoading(): boolean {
return settingsStore.get(projectLoadingAtom);
}
export function subscribeToProjectLoadingChange(callback: (loading: boolean) => void): () => void {
return settingsStore.sub(projectLoadingAtom, () => {
callback(settingsStore.get(projectLoadingAtom));
});
}
export function useProjectLoading() {
return useAtom(projectLoadingAtom, {
store: settingsStore,
@ -259,6 +274,11 @@ export function getSelectedTextContexts(): SelectedTextContext[] {
return settingsStore.get(selectedTextContextsAtom);
}
export function addSelectedTextContext(context: SelectedTextContext) {
const current = getSelectedTextContexts();
setSelectedTextContexts([...current, context]);
}
export function removeSelectedTextContext(id: string) {
const current = getSelectedTextContexts();
setSelectedTextContexts(current.filter((context) => context.id !== id));
@ -274,6 +294,13 @@ export function useSelectedTextContexts() {
});
}
/**
* Gets the project context load state from the atom.
*/
export function getProjectContextLoadState(): Readonly<ProjectContextLoadState> {
return settingsStore.get(projectContextLoadAtom);
}
/**
* Sets the project context load state in the atom.
*/
@ -294,6 +321,17 @@ export function updateProjectContextLoadState<K extends keyof ProjectContextLoad
}));
}
/**
* Subscribes to changes in the project context load state.
*/
export function subscribeToProjectContextLoadChange(
callback: (state: ProjectContextLoadState) => void
): () => void {
return settingsStore.sub(projectContextLoadAtom, () => {
callback(settingsStore.get(projectContextLoadAtom));
});
}
/**
* Hook to get the project context load state from the atom.
*/
@ -332,7 +370,7 @@ export function updateIndexingProgressState(partial: Partial<IndexingProgressSta
// cascading React re-renders from frequent Jotai atom updates.
let _lastUpdateTime = 0;
let _pendingCount = 0;
let _throttleTimer: number | null = null;
let _throttleTimer: ReturnType<typeof setTimeout> | null = null;
const THROTTLE_INTERVAL_MS = 500;
/**
@ -343,7 +381,7 @@ export function resetIndexingProgressState() {
// Cancel any pending throttled indexing count write so a stale timer from a
// previous run cannot corrupt the freshly-reset state.
if (_throttleTimer !== null) {
window.clearTimeout(_throttleTimer);
clearTimeout(_throttleTimer);
_throttleTimer = null;
}
_lastUpdateTime = 0;
@ -372,13 +410,13 @@ export function throttledUpdateIndexingCount(indexedCount: number): void {
// Enough time has passed — write immediately
_lastUpdateTime = now;
if (_throttleTimer !== null) {
window.clearTimeout(_throttleTimer);
clearTimeout(_throttleTimer);
_throttleTimer = null;
}
updateIndexingProgressState({ indexedCount: _pendingCount });
} else if (_throttleTimer === null) {
// Schedule a trailing write
_throttleTimer = window.setTimeout(
_throttleTimer = setTimeout(
() => {
_lastUpdateTime = Date.now();
_throttleTimer = null;
@ -395,7 +433,7 @@ export function throttledUpdateIndexingCount(indexedCount: number): void {
*/
export function flushIndexingCount(): void {
if (_throttleTimer !== null) {
window.clearTimeout(_throttleTimer);
clearTimeout(_throttleTimer);
_throttleTimer = null;
}
updateIndexingProgressState({ indexedCount: _pendingCount });

View file

@ -1,5 +1,5 @@
import { logError, logInfo } from "@/logger";
import { md5 } from "@/utils/hash";
import { MD5 } from "crypto-js";
import { TFile } from "obsidian";
export interface FileCacheEntry<T> {
@ -8,7 +8,7 @@ export interface FileCacheEntry<T> {
}
export class FileCache<T> {
private static instance: FileCache<unknown>;
private static instance: FileCache<any>;
private cacheDir: string;
private memoryCache: Map<string, FileCacheEntry<T>> = new Map();
@ -33,7 +33,7 @@ export class FileCache<T> {
getCacheKey(file: TFile, additionalContext?: string): string {
// Use file path, size and mtime for a unique but efficient cache key
const metadata = `${file.path}:${file.stat.size}:${file.stat.mtime}${additionalContext ? `:${additionalContext}` : ""}`;
return md5(metadata);
return MD5(metadata).toString();
}
private getCachePath(cacheKey: string): string {

View file

@ -1,6 +1,6 @@
import { Pdf4llmResponse } from "@/LLMProviders/brevilabsClient";
import { logError, logInfo } from "@/logger";
import { md5 } from "@/utils/hash";
import { MD5 } from "crypto-js";
import { TFile } from "obsidian";
export class PDFCache {
@ -26,7 +26,7 @@ export class PDFCache {
private getCacheKey(file: TFile): string {
// Use file path, size and mtime for a unique but efficient cache key
const metadata = `${file.path}:${file.stat.size}:${file.stat.mtime}`;
const key = md5(metadata);
const key = MD5(metadata).toString();
logInfo("Generated cache key for PDF:", { path: file.path, key });
return key;
}
@ -43,7 +43,7 @@ export class PDFCache {
if (await app.vault.adapter.exists(cachePath)) {
logInfo("Cache hit for PDF:", file.path);
const cacheContent = await app.vault.adapter.read(cachePath);
return JSON.parse(cacheContent) as Pdf4llmResponse;
return JSON.parse(cacheContent);
}
logInfo("Cache miss for PDF:", file.path);
return null;

View file

@ -2,10 +2,10 @@ import { ProjectConfig } from "@/aiParams";
import { FileCache } from "@/cache/fileCache";
import { logError, logInfo, logWarn } from "@/logger";
import { getMatchingPatterns, shouldIndexFile } from "@/search/searchUtils";
import { getCachedProjects } from "@/projects/state";
import { md5 } from "@/utils/hash";
import { getSettings } from "@/settings/model";
import { MD5 } from "crypto-js";
import { TAbstractFile, TFile, Vault } from "obsidian";
import { debounce } from "@/utils/debounce";
import debounce from "lodash.debounce";
import { Mutex } from "async-mutex";
export interface ContextCache {
@ -24,38 +24,6 @@ export interface ContextCache {
timestamp: number;
}
/** Reference to a cached file on disk, resolved from ContextCache. */
export interface CacheFileRef {
cacheKey: string;
cachePath: string;
}
/** Default cache directory used by FileCache. */
const FILE_CONTENT_CACHE_DIR = ".copilot/file-content-cache";
/**
* Synchronously resolve the on-disk cache file reference for a parsed file.
* Accepts an already-loaded ContextCache so the caller controls the data source
* (avoids depending on memoryCache state which may not be populated yet).
*
* @param cache - Already-loaded ContextCache (from parent component state)
* @param filePath - Original source file path in the vault
* @returns Cache file reference, or null if not cached / invalid key
*/
export function getFileCacheRef(
cache: ContextCache | null | undefined,
filePath: string
): CacheFileRef | null {
const entry = cache?.fileContexts?.[filePath];
if (!entry?.cacheKey || typeof entry.cacheKey !== "string" || !entry.cacheKey.trim()) {
return null;
}
return {
cacheKey: entry.cacheKey,
cachePath: `${FILE_CONTENT_CACHE_DIR}/${entry.cacheKey}.md`,
};
}
/**
* ProjectContextCache manages context for projects, including markdown files,
* external web content, and other file types.
@ -126,7 +94,8 @@ export class ProjectContextCache {
return;
}
const projects = getCachedProjects();
const settings = getSettings();
const projects = settings.projectList || [];
// Check each project to see if the file matches its patterns
for (const project of projects) {
@ -173,7 +142,7 @@ export class ProjectContextCache {
private getCacheKey(project: ProjectConfig): string {
// Use project ID as cache key
return md5(project.id);
return MD5(project.id).toString();
}
private getCachePath(cacheKey: string): string {
@ -221,7 +190,7 @@ export class ProjectContextCache {
if (await this.vault.adapter.exists(cachePath)) {
logInfo("File cache hit for project:", project.name);
const cacheContent = await this.vault.adapter.read(cachePath);
const contextCache = JSON.parse(cacheContent) as ContextCache;
const contextCache = JSON.parse(cacheContent);
// Store in memory cache
this.memoryCache.set(cacheKey, contextCache);
return contextCache;
@ -290,7 +259,7 @@ export class ProjectContextCache {
logInfo("Caching context for project:", project.name);
// Create a deep copy to avoid reference issues
const contextCacheCopy = JSON.parse(JSON.stringify(contextCache)) as ContextCache;
const contextCacheCopy = JSON.parse(JSON.stringify(contextCache));
// Store in memory cache
this.memoryCache.set(cacheKey, contextCacheCopy);
@ -640,7 +609,8 @@ export class ProjectContextCache {
filePath: string
): Promise<{ cacheKey: string; content: string } | null> {
try {
const projects = getCachedProjects();
const settings = getSettings();
const projects = settings.projectList || [];
if (projects.length === 0) {
return null;

213
src/chainFactory.ts Normal file
View file

@ -0,0 +1,213 @@
// 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 { 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;
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface Document<T = Record<string, any>> {
// 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'
// Add new chain types here
export enum ChainType {
LLM_CHAIN = "llm_chain",
VAULT_QA_CHAIN = "vault_qa",
COPILOT_PLUS_CHAIN = "copilot_plus",
PROJECT_CHAIN = "project",
}
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) => initialInput.input,
memory: () => memory.loadMemoryVariables({}),
},
{
input: (previousOutput) => previousOutput.input,
history: (previousOutput) => previousOutput.memory.history,
},
prompt,
model,
]);
ChainFactory.instances.set(ChainType.LLM_CHAIN, instance);
console.log("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) console.log("Input Question: ", input.question);
return input.question;
},
chat_history: (input: ConversationalRetrievalQAChainInput) => {
const formattedChatHistory = formatChatHistory(input.chat_history);
if (debug) console.log("Formatted Chat History: ", formattedChatHistory);
return formattedChatHistory;
},
},
CONDENSE_QUESTION_PROMPT,
llm,
new StringOutputParser(),
(output) => {
const thinkTagsCleaned = removeThinkTags(output);
const cleanedOutput = removeErrorTags(thinkTagsCleaned);
if (debug) console.log("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

@ -1,6 +0,0 @@
export enum ChainType {
LLM_CHAIN = "llm_chain",
VAULT_QA_CHAIN = "vault_qa",
COPILOT_PLUS_CHAIN = "copilot_plus",
PROJECT_CHAIN = "project",
}

View file

@ -44,8 +44,8 @@ describe("updateChatMemory with tool call markers", () => {
},
];
const memoryManager = new MockMemoryManager();
await updateChatMemory(messages, memoryManager as never);
const memoryManager: any = new MockMemoryManager();
await updateChatMemory(messages, memoryManager);
expect(memoryManager.getMemory().saved).toHaveLength(1);
expect(memoryManager.getMemory().saved[0].input).toBe("find my piano notes");

View file

@ -1,23 +1,19 @@
import { CustomModel, useModelKey } from "@/aiParams";
import { processCommandPrompt } from "@/commands/customCommandUtils";
import { MenuCommandModal, type ContentState } from "@/components/command-ui";
import {
MODAL_MIN_HEIGHT_COMPACT,
MODAL_MIN_HEIGHT_EXPANDED,
} from "@/components/command-ui/constants";
import { MODAL_MIN_HEIGHT_COMPACT, MODAL_MIN_HEIGHT_EXPANDED } from "@/components/command-ui/constants";
import { SelectionHighlight } from "@/editor/selectionHighlight";
import { createHighlightReplaceGuard, type ReplaceGuard } from "@/editor/replaceGuard";
import { logError } from "@/logger";
import { logError, logWarn } from "@/logger";
import { cleanMessageForCopy, findCustomModel, insertIntoEditor } from "@/utils";
import { computeVerticalPlacement } from "@/utils/panelPlacement";
import { computeSelectionAnchors } from "@/utils/selectionAnchors";
import type { EditorView } from "@codemirror/view";
import { PenLine } from "lucide-react";
import { App, Component, MarkdownRenderer, Notice, MarkdownView, Scope } from "obsidian";
import { App, Component, MarkdownRenderer, Notice, MarkdownView } from "obsidian";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { preprocessAIResponse } from "@/utils/markdownPreprocess";
import { Root } from "react-dom/client";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { createRoot, Root } from "react-dom/client";
import { CustomCommand } from "@/commands/type";
import { useSettingsValue, updateSetting } from "@/settings/model";
import {
@ -184,12 +180,12 @@ function CustomCommandChatModalContent({
return command.modelKey || globalModelKey;
}, [modelSelectionScope, settings.quickCommandModelKey, command.modelKey, globalModelKey]);
const [userSelectedModelKey, setUserSelectedModelKey] = useState(initialModelKey);
const [selectedModelKey, setSelectedModelKey] = useState(initialModelKey);
// Handle model change with scope-aware persistence
const handleModelChange = useCallback(
(newModelKey: string) => {
setUserSelectedModelKey(newModelKey);
setSelectedModelKey(newModelKey);
// Only persist for quick-command scope (shared with Quick Ask)
if (modelSelectionScope === "quick-command") {
updateSetting("quickCommandModelKey", newModelKey);
@ -210,13 +206,16 @@ function CustomCommandChatModalContent({
updateSetting("quickCommandIncludeNoteContext", checked);
}, []);
// Track if we've already shown the fallback notice to avoid repeated notices
const didShowFallbackNoticeRef = useRef(false);
// Safely resolve the selected model with fallback to first enabled model
const resolvedModel = useMemo((): CustomModel | null => {
try {
const model = findCustomModel(userSelectedModelKey, settings.activeModels);
const model = findCustomModel(selectedModelKey, settings.activeModels);
// Treat disabled models as invalid selections (ModelSelector won't present them)
if (!model.enabled) {
throw new Error(`Selected model is disabled: ${userSelectedModelKey}`);
throw new Error(`Selected model is disabled: ${selectedModelKey}`);
}
return model;
} catch {
@ -224,7 +223,7 @@ function CustomCommandChatModalContent({
// Avoid side effects during render; notify/log in the effect below.
return settings.activeModels.find((m) => m.enabled) ?? null;
}
}, [userSelectedModelKey, settings.activeModels]);
}, [selectedModelKey, settings.activeModels]);
// Compute the key for the resolved model
const resolvedModelKey = useMemo(() => {
@ -232,8 +231,23 @@ function CustomCommandChatModalContent({
return `${resolvedModel.name}|${resolvedModel.provider}`;
}, [resolvedModel]);
// Effective model key for the UI — falls back to user selection when resolution fails.
const effectiveModelKey = resolvedModelKey ?? userSelectedModelKey;
// Update selectedModelKey if we had to fall back to a different model
useEffect(() => {
if (!resolvedModelKey) return;
if (resolvedModelKey === selectedModelKey) return;
// Always keep UI selection consistent with the resolved model
setSelectedModelKey(resolvedModelKey);
// Notify only once per modal lifecycle
if (didShowFallbackNoticeRef.current) return;
didShowFallbackNoticeRef.current = true;
logWarn("Selected model is no longer available. Falling back to a default model.", {
selectedModelKey,
resolvedModelKey,
});
new Notice("Selected model is no longer available. Falling back to a default model.");
}, [resolvedModelKey, selectedModelKey]);
// Use shared streaming hook
const {
@ -260,15 +274,12 @@ function CustomCommandChatModalContent({
// Track the last input prompt for saving context on stop
const lastInputPromptRef = useRef<string>("");
// Sync editedText with finalText when finalText changes. Render-phase tracker
// preserves user edits made after the last finalText change until the next change.
const [prevFinalText, setPrevFinalText] = useState(finalText);
if (prevFinalText !== finalText) {
setPrevFinalText(finalText);
// Sync editedText with finalText when finalText changes
useEffect(() => {
if (finalText) {
setEditedText(finalText);
}
}
}, [finalText]);
// Compute content state for MenuCommandModal
const contentState: ContentState = useMemo(() => {
@ -318,7 +329,7 @@ function CustomCommandChatModalContent({
}
}
void generateInitialResponse();
generateInitialResponse();
return () => {
cancelled = true;
@ -439,7 +450,7 @@ function CustomCommandChatModalContent({
followUpValue={followUpValue}
onFollowUpChange={setFollowUpValue}
onFollowUpSubmit={handleFollowUpSubmit}
selectedModel={effectiveModelKey}
selectedModel={selectedModelKey}
onSelectModel={handleModelChange}
onStop={handleStop}
onCopy={handleCopy}
@ -472,7 +483,6 @@ export class CustomCommandChatModal {
private container: HTMLElement | null = null;
private highlightView: EditorView | null = null;
private replaceGuard: ReplaceGuard | null = null;
private scope: Scope | null = null;
constructor(
private app: App,
@ -491,7 +501,7 @@ export class CustomCommandChatModal {
* the modal is positioned relative to the correct window.
*/
private resolveWindow(view?: MarkdownView | null): Window {
return view?.containerEl?.win ?? window;
return view?.containerEl?.ownerDocument?.defaultView ?? window;
}
/**
@ -500,7 +510,7 @@ export class CustomCommandChatModal {
* appended to the document that owns the triggering view.
*/
private resolveDocument(view?: MarkdownView | null): Document {
return view?.containerEl?.doc ?? activeDocument;
return view?.containerEl?.ownerDocument ?? document;
}
/**
@ -510,11 +520,7 @@ export class CustomCommandChatModal {
* - Space checks use scrollRect (editor visible area), not window.
* - Horizontal clamp to scrollRect first, then viewport as safety net.
*/
private getInitialPosition(activeView: MarkdownView | null): {
x: number;
y: number;
anchorBottom?: number;
} {
private getInitialPosition(activeView: MarkdownView | null): { x: number; y: number; anchorBottom?: number } {
const win = this.resolveWindow(activeView);
const panelWidth = Math.min(500, win.innerWidth * 0.9);
// Reason: The actual initial panel height depends on whether ContentArea is shown.
@ -522,9 +528,7 @@ export class CustomCommandChatModal {
// Custom Commands always show ContentArea (expanded).
// Using the correct height prevents gaps (above) or overlaps (below).
const hideContentAreaOnIdle = this.configs.behaviorConfig?.hideContentAreaOnIdle ?? false;
const panelHeight = hideContentAreaOnIdle
? MODAL_MIN_HEIGHT_COMPACT
: MODAL_MIN_HEIGHT_EXPANDED;
const panelHeight = hideContentAreaOnIdle ? MODAL_MIN_HEIGHT_COMPACT : MODAL_MIN_HEIGHT_EXPANDED;
const margin = 12;
const gap = 6;
@ -579,11 +583,8 @@ export class CustomCommandChatModal {
(topCoords?.bottom ?? 0) - (topCoords?.top ?? 0),
(bottomCoords?.bottom ?? 0) - (bottomCoords?.top ?? 0)
);
const isVisualMultiLine =
!isCursor &&
topCoords &&
bottomCoords &&
Math.abs(topCoords.top - bottomCoords.top) > Math.max(caretHeight / 2, 2);
const isVisualMultiLine = !isCursor && topCoords && bottomCoords
&& Math.abs(topCoords.top - bottomCoords.top) > Math.max(caretHeight / 2, 2);
// --- Vertical positioning (decides placement first) ---
// Reason: Extracted to a pure helper (computeVerticalPlacement) so the
@ -633,14 +634,6 @@ export class CustomCommandChatModal {
}
open() {
// Reason: Push a Scope so user-bound global Cmd/Ctrl+Enter hotkeys don't fire
// while this modal is open. The Scope handlers return true to consume the event;
// the React onKeyDown inside MenuCommandModal does the actual Replace/Insert.
this.scope = new Scope();
this.scope.register(["Mod"], "Enter", () => true);
this.scope.register(["Mod", "Shift"], "Enter", () => true);
this.app.keymap.pushScope(this.scope);
// Reason: Capture activeView once at open time to ensure document/window/editor
// all reference the same view. Avoids subtle inconsistencies if the user switches
// tabs between resolveDocument() and the selection snapshot below.
@ -651,7 +644,7 @@ export class CustomCommandChatModal {
this.container.className = "copilot-menu-command-modal-container";
doc.body.appendChild(this.container);
this.root = createPluginRoot(this.container, this.app);
this.root = createRoot(this.container);
// Capture ReplaceGuard (replaces captureReplaceSnapshot)
const { selectedText, command, systemPrompt, behaviorConfig } = this.configs;
@ -685,7 +678,7 @@ export class CustomCommandChatModal {
const { anchorBottom, ...initialPosition } = this.getInitialPosition(activeView);
const handleInsert = (message: string) => {
void insertIntoEditor(message);
insertIntoEditor(message);
this.close();
};
@ -727,10 +720,6 @@ export class CustomCommandChatModal {
}
close() {
if (this.scope) {
this.app.keymap.popScope(this.scope);
this.scope = null;
}
// Hide selection highlight
if (this.highlightView) {
SelectionHighlight.hide(this.highlightView);

View file

@ -5,8 +5,7 @@ import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import React, { useState } from "react";
import { Root } from "react-dom/client";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { createRoot, Root } from "react-dom/client";
import { getModelKeyFromModel, useSettingsValue } from "@/settings/model";
import { getModelDisplayText } from "@/components/ui/model-display";
import { cn } from "@/lib/utils";
@ -42,7 +41,7 @@ function CustomCommandSettingsModalContent({
const [command, setCommand] = useState(initialCommand);
const [errors, setErrors] = useState<FormErrors>({});
const handleUpdate = (field: keyof CustomCommand, value: CustomCommand[keyof CustomCommand]) => {
const handleUpdate = (field: keyof CustomCommand, value: any) => {
setCommand((prev) => ({
...prev,
[field]: value,
@ -182,7 +181,7 @@ export class CustomCommandSettingsModal extends Modal {
app: App,
private commands: CustomCommand[],
private command: CustomCommand,
private onUpdate: (command: CustomCommand) => void | Promise<void>
private onUpdate: (command: CustomCommand) => void
) {
super(app);
// https://docs.obsidian.md/Reference/TypeScript+API/Modal/setTitle
@ -192,10 +191,10 @@ export class CustomCommandSettingsModal extends Modal {
onOpen() {
const { contentEl } = this;
this.root = createPluginRoot(contentEl, this.app);
this.root = createRoot(contentEl);
const handleConfirm = (command: CustomCommand) => {
void this.onUpdate(command);
this.onUpdate(command);
this.close();
};

View file

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

View file

@ -50,14 +50,12 @@ export async function createChatChain(
return RunnableSequence.from([
{
input: (initialInput: { input: string }) => initialInput.input,
input: (initialInput) => initialInput.input,
memory: () => memory.loadMemoryVariables({}),
},
{
input: (previousOutput: { input: string; memory: { history: unknown } }) =>
previousOutput.input,
history: (previousOutput: { input: string; memory: { history: unknown } }) =>
previousOutput.memory.history,
input: (previousOutput) => previousOutput.input,
history: (previousOutput) => previousOutput.memory.history,
},
chatPrompt,
chatModel,

View file

@ -21,7 +21,6 @@ import {
updateCachedCommands,
} from "./state";
import { ensureFolderExists } from "@/utils";
import { trashFile } from "@/utils/vaultAdapterUtils";
export class CustomCommandManager {
private static instance: CustomCommandManager;
@ -57,25 +56,20 @@ export class CustomCommandManager {
// Ensure nested folders are created cross-platform
await ensureFolderExists(folderPath);
const existingFile = app.vault.getAbstractFileByPath(filePath);
let commandFile: TFile;
if (existingFile instanceof TFile) {
await app.vault.modify(existingFile, command.content);
commandFile = existingFile;
} else {
let commandFile = app.vault.getAbstractFileByPath(filePath) as TFile;
if (!commandFile || !(commandFile instanceof TFile)) {
commandFile = await app.vault.create(filePath, command.content);
} else {
await app.vault.modify(commandFile, command.content);
}
await app.fileManager.processFrontMatter(
commandFile,
(frontmatter: Record<string, unknown>) => {
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] = command.showInContextMenu;
frontmatter[COPILOT_COMMAND_SLASH_ENABLED] = command.showInSlashMenu;
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ORDER] = command.order;
frontmatter[COPILOT_COMMAND_MODEL_KEY] = command.modelKey;
frontmatter[COPILOT_COMMAND_LAST_USED] = command.lastUsedMs;
}
);
await app.fileManager.processFrontMatter(commandFile, (frontmatter) => {
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] = command.showInContextMenu;
frontmatter[COPILOT_COMMAND_SLASH_ENABLED] = command.showInSlashMenu;
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ORDER] = command.order;
frontmatter[COPILOT_COMMAND_MODEL_KEY] = command.modelKey;
frontmatter[COPILOT_COMMAND_LAST_USED] = command.lastUsedMs;
});
if (!mergedOptions.skipStoreUpdate) {
updateCachedCommand(command, command.title);
@ -86,7 +80,7 @@ export class CustomCommandManager {
}
async recordUsage(command: CustomCommand) {
await this.updateCommand({ ...command, lastUsedMs: Date.now() }, command.title);
this.updateCommand({ ...command, lastUsedMs: Date.now() }, command.title);
}
async updateCommand(command: CustomCommand, prevCommandTitle: string, skipStoreUpdate = false) {
@ -129,16 +123,13 @@ export class CustomCommandManager {
if (commandFile instanceof TFile) {
await app.vault.modify(commandFile, command.content);
await app.fileManager.processFrontMatter(
commandFile,
(frontmatter: Record<string, unknown>) => {
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] = command.showInContextMenu;
frontmatter[COPILOT_COMMAND_SLASH_ENABLED] = command.showInSlashMenu;
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ORDER] = command.order;
frontmatter[COPILOT_COMMAND_MODEL_KEY] = command.modelKey;
frontmatter[COPILOT_COMMAND_LAST_USED] = command.lastUsedMs;
}
);
await app.fileManager.processFrontMatter(commandFile, (frontmatter) => {
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] = command.showInContextMenu;
frontmatter[COPILOT_COMMAND_SLASH_ENABLED] = command.showInSlashMenu;
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ORDER] = command.order;
frontmatter[COPILOT_COMMAND_MODEL_KEY] = command.modelKey;
frontmatter[COPILOT_COMMAND_LAST_USED] = command.lastUsedMs;
});
}
} finally {
removePendingFileWrite(filePath);
@ -172,7 +163,7 @@ export class CustomCommandManager {
deleteCachedCommand(command.title);
const file = app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
await trashFile(app, file);
await app.vault.delete(file);
}
} finally {
removePendingFileWrite(filePath);

View file

@ -9,7 +9,7 @@ import {
} from "@/commands/customCommandUtils";
import { Editor, Plugin, TFile, Vault } from "obsidian";
import { CustomCommandChatModal } from "@/commands/CustomCommandChatModal";
import { debounce } from "@/utils/debounce";
import debounce from "lodash.debounce";
import { CustomCommand } from "@/commands/type";
import {
deleteCachedCommand,
@ -38,9 +38,8 @@ export class CustomCommandRegister {
/**
* Register all custom commands found in the custom commands folder.
* Synchronous: iterates cached commands and registers each.
*/
private registerCommands() {
private async registerCommands() {
const commands = getCachedCustomCommands();
commands.forEach((command) => {
this.registerCommand(command);
@ -107,7 +106,7 @@ export class CustomCommandRegister {
return;
}
const commandId = getCommandId(file.basename);
(this.plugin as unknown as { removeCommand: (id: string) => void }).removeCommand(commandId);
(this.plugin as any).removeCommand(commandId);
deleteCachedCommand(file.basename);
};
@ -119,9 +118,7 @@ export class CustomCommandRegister {
const oldFilename = oldPath.split("/").pop()?.replace(/\.md$/, "");
if (oldFilename) {
const oldCommandId = getCommandId(oldFilename);
(this.plugin as unknown as { removeCommand: (id: string) => void }).removeCommand(
oldCommandId
);
(this.plugin as any).removeCommand(oldCommandId);
deleteCachedCommand(oldFilename);
}
// Register the new command if it's still a custom command file
@ -135,7 +132,7 @@ export class CustomCommandRegister {
private registerCommand(customCommand: CustomCommand) {
const commandId = getCommandId(customCommand.title);
(this.plugin as unknown as { removeCommand: (id: string) => void }).removeCommand(commandId);
(this.plugin as any).removeCommand(commandId);
this.plugin.addCommand({
id: commandId,
name: customCommand.title,
@ -144,9 +141,7 @@ export class CustomCommandRegister {
selectedText: editor.getSelection(),
command: customCommand,
}).open();
void CustomCommandManager.getInstance()
.recordUsage(customCommand)
.catch((err) => logError("recordUsage failed", err));
CustomCommandManager.getInstance().recordUsage(customCommand);
},
});
}

View file

@ -14,7 +14,6 @@ import { PromptSortStrategy } from "@/types";
import { sortSlashCommands } from "@/commands/customCommandUtils";
import { DEFAULT_SETTINGS } from "@/constants";
import { logWarn } from "@/logger";
import { mockTFile } from "@/__tests__/mockObsidian";
// Mock Obsidian
jest.mock("obsidian", () => ({
@ -32,7 +31,7 @@ jest.mock("@/logger", () => ({
// Mock the utility functions
jest.mock("@/utils", () => {
const actual = jest.requireActual<{ stripFrontmatter: unknown }>("@/utils");
const actual = jest.requireActual("@/utils");
return {
extractTemplateNoteFiles: jest.fn().mockReturnValue([]),
getFileContent: jest.fn(),
@ -65,10 +64,10 @@ describe("processedPrompt()", () => {
}),
},
} as unknown as Vault;
mockActiveNote = mockTFile({
mockActiveNote = {
path: "path/to/active/note.md",
basename: "Active Note",
});
} as TFile;
});
it("should add 1 context and selectedText", async () => {
@ -85,7 +84,7 @@ describe("processedPrompt()", () => {
(getFileContent as jest.Mock).mockResolvedValueOnce("here is the note content for note0");
(getFileName as jest.Mock).mockReturnValueOnce("Variable Note");
(getNotesFromPath as jest.Mock).mockReturnValueOnce([mockActiveNote]);
(getNotesFromPath as jest.Mock).mockResolvedValueOnce([mockActiveNote]);
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
@ -111,14 +110,14 @@ describe("processedPrompt()", () => {
.mockResolvedValueOnce("here is the note content for note0")
.mockResolvedValueOnce("note content for note1");
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockReturnValueOnce("Variable1 Note").mockReturnValueOnce("Variable2 Note");
const mockNote1 = mockTFile({ path: "path/to/note1.md", basename: "Variable1 Note" });
const mockNote2 = mockTFile({ path: "path/to/note2.md", basename: "Variable2 Note" });
const mockNote1 = { path: "path/to/note1.md", basename: "Variable1 Note" } as TFile;
const mockNote2 = { path: "path/to/note2.md", basename: "Variable2 Note" } as TFile;
(getNotesFromPath as jest.Mock)
.mockReturnValueOnce([mockNote1])
.mockReturnValueOnce([mockNote2]);
.mockResolvedValueOnce([mockNote1])
.mockResolvedValueOnce([mockNote2]);
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
@ -175,7 +174,7 @@ describe("processedPrompt()", () => {
const selectedText = "";
(getFileContent as jest.Mock).mockResolvedValue("Content of the active note");
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockReturnValue("Active Note");
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
@ -229,16 +228,16 @@ describe("processedPrompt()", () => {
const selectedText = "";
// Mock note file for the tag
const mockNoteForTag = mockTFile({
const mockNoteForTag = {
path: "path/to/tagged/note.md",
basename: "Tagged Note",
});
} as TFile;
// Mock getNotesFromTags to return our mock note
(getNotesFromTags as jest.Mock).mockReturnValue([mockNoteForTag]);
(getNotesFromTags as jest.Mock).mockResolvedValue([mockNoteForTag]);
// Mock getFileName to return the basename
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockReturnValue("Tagged Note");
// Mock getFileContent to return content for the note
@ -257,20 +256,20 @@ describe("processedPrompt()", () => {
const selectedText = "";
// Mock note files for the tags
const mockNoteForTag1 = mockTFile({
const mockNoteForTag1 = {
basename: "Tagged Note 1",
path: "path/to/tagged/note1.md",
});
const mockNoteForTag2 = mockTFile({
} as TFile;
const mockNoteForTag2 = {
basename: "Tagged Note 2",
path: "path/to/tagged/note2.md",
});
} as TFile;
// Mock getNotesFromTags to return our mock notes
(getNotesFromTags as jest.Mock).mockReturnValue([mockNoteForTag1, mockNoteForTag2]);
(getNotesFromTags as jest.Mock).mockResolvedValue([mockNoteForTag1, mockNoteForTag2]);
// Mock getFileName to return the basename
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockImplementation((file: TFile) => file.basename);
// Mock getFileContent to return content for each note
@ -295,7 +294,7 @@ describe("processedPrompt()", () => {
it("should process [[note title]] syntax correctly", async () => {
const customPrompt = "Content of [[Test Note]] is important.";
const selectedText = "";
const mockTestNote = mockTFile({ basename: "Test Note", path: "Test Note.md" });
const mockTestNote = { basename: "Test Note", path: "Test Note.md" } as TFile;
// Mock the necessary functions
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockTestNote]);
@ -317,20 +316,20 @@ describe("processedPrompt()", () => {
const selectedText = "";
// Mock the necessary functions
const mockNoteFile = mockTFile({
const mockNoteFile = {
basename: "Test Note",
path: "Test Note.md",
});
} as TFile;
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNoteFile]);
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockReturnValue("Test Note");
(getFileContent as jest.Mock).mockResolvedValue("Test note content");
// Mock getNotesFromPath to return our mock note
(getNotesFromPath as jest.Mock).mockReturnValue([mockNoteFile]);
(getNotesFromPath as jest.Mock).mockResolvedValue([mockNoteFile]);
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
@ -354,15 +353,15 @@ describe("processedPrompt()", () => {
const selectedText = "";
// Mock the necessary functions
const mockNote1 = mockTFile({
const mockNote1 = {
basename: "Note1",
path: "Note1.md",
});
} as TFile;
// Only Note1 should be extracted since it's wrapped in {[[]]}
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNote1]);
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockImplementation((file: TFile) => file.basename);
(getFileContent as jest.Mock).mockImplementation((file: TFile) => {
@ -373,7 +372,7 @@ describe("processedPrompt()", () => {
});
// Mock getNotesFromPath to return our mock note
(getNotesFromPath as jest.Mock).mockReturnValue([mockNote1]);
(getNotesFromPath as jest.Mock).mockResolvedValue([mockNote1]);
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
@ -395,13 +394,13 @@ describe("processedPrompt()", () => {
it("should handle multiple occurrences of {[[note title]]} syntax", async () => {
const customPrompt = "{[[Note1]]} is related to {[[Note2]]} and {[[Note3]]}.";
const selectedText = "";
const mockNote1 = mockTFile({ basename: "Note1", path: "Note1.md" });
const mockNote2 = mockTFile({ basename: "Note2", path: "Note2.md" });
const mockNote3 = mockTFile({ basename: "Note3", path: "Note3.md" });
const mockNote1 = { basename: "Note1", path: "Note1.md" } as TFile;
const mockNote2 = { basename: "Note2", path: "Note2.md" } as TFile;
const mockNote3 = { basename: "Note3", path: "Note3.md" } as TFile;
// Mock the necessary functions
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNote1, mockNote2, mockNote3]);
(getNotesFromPath as jest.Mock).mockReturnValue([]);
(getNotesFromPath as jest.Mock).mockResolvedValue([]);
(getFileContent as jest.Mock).mockImplementation((file: TFile) => {
if (file.basename === "Note1") {
return "Note1 content";
@ -459,7 +458,7 @@ describe("processedPrompt()", () => {
const selectedText = "";
// Mock getFileName and getFileContent
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockReturnValue("Active Note");
(getFileContent as jest.Mock).mockResolvedValue("Content of the active note");
@ -511,7 +510,7 @@ describe("processedPrompt()", () => {
// Mock getFileContent for the active note when processed via {}
(getFileContent as jest.Mock).mockResolvedValue("Content of the active note");
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockReturnValue("Active Note");
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
@ -630,7 +629,7 @@ describe("sortSlashCommands", () => {
});
const sorted = sortSlashCommands(sampleCommands);
expect(sorted.map((c: { title: string }) => c.title)).toEqual(["Gamma", "Alpha", "Beta"]);
expect(sorted.map((c: any) => c.title)).toEqual(["Gamma", "Alpha", "Beta"]);
});
it("sorts alphabetically (ALPHABETICAL)", () => {
@ -640,7 +639,7 @@ describe("sortSlashCommands", () => {
});
const sorted = sortSlashCommands(sampleCommands);
expect(sorted.map((c: { title: string }) => c.title)).toEqual(["Alpha", "Beta", "Gamma"]);
expect(sorted.map((c: any) => c.title)).toEqual(["Alpha", "Beta", "Gamma"]);
});
it("sorts by manual order (MANUAL)", () => {
@ -649,7 +648,7 @@ describe("sortSlashCommands", () => {
promptSortStrategy: PromptSortStrategy.MANUAL,
});
const sorted = sortSlashCommands(sampleCommands);
expect(sorted.map((c: { title: string }) => c.title)).toEqual(["Alpha", "Beta", "Gamma"]);
expect(sorted.map((c: any) => c.title)).toEqual(["Alpha", "Beta", "Gamma"]);
});
it("returns original order for unknown strategy", () => {
@ -663,30 +662,14 @@ describe("sortSlashCommands", () => {
});
describe("parseCustomCommandFile", () => {
interface MockFrontmatter {
"copilot-command-context-menu-enabled"?: boolean;
"copilot-command-slash-enabled"?: boolean;
"copilot-command-context-menu-order"?: number;
"copilot-command-model-key"?: string;
"copilot-command-last-used"?: number;
}
interface MockMetadata {
frontmatter: MockFrontmatter;
}
interface MockAppLike {
vault: { read: jest.Mock };
metadataCache: { getFileCache: jest.Mock };
}
type AppRef = { app: unknown };
let originalApp: unknown;
let mockFile: TFile;
let mockFrontmatter: MockFrontmatter;
let mockMetadata: MockMetadata;
let originalApp: any;
let mockFile: any;
let mockFrontmatter: any;
let mockMetadata: any;
beforeEach(() => {
// Save and mock global app
originalApp = (window as unknown as AppRef).app;
originalApp = global.app;
mockFrontmatter = {
"copilot-command-context-menu-enabled": true,
"copilot-command-slash-enabled": false,
@ -695,28 +678,27 @@ describe("parseCustomCommandFile", () => {
"copilot-command-last-used": 1234567890,
};
mockMetadata = { frontmatter: mockFrontmatter };
const mockedApp: MockAppLike = {
global.app = {
vault: {
read: jest
.fn()
.mockResolvedValue(
"---\ncopilot-command-context-menu-enabled: true\ncopilot-command-slash-enabled: false\ncopilot-command-context-menu-order: 42\ncopilot-command-model-key: gpt-4\ncopilot-command-last-used: 1234567890\n---\nPrompt content here."
),
},
) as any,
} as any,
metadataCache: {
getFileCache: jest.fn().mockReturnValue(mockMetadata),
},
};
(window as unknown as AppRef).app = mockedApp;
mockFile = mockTFile({
getFileCache: jest.fn().mockReturnValue(mockMetadata) as any,
} as any,
} as any;
mockFile = {
basename: "Test Command",
extension: "md",
path: "CustomCommands/Test Command.md",
});
};
});
afterEach(() => {
(window as unknown as AppRef).app = originalApp;
global.app = originalApp;
});
it("parses a custom command file with frontmatter and content", async () => {
@ -736,12 +718,8 @@ describe("parseCustomCommandFile", () => {
});
it("uses EMPTY_COMMAND defaults if frontmatter is missing", async () => {
(window.app.vault as unknown as { read: jest.Mock }).read.mockResolvedValue(
"Prompt content only, no frontmatter."
);
(
window.app.metadataCache as unknown as { getFileCache: jest.Mock }
).getFileCache.mockReturnValue({});
(global.app.vault as any).read.mockResolvedValue("Prompt content only, no frontmatter.");
(global.app.metadataCache as any).getFileCache.mockReturnValue({});
const { parseCustomCommandFile } = await import("@/commands/customCommandUtils");
const result = await parseCustomCommandFile(mockFile);
expect(result).toEqual({

View file

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

View file

@ -1,7 +1,6 @@
import { processPrompt } from "@/commands/customCommandUtils";
import { TFile, Vault } from "obsidian";
import { getFileContent, getFileName, getNotesFromPath } from "@/utils";
import { mockTFile } from "@/__tests__/mockObsidian";
// Mock the dependencies
jest.mock("@/utils", () => ({
@ -36,10 +35,10 @@ describe("XML Escaping in processPrompt", () => {
},
} as unknown as Vault;
mockActiveNote = mockTFile({
mockActiveNote = {
path: "path/to/active/note.md",
basename: "Active Note",
});
} as TFile;
});
it("should NOT escape XML special characters in selected text", async () => {
@ -60,12 +59,12 @@ describe("XML Escaping in processPrompt", () => {
it("should NOT escape XML in variable names", async () => {
const customPrompt = 'Use {my"variable<>}';
const mockNote = mockTFile({
const mockNote = {
basename: 'Note with <special> & "chars"',
path: "special.md",
});
} as TFile;
(getNotesFromPath as jest.Mock).mockReturnValue([mockNote]);
(getNotesFromPath as jest.Mock).mockResolvedValue([mockNote]);
(getFileName as jest.Mock).mockReturnValue(mockNote.basename);
(getFileContent as jest.Mock).mockResolvedValue('Content with <xml> & special "chars"');
@ -103,15 +102,13 @@ describe("XML Escaping in processPrompt", () => {
it("should NOT escape XML in note paths and metadata", async () => {
const customPrompt = "[[Special Note]]";
const mockNote = mockTFile({
const mockNote = {
basename: 'Special & "Note"',
path: 'folder<with>/special&chars/"note".md',
});
} as TFile;
(getNotesFromPath as jest.Mock).mockReturnValue([]);
(
jest.requireMock("@/utils") as unknown as { extractTemplateNoteFiles: jest.Mock }
).extractTemplateNoteFiles.mockReturnValue([mockNote]);
(getNotesFromPath as jest.Mock).mockResolvedValue([]);
jest.requireMock("@/utils").extractTemplateNoteFiles.mockReturnValue([mockNote]);
(getFileContent as jest.Mock).mockResolvedValue("Content with & and < and >");
const result = await processPrompt(customPrompt, "", mockVault, mockActiveNote);
@ -129,15 +126,13 @@ describe("XML Escaping in processPrompt", () => {
it("should NOT escape XML in tag variables", async () => {
const customPrompt = "Notes for {#tag&special}";
const mockNote = mockTFile({
const mockNote = {
basename: 'Tagged & "Note"',
path: "tagged.md",
});
} as TFile;
(getNotesFromPath as jest.Mock).mockReturnValue([]);
(
jest.requireMock("@/utils") as unknown as { getNotesFromTags: jest.Mock }
).getNotesFromTags.mockReturnValue([mockNote]);
(getNotesFromPath as jest.Mock).mockResolvedValue([]);
jest.requireMock("@/utils").getNotesFromTags.mockResolvedValue([mockNote]);
(getFileName as jest.Mock).mockReturnValue(mockNote.basename);
(getFileContent as jest.Mock).mockResolvedValue("Content: <tag> & </tag>");

View file

@ -34,47 +34,37 @@ import { COMMAND_IDS, COMMAND_ICONS, COMMAND_NAMES, CommandId } from "../constan
import { setSelectedTextContexts } from "@/aiParams";
/**
* Add a command to the plugin. Supports async callbacks; errors are logged.
* Add a command to the plugin.
*/
function addCommand(plugin: CopilotPlugin, id: CommandId, callback: () => void | Promise<void>) {
export function addCommand(plugin: CopilotPlugin, id: CommandId, callback: () => void) {
plugin.addCommand({
id,
name: COMMAND_NAMES[id],
icon: COMMAND_ICONS[id],
callback: () => {
const result = callback();
if (result instanceof Promise) {
result.catch((err) => logError(`Command ${id} failed`, err));
}
},
callback,
});
}
/**
* Add an editor command to the plugin. Supports async callbacks; errors are logged.
* Add an editor command to the plugin.
*/
function addEditorCommand(
plugin: CopilotPlugin,
id: CommandId,
callback: (editor: Editor) => void | Promise<void>
callback: (editor: Editor) => void
) {
plugin.addCommand({
id,
name: COMMAND_NAMES[id],
icon: COMMAND_ICONS[id],
editorCallback: (editor) => {
const result = callback(editor);
if (result instanceof Promise) {
result.catch((err) => logError(`Editor command ${id} failed`, err));
}
},
editorCallback: callback,
});
}
/**
* Add a check command to the plugin.
*/
function addCheckCommand(
export function addCheckCommand(
plugin: CopilotPlugin,
id: CommandId,
callback: (checking: boolean) => boolean | void
@ -93,7 +83,7 @@ export function registerCommands(
next: CopilotSettings
) {
addEditorCommand(plugin, COMMAND_IDS.COUNT_WORD_AND_TOKENS_SELECTION, async (editor: Editor) => {
const selectedText = editor.getSelection();
const selectedText = await editor.getSelection();
const wordCount = selectedText.split(" ").length;
const tokenCount = await plugin.projectManager
.getCurrentChainManager()
@ -118,13 +108,13 @@ export function registerCommands(
plugin.toggleView();
});
addCommand(plugin, COMMAND_IDS.OPEN_COPILOT_CHAT_WINDOW, async () => {
await plugin.activateView();
addCommand(plugin, COMMAND_IDS.OPEN_COPILOT_CHAT_WINDOW, () => {
plugin.activateView();
});
addCommand(plugin, COMMAND_IDS.NEW_CHAT, async () => {
addCommand(plugin, COMMAND_IDS.NEW_CHAT, () => {
clearRecordedPromptPayload();
await plugin.newChat();
plugin.newChat();
});
// Quick Command - opens a modal dialog for quick interactions
@ -306,8 +296,8 @@ export function registerCommands(
}
});
addCommand(plugin, COMMAND_IDS.LOAD_COPILOT_CHAT_CONVERSATION, async () => {
await plugin.loadCopilotChatHistory();
addCommand(plugin, COMMAND_IDS.LOAD_COPILOT_CHAT_CONVERSATION, () => {
plugin.loadCopilotChatHistory();
});
addCommand(plugin, COMMAND_IDS.LIST_INDEXED_FILES, async () => {
@ -387,16 +377,16 @@ export function registerCommands(
await ensureFolderExists(folderPath);
const existingFile = plugin.app.vault.getAbstractFileByPath(filePath);
if (existingFile instanceof TFile) {
await plugin.app.vault.modify(existingFile, content);
if (existingFile) {
await plugin.app.vault.modify(existingFile as TFile, content);
} else {
await plugin.app.vault.create(filePath, content);
}
// Open the file
const file = plugin.app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
await plugin.app.workspace.getLeaf().openFile(file);
if (file) {
await plugin.app.workspace.getLeaf().openFile(file as TFile);
new Notice(`Listed ${indexedFiles.size} indexed files`);
}
} catch (error) {
@ -424,30 +414,28 @@ export function registerCommands(
}
// Map hits to chunks (getDocsByPath returns {document, score} format)
const chunks: Record<string, unknown>[] = hits.map(
(hit) => hit.document as unknown as Record<string, unknown>
);
const chunks = hits.map((hit: any) => hit.document);
const content = [
`# Embedding Debug: ${activeFile.basename}`,
"",
`**Path:** ${activeFile.path}`,
`**Chunks:** ${chunks.length}`,
`**Embedding Model:** ${(chunks[0]?.embeddingModel as string | undefined) || "unknown"}`,
`**Embedding Model:** ${chunks[0]?.embeddingModel || "unknown"}`,
"",
...chunks.flatMap((chunk: Record<string, unknown>, index: number) => {
const embedding = (chunk.embedding as number[] | undefined) || [];
...chunks.flatMap((chunk: any, index: number) => {
const embedding = chunk.embedding || [];
const preview = embedding
.slice(0, 10)
.map((v: number) => v.toFixed(6))
.join(", ");
return [
`## Chunk ${index + 1}`,
`- **ID:** ${chunk.id as string}`,
`- **Content Preview:** "${((chunk.content as string | undefined) || "").substring(0, 200)}..."`,
`- **ID:** ${chunk.id}`,
`- **Content Preview:** "${(chunk.content || "").substring(0, 200)}..."`,
`- **Vector Length:** ${embedding.length}`,
`- **Vector Preview:** [${preview}${embedding.length > 10 ? ", ..." : ""}]`,
`- **Tags:** ${((chunk.tags as string[] | undefined) || []).join(", ") || "none"}`,
`- **Characters:** ${(chunk.nchars as number | undefined) || 0}`,
`- **Tags:** ${(chunk.tags || []).join(", ") || "none"}`,
`- **Characters:** ${chunk.nchars || 0}`,
"",
];
}),
@ -461,15 +449,15 @@ export function registerCommands(
await ensureFolderExists(folderPath);
const existingFile = plugin.app.vault.getAbstractFileByPath(filePath);
if (existingFile instanceof TFile) {
await plugin.app.vault.modify(existingFile, content);
if (existingFile) {
await plugin.app.vault.modify(existingFile as TFile, content);
} else {
await plugin.app.vault.create(filePath, content);
}
const file = plugin.app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
await plugin.app.workspace.getLeaf().openFile(file);
if (file) {
await plugin.app.workspace.getLeaf().openFile(file as TFile);
new Notice(`Embedding debug info for ${chunks.length} chunk(s)`);
}
} catch (error) {
@ -558,7 +546,7 @@ export function registerCommands(
setSelectedTextContexts([selectedTextContext]);
// Open chat window to show the context was added
await plugin.activateView();
plugin.activateView();
});
// Add web selection to chat context command (manual)
@ -604,7 +592,7 @@ export function registerCommands(
setSelectedTextContexts([webSelectedTextContext]);
// Open chat window to show the context was added
await plugin.activateView();
plugin.activateView();
} catch (error) {
logError("Error adding web selection to context:", error);
new Notice("Failed to get web selection");

View file

@ -13,7 +13,6 @@ import {
import { COPILOT_COMMAND_CONTEXT_MENU_ENABLED } from "@/commands/constants";
import { ConfirmModal } from "@/components/modals/ConfirmModal";
import { getCachedCustomCommands } from "@/commands/state";
import { logError } from "@/logger";
async function saveUnsupportedCommands(commands: CustomCommand[]) {
const folderPath = getCustomCommandsFolder();
@ -24,7 +23,7 @@ async function saveUnsupportedCommands(commands: CustomCommand[]) {
commands.map(async (command) => {
const filePath = `${unsupportedFolderPath}/${command.title}.md`;
const file = await app.vault.create(filePath, command.content);
await app.fileManager.processFrontMatter(file, (frontmatter: Record<string, unknown>) => {
await app.fileManager.processFrontMatter(file, (frontmatter) => {
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] = command.showInContextMenu;
frontmatter[COPILOT_COMMAND_SLASH_ENABLED] = command.showInSlashMenu;
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ORDER] = command.order;
@ -94,7 +93,7 @@ export async function generateDefaultCommands(): Promise<void> {
(command) => !existingCommands.some((c) => c.title === command.title)
);
const newCommands = [...existingCommands, ...defaultCommands];
await CustomCommandManager.getInstance().updateCommands(newCommands);
CustomCommandManager.getInstance().updateCommands(newCommands);
}
/** Suggests the default commands if the user has not created any commands yet. */
@ -109,9 +108,7 @@ export async function suggestDefaultCommands(): Promise<void> {
new ConfirmModal(
app,
() => {
void generateDefaultCommands().catch((err) =>
logError("generateDefaultCommands failed", err)
);
generateDefaultCommands();
},
"Would you like to add Copilot recommended commands in your custom prompts folder? These commands will be available through the right-click context menu and slash commands in chat.",
"Welcome to Copilot",

View file

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

View file

@ -4,6 +4,7 @@ import {
getSelectedTextContexts,
ProjectConfig,
removeSelectedTextContext,
setCurrentProject,
useChainType,
updateIndexingProgressState,
useIndexingProgress,
@ -11,7 +12,7 @@ import {
useSelectedTextContexts,
} from "@/aiParams";
import { resetSessionSystemPromptSettings } from "@/system-prompts";
import { ChainType } from "@/chainType";
import { ChainType } from "@/chainFactory";
import { useProjectContextStatus } from "@/hooks/useProjectContextStatus";
import { logInfo, logError } from "@/logger";
import type { WebTabContext } from "@/types/message";
@ -41,15 +42,12 @@ import { clearRecordedPromptPayload } from "@/LLMProviders/chainRunner/utils/pro
import { logFileManager } from "@/logFileManager";
import CopilotPlugin from "@/main";
import { useIsPlusUser } from "@/plusUtils";
import { ProjectFileManager } from "@/projects/ProjectFileManager";
import { useProjects } from "@/projects/state";
import { useSettingsValue } from "@/settings/model";
import { updateSetting, useSettingsValue } from "@/settings/model";
import { ChatUIState } from "@/state/ChatUIState";
import { FileParserManager } from "@/tools/FileParserManager";
import { ChatMessage } from "@/types/message";
import { err2String, isPlusChain } from "@/utils";
import { arrayBufferToBase64 } from "@/utils/base64";
import { appendUniqueFiles } from "@/utils/fileListUtils";
import { Notice, TFile } from "obsidian";
import { ContextManageModal } from "@/components/modals/project/context-manage-modal";
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
@ -80,7 +78,6 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
chatInput,
}) => {
const settings = useSettingsValue();
const projects = useProjects();
const eventTarget = useContext(EventTargetContext);
const { messages: chatHistory, addMessage: rawAddMessage } = useChatManager(chatUIState);
@ -88,6 +85,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
const [currentChain] = useChainType();
const [currentAiMessage, setCurrentAiMessage] = useState("");
const [inputMessage, setInputMessage] = useState("");
const [latestTokenCount, setLatestTokenCount] = useState<number | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
// Stable ID for streaming message, shared with final persisted message
// This allows collapsible UI state (think blocks) to persist across streaming -> history
@ -103,6 +101,12 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
const messageToAdd = shouldAttachId ? { ...message, id: streamingId } : message;
rawAddMessage(messageToAdd);
if (
messageToAdd.sender === AI_SENDER &&
messageToAdd.responseMetadata?.tokenUsage?.totalTokens
) {
setLatestTokenCount(messageToAdd.responseMetadata.tokenUsage.totalTokens);
}
},
[rawAddMessage]
);
@ -115,12 +119,8 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
const [loading, setLoading] = useState(false);
const [loadingMessage, setLoadingMessage] = useState(LOADING_MESSAGES.DEFAULT);
const [contextNotes, setContextNotes] = useState<TFile[]>([]);
const [includeActiveNote, setIncludeActiveNote] = useState(
settings.autoAddActiveContentToContext === true && currentChain !== ChainType.PROJECT_CHAIN
);
const [includeActiveWebTab, setIncludeActiveWebTab] = useState(
settings.autoAddActiveContentToContext === true && currentChain !== ChainType.PROJECT_CHAIN
);
const [includeActiveNote, setIncludeActiveNote] = useState(false);
const [includeActiveWebTab, setIncludeActiveWebTab] = useState(false);
const [selectedImages, setSelectedImages] = useState<File[]>([]);
const [showChatUI, setShowChatUI] = useState(false);
const [chatHistoryItems, setChatHistoryItems] = useState<ChatHistoryItem[]>([]);
@ -179,11 +179,10 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
return projectContextStatus === "loading" || projectContextStatus === "error";
};
const [prevProjectContextStatus, setPrevProjectContextStatus] = useState(projectContextStatus);
if (prevProjectContextStatus !== projectContextStatus) {
setPrevProjectContextStatus(projectContextStatus);
// Reset user preference when status changes to allow default behavior
useEffect(() => {
setProgressCardVisible(null);
}
}, [projectContextStatus]);
/**
* Whether to show the indexing progress card.
@ -196,22 +195,12 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
return indexingState.isActive || indexingState.completionStatus !== "none";
};
const [prevIndexingActivity, setPrevIndexingActivity] = useState({
isActive: indexingState.isActive,
completionStatus: indexingState.completionStatus,
});
if (
prevIndexingActivity.isActive !== indexingState.isActive ||
prevIndexingActivity.completionStatus !== indexingState.completionStatus
) {
setPrevIndexingActivity({
isActive: indexingState.isActive,
completionStatus: indexingState.completionStatus,
});
// Allow the card to show whenever new indexing activity or completion is detected
useEffect(() => {
if (indexingState.isActive || indexingState.completionStatus !== "none") {
setIndexingCardVisible(null);
}
}
}, [indexingState.isActive, indexingState.completionStatus]);
const handleIndexingCardClose = useCallback(() => {
setIndexingCardVisible(false);
@ -236,12 +225,11 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
await VectorStoreManager.getInstance().cancelIndexing();
}, []);
const latestTokenCount = useMemo(() => {
for (let i = chatHistory.length - 1; i >= 0; i--) {
const m = chatHistory[i];
if (m.sender === AI_SENDER) return m.responseMetadata?.tokenUsage?.totalTokens ?? null;
// Clear token count when chat is cleared or replaced (e.g., loading chat history)
useEffect(() => {
if (chatHistory.length === 0) {
setLatestTokenCount(null);
}
return null;
}, [chatHistory]);
const [previousMode, setPreviousMode] = useState<ChainType | null>(null);
@ -251,20 +239,13 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
const appContext = useContext(AppContext);
const app = plugin.app || appContext;
/**
* Add selected image files while preserving the original selection order.
*/
const handleAddImage = useCallback((files: File[]) => {
setSelectedImages((prev) => appendUniqueFiles(prev, files));
}, []);
// Drag-and-drop hook for file handling
const { isDragActive } = useChatFileDrop({
app,
contextNotes,
setContextNotes,
selectedImages,
onAddImage: handleAddImage,
onAddImage: (files) => setSelectedImages((prev) => [...prev, ...files]),
containerRef: chatContainerRef,
});
@ -295,10 +276,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
try {
// Create message content array
type MessageContentItem =
| { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string } };
const content: MessageContentItem[] = [];
const content: any[] = [];
// Add text content if present
if (inputMessage) {
@ -369,7 +347,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
// Autosave if enabled
if (settings.autosaveChat) {
await handleSaveAsNote();
handleSaveAsNote();
}
// Get the LLM message for AI processing
@ -387,7 +365,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
// Autosave again after AI response
if (settings.autosaveChat) {
await handleSaveAsNote();
handleSaveAsNote();
}
} catch (error) {
logError("Error sending message:", error);
@ -467,12 +445,12 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
if (!success) {
new Notice("Failed to regenerate message. Please try again.");
} else if (settings.debug) {
logInfo("Message regenerated successfully");
console.log("Message regenerated successfully");
}
// Autosave the chat if the setting is enabled
if (settings.autosaveChat) {
await handleSaveAsNote();
handleSaveAsNote();
}
} catch (error) {
logError("Error regenerating message:", error);
@ -549,7 +527,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
// Autosave the chat if the setting is enabled
if (settings.autosaveChat) {
await handleSaveAsNote();
handleSaveAsNote();
}
} catch (error) {
logError("Error editing message:", error);
@ -579,33 +557,71 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
}, [onSaveChat, handleSaveAsNote]);
const handleAddProject = useCallback(
async (project: ProjectConfig) => {
const manager = ProjectFileManager.getInstance(plugin.app);
await manager.createProject(project);
new Notice(`${project.name} added successfully`);
(project: ProjectConfig) => {
const currentProjects = settings.projectList || [];
const existingIndex = currentProjects.findIndex((p) => p.name === project.name);
// Reason: reload is best-effort — the project is already saved, so a reload failure
// must not surface as a "save failed" error to the modal (which would cause retries
// and duplicate-id errors). reloadCurrentProject() handles its own error notices.
if (existingIndex >= 0) {
throw new Error(`Project "${project.name}" already exists, please use a different name`);
}
const newProjectList = [...currentProjects, project];
updateSetting("projectList", newProjectList);
// Check if this project is now the current project
const currentProject = getCurrentProject();
if (currentProject?.id === project.id) {
void reloadCurrentProject(plugin.app);
// Reload the project context for the newly added project
reloadCurrentProject()
.then(() => {
new Notice(`${project.name} added and context loaded`);
})
.catch((error: Error) => {
logError("Error loading project context:", error);
new Notice(`${project.name} added but context loading failed`);
});
} else {
new Notice(`${project.name} added successfully`);
}
return true;
},
[plugin.app]
[settings.projectList]
);
const handleEditProject = useCallback(
async (originP: ProjectConfig, updateP: ProjectConfig) => {
const manager = ProjectFileManager.getInstance(plugin.app);
await manager.updateProject(originP.id, updateP);
new Notice(`${originP.name} updated successfully`);
// Reason: no explicit reload needed here — ProjectManager's project-record subscriber
// already reacts to the cache update from updateProject() and triggers
// setCurrentProject + loadProjectContext + createChainWithNewModel.
// Doing it here too would duplicate expensive work (URL fetches, chain recreation).
(originP: ProjectConfig, updateP: ProjectConfig) => {
const currentProjects = settings.projectList || [];
const existingProject = currentProjects.find((p) => p.name === originP.name);
if (!existingProject) {
throw new Error(`Project "${originP.name}" does not exist`);
}
const newProjectList = currentProjects.map((p) => (p.name === originP.name ? updateP : p));
updateSetting("projectList", newProjectList);
// If this is the current project, update the current project atom
const currentProject = getCurrentProject();
if (currentProject?.id === originP.id) {
setCurrentProject(updateP);
// Reload the project context
reloadCurrentProject()
.then(() => {
new Notice(`${originP.name} updated and context reloaded`);
})
.catch((error: Error) => {
logError("Error reloading project context:", error);
new Notice(`${originP.name} updated but context reload failed`);
});
} else {
new Notice(`${originP.name} updated successfully`);
}
return true;
},
[plugin.app]
[settings.projectList]
);
const handleRemoveSelectedText = useCallback(
@ -696,6 +712,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
// Additional UI state reset specific to this component
safeSet.setCurrentAiMessage("");
setContextNotes([]);
setLatestTokenCount(null); // Clear token count on new chat
// Capture web selection URL before clearing for suppression
const webSelectionUrl = selectedTextContexts.find((ctx) => ctx.sourceType === "web")?.url;
clearSelectedTextContexts();
@ -791,7 +808,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
// Event listener for abort stream events
useEffect(() => {
const handleAbortStream = (event: CustomEvent<{ reason?: ABORT_REASON }>) => {
const handleAbortStream = (event: CustomEvent) => {
const reason = event.detail?.reason || ABORT_REASON.NEW_CHAT;
handleStopGenerating(reason);
};
@ -804,19 +821,10 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
};
}, [eventTarget, handleStopGenerating]);
const [prevAutoAddTuple, setPrevAutoAddTuple] = useState({
autoAdd: settings.autoAddActiveContentToContext,
chain: selectedChain,
});
if (
prevAutoAddTuple.autoAdd !== settings.autoAddActiveContentToContext ||
prevAutoAddTuple.chain !== selectedChain
) {
setPrevAutoAddTuple({
autoAdd: settings.autoAddActiveContentToContext,
chain: selectedChain,
});
// Use the autoAddActiveContentToContext setting
useEffect(() => {
if (settings.autoAddActiveContentToContext !== undefined) {
// Only apply the setting if not in Project mode
if (selectedChain === ChainType.PROJECT_CHAIN) {
setIncludeActiveNote(false);
setIncludeActiveWebTab(false);
@ -825,7 +833,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
@ -861,7 +869,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
new ContextManageModal(
app,
(updatedProject) => {
void handleEditProject(currentProject, updatedProject);
handleEditProject(currentProject, updatedProject);
},
currentProject
).open();
@ -873,17 +881,17 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
<div className="tw-inset-0 tw-z-modal tw-flex tw-items-center tw-justify-center tw-rounded-xl">
<IndexingProgressCard
onClose={handleIndexingCardClose}
onPause={() => void handleIndexingPause()}
onResume={() => void handleIndexingResume()}
onStop={() => void handleIndexingStop()}
onPause={handleIndexingPause}
onResume={handleIndexingResume}
onStop={handleIndexingStop}
/>
</div>
) : (
<>
<ChatControls
onNewChat={() => void handleNewChat()}
onNewChat={handleNewChat}
onSaveAsNote={() => handleSaveAsNote()}
onLoadHistory={() => void handleLoadChatHistory()}
onLoadHistory={handleLoadChatHistory}
onModeChange={(newMode) => {
setPreviousMode(selectedChain);
// Hide chat UI when switching to project mode
@ -913,7 +921,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
setIncludeActiveWebTab={setIncludeActiveWebTab}
activeWebTab={currentActiveWebTab}
selectedImages={selectedImages}
onAddImage={handleAddImage}
onAddImage={(files: File[]) => setSelectedImages((prev) => [...prev, ...files])}
setSelectedImages={setSelectedImages}
disableModelSwitch={selectedChain === ChainType.PROJECT_CHAIN}
selectedTextContexts={selectedTextContexts}
@ -947,7 +955,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
{selectedChain === ChainType.PROJECT_CHAIN && (
<div className={`${selectedChain === ChainType.PROJECT_CHAIN ? "tw-z-modal" : ""}`}>
<ProjectList
projects={projects}
projects={settings.projectList || []}
defaultOpen={true}
app={app}
plugin={plugin}

View file

@ -2,14 +2,13 @@ import ChainManager from "@/LLMProviders/chainManager";
import Chat from "@/components/Chat";
import { ChatViewLayout } from "@/components/chat-components/ChatViewLayout";
import { CHAT_VIEWTYPE } from "@/constants";
import { EventTargetContext } from "@/context";
import { AppContext, EventTargetContext } from "@/context";
import CopilotPlugin from "@/main";
import { FileParserManager } from "@/tools/FileParserManager";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import * as Tooltip from "@radix-ui/react-tooltip";
import { ItemView, Platform, WorkspaceLeaf } from "obsidian";
import * as React from "react";
import { Root } from "react-dom/client";
import { createRoot, Root } from "react-dom/client";
export default class CopilotView extends ItemView {
private get chainManager(): ChainManager {
@ -23,7 +22,6 @@ export default class CopilotView extends ItemView {
private drawerHideObserver: MutationObserver | null = null;
private layout: ChatViewLayout | null = null;
private lastDrawerEl: HTMLElement | null = null;
private windowMigrationDestroy: (() => void) | null = null;
eventTarget: EventTarget;
constructor(
@ -56,7 +54,7 @@ export default class CopilotView extends ItemView {
}
async onOpen(): Promise<void> {
this.root = createPluginRoot(this.containerEl.children[1], this.app);
this.root = createRoot(this.containerEl.children[1]);
const handleSaveAsNote = (saveFunction: () => Promise<void>) => {
this.handleSaveAsNote = saveFunction;
};
@ -69,19 +67,6 @@ export default class CopilotView extends ItemView {
this.setupMobileKeyboardObserver();
this.setupDrawerHideObserver();
// Reason: When the leaf is dragged to (or back from) an Obsidian popout, the
// containerEl is reparented into a different window's document but onOpen
// does not re-fire. Lexical's editor._window stays bound to the original
// window, so input events fired in the popout never reach the editor.
// Tearing down and recreating the React root forces Lexical to re-register
// its root element under the new window — typing works again.
this.windowMigrationDestroy = this.containerEl.onWindowMigrated(() => {
if (!this.root) return;
this.root.unmount();
this.root = createPluginRoot(this.containerEl.children[1], this.app);
this.renderView(handleSaveAsNote, updateUserMessageHistory);
});
// Reason: The view can move between containers (e.g. editor tab → drawer)
// without onOpen firing again. Re-bind the drawer observer on layout changes
// so it always watches the correct drawer element.
@ -89,7 +74,7 @@ export default class CopilotView extends ItemView {
// before we disconnect and rebind.
this.registerEvent(
this.app.workspace.on("layout-change", () => {
window.requestAnimationFrame(() => this.setupDrawerHideObserver());
requestAnimationFrame(() => this.setupDrawerHideObserver());
})
);
}
@ -109,7 +94,7 @@ export default class CopilotView extends ItemView {
this.keyboardObserver?.disconnect();
const syncKeyboardClass = () => {
const drawer = this.containerEl.closest<HTMLElement>(".workspace-drawer");
const drawer = this.containerEl.closest(".workspace-drawer") as HTMLElement | null;
// Reason: If the view moved out of its previous drawer, clear the class on the old one
// so drawer chrome (header/tab options) is restored.
@ -124,13 +109,13 @@ export default class CopilotView extends ItemView {
// querying by data-type which is more brittle across Obsidian versions.
const isCopilotActive = !!this.containerEl.closest(".workspace-drawer-active-tab-content");
const kbHeight = parseFloat(
this.containerEl.doc.documentElement.style.getPropertyValue("--keyboard-height") || "0"
document.documentElement.style.getPropertyValue("--keyboard-height") || "0"
);
drawer.classList.toggle("copilot-keyboard-open", isCopilotActive && kbHeight > 0);
};
this.keyboardObserver = new MutationObserver(syncKeyboardClass);
this.keyboardObserver.observe(this.containerEl.doc.documentElement, {
this.keyboardObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["style"],
});
@ -153,7 +138,7 @@ export default class CopilotView extends ItemView {
this.drawerHideObserver?.disconnect();
const drawer = this.containerEl.closest<HTMLElement>(".workspace-drawer");
const drawer = this.containerEl.closest(".workspace-drawer") as HTMLElement | null;
if (!drawer) return;
let wasHidden = drawer.classList.contains("is-hidden");
@ -183,18 +168,20 @@ export default class CopilotView extends ItemView {
if (!this.root) return;
this.root.render(
<EventTargetContext.Provider value={this.eventTarget}>
<Tooltip.Provider delayDuration={0}>
<Chat
chainManager={this.chainManager}
updateUserMessageHistory={updateUserMessageHistory}
fileParserManager={this.fileParserManager}
plugin={this.plugin}
onSaveChat={handleSaveAsNote}
chatUIState={this.plugin.chatUIState}
/>
</Tooltip.Provider>
</EventTargetContext.Provider>
<AppContext.Provider value={this.app}>
<EventTargetContext.Provider value={this.eventTarget}>
<Tooltip.Provider delayDuration={0}>
<Chat
chainManager={this.chainManager}
updateUserMessageHistory={updateUserMessageHistory}
fileParserManager={this.fileParserManager}
plugin={this.plugin}
onSaveChat={handleSaveAsNote}
chatUIState={this.plugin.chatUIState}
/>
</Tooltip.Provider>
</EventTargetContext.Provider>
</AppContext.Provider>
);
}
@ -222,8 +209,6 @@ export default class CopilotView extends ItemView {
this.keyboardObserver = null;
this.drawerHideObserver?.disconnect();
this.drawerHideObserver = null;
this.windowMigrationDestroy?.();
this.windowMigrationDestroy = null;
this.layout?.destroy();
this.layout = null;
// Reason: Clean up the class on the tracked drawer element when the view is closed.

View file

@ -25,7 +25,7 @@ export default function IndexingProgressCard({
onStop,
}: IndexingProgressCardProps) {
const [indexingState] = useIndexingProgress();
const autoCloseTimerRef = useRef<number | null>(null);
const autoCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { isActive, isPaused, indexedCount, totalFiles, errors, completionStatus } = indexingState;
@ -34,7 +34,7 @@ export default function IndexingProgressCard({
// Auto-close 3s after completion
useEffect(() => {
if (autoCloseTimerRef.current) {
window.clearTimeout(autoCloseTimerRef.current);
clearTimeout(autoCloseTimerRef.current);
autoCloseTimerRef.current = null;
}
@ -44,14 +44,14 @@ export default function IndexingProgressCard({
completionStatus !== "none" &&
!(completionStatus === "error" && totalFiles === 0);
if (shouldAutoClose) {
autoCloseTimerRef.current = window.setTimeout(() => {
autoCloseTimerRef.current = setTimeout(() => {
onClose();
}, 3000);
}
return () => {
if (autoCloseTimerRef.current) {
window.clearTimeout(autoCloseTimerRef.current);
clearTimeout(autoCloseTimerRef.current);
autoCloseTimerRef.current = null;
}
};

View file

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

View file

@ -2,7 +2,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { cn } from "@/lib/utils";
import { ReasoningStatus } from "@/LLMProviders/chainRunner/utils/AgentReasoningState";
import { ChevronRight } from "lucide-react";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
/**
* Props for the AgentReasoningBlock component
@ -65,13 +65,13 @@ const CopilotSpinner: React.FC = () => {
viewBox={`0 0 ${gridSize} ${gridSize}`}
className="copilot-spinner"
>
{sigmaDots.map((dot) => {
{sigmaDots.map((dot, index) => {
const cx = dot.col * (dotSize + gap) + dotSize / 2;
const cy = dot.row * (dotSize + gap) + dotSize / 2;
return (
<circle
key={`${dot.row}-${dot.col}`}
key={index}
cx={cx}
cy={cy}
r={dotSize / 2}
@ -106,16 +106,15 @@ export const AgentReasoningBlock: React.FC<AgentReasoningBlockProps> = ({
isStreaming,
}) => {
const [isExpanded, setIsExpanded] = useState(status === "reasoning");
const [prevStatus, setPrevStatus] = useState(status);
if (status !== prevStatus) {
setPrevStatus(status);
// Auto-expand when reasoning, auto-collapse when done
useEffect(() => {
if (status === "reasoning") {
setIsExpanded(true);
} else if (status === "collapsed" || status === "complete") {
setIsExpanded(false);
}
}
}, [status]);
// Don't render anything if idle
if (status === "idle") {
@ -165,7 +164,6 @@ export const AgentReasoningBlock: React.FC<AgentReasoningBlockProps> = ({
{steps.length > 0 && (
<ul className="agent-reasoning-steps">
{steps.map((step, i) => (
// eslint-disable-next-line @eslint-react/no-array-index-key -- steps are append-only with no stable id; text may repeat
<li key={i} className="agent-reasoning-step">
{step}
</li>
@ -176,3 +174,5 @@ export const AgentReasoningBlock: React.FC<AgentReasoningBlockProps> = ({
</Collapsible>
);
};
export default AgentReasoningBlock;

View file

@ -1,5 +1,5 @@
import React, { useCallback, useState } from "react";
import { TFile, TFolder } from "obsidian";
import React, { useCallback, useEffect, useState } from "react";
import { TFile } from "obsidian";
import { TypeaheadMenuPopover } from "./TypeaheadMenuPopover";
import {
useAtMentionCategories,
@ -8,12 +8,11 @@ import {
CategoryOption,
} from "./hooks/useAtMentionCategories";
import { useAtMentionSearch } from "./hooks/useAtMentionSearch";
import type { WebTabContext } from "@/types/message";
interface AtMentionTypeaheadProps {
isOpen: boolean;
onClose: () => void;
onSelect: (category: AtMentionCategory, data: TFile | string | TFolder | WebTabContext) => void;
onSelect: (category: AtMentionCategory, data: any) => void;
isCopilotPlus?: boolean;
currentActiveFile?: TFile | null;
}
@ -42,8 +41,6 @@ export function AtMentionTypeahead({
}>({
mode: "category",
});
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
const [prevResultsLength, setPrevResultsLength] = useState(0);
const availableCategoryOptions = useAtMentionCategories(isCopilotPlus);
@ -59,9 +56,9 @@ export function AtMentionTypeahead({
// Handle selection
const handleSelect = useCallback(
(option: CategoryOption | AtMentionOption) => {
(option: any) => {
// Guard: never select disabled options (defensive check for click events)
if ((option as { disabled?: boolean })?.disabled) return;
if (option?.disabled) return;
if (extendedState.mode === "category" && isCategoryOption(option) && !searchQuery) {
// Category was selected - switch to search mode for that category
@ -163,8 +160,8 @@ export function AtMentionTypeahead({
[selectedIndex, searchResults, handleSelect, onClose, extendedState.mode, searchQuery]
);
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen);
// Reset state when menu closes
useEffect(() => {
if (!isOpen) {
setSearchQuery("");
setSelectedIndex(0);
@ -173,12 +170,12 @@ export function AtMentionTypeahead({
selectedCategory: undefined,
});
}
}
}, [isOpen]);
if (searchResults.length !== prevResultsLength) {
setPrevResultsLength(searchResults.length);
// Reset selected index when options change
useEffect(() => {
setSelectedIndex(0);
}
}, [searchResults.length]);
if (!isOpen) {
return null;
@ -186,7 +183,7 @@ export function AtMentionTypeahead({
return (
<TypeaheadMenuPopover
options={searchResults}
options={searchResults as any[]}
selectedIndex={selectedIndex}
onSelect={handleSelect}
onHighlight={handleHighlight}

View file

@ -1,5 +1,5 @@
import { AlertCircle, CheckCircle, CircleDashed, FileText, Loader2, X } from "lucide-react";
import { Platform, TFile, TFolder } from "obsidian";
import { Platform, TFile } from "obsidian";
import React, { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@ -13,7 +13,7 @@ import {
FaviconOrGlobe,
} from "@/components/chat-components/ContextBadges";
import { SelectedTextContext, WebTabContext, isWebSelectedTextContext } from "@/types/message";
import { ChainType } from "@/chainType";
import { ChainType } from "@/chainFactory";
import { Separator } from "@/components/ui/separator";
import { useChainType, useIndexingProgress } from "@/aiParams";
import { useProjectContextStatus } from "@/hooks/useProjectContextStatus";
@ -22,8 +22,6 @@ import { mergeWebTabContexts } from "@/utils/urlNormalization";
import { AtMentionTypeahead } from "./AtMentionTypeahead";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
const EMPTY_SELECTED_TEXT_CONTEXTS: SelectedTextContext[] = [];
interface ChatContextMenuProps {
includeActiveNote: boolean;
currentActiveFile: TFile | null;
@ -34,11 +32,11 @@ interface ChatContextMenuProps {
contextFolders: string[];
contextWebTabs: WebTabContext[];
selectedTextContexts?: SelectedTextContext[];
onRemoveContext: (category: string, data: string) => void;
onRemoveContext: (category: string, data: any) => void;
showProgressCard: () => void;
showIndexingCard?: () => void;
onTypeaheadSelect: (category: string, data: TFile | string | TFolder | WebTabContext) => void;
lexicalEditorRef?: React.RefObject<{ focus: () => void }>;
onTypeaheadSelect: (category: string, data: any) => void;
lexicalEditorRef?: React.RefObject<any>;
}
function ContextSelection({
@ -46,7 +44,7 @@ function ContextSelection({
onRemoveContext,
}: {
selectedText: SelectedTextContext;
onRemoveContext: (category: string, data: string) => void;
onRemoveContext: (category: string, data: any) => void;
}) {
// Handle web selected text
if (isWebSelectedTextContext(selectedText)) {
@ -106,7 +104,7 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
contextUrls,
contextFolders,
contextWebTabs,
selectedTextContexts = EMPTY_SELECTED_TEXT_CONTEXTS,
selectedTextContexts = [],
onRemoveContext,
showProgressCard,
showIndexingCard,
@ -125,15 +123,12 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
};
// Simple wrapper that adds focus management to the ContextControl handler
const handleTypeaheadSelect = (
category: string,
data: TFile | string | TFolder | WebTabContext
) => {
const handleTypeaheadSelect = (category: string, data: any) => {
// Delegate to ContextControl handler
onTypeaheadSelect(category, data);
// Return focus to the editor after selection
window.setTimeout(() => {
setTimeout(() => {
if (lexicalEditorRef?.current) {
lexicalEditorRef.current.focus();
}
@ -144,7 +139,7 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
* Handles clicking on a badge to open the file in a new tab (or focus existing tab)
*/
const handleBadgeClick = (file: TFile) => {
void openFileInWorkspace(file);
openFileInWorkspace(file);
};
const uniqueNotes = React.useMemo(() => {

View file

@ -1,6 +1,6 @@
import { getCurrentProject, setCurrentProject, setProjectLoading, useChainType } from "@/aiParams";
import { ProjectContextCache } from "@/cache/projectContextCache";
import { ChainType } from "@/chainType";
import { ChainType } from "@/chainFactory";
import { ConfirmModal } from "@/components/modals/ConfirmModal";
import { Button } from "@/components/ui/button";
import { DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
@ -13,7 +13,6 @@ import { navigateToPlusPage, useIsPlusUser } from "@/plusUtils";
import { updateSetting, useSettingsValue } from "@/settings/model";
import { Docs4LLMParser } from "@/tools/FileParserManager";
import { isRateLimitError } from "@/utils/rateLimitUtils";
import { useApp } from "@/context";
import { DropdownMenu, DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
import {
AlertTriangle,
@ -29,7 +28,7 @@ import {
Sparkles,
SquareArrowOutUpRight,
} from "lucide-react";
import { App, Notice } from "obsidian";
import { Notice } from "obsidian";
import React from "react";
import {
ChatHistoryItem,
@ -38,21 +37,7 @@ import {
import { TokenCounter } from "./TokenCounter";
import { ChatSettingsPopover } from "@/components/chat-components/ChatSettingsPopover";
/** Minimal type for the internal Obsidian app with plugin access. */
interface CopilotPluginInternal {
projectManager?: {
getProjectContext: (id: string) => Promise<unknown>;
getCurrentChainManager: () => { createChainWithNewModel: () => Promise<void> };
};
}
interface ObsidianAppWithPlugins {
plugins: {
getPlugin: (id: string) => CopilotPluginInternal | undefined;
};
}
async function refreshVaultIndex() {
export async function refreshVaultIndex() {
try {
const { getSettings } = await import("@/settings/model");
const settings = getSettings();
@ -73,12 +58,12 @@ async function refreshVaultIndex() {
new Notice("Lexical search builds indexes on demand. No manual indexing required.");
}
} catch (error) {
logError("Error refreshing vault index:", error);
console.error("Error refreshing vault index:", error);
new Notice("Failed to refresh vault index. Check console for details.");
}
}
async function forceReindexVault() {
export async function forceReindexVault() {
try {
const { getSettings } = await import("@/settings/model");
const settings = getSettings();
@ -99,12 +84,12 @@ async function forceReindexVault() {
new Notice("Lexical search builds indexes on demand. No manual indexing required.");
}
} catch (error) {
logError("Error force reindexing vault:", error);
console.error("Error force reindexing vault:", error);
new Notice("Failed to force reindex vault. Check console for details.");
}
}
export async function reloadCurrentProject(app: App) {
export async function reloadCurrentProject() {
const currentProject = getCurrentProject();
if (!currentProject) {
new Notice("No project is currently selected to reload.");
@ -123,12 +108,9 @@ export async function reloadCurrentProject(app: App) {
// Then, trigger the full load and processing logic via ProjectManager.
// getProjectContext will call loadProjectContext if markdownNeedsReload is true (which it is now).
// loadProjectContext will handle markdown, web, youtube, and other file types (including API calls for new ones).
const plugin = (app as unknown as ObsidianAppWithPlugins).plugins.getPlugin("copilot");
const plugin = (app as any).plugins.getPlugin("copilot");
if (plugin && plugin.projectManager) {
await plugin.projectManager.getProjectContext(currentProject.id);
// Reason: chain/model config must be refreshed after context reload so the next
// user message uses the updated system prompt and model settings.
await plugin.projectManager.getCurrentChainManager().createChainWithNewModel();
new Notice(`Project context for "${currentProject.name}" reloaded successfully.`);
} else {
throw new Error("Copilot plugin or ProjectManager not available.");
@ -146,7 +128,7 @@ export async function reloadCurrentProject(app: App) {
}
}
async function forceRebuildCurrentProjectContext(app: App) {
export async function forceRebuildCurrentProjectContext() {
const currentProject = getCurrentProject();
if (!currentProject) {
new Notice("No project is currently selected to rebuild.");
@ -173,7 +155,7 @@ async function forceRebuildCurrentProjectContext(app: App) {
// Step 2: Trigger a full reload from scratch.
// getProjectContext will call loadProjectContext as the cache is now empty.
// loadProjectContext will handle markdown, web, youtube, and all other file types.
const plugin = (app as unknown as ObsidianAppWithPlugins).plugins.getPlugin("copilot");
const plugin = (app as any).plugins.getPlugin("copilot");
if (plugin && plugin.projectManager) {
await plugin.projectManager.getProjectContext(currentProject.id);
new Notice(
@ -228,7 +210,6 @@ export function ChatControls({
onOpenSourceFile,
latestTokenCount,
}: ChatControlsProps) {
const app = useApp();
const settings = useSettingsValue();
const [selectedChain, setSelectedChain] = useChainType();
const isPlusUser = useIsPlusUser();
@ -271,14 +252,14 @@ export function ChatControls({
<DropdownMenuContent align="start">
<DropdownMenuItem
onSelect={() => {
void handleModeChange(ChainType.LLM_CHAIN);
handleModeChange(ChainType.LLM_CHAIN);
}}
>
chat (free)
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void handleModeChange(ChainType.VAULT_QA_CHAIN);
handleModeChange(ChainType.VAULT_QA_CHAIN);
}}
>
vault QA (free)
@ -286,7 +267,7 @@ export function ChatControls({
{isPlusUser ? (
<DropdownMenuItem
onSelect={() => {
void handleModeChange(ChainType.COPILOT_PLUS_CHAIN);
handleModeChange(ChainType.COPILOT_PLUS_CHAIN);
}}
>
<div className="tw-flex tw-items-center tw-gap-1">
@ -310,7 +291,7 @@ export function ChatControls({
<DropdownMenuItem
className="tw-flex tw-items-center tw-gap-1"
onSelect={() => {
void handleModeChange(ChainType.PROJECT_CHAIN);
handleModeChange(ChainType.PROJECT_CHAIN);
}}
>
<LibraryBig className="tw-size-4" />
@ -346,12 +327,7 @@ export function ChatControls({
{!settings.autosaveChat && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost2"
size="icon"
title="Save Chat as Note"
onClick={() => void onSaveAsNote()}
>
<Button variant="ghost2" size="icon" title="Save Chat as Note" onClick={onSaveAsNote}>
<Download className="tw-size-4" />
</Button>
</TooltipTrigger>
@ -425,14 +401,14 @@ export function ChatControls({
<>
<DropdownMenuItem
className="tw-flex tw-items-center tw-gap-2"
onSelect={() => void reloadCurrentProject(app)}
onSelect={() => reloadCurrentProject()}
>
<RefreshCw className="tw-size-4" />
Reload Current Project
</DropdownMenuItem>
<DropdownMenuItem
className="tw-flex tw-items-center tw-gap-2"
onSelect={() => void forceRebuildCurrentProjectContext(app)}
onSelect={() => forceRebuildCurrentProjectContext()}
>
<AlertTriangle className="tw-size-4" />
Force Rebuild Context
@ -442,7 +418,7 @@ export function ChatControls({
<>
<DropdownMenuItem
className="tw-flex tw-items-center tw-gap-2"
onSelect={() => void refreshVaultIndex()}
onSelect={() => refreshVaultIndex()}
>
<RefreshCw className="tw-size-4" />
Refresh Vault Index

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