mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
Compare commits
85 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
632c1e81b3 | ||
|
|
bd8829f538 | ||
|
|
05a0d03fd9 | ||
|
|
f28ced14ff | ||
|
|
58edadefdb | ||
|
|
ba378e8389 | ||
|
|
e03234137d | ||
|
|
3f12c14444 | ||
|
|
475d5b9c18 | ||
|
|
de443ae5d9 | ||
|
|
5527942100 | ||
|
|
6a71cf8586 | ||
|
|
01a01e2951 | ||
|
|
19dd1aab40 | ||
|
|
0c9ce57dd5 | ||
|
|
adf8c68a73 | ||
|
|
a92012dc4d | ||
|
|
7b62cde598 | ||
|
|
efee65ca7a | ||
|
|
c8a6ad9374 | ||
|
|
34fa3c5507 | ||
|
|
13beaf133b | ||
|
|
6ca2dc01ea | ||
|
|
e1385d95f1 | ||
|
|
6ae2245830 | ||
|
|
98e8d0316e | ||
|
|
d6255374cb | ||
|
|
8239443814 | ||
|
|
7009925585 | ||
|
|
249f9e2472 | ||
|
|
dd74cf9654 | ||
|
|
1203a4dc84 | ||
|
|
7587b4fa4d | ||
|
|
280d95ebde | ||
|
|
500bc347a0 | ||
|
|
c5a990e48f | ||
|
|
020e24507c | ||
|
|
e3c5e24f1b | ||
|
|
4b592f6782 | ||
|
|
8658282aa9 | ||
|
|
f901343583 | ||
|
|
df6f455662 | ||
|
|
8499b85a1b | ||
|
|
3b8af4d341 | ||
|
|
bb5a166477 | ||
|
|
28cfff08ef | ||
|
|
fdb338110e | ||
|
|
20d98120e4 | ||
|
|
54d5c7bbfe | ||
|
|
ca885b03ac | ||
|
|
602f6d6ac0 | ||
|
|
cbe2ed771d | ||
|
|
eca1b081d5 | ||
|
|
9dba5ff02b | ||
|
|
2df1be0b4f | ||
|
|
f7bcac3d86 | ||
|
|
a529387533 | ||
|
|
08c14440dc | ||
|
|
f767d88038 | ||
|
|
c88b1e2e5b | ||
|
|
710acc2436 | ||
|
|
48794ce097 | ||
|
|
99b3b1e22a | ||
|
|
17739cafa0 | ||
|
|
bb013ef4bb | ||
|
|
15ac5844ed | ||
|
|
97165937bb | ||
|
|
858ce4c862 | ||
|
|
8b01ef746e | ||
|
|
6331631dd0 | ||
|
|
c67c4fc200 | ||
|
|
7619fb5578 | ||
|
|
e470b5c70c | ||
|
|
b7ad36624a | ||
|
|
2fb1552a49 | ||
|
|
8f35b396bc | ||
|
|
358b373953 | ||
|
|
38a23358cd | ||
|
|
e826889c11 | ||
|
|
baa2b954dc | ||
|
|
bfc7a4e017 | ||
|
|
c7a1d36b3b | ||
|
|
506de5477b | ||
|
|
94737297b7 | ||
|
|
37bea9a65c |
428 changed files with 22648 additions and 17515 deletions
258
.claude/agents/prerelease.md
Normal file
258
.claude/agents/prerelease.md
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
---
|
||||
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`.
|
||||
|
|
@ -19,6 +19,78 @@ 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:
|
||||
|
|
@ -159,8 +231,12 @@ 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 semver string** (e.g., `3.2.4`, not `v3.2.4` or `Release 3.2.4`)
|
||||
- **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.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
node_modules/
|
||||
|
||||
main.js
|
||||
52
.eslintrc
52
.eslintrc
|
|
@ -1,52 +0,0 @@
|
|||
{
|
||||
"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"],
|
||||
},
|
||||
},
|
||||
}
|
||||
118
.github/workflows/release.yml
vendored
118
.github/workflows/release.yml
vendored
|
|
@ -1,6 +1,9 @@
|
|||
# Release workflow: triggered when a PR targeting master is merged and its title
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# Non-semver PR titles (feature PRs, bug fixes, etc.) are silently ignored —
|
||||
# the workflow runs but exits early without creating a release.
|
||||
|
|
@ -21,28 +24,37 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write # required to create GitHub releases and tags
|
||||
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)
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
steps:
|
||||
# ── Step 1: Validate that the PR title is a strict semver ──────────────
|
||||
# ── Step 1: Validate that the PR title is a semver (stable or prerelease) ──
|
||||
# Exits without error when the title is not semver so non-release PRs
|
||||
# pass silently. Sets outputs.version and outputs.is_release for
|
||||
# downstream steps.
|
||||
# pass silently. Sets outputs.version, outputs.is_release, and
|
||||
# outputs.is_prerelease 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 valid semver — proceeding with release."
|
||||
echo "PR title '$PR_TITLE' is a stable 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 strict semver (X.Y.Z) — skipping release."
|
||||
echo "PR title '$PR_TITLE' is not semver (X.Y.Z or X.Y.Z-tag.N) — skipping release."
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# ── Step 2: Checkout the merge commit on master ─────────────────────────
|
||||
|
|
@ -54,18 +66,54 @@ 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 manifest.json ───────────────
|
||||
- name: Verify version matches manifest.json
|
||||
# ── 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
|
||||
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: |
|
||||
MANIFEST_VERSION=$(node -p "require('./manifest.json').version")
|
||||
if [ "$VERSION" != "$MANIFEST_VERSION" ]; then
|
||||
echo "Version mismatch: PR title='$VERSION', manifest.json='$MANIFEST_VERSION'"
|
||||
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
|
||||
echo "Version check passed: $VERSION"
|
||||
MANIFEST_VERSION=$(node -p "require('./$MANIFEST_FILE').version")
|
||||
if [ "$VERSION" != "$MANIFEST_VERSION" ]; then
|
||||
echo "Version mismatch: PR title='$VERSION', $MANIFEST_FILE='$MANIFEST_VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version check passed: $VERSION (verified against $MANIFEST_FILE)"
|
||||
|
||||
# ── Step 4: Set up Node.js ───────────────────────────────────────────────
|
||||
- name: Setup Node.js 22.x
|
||||
|
|
@ -109,20 +157,60 @@ jobs:
|
|||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# ── Step 9: Create GitHub Release ────────────────────────────────────────
|
||||
# ── 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 ───────────────────────────────────────
|
||||
# --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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
npx lint-staged
|
||||
npx nano-staged
|
||||
|
|
|
|||
112
AGENTS.md
112
AGENTS.md
|
|
@ -12,6 +12,7 @@ Copilot for Obsidian is an AI-powered assistant plugin that integrates various L
|
|||
|
||||
- **NEVER RUN `npm run dev`** - The user will handle all builds manually
|
||||
- `npm run build` - Production build (TypeScript check + minified output)
|
||||
- `npm run test:vault` - macOS only. Installs deps, builds, symlinks `main.js` / `manifest.json` / `styles.css` from the current worktree into `$COPILOT_TEST_VAULT_PATH/.obsidian/plugins/copilot/`, then reloads the plugin via the Obsidian CLI. Requires the user-level env var `COPILOT_TEST_VAULT_PATH` to be set to a vault that has been opened in Obsidian at least once. Use this when the user asks you to load the plugin into their test vault — it replaces manual build + copy + reload.
|
||||
|
||||
### Code Quality
|
||||
|
||||
|
|
@ -27,6 +28,38 @@ 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
|
||||
|
|
@ -191,7 +224,15 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE
|
|||
- `logWarn()` for warnings
|
||||
- `logError()` for errors
|
||||
- Import from logger: `import { logInfo, logWarn, logError } from "@/logger"`
|
||||
- These utilities already respect the debug flag internally — never wrap them in `if (getSettings().debug)`
|
||||
|
||||
### 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
|
||||
|
||||
|
|
@ -201,6 +242,17 @@ 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
|
||||
|
|
@ -262,10 +314,67 @@ 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
|
||||
|
|
@ -279,3 +388,4 @@ The TODO.md should be:
|
|||
- 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
360
CLAUDE.md
|
|
@ -1,361 +1,3 @@
|
|||
# CLAUDE.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
|
||||
@AGENTS.md
|
||||
|
|
@ -53,6 +53,63 @@ 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:
|
||||
|
|
|
|||
59
README.md
59
README.md
|
|
@ -53,24 +53,38 @@ 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)
|
||||
- [Need Help?](#need-help)
|
||||
- [FAQ](#faq)
|
||||
- [**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)
|
||||
|
||||
## Copilot V3 is a New Era 🔥
|
||||
## Copilot v4: Agent Mode, Reimagined 🚀
|
||||
|
||||
After months of hard work, we have revamped the codebase and adopted a new paradigm for our agentic infrastructure. It opens the door for easier addition of agentic tools (MCP support coming). We will provide a new version of the documentation soon. Here is a couple of new things that you cannot miss!
|
||||
Our biggest leap yet. **Copilot v4** lets you run the most capable coding agents available — **opencode**, **Claude Code**, or **Codex** — natively inside your vault, tuned for knowledge work and entirely on your terms. Bring your own agent, keep every note on your device, and let it plan, search, and act across your Second Brain. No lock-in, no compromise.
|
||||
|
||||
- FOR ALL USERS: You can do vault search out-of-the-box **without building an index first** (Indexing is still available but optional behind the "Semantic Search" toggle in QA settings).
|
||||
- FOR FREE USERS: Image support and chat context menu are available to all users starting from v3.0.0!
|
||||
- FOR PLUS USERS: **Autonomous agent** is available with vault search, web search, youtube, composer and soon a lot other tools! **Long-term memory** is also a tool the agent can use by itself starting from 3.1.0!
|
||||
**Join Supporter to experience the magic of Copilot v4 now!**
|
||||
|
||||
Read the [Changelog](https://github.com/logancyang/obsidian-copilot/releases/tag/3.0.0).
|
||||
👉 **[Discover Copilot v4 →](https://www.obsidiancopilot.com/v4)**
|
||||
|
||||
## Why People Love It ❤️
|
||||
|
||||
|
|
@ -101,35 +115,6 @@ Read the [Changelog](https://github.com/logancyang/obsidian-copilot/releases/tag
|
|||
|
||||
## 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**
|
||||
|
|
|
|||
215
RELEASES.md
215
RELEASES.md
|
|
@ -1,5 +1,220 @@
|
|||
# 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!
|
||||
|
|
|
|||
|
|
@ -1,8 +1,25 @@
|
|||
// __mocks__/obsidian.js
|
||||
/* eslint-disable no-undef */
|
||||
import yaml from "js-yaml";
|
||||
import { parse as parseYamlString } from "yaml";
|
||||
|
||||
// Per-test overrides set via the exported `__setRequestUrlImpl` helper.
|
||||
// Default: empty success response. Tests that exercise network paths should
|
||||
// install their own implementation.
|
||||
let requestUrlImpl = jest.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
text: "",
|
||||
json: undefined,
|
||||
arrayBuffer: new ArrayBuffer(0),
|
||||
headers: {},
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
// Reason: normalizePath is used by projectPaths.ts; identity function is sufficient for tests
|
||||
normalizePath: jest.fn().mockImplementation((p) => p),
|
||||
moment: jest.requireActual("moment"),
|
||||
requestUrl: (...args) => requestUrlImpl(...args),
|
||||
__setRequestUrlImpl: (impl) => {
|
||||
requestUrlImpl = impl;
|
||||
},
|
||||
Vault: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
getMarkdownFiles: jest.fn().mockImplementation(() => {
|
||||
|
|
@ -30,7 +47,7 @@ module.exports = {
|
|||
isDesktop: true,
|
||||
},
|
||||
parseYaml: jest.fn().mockImplementation((content) => {
|
||||
return yaml.load(content);
|
||||
return parseYamlString(content);
|
||||
}),
|
||||
Modal: class Modal {
|
||||
constructor() {
|
||||
|
|
@ -49,7 +66,7 @@ module.exports = {
|
|||
},
|
||||
})),
|
||||
ItemView: jest.fn().mockImplementation(function () {
|
||||
this.containerEl = document.createElement("div");
|
||||
this.containerEl = window.document.createElement("div");
|
||||
this.onOpen = jest.fn();
|
||||
this.onClose = jest.fn();
|
||||
this.getDisplayText = jest.fn().mockReturnValue("Mock View");
|
||||
|
|
@ -58,7 +75,7 @@ module.exports = {
|
|||
}),
|
||||
Notice: jest.fn().mockImplementation(function (message) {
|
||||
this.message = message;
|
||||
this.noticeEl = document.createElement("div");
|
||||
this.noticeEl = window.document.createElement("div");
|
||||
this.hide = jest.fn();
|
||||
}),
|
||||
TFile: jest.fn().mockImplementation(function (path) {
|
||||
|
|
@ -80,7 +97,7 @@ module.exports = {
|
|||
};
|
||||
|
||||
// Mock the global app object
|
||||
global.app = {
|
||||
window.app = {
|
||||
vault: {
|
||||
getAbstractFileByPath: jest.fn().mockReturnValue({
|
||||
name: "test-file.md",
|
||||
|
|
@ -101,4 +118,7 @@ global.app = {
|
|||
getFirstLinkpathDest: jest.fn().mockReturnValue(null),
|
||||
getFileCache: jest.fn().mockReturnValue(null),
|
||||
},
|
||||
fileManager: {
|
||||
trashFile: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ Access to Claude models (Opus, Sonnet, etc.).
|
|||
Access to Google's Gemini family of models.
|
||||
|
||||
- **Get a key**: https://makersuite.google.com/app/apikey
|
||||
- **Models include**: gemini-2.5-pro, gemini-2.5-flash, gemini-3-flash-preview, gemini-3.1-pro-preview
|
||||
- **Models include**: gemini-2.5-pro, gemini-2.5-flash, gemini-3.5-flash, gemini-3.1-pro-preview
|
||||
- **Setting key**: `googleApiKey`
|
||||
|
||||
### XAI / Grok
|
||||
|
|
@ -102,12 +102,12 @@ A Chinese AI cloud platform with access to DeepSeek and Qwen models.
|
|||
|
||||
Access to OpenAI models deployed on Microsoft Azure. Requires four fields to be configured:
|
||||
|
||||
| Setting | Description |
|
||||
|---|---|
|
||||
| API Key | Your Azure OpenAI key |
|
||||
| Instance Name | Your Azure resource name |
|
||||
| Setting | Description |
|
||||
| --------------- | -------------------------- |
|
||||
| API Key | Your Azure OpenAI key |
|
||||
| Instance Name | Your Azure resource name |
|
||||
| Deployment Name | Your model deployment name |
|
||||
| API Version | e.g., `2024-02-01` |
|
||||
| API Version | e.g., `2024-02-01` |
|
||||
|
||||
- **Note**: Unlike other providers, Azure OpenAI uses your own Azure deployment
|
||||
- **Embedding**: Can also use Azure for embeddings (separate deployment name required)
|
||||
|
|
@ -121,6 +121,7 @@ Access to models hosted on AWS Bedrock.
|
|||
- **Setting key**: `amazonBedrockApiKey`
|
||||
|
||||
**Important**: Always use cross-region inference profile IDs, not bare model IDs. For example:
|
||||
|
||||
- Use: `us.anthropic.claude-sonnet-4-5-20250929-v1:0`
|
||||
- Not: `anthropic.claude-sonnet-4-5-20250929-v1:0`
|
||||
|
||||
|
|
@ -171,14 +172,14 @@ For any API that follows the OpenAI API format. Useful for custom deployments, p
|
|||
|
||||
## Provider-Specific Gotchas
|
||||
|
||||
| Provider | Common Issue | Fix |
|
||||
|---|---|---|
|
||||
| Azure OpenAI | Missing one of four required fields | Check all four settings: key, instance name, deployment name, API version |
|
||||
| Amazon Bedrock | Rate limit or model not found | Use cross-region inference profile IDs with `us.`, `eu.`, `apac.`, or `global.` prefix |
|
||||
| GitHub Copilot | Token expired | Re-authenticate via the OAuth button in API key dialog |
|
||||
| Ollama | Connection refused | Make sure Ollama is running (`ollama serve`) and the port is correct |
|
||||
| Google Gemini | Quota exceeded | Use a different model or check your quota at console.cloud.google.com |
|
||||
| DeepSeek | Streaming errors | Try disabling streaming in the per-session settings if you encounter issues |
|
||||
| Provider | Common Issue | Fix |
|
||||
| -------------- | ----------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Azure OpenAI | Missing one of four required fields | Check all four settings: key, instance name, deployment name, API version |
|
||||
| Amazon Bedrock | Rate limit or model not found | Use cross-region inference profile IDs with `us.`, `eu.`, `apac.`, or `global.` prefix |
|
||||
| GitHub Copilot | Token expired | Re-authenticate via the OAuth button in API key dialog |
|
||||
| Ollama | Connection refused | Make sure Ollama is running (`ollama serve`) and the port is correct |
|
||||
| Google Gemini | Quota exceeded | Use a different model or check your quota at console.cloud.google.com |
|
||||
| DeepSeek | Streaming errors | Try disabling streaming in the per-session settings if you encounter issues |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -10,27 +10,27 @@ This guide explains how to manage chat models, embedding models, and the paramet
|
|||
|
||||
Copilot comes with a set of built-in models across many providers. Some are always included ("core" models); others can be enabled or disabled.
|
||||
|
||||
| Model | Provider | Capabilities |
|
||||
|---|---|---|
|
||||
| copilot-plus-flash | Copilot Plus | Vision (Plus exclusive) |
|
||||
| google/gemini-2.5-flash | OpenRouter | Vision |
|
||||
| google/gemini-2.5-pro | OpenRouter | Vision |
|
||||
| google/gemini-3-flash-preview | OpenRouter | Vision, Reasoning |
|
||||
| google/gemini-3.1-pro-preview | OpenRouter | Vision, Reasoning |
|
||||
| openai/gpt-5.4 | OpenRouter | Vision |
|
||||
| openai/gpt-5-mini | OpenRouter | Vision |
|
||||
| gpt-5.4 | OpenAI | Vision |
|
||||
| gpt-5-mini | OpenAI | Vision |
|
||||
| gpt-4.1 | OpenAI | Vision |
|
||||
| gpt-4.1-mini | OpenAI | Vision |
|
||||
| claude-opus-4-6 | Anthropic | Vision, Reasoning |
|
||||
| claude-sonnet-4-5-20250929 | Anthropic | Vision, Reasoning |
|
||||
| gemini-2.5-pro | Google | Vision |
|
||||
| gemini-2.5-flash | Google | Vision |
|
||||
| gemini-3-flash-preview | Google | Vision, Reasoning |
|
||||
| grok-4-1-fast | XAI | Vision |
|
||||
| deepseek-chat | DeepSeek | — |
|
||||
| deepseek-reasoner | DeepSeek | Reasoning |
|
||||
| Model | Provider | Capabilities |
|
||||
| ----------------------------- | ------------ | ----------------------- |
|
||||
| copilot-plus-flash | Copilot Plus | Vision (Plus exclusive) |
|
||||
| google/gemini-2.5-flash | OpenRouter | Vision |
|
||||
| google/gemini-2.5-pro | OpenRouter | Vision |
|
||||
| google/gemini-3.5-flash | OpenRouter | Vision, Reasoning |
|
||||
| google/gemini-3.1-pro-preview | OpenRouter | Vision, Reasoning |
|
||||
| openai/gpt-5.4 | OpenRouter | Vision |
|
||||
| openai/gpt-5-mini | OpenRouter | Vision |
|
||||
| gpt-5.4 | OpenAI | Vision |
|
||||
| gpt-5-mini | OpenAI | Vision |
|
||||
| gpt-4.1 | OpenAI | Vision |
|
||||
| gpt-4.1-mini | OpenAI | Vision |
|
||||
| claude-opus-4-6 | Anthropic | Vision, Reasoning |
|
||||
| claude-sonnet-4-5-20250929 | Anthropic | Vision, Reasoning |
|
||||
| gemini-2.5-pro | Google | Vision |
|
||||
| gemini-2.5-flash | Google | Vision |
|
||||
| gemini-3.5-flash | Google | Vision, Reasoning |
|
||||
| grok-4-1-fast | XAI | Vision |
|
||||
| deepseek-chat | DeepSeek | — |
|
||||
| deepseek-reasoner | DeepSeek | Reasoning |
|
||||
|
||||
### Model Capability Badges
|
||||
|
||||
|
|
@ -75,18 +75,18 @@ Embedding models convert text into numerical vectors, which powers semantic (mea
|
|||
|
||||
### Built-In Embedding Models
|
||||
|
||||
| Model | Provider |
|
||||
|---|---|
|
||||
| copilot-plus-small | Copilot Plus (Plus exclusive) |
|
||||
| copilot-plus-large | Copilot Plus (Believer exclusive) |
|
||||
| copilot-plus-multilingual | Copilot Plus (Plus exclusive) |
|
||||
| openai/text-embedding-3-small | OpenRouter |
|
||||
| text-embedding-3-small | OpenAI |
|
||||
| text-embedding-3-large | OpenAI |
|
||||
| embed-multilingual-light-v3.0 | Cohere |
|
||||
| text-embedding-004 | Google |
|
||||
| gemini-embedding-001 | Google |
|
||||
| Qwen3-Embedding-0.6B | SiliconFlow |
|
||||
| Model | Provider |
|
||||
| ----------------------------- | --------------------------------- |
|
||||
| copilot-plus-small | Copilot Plus (Plus exclusive) |
|
||||
| copilot-plus-large | Copilot Plus (Believer exclusive) |
|
||||
| copilot-plus-multilingual | Copilot Plus (Plus exclusive) |
|
||||
| openai/text-embedding-3-small | OpenRouter |
|
||||
| text-embedding-3-small | OpenAI |
|
||||
| text-embedding-3-large | OpenAI |
|
||||
| embed-multilingual-light-v3.0 | Cohere |
|
||||
| text-embedding-004 | Google |
|
||||
| gemini-embedding-001 | Google |
|
||||
| Qwen3-Embedding-0.6B | SiliconFlow |
|
||||
|
||||
### Selecting an Embedding Model
|
||||
|
||||
|
|
|
|||
|
|
@ -189,15 +189,16 @@ 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 will delete all your settings including API keys. Back them up first.
|
||||
⚠️ 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.
|
||||
|
||||
### API Key Encryption
|
||||
### API Key Storage
|
||||
|
||||
Copilot can encrypt your API keys at rest for added security.
|
||||
Copilot has two ways to store API keys:
|
||||
|
||||
**Enable**: **Settings → Copilot → Advanced → Enable Encryption**
|
||||
- **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.
|
||||
|
||||
If you see strange authentication errors after enabling this, try disabling encryption and re-entering your keys.
|
||||
The Obsidian Keychain is per device. If you sync your vault to another device, you may need to re-enter API keys there.
|
||||
|
||||
### Debug Mode and Logs
|
||||
|
||||
|
|
|
|||
279
eslint.config.mjs
Normal file
279
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
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",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -6,9 +6,12 @@ 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"],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,24 @@
|
|||
import "web-streams-polyfill/dist/polyfill.min.js";
|
||||
import { TextEncoder, TextDecoder } from "util";
|
||||
|
||||
global.TextEncoder = TextEncoder;
|
||||
global.TextDecoder = TextDecoder;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
7
knip.json
Normal file
7
knip.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"entry": ["src/main.ts", "scripts/printPromptDebug.js", "scripts/printPromptDebugEntry.ts"],
|
||||
"project": ["src/**/*.{ts,tsx,js,jsx}", "scripts/**/*.{ts,js,mjs}"],
|
||||
"ignore": ["src/styles/tailwind.css", "src/integration_tests/**"],
|
||||
"ignoreDependencies": ["buffer"]
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
{
|
||||
"id": "copilot",
|
||||
"name": "Copilot",
|
||||
"version": "3.2.8",
|
||||
"minAppVersion": "0.15.0",
|
||||
"version": "3.3.3",
|
||||
"minAppVersion": "1.11.4",
|
||||
"isDesktopOnly": false,
|
||||
"description": "Your AI Copilot: Chat with Your Second Brain, Learn Faster, Work Smarter.",
|
||||
"author": "Logan Yang",
|
||||
"authorUrl": "https://twitter.com/logancyang",
|
||||
|
|
|
|||
11858
package-lock.json
generated
11858
package-lock.json
generated
File diff suppressed because it is too large
Load diff
88
package.json
88
package.json
|
|
@ -1,28 +1,30 @@
|
|||
{
|
||||
"name": "obsidian-copilot",
|
||||
"version": "3.2.8",
|
||||
"version": "3.3.3",
|
||||
"description": "Your AI Copilot: Chat with Your Second Brain, Learn Faster, Work Smarter.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "npm-run-all --parallel dev:*",
|
||||
"dev": "run-p 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 . --ext .js,.jsx,.ts,.tsx",
|
||||
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
|
||||
"lint": "eslint .",
|
||||
"lint:dead": "npx --yes knip@5",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write 'src/**/*.{js,ts,tsx,md}'",
|
||||
"format:check": "prettier --check 'src/**/*.{js,ts,tsx,md}'",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"version": "node version-bump.mjs",
|
||||
"test": "jest --testPathIgnorePatterns=src/integration_tests/",
|
||||
"test:integration": "jest src/integration_tests/",
|
||||
"prepare": "husky",
|
||||
"prompt:debug": "node scripts/printPromptDebug.js"
|
||||
"prompt:debug": "node scripts/printPromptDebug.js",
|
||||
"test:vault": "bash scripts/test-vault.sh"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Logan Yang",
|
||||
"lint-staged": {
|
||||
"nano-staged": {
|
||||
"*.{js,jsx,ts,tsx,json,css,md}": [
|
||||
"prettier --write"
|
||||
],
|
||||
|
|
@ -32,70 +34,59 @@
|
|||
},
|
||||
"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",
|
||||
"@typescript-eslint/eslint-plugin": "^8.19.1",
|
||||
"@typescript-eslint/parser": "^8.19.1",
|
||||
"builtin-modules": "3.3.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"electron": "^27.3.2",
|
||||
"esbuild": "^0.25.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-json": "^4.0.1",
|
||||
"eslint-plugin-react": "^7.37.3",
|
||||
"esbuild-plugin-svg": "^0.1.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-plugin-obsidianmd": "^0.3.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-tailwindcss": "^3.18.0",
|
||||
"globals": "^15.14.0",
|
||||
"husky": "^9.1.5",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.5.0",
|
||||
"lint-staged": "^15.2.9",
|
||||
"node-fetch": "^2.7.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"nano-staged": "^0.8.0",
|
||||
"npm-run-all2": "^7.0.2",
|
||||
"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"
|
||||
"web-streams-polyfill": "^3.3.2",
|
||||
"yaml": "^2.6.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@google/generative-ai": "^0.24.0",
|
||||
"@huggingface/inference": "^4.11.3",
|
||||
"@koa/cors": "^5.0.0",
|
||||
"@langchain/anthropic": "^1.0.0",
|
||||
"@langchain/cohere": "^1.0.0",
|
||||
"@langchain/community": "^1.0.0",
|
||||
"@langchain/anthropic": "^1.3.29",
|
||||
"@langchain/classic": "^1.0.9",
|
||||
"@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",
|
||||
|
|
@ -109,45 +100,26 @@
|
|||
"@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",
|
||||
"next-i18next": "^13.2.2",
|
||||
"p-queue": "^8.1.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"openai": "^6.10.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^3.0.2",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"sse": "github:mpetazzoni/sse.js",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"trie-search": "^2.2.0",
|
||||
"turndown": "^7.2.2"
|
||||
"turndown": "^7.2.2",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
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";
|
||||
|
|
@ -6,6 +7,8 @@ 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: {
|
||||
|
|
@ -94,7 +97,8 @@ export async function run(args: string[]): Promise<void> {
|
|||
}
|
||||
|
||||
const app = createHeadlessApp();
|
||||
(globalThis as any).app = app;
|
||||
// eslint-disable-next-line obsidianmd/no-global-this -- node-only debug script, no window available
|
||||
(global as unknown as { app: unknown }).app = app;
|
||||
|
||||
initializeBuiltinTools();
|
||||
|
||||
|
|
@ -109,13 +113,15 @@ export async function run(args: string[]): Promise<void> {
|
|||
.join("\n");
|
||||
|
||||
const memoryManager = MemoryManager.getInstance();
|
||||
const userMemoryManager = new UserMemoryManager(app as any);
|
||||
const userMemoryManager = new UserMemoryManager(app as unknown as App);
|
||||
const chainContext = {
|
||||
memoryManager,
|
||||
userMemoryManager,
|
||||
} as any;
|
||||
} as unknown as ChainManager;
|
||||
|
||||
const adapter = ModelAdapterFactory.createAdapter({ modelName: "gpt-4" } as any);
|
||||
const adapter = ModelAdapterFactory.createAdapter({
|
||||
modelName: "gpt-4",
|
||||
} as unknown as BaseChatModel);
|
||||
const report = await buildAgentPromptDebugReport({
|
||||
chainManager: chainContext,
|
||||
adapter,
|
||||
|
|
|
|||
|
|
@ -88,6 +88,6 @@ export class Modal {
|
|||
}
|
||||
}
|
||||
|
||||
export function parseYaml(_: string): any {
|
||||
export function parseYaml(_: string): unknown {
|
||||
return {};
|
||||
}
|
||||
|
|
|
|||
91
scripts/test-vault.sh
Executable file
91
scripts/test-vault.sh
Executable file
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OBSIDIAN_BIN="/Applications/Obsidian.app/Contents/MacOS/obsidian"
|
||||
|
||||
if [[ -z "${COPILOT_TEST_VAULT_PATH:-}" ]]; then
|
||||
cat >&2 <<'EOF'
|
||||
error: COPILOT_TEST_VAULT_PATH is not set.
|
||||
|
||||
Set it once at the user level (e.g. in ~/.zshrc or ~/.config/fish/config.fish)
|
||||
to the absolute path of an Obsidian vault you've opened at least once:
|
||||
|
||||
export COPILOT_TEST_VAULT_PATH="$HOME/Obsidian/CopilotTestVault"
|
||||
|
||||
Then re-run: npm run test:vault
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VAULT_PATH="$COPILOT_TEST_VAULT_PATH"
|
||||
|
||||
if [[ ! -d "$VAULT_PATH" ]]; then
|
||||
echo "error: vault directory not found: $VAULT_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$VAULT_PATH/.obsidian" ]]; then
|
||||
echo "error: $VAULT_PATH has no .obsidian/ folder." >&2
|
||||
echo "Open the folder as a vault in Obsidian once, then re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORKTREE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$WORKTREE_ROOT"
|
||||
|
||||
echo "==> Installing dependencies"
|
||||
npm install --prefer-offline --no-audit --no-fund
|
||||
|
||||
echo "==> Building plugin"
|
||||
npm run build
|
||||
|
||||
PLUGIN_ID="$(node -p "require('./manifest.json').id")"
|
||||
if [[ -z "$PLUGIN_ID" ]]; then
|
||||
echo "error: could not read plugin id from manifest.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PLUGIN_DIR="$VAULT_PATH/.obsidian/plugins/$PLUGIN_ID"
|
||||
mkdir -p "$PLUGIN_DIR"
|
||||
|
||||
echo "==> Linking artifacts into $PLUGIN_DIR"
|
||||
for f in main.js styles.css; do
|
||||
if [[ ! -f "$WORKTREE_ROOT/$f" ]]; then
|
||||
echo "error: expected build artifact missing: $WORKTREE_ROOT/$f" >&2
|
||||
exit 1
|
||||
fi
|
||||
ln -sfn "$WORKTREE_ROOT/$f" "$PLUGIN_DIR/$f"
|
||||
done
|
||||
|
||||
# Write a branch- and timestamp-tagged manifest.json (real file, not a symlink)
|
||||
# so Obsidian's Community plugins list visibly reflects which worktree/branch
|
||||
# is loaded and when this build was deployed.
|
||||
BRANCH="$(git -C "$WORKTREE_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
|
||||
BUILD_TS="$(date +%Y%m%d-%H%M%S)"
|
||||
echo "==> Writing branch-tagged manifest.json (branch: $BRANCH, build: $BUILD_TS)"
|
||||
rm -f "$PLUGIN_DIR/manifest.json"
|
||||
SRC="$WORKTREE_ROOT/manifest.json" DEST="$PLUGIN_DIR/manifest.json" BRANCH="$BRANCH" BUILD_TS="$BUILD_TS" node -e '
|
||||
const fs = require("fs");
|
||||
const m = JSON.parse(fs.readFileSync(process.env.SRC, "utf8"));
|
||||
m.name = m.name + " [" + process.env.BRANCH + " @ " + process.env.BUILD_TS + "]";
|
||||
m.description = "[branch: " + process.env.BRANCH + " | build: " + process.env.BUILD_TS + "] " + m.description;
|
||||
fs.writeFileSync(process.env.DEST, JSON.stringify(m, null, 2) + "\n");
|
||||
'
|
||||
|
||||
echo "==> Reloading plugin in Obsidian"
|
||||
if [[ ! -x "$OBSIDIAN_BIN" ]]; then
|
||||
echo "warning: Obsidian CLI not found at $OBSIDIAN_BIN; skipping reload." >&2
|
||||
else
|
||||
if ! "$OBSIDIAN_BIN" plugin:enable id="$PLUGIN_ID" >/dev/null 2>&1 \
|
||||
|| ! "$OBSIDIAN_BIN" plugin:reload id="$PLUGIN_ID" >/dev/null 2>&1; then
|
||||
echo "warning: Obsidian doesn't appear to be running. Start it and the symlinked plugin will load on next open." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Done."
|
||||
echo " worktree: $WORKTREE_ROOT"
|
||||
echo " branch: $BRANCH"
|
||||
echo " build: $BUILD_TS"
|
||||
echo " vault: $VAULT_PATH"
|
||||
echo " plugin: $PLUGIN_ID"
|
||||
|
|
@ -1,5 +1,61 @@
|
|||
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.
|
||||
|
|
@ -23,18 +79,38 @@ const buildEventStreamChunk = (payload: string): string => {
|
|||
return Buffer.from(buffer).toString("base64");
|
||||
};
|
||||
|
||||
const createModel = (enableThinking = false): BedrockChatModel =>
|
||||
const createModel = (
|
||||
enableThinking = false,
|
||||
modelId = "anthropic.claude-3-haiku-20240307-v1:0"
|
||||
): BedrockChatModel =>
|
||||
new BedrockChatModel({
|
||||
modelId: "anthropic.claude-3-haiku-20240307-v1:0",
|
||||
modelId,
|
||||
apiKey: "test-key",
|
||||
endpoint: "https://example.com/model/anthropic.claude-3-haiku-20240307-v1%3A0/invoke",
|
||||
streamEndpoint:
|
||||
"https://example.com/model/anthropic.claude-3-haiku-20240307-v1%3A0/invoke-with-response-stream",
|
||||
endpoint: `https://example.com/model/${encodeURIComponent(modelId)}/invoke`,
|
||||
streamEndpoint: `https://example.com/model/${encodeURIComponent(modelId)}/invoke-with-response-stream`,
|
||||
anthropicVersion: "bedrock-2023-05-31",
|
||||
enableThinking,
|
||||
fetchImplementation: jest.fn(),
|
||||
});
|
||||
|
||||
const createModelWithFetch = (
|
||||
fetchMock: jest.Mock,
|
||||
opts?: { modelId?: string; noStream?: boolean }
|
||||
): BedrockChatModel =>
|
||||
new BedrockChatModel({
|
||||
modelId: opts?.modelId ?? "anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
apiKey: "test-key",
|
||||
endpoint: "https://example.com/model/anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke",
|
||||
...(opts?.noStream
|
||||
? {}
|
||||
: {
|
||||
streamEndpoint:
|
||||
"https://example.com/model/anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke-with-response-stream",
|
||||
}),
|
||||
anthropicVersion: "bedrock-2023-05-31",
|
||||
fetchImplementation: fetchMock,
|
||||
});
|
||||
|
||||
describe("BedrockChatModel streaming decode", () => {
|
||||
it("decodes simple base64 JSON payloads", () => {
|
||||
const payload = JSON.stringify({
|
||||
|
|
@ -48,7 +124,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
const base64 = Buffer.from(payload, "utf-8").toString("base64");
|
||||
|
||||
const model = createModel();
|
||||
const decoded = (model as any).decodeChunkBytes(base64);
|
||||
const decoded = asInternal(model).decodeChunkBytes(base64);
|
||||
|
||||
expect(decoded).toEqual([payload]);
|
||||
});
|
||||
|
|
@ -64,7 +140,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
|
||||
const base64 = buildEventStreamChunk(payload);
|
||||
const model = createModel();
|
||||
const decoded = (model as any).decodeChunkBytes(base64);
|
||||
const decoded = asInternal(model).decodeChunkBytes(base64);
|
||||
|
||||
expect(decoded).toEqual([payload]);
|
||||
});
|
||||
|
|
@ -85,7 +161,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
};
|
||||
|
||||
const model = createModel();
|
||||
const processed = await (model as any).processStreamEvent(
|
||||
const processed = await asInternal(model).processStreamEvent(
|
||||
event,
|
||||
undefined,
|
||||
undefined,
|
||||
|
|
@ -111,10 +187,10 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
};
|
||||
|
||||
const model = createModel();
|
||||
const contentItems = (model as any).buildContentItemsFromDelta(event);
|
||||
const contentItems = asInternal(model).buildContentItemsFromDelta(event);
|
||||
|
||||
expect(contentItems).toHaveLength(1);
|
||||
expect(contentItems[0]).toEqual({
|
||||
expect(contentItems![0]).toEqual({
|
||||
type: "thinking",
|
||||
thinking: "Let me analyze this problem...",
|
||||
});
|
||||
|
|
@ -133,10 +209,10 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
};
|
||||
|
||||
const model = createModel();
|
||||
const contentItems = (model as any).buildContentItemsFromDelta(event);
|
||||
const contentItems = asInternal(model).buildContentItemsFromDelta(event);
|
||||
|
||||
expect(contentItems).toHaveLength(1);
|
||||
expect(contentItems[0]).toEqual({
|
||||
expect(contentItems![0]).toEqual({
|
||||
type: "text",
|
||||
text: "Based on my analysis, the answer is...",
|
||||
});
|
||||
|
|
@ -161,7 +237,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
};
|
||||
|
||||
const model = createModel();
|
||||
const processed = await (model as any).processStreamEvent(
|
||||
const processed = await asInternal(model).processStreamEvent(
|
||||
event,
|
||||
undefined,
|
||||
undefined,
|
||||
|
|
@ -175,7 +251,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 any[];
|
||||
const content = chunk?.message.content as unknown[];
|
||||
expect(content).toHaveLength(1);
|
||||
expect(content[0]).toEqual({
|
||||
type: "thinking",
|
||||
|
|
@ -208,7 +284,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
};
|
||||
|
||||
const model = createModel();
|
||||
const processed = await (model as any).processStreamEvent(
|
||||
const processed = await asInternal(model).processStreamEvent(
|
||||
event,
|
||||
undefined,
|
||||
undefined,
|
||||
|
|
@ -222,7 +298,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 any[];
|
||||
const content = chunk?.message.content as unknown[];
|
||||
expect(content).toHaveLength(1);
|
||||
expect(content[0]).toEqual({
|
||||
type: "text",
|
||||
|
|
@ -254,7 +330,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
chunk: { bytes: thinkingBase64 },
|
||||
};
|
||||
|
||||
const thinkingResult = await (model as any).processStreamEvent(
|
||||
const thinkingResult = await asInternal(model).processStreamEvent(
|
||||
thinkingEvent,
|
||||
undefined,
|
||||
undefined,
|
||||
|
|
@ -263,7 +339,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
|
||||
expect(thinkingResult.deltaChunks).toHaveLength(1);
|
||||
const thinkingChunk = thinkingResult.deltaChunks[0];
|
||||
expect((thinkingChunk?.message.content as any[])[0]?.type).toBe("thinking");
|
||||
expect((thinkingChunk?.message.content as Array<{ type: string }>)[0]?.type).toBe("thinking");
|
||||
|
||||
// Second chunk: text
|
||||
const textPayload = JSON.stringify({
|
||||
|
|
@ -283,7 +359,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
chunk: { bytes: textBase64 },
|
||||
};
|
||||
|
||||
const textResult = await (model as any).processStreamEvent(
|
||||
const textResult = await asInternal(model).processStreamEvent(
|
||||
textEvent,
|
||||
undefined,
|
||||
undefined,
|
||||
|
|
@ -292,7 +368,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
|
||||
expect(textResult.deltaChunks).toHaveLength(1);
|
||||
const textChunk = textResult.deltaChunks[0];
|
||||
expect((textChunk?.message.content as any[])[0]?.type).toBe("text");
|
||||
expect((textChunk?.message.content as Array<{ type: string }>)[0]?.type).toBe("text");
|
||||
});
|
||||
|
||||
it("extractStreamText can fallback to extract thinking content", () => {
|
||||
|
|
@ -307,7 +383,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
};
|
||||
|
||||
const model = createModel();
|
||||
const extracted = (model as any).extractStreamText(event);
|
||||
const extracted = asInternal(model).extractStreamText(event);
|
||||
|
||||
expect(extracted).toBe("Fallback thinking extraction");
|
||||
});
|
||||
|
|
@ -324,10 +400,10 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
};
|
||||
|
||||
const model = createModel();
|
||||
const contentItems = (model as any).buildContentItemsFromDelta(event);
|
||||
const contentItems = asInternal(model).buildContentItemsFromDelta(event);
|
||||
|
||||
expect(contentItems).toHaveLength(1);
|
||||
expect(contentItems[0]).toEqual({
|
||||
expect(contentItems![0]).toEqual({
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
});
|
||||
|
|
@ -337,8 +413,8 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
describe("thinking mode enablement", () => {
|
||||
it("includes thinking parameter when enableThinking is true", () => {
|
||||
const model = createModel(true);
|
||||
const requestBody = (model as any).buildRequestBody([
|
||||
{ role: "user", content: "test", _getType: () => "human" },
|
||||
const requestBody = asInternal(model).buildRequestBody([
|
||||
{ role: "user", content: "test", getType: () => "human" },
|
||||
]);
|
||||
|
||||
expect(requestBody.thinking).toEqual({
|
||||
|
|
@ -351,8 +427,8 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
|
||||
it("does not include thinking parameter when enableThinking is false", () => {
|
||||
const model = createModel(false);
|
||||
const requestBody = (model as any).buildRequestBody(
|
||||
[{ role: "user", content: "test", _getType: () => "human" }],
|
||||
const requestBody = asInternal(model).buildRequestBody(
|
||||
[{ role: "user", content: "test", getType: () => "human" }],
|
||||
{ temperature: 0.7 }
|
||||
);
|
||||
|
||||
|
|
@ -364,8 +440,8 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
|
||||
it("respects user temperature when thinking is disabled", () => {
|
||||
const model = createModel(false);
|
||||
const requestBody = (model as any).buildRequestBody(
|
||||
[{ role: "user", content: "test", _getType: () => "human" }],
|
||||
const requestBody = asInternal(model).buildRequestBody(
|
||||
[{ role: "user", content: "test", getType: () => "human" }],
|
||||
{ temperature: 0.5 }
|
||||
);
|
||||
|
||||
|
|
@ -375,14 +451,76 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
|
||||
it("forces temperature to 1 when thinking is enabled", () => {
|
||||
const model = createModel(true);
|
||||
const requestBody = (model as any).buildRequestBody(
|
||||
[{ role: "user", content: "test", _getType: () => "human" }],
|
||||
const requestBody = asInternal(model).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", () => {
|
||||
|
|
@ -390,7 +528,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 = (model as any).convertImageContent(dataUrl);
|
||||
const result = asInternal(model).convertImageContent(dataUrl);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "image",
|
||||
|
|
@ -405,7 +543,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
it("handles PNG images", () => {
|
||||
const model = createModel();
|
||||
const dataUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
|
||||
const result = (model as any).convertImageContent(dataUrl);
|
||||
const result = asInternal(model).convertImageContent(dataUrl);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "image",
|
||||
|
|
@ -420,7 +558,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
it("returns null for invalid data URL format", () => {
|
||||
const model = createModel();
|
||||
const invalidUrl = "not-a-data-url";
|
||||
const result = (model as any).convertImageContent(invalidUrl);
|
||||
const result = asInternal(model).convertImageContent(invalidUrl);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
|
@ -428,7 +566,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 = (model as any).convertImageContent(dataUrl);
|
||||
const result = asInternal(model).convertImageContent(dataUrl);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
|
@ -445,10 +583,10 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
image_url: { url: "data:image/jpeg;base64,/9j/4AAQSkZJRg==" },
|
||||
},
|
||||
],
|
||||
_getType: () => "human",
|
||||
getType: () => "human",
|
||||
};
|
||||
|
||||
const result = (model as any).normaliseMessageContent(message);
|
||||
const result = asInternal(model).normaliseMessageContent(message);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(2);
|
||||
|
|
@ -466,10 +604,10 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
{ type: "text", text: "Hello " },
|
||||
{ type: "text", text: "world!" },
|
||||
],
|
||||
_getType: () => "human",
|
||||
getType: () => "human",
|
||||
};
|
||||
|
||||
const result = (model as any).normaliseMessageContent(message);
|
||||
const result = asInternal(model).normaliseMessageContent(message);
|
||||
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).toBe("Hello world!");
|
||||
|
|
@ -479,10 +617,10 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
const model = createModel();
|
||||
const message = {
|
||||
content: "Simple text message",
|
||||
_getType: () => "human",
|
||||
getType: () => "human",
|
||||
};
|
||||
|
||||
const result = (model as any).normaliseMessageContent(message);
|
||||
const result = asInternal(model).normaliseMessageContent(message);
|
||||
|
||||
expect(result).toBe("Simple text message");
|
||||
});
|
||||
|
|
@ -500,11 +638,11 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
image_url: { url: "data:image/jpeg;base64,/9j/4AAQSkZJRg==" },
|
||||
},
|
||||
],
|
||||
_getType: () => "human",
|
||||
getType: () => "human",
|
||||
},
|
||||
];
|
||||
|
||||
const requestBody = (model as any).buildRequestBody(messages);
|
||||
const requestBody = asInternal(model).buildRequestBody(messages);
|
||||
|
||||
expect(requestBody.messages).toHaveLength(1);
|
||||
expect(requestBody.messages[0].content).toHaveLength(2);
|
||||
|
|
@ -541,18 +679,18 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
image_url: { url: "data:image/png;base64,IMAGE2DATA" },
|
||||
},
|
||||
],
|
||||
_getType: () => "human",
|
||||
getType: () => "human",
|
||||
},
|
||||
];
|
||||
|
||||
const requestBody = (model as any).buildRequestBody(messages);
|
||||
const requestBody = asInternal(model).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", () => {
|
||||
|
|
@ -560,11 +698,11 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
const messages = [
|
||||
{
|
||||
content: "Just text, no images",
|
||||
_getType: () => "human",
|
||||
getType: () => "human",
|
||||
},
|
||||
];
|
||||
|
||||
const requestBody = (model as any).buildRequestBody(messages);
|
||||
const requestBody = asInternal(model).buildRequestBody(messages);
|
||||
|
||||
expect(requestBody.messages).toHaveLength(1);
|
||||
expect(requestBody.messages[0].content).toHaveLength(1);
|
||||
|
|
@ -589,11 +727,11 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
image_url: { url: "data:image/jpeg;base64,VALIDDATA" }, // Valid
|
||||
},
|
||||
],
|
||||
_getType: () => "human",
|
||||
getType: () => "human",
|
||||
},
|
||||
];
|
||||
|
||||
const requestBody = (model as any).buildRequestBody(messages);
|
||||
const requestBody = asInternal(model).buildRequestBody(messages);
|
||||
|
||||
// Should have text + 1 valid image (invalid one skipped)
|
||||
expect(requestBody.messages[0].content).toHaveLength(2);
|
||||
|
|
@ -603,3 +741,94 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("BedrockChatModel inference-profile error rewriting", () => {
|
||||
const awsInferenceProfileError = JSON.stringify({
|
||||
message:
|
||||
"Invocation of model ID anthropic.claude-sonnet-4-5 with on-demand throughput isn't supported. Retry your request with the ID or ARN of an inference profile that contains this model.",
|
||||
});
|
||||
|
||||
const makeErrorResponse = (status: number, body: string): Response =>
|
||||
({
|
||||
ok: false,
|
||||
status,
|
||||
text: () => Promise.resolve(body),
|
||||
}) as unknown as Response;
|
||||
|
||||
it("rewrites 400 inference-profile error in non-streaming path to actionable message", async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, awsInferenceProfileError));
|
||||
const model = createModelWithFetch(fetchMock, { noStream: true });
|
||||
|
||||
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
|
||||
await expect(model._generate(messages as never, {})).rejects.toThrow(
|
||||
/cross-region inference profile ID/
|
||||
);
|
||||
});
|
||||
|
||||
it("rewrites 400 inference-profile error in streaming path to actionable message", async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, awsInferenceProfileError));
|
||||
const model = createModelWithFetch(fetchMock);
|
||||
|
||||
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
|
||||
const gen = model._streamResponseChunks(messages as never, {});
|
||||
await expect(gen.next()).rejects.toThrow(/cross-region inference profile ID/);
|
||||
});
|
||||
|
||||
it("includes the bare model ID in the rewritten message", async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, awsInferenceProfileError));
|
||||
const model = createModelWithFetch(fetchMock, { noStream: true });
|
||||
|
||||
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
|
||||
await expect(model._generate(messages as never, {})).rejects.toThrow(
|
||||
/anthropic\.claude-sonnet-4-5/
|
||||
);
|
||||
});
|
||||
|
||||
it("does not rewrite a 400 error that is unrelated to inference profiles", async () => {
|
||||
const genericBody = JSON.stringify({ message: "ValidationException: bad request" });
|
||||
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, genericBody));
|
||||
const model = createModelWithFetch(fetchMock, { noStream: true });
|
||||
|
||||
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
|
||||
await expect(model._generate(messages as never, {})).rejects.toThrow(
|
||||
/Amazon Bedrock request failed with status 400/
|
||||
);
|
||||
});
|
||||
|
||||
it("does not rewrite non-400 errors", async () => {
|
||||
const body = JSON.stringify({ message: "Internal Server Error" });
|
||||
const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(500, body));
|
||||
const model = createModelWithFetch(fetchMock, { noStream: true });
|
||||
|
||||
const messages = [{ content: "hi", getType: () => "human", type: "human" }];
|
||||
await expect(model._generate(messages as never, {})).rejects.toThrow(
|
||||
/Amazon Bedrock request failed with status 500/
|
||||
);
|
||||
});
|
||||
|
||||
it("rewrites the error even when AWS uses a curly apostrophe in 'isn’t supported'", async () => {
|
||||
const curlyApostropheBody = 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 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>/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
// 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,
|
||||
|
|
@ -36,6 +41,42 @@ export interface BedrockChatModelFields extends BaseChatModelParams {
|
|||
streaming?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites Bedrock HTTP error messages into actionable text when possible.
|
||||
* Falls back to the original "Amazon Bedrock ... failed with status N: body" form.
|
||||
*
|
||||
* The detection uses two stable substrings from the AWS ValidationException body
|
||||
* ("on-demand throughput" and "inference profile") rather than the apostrophe-bearing
|
||||
* phrase "isn't supported", because AWS has been observed serving both straight (')
|
||||
* and curly (’) apostrophe variants. False positives are harmless: the rewritten
|
||||
* message still names the bare model ID from the original body.
|
||||
*/
|
||||
function rewriteBedrockErrorMessage(status: number, body: string, streaming = false): string {
|
||||
const prefix = streaming
|
||||
? "Amazon Bedrock streaming request failed with status"
|
||||
: "Amazon Bedrock request failed with status";
|
||||
|
||||
if (
|
||||
status === 400 &&
|
||||
body.includes("on-demand throughput") &&
|
||||
body.includes("inference profile")
|
||||
) {
|
||||
const modelIdMatch = body.match(/model ID ([^\s]+) with/);
|
||||
const bareId = modelIdMatch?.[1] ?? "<model-id>";
|
||||
// Provider segment of the model ID (e.g. "anthropic" from "anthropic.claude-...").
|
||||
// Falls back to a generic placeholder when the ID isn't in <provider>.<model> form.
|
||||
const providerSegment = bareId.includes(".") ? bareId.split(".")[0] : "<provider>";
|
||||
return (
|
||||
`This Bedrock model requires a cross-region inference profile ID, not a bare regional model ID. ` +
|
||||
`Update the model name in Settings → Models to use one of the prefixed forms: ` +
|
||||
`global.${providerSegment}.<id> (recommended), us.${providerSegment}.<id>, eu.${providerSegment}.<id>, or apac.${providerSegment}.<id>. ` +
|
||||
`The current value "${bareId}" is not accepted by AWS on-demand throughput.`
|
||||
);
|
||||
}
|
||||
|
||||
return `${prefix} ${status}: ${body}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight ChatModel integration for Amazon Bedrock using a simple API key header.
|
||||
* This implementation issues JSON requests against the public Bedrock runtime endpoint.
|
||||
|
|
@ -84,7 +125,8 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
|
||||
super(baseParams);
|
||||
|
||||
const globalFetch = typeof fetch !== "undefined" ? fetch.bind(globalThis) : undefined;
|
||||
// scorecard: streaming requires fetch — cannot use requestUrl
|
||||
const globalFetch = typeof fetch !== "undefined" ? fetch.bind(window) : undefined;
|
||||
|
||||
this.fetchImpl = fetchImplementation ?? globalFetch;
|
||||
if (!this.fetchImpl) {
|
||||
|
|
@ -127,11 +169,14 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
/**
|
||||
* Convert LangChain tools to Claude's tool format for Bedrock.
|
||||
*/
|
||||
private convertToolsToClaude(tools: StructuredToolInterface[]): any[] {
|
||||
private convertToolsToClaude(tools: StructuredToolInterface[]): Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: Record<string, unknown>;
|
||||
}> {
|
||||
return tools.map((tool) => {
|
||||
let inputSchema: any = { type: "object", properties: {} };
|
||||
let inputSchema: Record<string, unknown> = { type: "object", properties: {} };
|
||||
if (tool.schema) {
|
||||
// Use LangChain's schema conversion utilities
|
||||
inputSchema = isInteropZodSchema(tool.schema) ? toJsonSchema(tool.schema) : tool.schema;
|
||||
}
|
||||
return {
|
||||
|
|
@ -145,17 +190,24 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
/**
|
||||
* Extract tool calls from Claude's response format.
|
||||
*/
|
||||
private extractToolCalls(data: any): any[] | undefined {
|
||||
if (!Array.isArray(data?.content)) return undefined;
|
||||
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;
|
||||
|
||||
const toolUseBlocks = data.content.filter((block: any) => block.type === "tool_use");
|
||||
const toolUseBlocks = (dataObj.content as Record<string, unknown>[]).filter(
|
||||
(block) => block.type === "tool_use"
|
||||
);
|
||||
|
||||
if (toolUseBlocks.length === 0) return undefined;
|
||||
|
||||
return toolUseBlocks.map((block: any) => ({
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
args: block.input || {},
|
||||
return toolUseBlocks.map((block) => ({
|
||||
id: block.id as string,
|
||||
name: block.name as string,
|
||||
args: (block.input || {}) as Record<string, unknown>,
|
||||
type: "tool_call" as const,
|
||||
}));
|
||||
}
|
||||
|
|
@ -179,10 +231,10 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Amazon Bedrock request failed with status ${response.status}: ${errorText}`);
|
||||
throw new Error(rewriteBedrockErrorMessage(response.status, errorText));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
const text = this.extractText(data);
|
||||
const toolCalls = this.extractToolCalls(data);
|
||||
|
||||
|
|
@ -261,9 +313,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`Amazon Bedrock streaming request failed with status ${response.status}: ${errorText}`
|
||||
);
|
||||
throw new Error(rewriteBedrockErrorMessage(response.status, errorText, true));
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
|
|
@ -300,13 +350,14 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
byteBuffer = new Uint8Array(remainingBytes);
|
||||
|
||||
for (const messagePayload of messages) {
|
||||
const outerEvent = this.safeJsonParse(messagePayload);
|
||||
if (!outerEvent) {
|
||||
const parsedOuter = this.safeJsonParse(messagePayload);
|
||||
if (!parsedOuter || typeof parsedOuter !== "object") {
|
||||
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;
|
||||
|
|
@ -385,19 +436,20 @@ 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: fallback.llmOutput ?? {},
|
||||
response_metadata: fallbackMetadata,
|
||||
}),
|
||||
text: fallbackText,
|
||||
generationInfo: fallback.llmOutput ?? {},
|
||||
generationInfo: fallbackMetadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private safeJsonParse(value: string): any | null {
|
||||
private safeJsonParse(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
|
|
@ -413,18 +465,21 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
* @returns Claude-style content array or null if not classifiable
|
||||
*/
|
||||
private buildContentItemsFromDelta(
|
||||
event: any
|
||||
event: Record<string, unknown>
|
||||
): Array<{ type: string; text?: string; thinking?: string }> | null {
|
||||
if (!event || typeof event !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for content_block_delta format (nested delta)
|
||||
const delta = event.content_block_delta?.delta || event.contentBlockDelta?.delta || event.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;
|
||||
|
||||
if (!delta || typeof delta !== "object") {
|
||||
if (!deltaUnknown || typeof deltaUnknown !== "object") {
|
||||
return null;
|
||||
}
|
||||
const delta = deltaUnknown as Record<string, unknown>;
|
||||
|
||||
const deltaType = delta.type;
|
||||
|
||||
|
|
@ -454,27 +509,29 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
* Returns tool_call_chunks format that LangChain can concatenate.
|
||||
*/
|
||||
private extractToolCallChunk(
|
||||
event: any
|
||||
event: Record<string, unknown>
|
||||
): { 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
|
||||
if (event.type === "content_block_start" && event.content_block?.type === "tool_use") {
|
||||
const contentBlock = event.content_block as Record<string, unknown> | undefined;
|
||||
if (event.type === "content_block_start" && contentBlock?.type === "tool_use") {
|
||||
return {
|
||||
id: event.content_block.id,
|
||||
index: event.index ?? 0,
|
||||
name: event.content_block.name,
|
||||
id: contentBlock.id as string | undefined,
|
||||
index: typeof event.index === "number" ? event.index : 0,
|
||||
name: contentBlock.name as string | undefined,
|
||||
args: "",
|
||||
};
|
||||
}
|
||||
|
||||
// Handle content_block_delta with input_json_delta - partial tool args
|
||||
if (event.type === "content_block_delta" && event.delta?.type === "input_json_delta") {
|
||||
const eventDelta = event.delta as Record<string, unknown> | undefined;
|
||||
if (event.type === "content_block_delta" && eventDelta?.type === "input_json_delta") {
|
||||
return {
|
||||
index: event.index ?? 0,
|
||||
args: event.delta.partial_json || "",
|
||||
index: typeof event.index === "number" ? event.index : 0,
|
||||
args: typeof eventDelta.partial_json === "string" ? eventDelta.partial_json : "",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -482,7 +539,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
}
|
||||
|
||||
private async processStreamEvent(
|
||||
event: any,
|
||||
event: Record<string, unknown>,
|
||||
runManager: CallbackManagerForLLMRun | undefined,
|
||||
currentUsage?: Record<string, unknown>,
|
||||
currentStopReason?: string
|
||||
|
|
@ -499,15 +556,17 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
let hasText = false;
|
||||
const debugSummaries: string[] = [];
|
||||
|
||||
if (event?.type === "chunk" && typeof event.chunk?.bytes === "string") {
|
||||
const decodedPayloads = this.decodeChunkBytes(event.chunk.bytes);
|
||||
const eventChunk = event.chunk as Record<string, unknown> | undefined;
|
||||
if (event?.type === "chunk" && typeof eventChunk?.bytes === "string") {
|
||||
const decodedPayloads = this.decodeChunkBytes(eventChunk.bytes);
|
||||
|
||||
for (const payload of decodedPayloads) {
|
||||
const innerEvent = this.safeJsonParse(payload);
|
||||
if (!innerEvent) {
|
||||
const parsedInner = this.safeJsonParse(payload);
|
||||
if (!parsedInner || typeof parsedInner !== "object") {
|
||||
debugSummaries.push(`Failed to parse inner payload: ${this.describePayload(payload)}`);
|
||||
continue;
|
||||
}
|
||||
const innerEvent = parsedInner as Record<string, unknown>;
|
||||
|
||||
const chunkMetadata = this.buildChunkMetadata(innerEvent);
|
||||
|
||||
|
|
@ -588,9 +647,10 @@ 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" &&
|
||||
innerEvent.delta?.type !== "input_json_delta"
|
||||
innerDelta?.type !== "input_json_delta"
|
||||
) {
|
||||
const summary = `No content in content_block_delta event: ${this.describeEvent(innerEvent)}`;
|
||||
debugSummaries.push(summary);
|
||||
|
|
@ -704,14 +764,15 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
};
|
||||
}
|
||||
|
||||
private describeEvent(event: Record<string, unknown>): string {
|
||||
if (!event) {
|
||||
private describeEvent(event: unknown): string {
|
||||
if (!event || typeof event !== "object") {
|
||||
return "<empty event>";
|
||||
}
|
||||
|
||||
const type = typeof event.type === "string" ? event.type : "unknown";
|
||||
const keys = Object.keys(event).slice(0, 6).join(",");
|
||||
const summary = this.stringifyForLog(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);
|
||||
return `${type} {${keys}} -> ${summary}`;
|
||||
}
|
||||
|
||||
|
|
@ -791,20 +852,9 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
|
||||
private decodeBase64ToUint8Array(encoded: string): Uint8Array | null {
|
||||
try {
|
||||
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;
|
||||
// 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"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -945,18 +995,28 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
return metadata;
|
||||
}
|
||||
|
||||
private extractStreamText(event: any): string | null {
|
||||
private extractStreamText(event: Record<string, unknown>): 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> = [
|
||||
event.delta?.thinking,
|
||||
event.content_block_delta?.delta?.thinking,
|
||||
event.contentBlockDelta?.delta?.thinking,
|
||||
event.delta?.reasoning_content, // Deepseek compatibility
|
||||
evDelta?.thinking,
|
||||
evContentBlockDeltaInner?.thinking,
|
||||
evContentBlockDeltaCamelInner?.thinking,
|
||||
evDelta?.reasoning_content, // Deepseek compatibility
|
||||
event.reasoning_content,
|
||||
];
|
||||
|
||||
|
|
@ -980,19 +1040,27 @@ 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> = [
|
||||
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,
|
||||
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.content,
|
||||
];
|
||||
|
||||
|
|
@ -1017,20 +1085,24 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
|
||||
if (Array.isArray(candidate)) {
|
||||
const combined = candidate
|
||||
.map((part) => {
|
||||
.map((part: unknown): string => {
|
||||
if (typeof part === "string") {
|
||||
return part;
|
||||
}
|
||||
if (part && typeof part === "object") {
|
||||
if (typeof (part as any).text === "string") {
|
||||
return (part as any).text;
|
||||
const partObj = part as Record<string, unknown>;
|
||||
if (typeof partObj.text === "string") {
|
||||
return partObj.text;
|
||||
}
|
||||
if (typeof (part as any).value === "string") {
|
||||
return (part as any).value;
|
||||
if (typeof partObj.value === "string") {
|
||||
return partObj.value;
|
||||
}
|
||||
if (Array.isArray((part as any).content)) {
|
||||
return (part as any).content
|
||||
.map((sub: any) => (typeof sub?.text === "string" ? sub.text : ""))
|
||||
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 : "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
|
@ -1074,7 +1146,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
return null;
|
||||
}
|
||||
|
||||
private extractUsage(event: any): Record<string, unknown> | undefined {
|
||||
private extractUsage(event: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
if (!event || typeof event !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -1096,29 +1168,31 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
}
|
||||
|
||||
if (event.messageStop && typeof event.messageStop === "object") {
|
||||
return this.extractUsage(event.messageStop);
|
||||
return this.extractUsage(event.messageStop as Record<string, unknown>);
|
||||
}
|
||||
|
||||
if (event.message_stop && typeof event.message_stop === "object") {
|
||||
return this.extractUsage(event.message_stop);
|
||||
return this.extractUsage(event.message_stop as Record<string, unknown>);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private extractStopReason(event: any): string | undefined {
|
||||
private extractStopReason(event: Record<string, unknown>): 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 ||
|
||||
event.messageStop?.stopReason ||
|
||||
event.message_stop?.stop_reason ||
|
||||
evMessageStop?.stopReason ||
|
||||
evMessageStopSnake?.stop_reason ||
|
||||
(event.type === "message_stop" ? event.reason : undefined);
|
||||
|
||||
return typeof stopReason === "string" ? stopReason : undefined;
|
||||
|
|
@ -1255,7 +1329,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
const systemPrompts: string[] = [];
|
||||
|
||||
messages.forEach((message) => {
|
||||
const messageType = message._getType();
|
||||
const messageType = message.type;
|
||||
|
||||
// Handle system messages (always text-only)
|
||||
if (messageType === "system") {
|
||||
|
|
@ -1345,9 +1419,13 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
type: "text",
|
||||
text: block.text,
|
||||
});
|
||||
} else if (block.type === "image_url" && block.image_url?.url) {
|
||||
} else if (
|
||||
block.type === "image_url" &&
|
||||
(block.image_url as Record<string, unknown> | undefined)?.url
|
||||
) {
|
||||
// Image block in OpenAI format - convert to Claude format
|
||||
const claudeImage = this.convertImageContent(block.image_url.url);
|
||||
const imageUrlBlock = block.image_url as Record<string, unknown>;
|
||||
const claudeImage = this.convertImageContent(imageUrlBlock.url as string);
|
||||
if (claudeImage) {
|
||||
contentBlocks.push(claudeImage);
|
||||
}
|
||||
|
|
@ -1395,12 +1473,19 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
// Handle thinking mode for Claude models
|
||||
// Only enable if user has explicitly enabled REASONING capability for this model
|
||||
if (this.enableThinking) {
|
||||
// Enable thinking mode for Claude models on Bedrock
|
||||
// This allows the model to generate reasoning tokens
|
||||
payload.thinking = {
|
||||
type: "enabled",
|
||||
budget_tokens: 2048,
|
||||
};
|
||||
// claude-opus-4-7+ rejects { type: "enabled", budget_tokens } with a 400 and requires
|
||||
// { type: "adaptive" }. Unanchored match because Bedrock IDs include provider/profile
|
||||
// prefixes (e.g. "global.anthropic.claude-opus-4-7-20260115-v1:0"). Constrain the minor
|
||||
// to 1-2 digits followed by a delimiter so dated snapshot IDs like
|
||||
// "claude-opus-4-20250514-v1:0" aren't misread as Opus 4.20250514.
|
||||
const opusMinorMatch = this.modelName.match(/claude-opus-4-(\d{1,2})(?:[-.]|$)/);
|
||||
const usesAdaptiveThinking = opusMinorMatch ? parseInt(opusMinorMatch[1], 10) >= 7 : false;
|
||||
// Opus 4.7+ defaults thinking.display to "omitted" so thinking summaries
|
||||
// never reach the UI; force "summarized" for the adaptive branch. Pre-4.7
|
||||
// models default to "summarized" server-side.
|
||||
payload.thinking = usesAdaptiveThinking
|
||||
? { type: "adaptive", display: "summarized" }
|
||||
: { type: "enabled", budget_tokens: 2048 };
|
||||
// When thinking is enabled, temperature must be 1
|
||||
// https://docs.claude.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
|
||||
payload.temperature = 1;
|
||||
|
|
@ -1426,7 +1511,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
*/
|
||||
private normaliseMessageContent(
|
||||
message: BaseMessage
|
||||
): string | Array<{ type: string; [key: string]: any }> {
|
||||
): string | Array<{ type: string; [key: string]: unknown }> {
|
||||
const { content } = message;
|
||||
|
||||
// Handle string content (simple text message)
|
||||
|
|
@ -1466,7 +1551,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
}
|
||||
return null;
|
||||
})
|
||||
.filter((part): part is { type: string; [key: string]: any } => part !== null);
|
||||
.filter((part): part is { type: string; [key: string]: unknown } => part !== null);
|
||||
}
|
||||
|
||||
// No images, flatten to string
|
||||
|
|
@ -1497,20 +1582,21 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
return "";
|
||||
}
|
||||
|
||||
private extractText(data: any): string {
|
||||
private extractText(data: Record<string, unknown>): string {
|
||||
if (typeof data?.outputText === "string") {
|
||||
return data.outputText;
|
||||
}
|
||||
|
||||
if (Array.isArray(data?.content)) {
|
||||
return data.content
|
||||
.map((item: any) => {
|
||||
return (data.content as unknown[])
|
||||
.map((item: unknown): string => {
|
||||
if (!item) return "";
|
||||
if (typeof item === "string") return item;
|
||||
if (typeof item === "object") {
|
||||
if (typeof item.text === "string") return item.text;
|
||||
if (item.text && typeof item.text === "object" && "text" in item.text) {
|
||||
return item.text.text ?? "";
|
||||
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) ?? "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
|
|
|
|||
|
|
@ -11,14 +11,14 @@ import { ChatOpenAI } from "@langchain/openai";
|
|||
export interface ChatLMStudioInput {
|
||||
modelName?: string;
|
||||
apiKey?: string;
|
||||
configuration?: any;
|
||||
configuration?: Record<string, unknown>;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
topP?: number;
|
||||
frequencyPenalty?: number;
|
||||
streaming?: boolean;
|
||||
streamUsage?: boolean;
|
||||
[key: string]: any;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -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 globalThis.fetch): typeof globalThis.fetch {
|
||||
const underlyingFetch = baseFetch || globalThis.fetch;
|
||||
function createLMStudioFetch(baseFetch?: typeof window.fetch): typeof window.fetch {
|
||||
const underlyingFetch = baseFetch || window.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);
|
||||
const body = JSON.parse(init.body) as { tools?: unknown };
|
||||
let modified = false;
|
||||
|
||||
// Strip null/undefined values from tool definitions
|
||||
|
|
@ -63,7 +63,8 @@ function createLMStudioFetch(baseFetch?: typeof globalThis.fetch): typeof global
|
|||
|
||||
export class ChatLMStudio extends ChatOpenAI {
|
||||
constructor(fields: ChatLMStudioInput) {
|
||||
const originalFetch = fields.configuration?.fetch;
|
||||
const configuration = fields.configuration as { fetch?: typeof window.fetch } | undefined;
|
||||
const originalFetch = configuration?.fetch;
|
||||
|
||||
super({
|
||||
...fields,
|
||||
|
|
@ -78,7 +79,7 @@ export class ChatLMStudio extends ChatOpenAI {
|
|||
// `text: { format: undefined }` (serializes to `text: {}`) which LM Studio
|
||||
// rejects with "Required: text.format".
|
||||
modelKwargs: {
|
||||
...fields.modelKwargs,
|
||||
...(fields.modelKwargs as Record<string, unknown> | undefined),
|
||||
text: { format: { type: "text" } },
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { BaseChatModelParams } from "@langchain/core/language_models/chat_models";
|
||||
import { AIMessageChunk } from "@langchain/core/messages";
|
||||
import { AIMessageChunk, BaseMessage } from "@langchain/core/messages";
|
||||
import type { UsageMetadata } from "@langchain/core/messages";
|
||||
import { ChatGenerationChunk } from "@langchain/core/outputs";
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
|
|
@ -44,7 +44,12 @@ export interface ChatOpenRouterInput extends BaseChatModelParams {
|
|||
// All other ChatOpenAI parameters
|
||||
modelName?: string;
|
||||
apiKey?: string;
|
||||
configuration?: any;
|
||||
configuration?: {
|
||||
baseURL?: string;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
topP?: number;
|
||||
|
|
@ -52,7 +57,7 @@ export interface ChatOpenRouterInput extends BaseChatModelParams {
|
|||
streaming?: boolean;
|
||||
maxRetries?: number;
|
||||
maxConcurrency?: number;
|
||||
[key: string]: any;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class ChatOpenRouter extends ChatOpenAI {
|
||||
|
|
@ -101,7 +106,7 @@ export class ChatOpenRouter extends ChatOpenAI {
|
|||
*
|
||||
* @see https://openrouter.ai/docs/features/prompt-caching
|
||||
*/
|
||||
override invocationParams(options?: this["ParsedCallOptions"]): any {
|
||||
override invocationParams(options?: this["ParsedCallOptions"]): Record<string, unknown> {
|
||||
const baseParams = super.invocationParams(options);
|
||||
|
||||
// Only inject cache_control for OpenRouter endpoints. LM Studio, Copilot Plus, and
|
||||
|
|
@ -150,9 +155,9 @@ export class ChatOpenRouter extends ChatOpenAI {
|
|||
* LangChain filters out reasoning_details, so we bypass it completely
|
||||
*/
|
||||
override async *_streamResponseChunks(
|
||||
messages: any[],
|
||||
messages: BaseMessage[],
|
||||
options: this["ParsedCallOptions"],
|
||||
_runManager?: any
|
||||
_runManager?: { handleLLMNewToken: (token: string) => Promise<void> }
|
||||
): AsyncGenerator<ChatGenerationChunk> {
|
||||
const params = this.invocationParams(options);
|
||||
const openaiMessages = this.toOpenRouterMessages(messages);
|
||||
|
|
@ -165,11 +170,13 @@ export class ChatOpenRouter extends ChatOpenAI {
|
|||
...(params.stream_options ?? {}),
|
||||
include_usage: true,
|
||||
},
|
||||
})) as unknown as AsyncIterable<OpenRouterChatChunk>;
|
||||
} as Parameters<
|
||||
typeof this.openaiClient.chat.completions.create
|
||||
>[0])) as unknown as AsyncIterable<OpenRouterChatChunk>;
|
||||
|
||||
let usageSummary: OpenRouterUsage | undefined;
|
||||
|
||||
for await (const rawChunk of stream as AsyncIterable<OpenRouterChatChunk>) {
|
||||
for await (const rawChunk of stream) {
|
||||
if (rawChunk.usage) {
|
||||
usageSummary = rawChunk.usage;
|
||||
}
|
||||
|
|
@ -188,7 +195,7 @@ export class ChatOpenRouter extends ChatOpenAI {
|
|||
|
||||
const messageChunk = this.buildMessageChunk({
|
||||
rawChunk,
|
||||
delta,
|
||||
delta: delta as unknown as Record<string, unknown>,
|
||||
content,
|
||||
finishReason: choice.finish_reason,
|
||||
reasoningDetails,
|
||||
|
|
@ -200,7 +207,9 @@ export class ChatOpenRouter extends ChatOpenAI {
|
|||
text: typeof messageChunk.content === "string" ? messageChunk.content : "",
|
||||
generationInfo: {
|
||||
finish_reason: choice.finish_reason,
|
||||
system_fingerprint: rawChunk.system_fingerprint,
|
||||
// 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"],
|
||||
model: rawChunk.model,
|
||||
},
|
||||
});
|
||||
|
|
@ -226,9 +235,13 @@ export class ChatOpenRouter extends ChatOpenAI {
|
|||
* @param messages LangChain messages passed into the model
|
||||
* @returns Messages formatted for the OpenRouter API
|
||||
*/
|
||||
private toOpenRouterMessages(messages: any[]): OpenRouterMessageParam[] {
|
||||
private toOpenRouterMessages(messages: BaseMessage[]): OpenRouterMessageParam[] {
|
||||
return messages.map((msg) => {
|
||||
const role = typeof msg._getType === "function" ? msg._getType() : (msg.role ?? "user");
|
||||
const msgRecord = msg as unknown as Record<string, unknown>;
|
||||
const role =
|
||||
typeof msg._getType === "function"
|
||||
? msg._getType()
|
||||
: ((msgRecord.role as string) ?? "user");
|
||||
const mappedRole =
|
||||
role === "human"
|
||||
? "user"
|
||||
|
|
@ -236,11 +249,11 @@ export class ChatOpenRouter extends ChatOpenAI {
|
|||
? "assistant"
|
||||
: (role as OpenAI.ChatCompletionRole);
|
||||
|
||||
if (msg.tool_call_id) {
|
||||
if (msgRecord.tool_call_id) {
|
||||
return {
|
||||
role: "tool",
|
||||
content: msg.content,
|
||||
tool_call_id: msg.tool_call_id,
|
||||
tool_call_id: msgRecord.tool_call_id as string,
|
||||
} as OpenRouterMessageParam;
|
||||
}
|
||||
|
||||
|
|
@ -276,7 +289,7 @@ export class ChatOpenRouter extends ChatOpenAI {
|
|||
*/
|
||||
private buildMessageChunk(config: {
|
||||
rawChunk: OpenRouterChatChunk;
|
||||
delta: Record<string, any>;
|
||||
delta: Record<string, unknown>;
|
||||
content: string;
|
||||
finishReason: string | null | undefined;
|
||||
reasoningText?: string;
|
||||
|
|
@ -372,17 +385,20 @@ 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: any): unknown[] | undefined {
|
||||
private extractReasoningDetails(
|
||||
choice: OpenAI.ChatCompletionChunk.Choice
|
||||
): unknown[] | undefined {
|
||||
const choiceRecord = choice as unknown as Record<string, Record<string, unknown>>;
|
||||
const candidate =
|
||||
choice?.delta?.reasoning_details ??
|
||||
choice?.message?.reasoning_details ??
|
||||
choice?.reasoning_details;
|
||||
choiceRecord?.delta?.reasoning_details ??
|
||||
choiceRecord?.message?.reasoning_details ??
|
||||
(choice as unknown as Record<string, unknown>)?.reasoning_details;
|
||||
|
||||
if (!Array.isArray(candidate)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return candidate.filter((detail) => detail !== undefined && detail !== null);
|
||||
return (candidate as unknown[]).filter((detail) => detail !== undefined && detail !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -402,8 +418,12 @@ export class ChatOpenRouter extends ChatOpenAI {
|
|||
if (typeof part === "string") {
|
||||
return part;
|
||||
}
|
||||
if (part && typeof part === "object" && typeof part.text === "string") {
|
||||
return part.text;
|
||||
if (
|
||||
part &&
|
||||
typeof part === "object" &&
|
||||
typeof (part as { text?: unknown }).text === "string"
|
||||
) {
|
||||
return (part as { text: string }).text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
|
|
@ -420,7 +440,7 @@ export class ChatOpenRouter extends ChatOpenAI {
|
|||
* @returns Tool call chunk array compatible with LangChain
|
||||
*/
|
||||
private extractToolCallChunks(
|
||||
toolCalls: any
|
||||
toolCalls: unknown
|
||||
):
|
||||
| Array<{ name?: string; args?: string; id?: string; index?: number; type: "tool_call_chunk" }>
|
||||
| undefined {
|
||||
|
|
@ -428,13 +448,19 @@ export class ChatOpenRouter extends ChatOpenAI {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
return toolCalls.map((call) => ({
|
||||
name: call?.function?.name,
|
||||
args: call?.function?.arguments,
|
||||
id: call?.id,
|
||||
index: call?.index,
|
||||
type: "tool_call_chunk" as const,
|
||||
}));
|
||||
return toolCalls.map((rawCall) => {
|
||||
const call = rawCall as
|
||||
| { function?: { name?: string; arguments?: string }; id?: string; index?: number }
|
||||
| null
|
||||
| undefined;
|
||||
return {
|
||||
name: call?.function?.name,
|
||||
args: call?.function?.arguments,
|
||||
id: call?.id,
|
||||
index: call?.index,
|
||||
type: "tool_call_chunk" as const,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -460,8 +486,12 @@ export class ChatOpenRouter extends ChatOpenAI {
|
|||
metadata.model = rawChunk.model;
|
||||
}
|
||||
|
||||
if (rawChunk.system_fingerprint) {
|
||||
metadata.system_fingerprint = rawChunk.system_fingerprint;
|
||||
// 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.usage) {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,186 @@
|
|||
import { JinaEmbeddings, JinaEmbeddingsParams } from "@langchain/community/embeddings/jina";
|
||||
/*
|
||||
* Adapted from @langchain/community JinaEmbeddings.
|
||||
* Copyright (c) LangChain, Inc. Licensed under the MIT License.
|
||||
* Source: https://github.com/langchain-ai/langchainjs-community/blob/886df5749a926f59e6fdf38a3465c62ec9e7ce32/libs/community/src/embeddings/jina.ts
|
||||
*/
|
||||
|
||||
export class CustomJinaEmbeddings extends JinaEmbeddings {
|
||||
import { Embeddings, type EmbeddingsParams } from "@langchain/core/embeddings";
|
||||
import { chunkArray } from "@langchain/core/utils/chunk_array";
|
||||
import { getEnvironmentVariable } from "@langchain/core/utils/env";
|
||||
|
||||
export interface JinaEmbeddingsParams extends EmbeddingsParams {
|
||||
/** Model name to use. */
|
||||
model: string;
|
||||
/** Compatibility alias used by this plugin's embedding manager. */
|
||||
modelName?: string;
|
||||
/** Jina-compatible embeddings endpoint. */
|
||||
baseUrl?: string;
|
||||
/** Timeout to use when making requests to Jina. */
|
||||
timeout?: number;
|
||||
/** The maximum number of documents to embed in a single request. */
|
||||
batchSize?: number;
|
||||
/** Whether to strip new lines from the input text. */
|
||||
stripNewLines?: boolean;
|
||||
/** The dimensions of the embedding. */
|
||||
dimensions?: number;
|
||||
/** Whether to L2-normalize the embedding vectors. */
|
||||
normalized?: boolean;
|
||||
}
|
||||
|
||||
type JinaMultiModelInput =
|
||||
| {
|
||||
text: string;
|
||||
image?: never;
|
||||
}
|
||||
| {
|
||||
image: string;
|
||||
text?: never;
|
||||
};
|
||||
|
||||
export type JinaEmbeddingsInput = string | JinaMultiModelInput;
|
||||
|
||||
interface EmbeddingCreateParams {
|
||||
model: JinaEmbeddingsParams["model"];
|
||||
input: JinaEmbeddingsInput[];
|
||||
dimensions: number;
|
||||
task: "retrieval.query" | "retrieval.passage";
|
||||
normalized?: boolean;
|
||||
}
|
||||
|
||||
interface EmbeddingResponse {
|
||||
model: string;
|
||||
object: string;
|
||||
usage: {
|
||||
total_tokens: number;
|
||||
prompt_tokens: number;
|
||||
};
|
||||
data: {
|
||||
object: string;
|
||||
index: number;
|
||||
embedding: number[];
|
||||
}[];
|
||||
}
|
||||
|
||||
interface EmbeddingErrorResponse {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export class CustomJinaEmbeddings extends Embeddings implements JinaEmbeddingsParams {
|
||||
model: JinaEmbeddingsParams["model"] = "jina-clip-v2";
|
||||
batchSize = 24;
|
||||
baseUrl = "https://api.jina.ai/v1/embeddings";
|
||||
stripNewLines = true;
|
||||
dimensions = 1024;
|
||||
apiKey: string;
|
||||
normalized = true;
|
||||
|
||||
/**
|
||||
* Creates a Jina embeddings client using local configuration or Jina environment variables.
|
||||
*/
|
||||
constructor(
|
||||
fields?: Partial<JinaEmbeddingsParams> & {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
) {
|
||||
super(fields);
|
||||
if (fields?.baseUrl) {
|
||||
this.baseUrl = fields.baseUrl;
|
||||
const fieldsWithDefaults = { maxConcurrency: 2, ...fields };
|
||||
super(fieldsWithDefaults);
|
||||
|
||||
const apiKey =
|
||||
fieldsWithDefaults?.apiKey ||
|
||||
getEnvironmentVariable("JINA_API_KEY") ||
|
||||
getEnvironmentVariable("JINA_AUTH_TOKEN");
|
||||
|
||||
if (!apiKey) throw new Error("Jina API key not found");
|
||||
|
||||
this.apiKey = apiKey;
|
||||
this.model = fieldsWithDefaults?.model ?? fieldsWithDefaults?.modelName ?? this.model;
|
||||
this.baseUrl = fieldsWithDefaults?.baseUrl ?? this.baseUrl;
|
||||
this.dimensions = fieldsWithDefaults?.dimensions ?? this.dimensions;
|
||||
this.batchSize = fieldsWithDefaults?.batchSize ?? this.batchSize;
|
||||
this.stripNewLines = fieldsWithDefaults?.stripNewLines ?? this.stripNewLines;
|
||||
this.normalized = fieldsWithDefaults?.normalized ?? this.normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Embeds passage documents with Jina retrieval-passage task parameters.
|
||||
*/
|
||||
async embedDocuments(input: JinaEmbeddingsInput[]): Promise<number[][]> {
|
||||
const batches = chunkArray(this.doStripNewLines(input), this.batchSize);
|
||||
const batchRequests = batches.map((batch) => {
|
||||
const params = this.getParams(batch);
|
||||
return this.embeddingWithRetry(params);
|
||||
});
|
||||
|
||||
const batchResponses = await Promise.all(batchRequests);
|
||||
const embeddings: number[][] = [];
|
||||
|
||||
for (let i = 0; i < batchResponses.length; i += 1) {
|
||||
const batch = batches[i];
|
||||
const batchResponse = batchResponses[i] || [];
|
||||
for (let j = 0; j < batch.length; j += 1) {
|
||||
embeddings.push(batchResponse[j]);
|
||||
}
|
||||
}
|
||||
|
||||
return embeddings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Embeds a query with Jina retrieval-query task parameters.
|
||||
*/
|
||||
async embedQuery(input: JinaEmbeddingsInput): Promise<number[]> {
|
||||
const params = this.getParams(this.doStripNewLines([input]), true);
|
||||
const embeddings = (await this.embeddingWithRetry(params)) || [[]];
|
||||
return embeddings[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes newlines from string inputs when configured to match upstream Jina behavior.
|
||||
*/
|
||||
private doStripNewLines(input: JinaEmbeddingsInput[]): JinaEmbeddingsInput[] {
|
||||
if (this.stripNewLines) {
|
||||
return input.map((item) => {
|
||||
if (typeof item === "string") {
|
||||
return item.replace(/\n/g, " ");
|
||||
}
|
||||
if (item.text) {
|
||||
return { text: item.text.replace(/\n/g, " ") };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the request body for Jina's retrieval embedding API.
|
||||
*/
|
||||
private getParams(input: JinaEmbeddingsInput[], query?: boolean): EmbeddingCreateParams {
|
||||
return {
|
||||
model: this.model,
|
||||
input,
|
||||
dimensions: this.dimensions,
|
||||
task: query ? "retrieval.query" : "retrieval.passage",
|
||||
normalized: this.normalized,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a single embeddings request and returns vectors in response order.
|
||||
*/
|
||||
private async embeddingWithRetry(body: EmbeddingCreateParams): Promise<number[][]> {
|
||||
const response = await fetch(this.baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const embeddingData: EmbeddingResponse | EmbeddingErrorResponse = await response.json();
|
||||
if ("detail" in embeddingData && embeddingData.detail) {
|
||||
throw new Error(`${embeddingData.detail}`);
|
||||
}
|
||||
return (embeddingData as EmbeddingResponse).data.map(({ embedding }) => embedding);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { safeFetchNoThrow } from "@/utils";
|
||||
import { OpenAIEmbeddings } from "@langchain/openai";
|
||||
|
||||
export class CustomOpenAIEmbeddings extends OpenAIEmbeddings {
|
||||
private customConfig: any;
|
||||
private customConfig: Record<string, unknown>;
|
||||
|
||||
constructor(config: any) {
|
||||
constructor(config: Record<string, unknown>) {
|
||||
super(config);
|
||||
// Store the config for our custom methods
|
||||
this.customConfig = config;
|
||||
|
|
@ -29,17 +30,20 @@ export class CustomOpenAIEmbeddings extends OpenAIEmbeddings {
|
|||
};
|
||||
|
||||
// Get the correct baseURL, apiKey, and fetch function from the configuration
|
||||
const baseURL = this.customConfig.configuration?.baseURL || "https://api.openai.com/v1";
|
||||
const configuration = this.customConfig.configuration as
|
||||
| { baseURL?: string; fetch?: typeof fetch }
|
||||
| undefined;
|
||||
const baseURL = configuration?.baseURL || "https://api.openai.com/v1";
|
||||
const url = `${baseURL}/embeddings`;
|
||||
const apiKey = this.customConfig.apiKey;
|
||||
const fetchFn = this.customConfig.configuration?.fetch || fetch;
|
||||
const apiKey = this.customConfig.apiKey as string;
|
||||
const fetchFn = configuration?.fetch || safeFetchNoThrow;
|
||||
|
||||
const response = await fetchFn(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
...(this.customConfig.headers || {}),
|
||||
...((this.customConfig.headers as Record<string, string>) || {}),
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
|
@ -51,17 +55,17 @@ export class CustomOpenAIEmbeddings extends OpenAIEmbeddings {
|
|||
);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
const responseData = (await response.json()) as { data?: Array<{ embedding?: unknown }> };
|
||||
|
||||
if (!responseData.data || !Array.isArray(responseData.data)) {
|
||||
throw new Error("Invalid API response format: missing or invalid data array");
|
||||
}
|
||||
|
||||
return responseData.data.map((item: any) => {
|
||||
return responseData.data.map((item) => {
|
||||
if (!item.embedding || !Array.isArray(item.embedding)) {
|
||||
throw new Error("Invalid API response format: missing or invalid embedding array");
|
||||
}
|
||||
return item.embedding;
|
||||
return item.embedding as number[];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,86 @@ import { MissingPlusLicenseError } from "@/error";
|
|||
import { logInfo } from "@/logger";
|
||||
import { turnOffPlus, turnOnPlus } from "@/plusUtils";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import { safeFetchNoThrow } from "@/utils";
|
||||
import { arrayBufferToBase64 } from "@/utils/base64";
|
||||
import { requestUrl } from "obsidian";
|
||||
|
||||
/**
|
||||
* Build a multipart/form-data body buffer from a FormData instance.
|
||||
* Returned as an ArrayBuffer suitable for passing to Obsidian's requestUrl.
|
||||
*
|
||||
* @param formData - FormData containing strings and/or File/Blob entries.
|
||||
* @returns The serialized multipart body and the Content-Type header (including boundary).
|
||||
*/
|
||||
async function buildMultipartFromFormData(
|
||||
formData: FormData
|
||||
): Promise<{ body: ArrayBuffer; contentType: string }> {
|
||||
const boundary = `----CopilotBoundary${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`;
|
||||
const encoder = new TextEncoder();
|
||||
const parts: Uint8Array[] = [];
|
||||
|
||||
for (const [name, value] of formData.entries()) {
|
||||
parts.push(encoder.encode(`--${boundary}\r\n`));
|
||||
if (value instanceof Blob) {
|
||||
const filename = value instanceof File ? value.name : "blob";
|
||||
const contentType = value.type || "application/octet-stream";
|
||||
parts.push(
|
||||
encoder.encode(
|
||||
`Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` +
|
||||
`Content-Type: ${contentType}\r\n\r\n`
|
||||
)
|
||||
);
|
||||
const buf = await value.arrayBuffer();
|
||||
parts.push(new Uint8Array(buf));
|
||||
parts.push(encoder.encode("\r\n"));
|
||||
} else {
|
||||
parts.push(encoder.encode(`Content-Disposition: form-data; name="${name}"\r\n\r\n`));
|
||||
parts.push(encoder.encode(String(value)));
|
||||
parts.push(encoder.encode("\r\n"));
|
||||
}
|
||||
}
|
||||
parts.push(encoder.encode(`--${boundary}--\r\n`));
|
||||
|
||||
const totalLength = parts.reduce((sum, p) => sum + p.byteLength, 0);
|
||||
const out = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
out.set(part, offset);
|
||||
offset += part.byteLength;
|
||||
}
|
||||
return {
|
||||
body: out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength),
|
||||
contentType: `multipart/form-data; boundary=${boundary}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a requestUrl response into the {data, error} shape used by Brevilabs API methods.
|
||||
* Handles the case where `response.json` is a raw string (non-JSON body, e.g. HTML error page).
|
||||
*/
|
||||
function parseBrevilabsResponse<T>(
|
||||
response: { status: number; json: unknown },
|
||||
endpoint: string
|
||||
): { data: T | null; error?: Error } {
|
||||
let data: unknown = response.json;
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch {
|
||||
// Non-JSON body — fall through to status-based error.
|
||||
}
|
||||
}
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const detail = (data as { detail?: { reason?: string; error?: string } } | null)?.detail;
|
||||
if (detail?.reason) {
|
||||
const error = new Error(detail.reason);
|
||||
if (detail.error) error.name = detail.error;
|
||||
return { data: null, error };
|
||||
}
|
||||
return { data: null, error: new Error(`HTTP error: ${response.status}`) };
|
||||
}
|
||||
logInfo(`[API ${endpoint} request]:`, data);
|
||||
return { data: data as T };
|
||||
}
|
||||
|
||||
export interface RerankResponse {
|
||||
response: {
|
||||
|
|
@ -23,22 +101,22 @@ export interface RerankResponse {
|
|||
}
|
||||
|
||||
export interface ToolCall {
|
||||
tool: any;
|
||||
args: any;
|
||||
tool: unknown;
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
export interface Url4llmResponse {
|
||||
response: any;
|
||||
response: string;
|
||||
elapsed_time_ms: number;
|
||||
}
|
||||
|
||||
export interface Pdf4llmResponse {
|
||||
response: any;
|
||||
response: string;
|
||||
elapsed_time_ms: number;
|
||||
}
|
||||
|
||||
export interface Docs4llmResponse {
|
||||
response: any;
|
||||
response: unknown;
|
||||
elapsed_time_ms: number;
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +142,7 @@ export interface Youtube4llmResponse {
|
|||
}
|
||||
|
||||
export interface Twitter4llmResponse {
|
||||
response: any;
|
||||
response: string;
|
||||
elapsed_time_ms: number;
|
||||
}
|
||||
|
||||
|
|
@ -98,7 +176,7 @@ export class BrevilabsClient {
|
|||
|
||||
private async makeRequest<T>(
|
||||
endpoint: string,
|
||||
body: any,
|
||||
body: Record<string, unknown>,
|
||||
method = "POST",
|
||||
excludeAuthHeader = false,
|
||||
skipLicenseCheck = false
|
||||
|
|
@ -123,25 +201,14 @@ export class BrevilabsClient {
|
|||
if (!excludeAuthHeader) {
|
||||
headers.Authorization = `Bearer ${await getDecryptedKey(getSettings().plusLicenseKey)}`;
|
||||
}
|
||||
const response = await safeFetchNoThrow(url.toString(), {
|
||||
const response = await requestUrl({
|
||||
url: url.toString(),
|
||||
method,
|
||||
headers,
|
||||
...(method === "POST" && { body: JSON.stringify(body) }),
|
||||
throw: false,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const errorDetail = data.detail;
|
||||
const error = new Error(errorDetail.reason);
|
||||
error.name = errorDetail.error;
|
||||
return { data: null, error };
|
||||
} catch {
|
||||
return { data: null, error: new Error("Unknown error") };
|
||||
}
|
||||
}
|
||||
logInfo(`[API ${endpoint} request]:`, data);
|
||||
|
||||
return { data };
|
||||
return parseBrevilabsResponse<T>(response, endpoint);
|
||||
}
|
||||
|
||||
private async makeFormDataRequest<T>(
|
||||
|
|
@ -159,29 +226,21 @@ export class BrevilabsClient {
|
|||
const url = new URL(`${BREVILABS_API_BASE_URL}${endpoint}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
// Build multipart body manually for requestUrl (does not natively support FormData).
|
||||
const { body, contentType } = await buildMultipartFromFormData(formData);
|
||||
|
||||
const response = await requestUrl({
|
||||
url: url.toString(),
|
||||
method: "POST",
|
||||
headers: {
|
||||
// No Content-Type header - browser will set it automatically with boundary
|
||||
"Content-Type": contentType,
|
||||
Authorization: `Bearer ${await getDecryptedKey(getSettings().plusLicenseKey)}`,
|
||||
"X-Client-Version": this.pluginVersion,
|
||||
},
|
||||
body: formData,
|
||||
body,
|
||||
throw: false,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const errorDetail = data.detail;
|
||||
const error = new Error(errorDetail.reason);
|
||||
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 };
|
||||
return parseBrevilabsResponse<T>(response, `${endpoint} form-data`);
|
||||
} catch (error) {
|
||||
return { data: null, error: error instanceof Error ? error : new Error(String(error)) };
|
||||
}
|
||||
|
|
@ -194,10 +253,10 @@ export class BrevilabsClient {
|
|||
* unknown error.
|
||||
*/
|
||||
async validateLicenseKey(
|
||||
context?: Record<string, any>
|
||||
context?: Record<string, unknown>
|
||||
): Promise<{ isValid: boolean | undefined; plan?: string }> {
|
||||
// Build the request body with proper structure
|
||||
const requestBody: Record<string, any> = {
|
||||
const requestBody: Record<string, unknown> = {
|
||||
license_key: await getDecryptedKey(getSettings().plusLicenseKey),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
import {
|
||||
getChainType,
|
||||
getCurrentProject,
|
||||
getModelKey,
|
||||
SetChainOptions,
|
||||
setChainType,
|
||||
} from "@/aiParams";
|
||||
import ChainFactory, { ChainType, Document } from "@/chainFactory";
|
||||
import { getChainType, getCurrentProject, getModelKey, SetChainOptions } from "@/aiParams";
|
||||
import { ChainType } from "@/chainType";
|
||||
import { BUILTIN_CHAT_MODELS, USER_SENDER } from "@/constants";
|
||||
import {
|
||||
AutonomousAgentChainRunner,
|
||||
|
|
@ -19,14 +13,14 @@ import { logError, logInfo } from "@/logger";
|
|||
import { getSettings, subscribeToSettingsChange } from "@/settings/model";
|
||||
import { getSystemPrompt } from "@/system-prompts/systemPromptBuilder";
|
||||
import { ChatMessage } from "@/types/message";
|
||||
import { findCustomModel, isOSeriesModel, isSupportedChain } from "@/utils";
|
||||
import { findCustomModel, isOSeriesModel } from "@/utils";
|
||||
import { MissingModelKeyError } from "@/error";
|
||||
import {
|
||||
ChatPromptTemplate,
|
||||
HumanMessagePromptTemplate,
|
||||
MessagesPlaceholder,
|
||||
} from "@langchain/core/prompts";
|
||||
import { RunnableSequence } from "@langchain/core/runnables";
|
||||
import { Document } from "@langchain/core/documents";
|
||||
import { App, Notice } from "obsidian";
|
||||
import ChatModelManager from "./chatModelManager";
|
||||
import MemoryManager from "./memoryManager";
|
||||
|
|
@ -34,10 +28,6 @@ import PromptManager from "./promptManager";
|
|||
import { UserMemoryManager } from "@/memory/UserMemoryManager";
|
||||
|
||||
export default class ChainManager {
|
||||
// TODO: These chains are deprecated since we now use direct chat model calls in chain runners
|
||||
// Consider removing after verifying no dependencies remain
|
||||
private chain: RunnableSequence;
|
||||
private retrievalChain: RunnableSequence;
|
||||
private retrievedDocuments: Document[] = [];
|
||||
|
||||
public getRetrievedDocuments(): Document[] {
|
||||
|
|
@ -60,10 +50,12 @@ export default class ChainManager {
|
|||
this.userMemoryManager = new UserMemoryManager(app);
|
||||
|
||||
// Initialize async operations
|
||||
this.initialize();
|
||||
void this.initialize().catch((err) => logError("ChainManager initialize failed", err));
|
||||
|
||||
subscribeToSettingsChange(async () => {
|
||||
await this.createChainWithNewModel();
|
||||
subscribeToSettingsChange(() => {
|
||||
void this.createChainWithNewModel().catch((err) =>
|
||||
logError("createChainWithNewModel failed", err)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -71,16 +63,6 @@ export default class ChainManager {
|
|||
await this.createChainWithNewModel();
|
||||
}
|
||||
|
||||
// TODO: These methods are deprecated - chain runners now use direct chat model calls
|
||||
// Remove after confirming no usage remains
|
||||
public getChain(): RunnableSequence {
|
||||
return this.chain;
|
||||
}
|
||||
|
||||
public getRetrievalChain(): RunnableSequence {
|
||||
return this.retrievalChain;
|
||||
}
|
||||
|
||||
private validateChainType(chainType: ChainType): void {
|
||||
if (chainType === undefined || chainType === null) throw new Error("No chain type set");
|
||||
}
|
||||
|
|
@ -97,15 +79,6 @@ export default class ChainManager {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: This method is deprecated - chain validation no longer needed
|
||||
// Remove after confirming no dependencies
|
||||
private validateChainInitialization() {
|
||||
if (!this.chain || !isSupportedChain(this.chain)) {
|
||||
logInfo("Reinitializing chat chain after detecting missing or unsupported instance.");
|
||||
this.createChainWithNewModel({}, false);
|
||||
}
|
||||
}
|
||||
|
||||
public storeRetrieverDocuments(documents: Document[]) {
|
||||
this.retrievedDocuments = documents;
|
||||
}
|
||||
|
|
@ -170,10 +143,23 @@ export default class ChainManager {
|
|||
this.pendingModelError = null;
|
||||
}
|
||||
|
||||
// Must update the chatModel for chain because ChainFactory always
|
||||
// retrieves the old chain without the chatModel change if it exists!
|
||||
// Create a new chain with the new chatModel
|
||||
this.setChain(chainType, options);
|
||||
// Chain-type housekeeping. Do NOT write `chainType` back to the atom —
|
||||
// the atom is owned by the UI dropdowns and `applyPlusSettings`. The
|
||||
// captured local `chainType` may already be stale by the time we reach
|
||||
// here (we just awaited `setChatModel(...)`), and writing it back used
|
||||
// to create a self-sustaining `setChainType` → ProjectManager
|
||||
// subscriber → `createChainWithNewModel` loop that froze Obsidian on
|
||||
// apply-Plus-key.
|
||||
if (this.chatModelManager.validateChatModel(this.chatModelManager.getChatModel())) {
|
||||
this.validateChainType(chainType);
|
||||
if (options.refreshIndex) {
|
||||
await this.refreshVaultIndex();
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
"createChainWithNewModel: skipping chain-type housekeeping — no chat model set."
|
||||
);
|
||||
}
|
||||
logInfo(`Setting model to ${newModelKey}`);
|
||||
} catch (error) {
|
||||
this.pendingModelError = error instanceof Error ? error : new Error(String(error));
|
||||
|
|
@ -182,108 +168,6 @@ export default class ChainManager {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: This method is deprecated - chain runners now handle chain logic directly
|
||||
// Remove after confirming no usage remains
|
||||
async setChain(chainType: ChainType, options: SetChainOptions = {}): Promise<void> {
|
||||
if (!this.chatModelManager.validateChatModel(this.chatModelManager.getChatModel())) {
|
||||
console.error("setChain failed: No chat model set.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.validateChainType(chainType);
|
||||
|
||||
// Get chatModel, memory, prompt, and embeddingAPI from respective managers
|
||||
const chatModel = this.chatModelManager.getChatModel();
|
||||
const memory = this.memoryManager.getMemory();
|
||||
const chatPrompt = this.promptManager.getChatPrompt();
|
||||
|
||||
switch (chainType) {
|
||||
case ChainType.LLM_CHAIN: {
|
||||
// TODO: LLMChainRunner now handles this directly without chains
|
||||
this.chain = ChainFactory.createNewLLMChain({
|
||||
llm: chatModel,
|
||||
memory: memory,
|
||||
prompt: options.prompt || chatPrompt,
|
||||
abortController: options.abortController,
|
||||
}) 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();
|
||||
|
|
@ -302,21 +186,19 @@ export default class ChainManager {
|
|||
case ChainType.PROJECT_CHAIN:
|
||||
return new ProjectChainRunner(this);
|
||||
default:
|
||||
throw new Error(`Unsupported chain type: ${chainType}`);
|
||||
throw new Error(`Unsupported chain type: ${String(chainType)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeQAChain(options: SetChainOptions) {
|
||||
// Handle index refresh if needed
|
||||
if (options.refreshIndex) {
|
||||
const settings = getSettings();
|
||||
if (settings.enableSemanticSearchV3) {
|
||||
// Use VectorStoreManager for Orama indexing
|
||||
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
|
||||
await VectorStoreManager.getInstance().indexVaultToVectorStore(false);
|
||||
}
|
||||
// V3 search builds indexes on demand, no action needed
|
||||
}
|
||||
/**
|
||||
* Re-index the vault into the Orama vector store. No-op when legacy
|
||||
* semantic search is disabled — v3 lexical search builds its index on
|
||||
* demand and doesn't need a precomputed store.
|
||||
*/
|
||||
private async refreshVaultIndex() {
|
||||
if (!getSettings().enableSemanticSearchV3) return;
|
||||
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
|
||||
await VectorStoreManager.getInstance().indexVaultToVectorStore(false);
|
||||
}
|
||||
|
||||
async runChain(
|
||||
|
|
@ -339,7 +221,6 @@ export default class ChainManager {
|
|||
);
|
||||
|
||||
this.validateChatModel();
|
||||
this.validateChainInitialization();
|
||||
|
||||
const chatModel = this.chatModelManager.getChatModel();
|
||||
|
||||
|
|
@ -359,7 +240,9 @@ export default class ChainManager {
|
|||
]);
|
||||
}
|
||||
|
||||
this.createChainWithNewModel({ prompt: effectivePrompt }, false);
|
||||
void this.createChainWithNewModel({ prompt: effectivePrompt }, false).catch((err) =>
|
||||
logError("createChainWithNewModel failed", err)
|
||||
);
|
||||
/*this.setChain(getChainType(), {
|
||||
prompt: effectivePrompt,
|
||||
});*/
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ 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";
|
||||
|
|
@ -28,6 +29,7 @@ import {
|
|||
buildToolCallsFromChunks,
|
||||
accumulateToolCallChunk,
|
||||
ToolCallChunk,
|
||||
type RawToolCallChunk,
|
||||
} from "./utils/nativeToolCalling";
|
||||
|
||||
import { ensureCiCOrderingWithQuestion } from "./utils/cicPromptUtils";
|
||||
|
|
@ -56,7 +58,7 @@ type AgentSource = {
|
|||
title: string;
|
||||
path: string;
|
||||
score: number;
|
||||
explanation?: any;
|
||||
explanation?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -120,7 +122,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
|
|||
|
||||
// Agent Reasoning Block state
|
||||
private reasoningState: AgentReasoningState = createInitialReasoningState();
|
||||
private reasoningTimerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private reasoningTimerInterval: number | 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
|
||||
|
|
@ -195,7 +197,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
|
|||
this.addReasoningStep(randomStep);
|
||||
|
||||
// Update every 100ms for smooth timer - always includes accumulated content
|
||||
this.reasoningTimerInterval = setInterval(() => {
|
||||
this.reasoningTimerInterval = window.setInterval(() => {
|
||||
// Check for abort and show interrupted message immediately
|
||||
if (abortController?.signal.aborted && this.reasoningState.status === "reasoning") {
|
||||
this.stopReasoningTimer();
|
||||
|
|
@ -259,7 +261,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
|
|||
*/
|
||||
private stopReasoningTimer(): void {
|
||||
if (this.reasoningTimerInterval) {
|
||||
clearInterval(this.reasoningTimerInterval);
|
||||
window.clearInterval(this.reasoningTimerInterval);
|
||||
this.reasoningTimerInterval = null;
|
||||
}
|
||||
this.reasoningState.status = "collapsed";
|
||||
|
|
@ -396,7 +398,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
|
|||
if (!isPlusUser) {
|
||||
await this.handleError(
|
||||
new Error("Invalid license key"),
|
||||
thinkStreamer.processErrorChunk.bind(thinkStreamer)
|
||||
thinkStreamer.processErrorChunk.bind(thinkStreamer) as (message: string) => void
|
||||
);
|
||||
const errorResponse = thinkStreamer.close().content;
|
||||
return this.handleResponse(
|
||||
|
|
@ -474,11 +476,11 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
|
|||
|
||||
this.lastDisplayedContent = "";
|
||||
return loopResult.finalResponse;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
// Always stop the reasoning timer on error
|
||||
this.stopReasoningTimer();
|
||||
|
||||
if (error.name === "AbortError" || abortController.signal.aborted) {
|
||||
if ((error as { name?: string }).name === "AbortError" || abortController.signal.aborted) {
|
||||
logInfo("Autonomous agent stream aborted by user", {
|
||||
reason: abortController.signal.reason,
|
||||
});
|
||||
|
|
@ -508,7 +510,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
|
|||
|
||||
await this.handleError(
|
||||
new Error(autonomousAgentErrorMsg + fallbackErrorMsg),
|
||||
thinkStreamer.processErrorChunk.bind(thinkStreamer)
|
||||
thinkStreamer.processErrorChunk.bind(thinkStreamer) as (message: string) => void
|
||||
);
|
||||
|
||||
const fullAIResponse = thinkStreamer.close().content;
|
||||
|
|
@ -536,14 +538,18 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
|
|||
*/
|
||||
private async prepareAgentConversation(
|
||||
userMessage: ChatMessage,
|
||||
chatModel: any,
|
||||
chatModel: BaseChatModel & {
|
||||
modelName?: string;
|
||||
model?: string;
|
||||
bindTools?: (tools: unknown[]) => unknown;
|
||||
},
|
||||
_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 as any).modelName || (chatModel as any).model || "unknown";
|
||||
const modelName = chatModel.modelName || chatModel.model || "unknown";
|
||||
if (typeof chatModel.bindTools !== "function") {
|
||||
throw new Error(
|
||||
`Model ${modelName} does not support native tool calling (bindTools not available). ` +
|
||||
|
|
@ -711,7 +717,8 @@ 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 any).tool_call_chunks ?? [];
|
||||
const rawToolCallChunks =
|
||||
(aiMessage as { tool_call_chunks?: unknown[] }).tool_call_chunks ?? [];
|
||||
logWarn(
|
||||
`[Agent] Empty response detected (iteration ${iteration}). ` +
|
||||
`Content length: ${content?.length ?? 0}, ` +
|
||||
|
|
@ -738,7 +745,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
|
|||
: displayedContent;
|
||||
updateCurrentAiMessage(currentResponse);
|
||||
if (i + STREAM_CHUNK_SIZE < finalContent.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, STREAM_DELAY_MS));
|
||||
await new Promise((resolve) => window.setTimeout(resolve, STREAM_DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1023,9 +1030,15 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
|
|||
})
|
||||
);
|
||||
|
||||
for await (const chunk of stream) {
|
||||
for await (const rawChunk of stream) {
|
||||
if (abortController.signal.aborted) break;
|
||||
|
||||
const chunk = rawChunk as {
|
||||
response_metadata?: { finish_reason?: string };
|
||||
tool_call_chunks?: unknown;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
// Check for MALFORMED_FUNCTION_CALL error - throw to trigger fallback
|
||||
const finishReason = chunk.response_metadata?.finish_reason;
|
||||
if (finishReason === "MALFORMED_FUNCTION_CALL") {
|
||||
|
|
@ -1037,7 +1050,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);
|
||||
accumulateToolCallChunk(toolCallChunks, tc as RawToolCallChunk);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1046,7 +1059,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
|
|||
if (chunkContent) rawContent += chunkContent;
|
||||
|
||||
// Process chunk through ThinkBlockStreamer to strip thinking content
|
||||
thinkStreamer.processChunk(chunk);
|
||||
thinkStreamer.processChunk(chunk as Parameters<typeof thinkStreamer.processChunk>[0]);
|
||||
}
|
||||
|
||||
// Close the streamer to finalize content (handles unclosed think blocks, etc.)
|
||||
|
|
@ -1081,9 +1094,9 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
|
|||
aiMessage,
|
||||
streamingResult,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logError(`Stream error: ${error.message}`);
|
||||
if (error.name === "AbortError" || abortController.signal.aborted) {
|
||||
} catch (error: unknown) {
|
||||
logError(`Stream error: ${(error as Error).message}`);
|
||||
if ((error as { name?: string }).name === "AbortError" || abortController.signal.aborted) {
|
||||
const streamingResult = thinkStreamer.close();
|
||||
return {
|
||||
content: streamingResult.content,
|
||||
|
|
|
|||
|
|
@ -110,8 +110,9 @@ 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 any)
|
||||
.messages;
|
||||
const historyMessages = (
|
||||
this.chainManager.memoryManager.getMemory().chatHistory as { messages?: unknown[] }
|
||||
).messages;
|
||||
logInfo("Chat memory updated:\n", {
|
||||
turns: Array.isArray(historyMessages) ? historyMessages.length : 0,
|
||||
});
|
||||
|
|
@ -120,8 +121,8 @@ export abstract class BaseChainRunner implements ChainRunner {
|
|||
try {
|
||||
const { parseToolCallMarkers } = await import("./utils/toolCallParser");
|
||||
const parsed = parseToolCallMarkers(fullAIResponse);
|
||||
let textOnly = parsed.segments
|
||||
.map((seg: any) => (seg.type === "text" ? seg.content : ""))
|
||||
let textOnly = (parsed.segments as { type: string; content: string }[])
|
||||
.map((seg) => (seg.type === "text" ? seg.content : ""))
|
||||
.join("")
|
||||
.trim();
|
||||
if (!textOnly) textOnly = fullAIResponse || "";
|
||||
|
|
@ -146,15 +147,16 @@ 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: any, processErrorChunk: (message: string) => void) {
|
||||
protected async handleError(error: unknown, processErrorChunk: (message: string) => void) {
|
||||
const msg = err2String(error);
|
||||
logError("Error during LLM invocation:", msg);
|
||||
const errorData = error?.response?.data?.error || msg;
|
||||
const errorCode = errorData?.code || msg;
|
||||
const errorData =
|
||||
(error as { response?: { data?: { error?: unknown } } })?.response?.data?.error || msg;
|
||||
const errorCode = (errorData as { code?: string })?.code || msg;
|
||||
let errorMessage = "";
|
||||
|
||||
// Check for specific error messages
|
||||
if (error?.message?.includes("Invalid license key")) {
|
||||
if ((error as { message?: string })?.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 =
|
||||
|
|
@ -200,13 +202,19 @@ 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?: any } })?.response;
|
||||
const responseError = (
|
||||
error as {
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
error?: { status?: number | string; code?: string; message?: string; type?: string };
|
||||
};
|
||||
};
|
||||
}
|
||||
)?.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 as number | undefined);
|
||||
const statusCode = typeof rawStatus === "string" ? Number.parseInt(rawStatus, 10) : rawStatus;
|
||||
const errorObject =
|
||||
typeof errorData === "object" && errorData !== null
|
||||
? (errorData as Record<string, unknown>)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
isFilterOnlyResults,
|
||||
isTimeDominantResults,
|
||||
logSearchResultsDebugTable,
|
||||
type SearchDoc,
|
||||
} from "./utils/searchResultUtils";
|
||||
import {
|
||||
buildLocalSearchInnerContent,
|
||||
|
|
@ -68,8 +69,8 @@ import ProjectManager from "@/LLMProviders/projectManager";
|
|||
import { isProjectMode } from "@/aiParams";
|
||||
|
||||
type ToolCallWithExecutor = {
|
||||
tool: any;
|
||||
args: any;
|
||||
tool: StructuredTool;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export class CopilotPlusChainRunner extends BaseChainRunner {
|
||||
|
|
@ -108,12 +109,16 @@ export class CopilotPlusChainRunner extends BaseChainRunner {
|
|||
*/
|
||||
private async planToolCalls(
|
||||
userMessage: string,
|
||||
chatModel: BaseChatModel
|
||||
chatModel: BaseChatModel,
|
||||
hasActiveContextNote: boolean = false
|
||||
): Promise<{ toolCalls: ToolCallWithExecutor[]; salientTerms: string[] }> {
|
||||
const availableTools = this.getAvailableToolsForPlanning();
|
||||
|
||||
// Check if model supports native tool calling
|
||||
if (typeof (chatModel as any).bindTools !== "function") {
|
||||
const modelWithTools = chatModel as BaseChatModel & {
|
||||
bindTools?: (tools: StructuredTool[]) => unknown;
|
||||
};
|
||||
if (typeof modelWithTools.bindTools !== "function") {
|
||||
logWarn("[CopilotPlus] Model does not support native tool calling, skipping tool planning");
|
||||
return {
|
||||
toolCalls: [],
|
||||
|
|
@ -122,15 +127,22 @@ export class CopilotPlusChainRunner extends BaseChainRunner {
|
|||
}
|
||||
|
||||
// Bind tools to the model for native function calling
|
||||
const boundModel = (chatModel as any).bindTools(availableTools);
|
||||
const boundModel = modelWithTools.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
|
||||
- For file structure queries, use getFileTree to explore the vault
|
||||
- Use getFileTree ONLY when the user wants to discover or list notes/folders in the vault — not to read content already in context${activeContextHint}
|
||||
- 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:
|
||||
|
|
@ -160,9 +172,11 @@ 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 = (await withSuppressedTokenWarnings(() =>
|
||||
boundModel.stream(planningMessages)
|
||||
)) as AsyncIterable<AIMessageChunk>;
|
||||
const stream: AsyncIterable<AIMessageChunk> = await withSuppressedTokenWarnings(() =>
|
||||
(
|
||||
boundModel as { stream: (msgs: unknown) => Promise<AsyncIterable<AIMessageChunk>> }
|
||||
).stream(planningMessages)
|
||||
);
|
||||
let aggregated: AIMessageChunk | undefined;
|
||||
for await (const chunk of stream) {
|
||||
aggregated = aggregated ? aggregated.concat(chunk) : chunk;
|
||||
|
|
@ -180,7 +194,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 : String(response.content);
|
||||
typeof response.content === "string" ? response.content : JSON.stringify(response.content);
|
||||
|
||||
logInfo("[CopilotPlus] Native tool calls:", nativeToolCalls.length);
|
||||
|
||||
|
|
@ -248,7 +262,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
private async processAtCommands(
|
||||
userMessage: string,
|
||||
existingToolCalls: ToolCallWithExecutor[],
|
||||
context: { salientTerms: string[]; timeRange?: any }
|
||||
context: { salientTerms: string[]; timeRange?: unknown }
|
||||
): Promise<ToolCallWithExecutor[]> {
|
||||
const message = userMessage.toLowerCase();
|
||||
const cleanQuery = this.removeAtCommands(userMessage);
|
||||
|
|
@ -517,7 +531,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
}
|
||||
|
||||
// Process existing chat content images if present
|
||||
const existingContent = userMessage.content;
|
||||
const existingContent = userMessage.content as MessageContent[] | undefined;
|
||||
if (existingContent && existingContent.length > 0) {
|
||||
const result = await this.processChatInputImages(existingContent);
|
||||
successfulImages.push(...result.successfulImages);
|
||||
|
|
@ -546,7 +560,8 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
}
|
||||
|
||||
protected hasCapability(model: BaseChatModel, capability: ModelCapability): boolean {
|
||||
const modelName = (model as any).modelName || (model as any).model || "";
|
||||
const modelWithName = model as BaseChatModel & { modelName?: string; model?: string };
|
||||
const modelName: string = modelWithName.modelName || modelWithName.model || "";
|
||||
const customModel = this.chainManager.chatModelManager.findModelByName(modelName);
|
||||
return customModel?.capabilities?.includes(capability) ?? false;
|
||||
}
|
||||
|
|
@ -570,7 +585,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
private async streamMultimodalResponse(
|
||||
textContent: string,
|
||||
userMessage: ChatMessage,
|
||||
allToolOutputs: any[],
|
||||
allToolOutputs: { tool: string; output: unknown }[],
|
||||
abortController: AbortController,
|
||||
thinkStreamer: ThinkBlockStreamer,
|
||||
originalUserQuestion: string,
|
||||
|
|
@ -584,7 +599,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
const isMultimodalCurrent = this.isMultimodalModel(chatModel);
|
||||
|
||||
// Create messages array
|
||||
const messages: any[] = [];
|
||||
const messages: { role: string; content: string | MessageContent[] }[] = [];
|
||||
|
||||
// Envelope-based context construction (required)
|
||||
const envelope = userMessage.contextEnvelope;
|
||||
|
|
@ -713,7 +728,9 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
});
|
||||
break;
|
||||
}
|
||||
for await (const processedChunk of actionStreamer.processChunk(chunk)) {
|
||||
for await (const processedChunk of actionStreamer.processChunk(
|
||||
chunk as unknown as Record<string, unknown>
|
||||
)) {
|
||||
thinkStreamer.processChunk(processedChunk);
|
||||
}
|
||||
}
|
||||
|
|
@ -739,7 +756,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?: any }[] = [];
|
||||
let sources: { title: string; path: string; score: number; explanation?: unknown }[] = [];
|
||||
|
||||
const isPlusUser = await checkIsPlusUser({
|
||||
isCopilotPlus: true,
|
||||
|
|
@ -747,7 +764,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)
|
||||
thinkStreamer.processErrorChunk.bind(thinkStreamer) as (message: string) => void
|
||||
);
|
||||
const errorResponse = thinkStreamer.close().content;
|
||||
|
||||
|
|
@ -778,11 +795,26 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
try {
|
||||
// Use model-based planning instead of Broca
|
||||
const chatModel = this.chainManager.chatModelManager.getChatModel();
|
||||
const planningResult = await this.planToolCalls(messageForAnalysis, chatModel);
|
||||
// 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
|
||||
);
|
||||
|
||||
// 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: any = undefined;
|
||||
let timeRange: unknown = undefined;
|
||||
const timeRangeCall = planningResult.toolCalls.find(
|
||||
(tc) => tc.tool.name === "getTimeRangeMs"
|
||||
);
|
||||
|
|
@ -793,7 +825,12 @@ 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}
|
||||
const extractEpochValues = (result: any) => {
|
||||
type TimeInfoResult = {
|
||||
startTime?: { epoch?: number };
|
||||
endTime?: { epoch?: number };
|
||||
error?: unknown;
|
||||
};
|
||||
const extractEpochValues = (result: TimeInfoResult): unknown => {
|
||||
if (result?.startTime?.epoch !== undefined && result?.endTime?.epoch !== undefined) {
|
||||
return {
|
||||
startTime: result.startTime.epoch,
|
||||
|
|
@ -805,7 +842,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
|
||||
if (typeof timeRangeResult === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(timeRangeResult);
|
||||
const parsed = JSON.parse(timeRangeResult) as TimeInfoResult;
|
||||
// Only use result if it's not an error
|
||||
if (!parsed.error) {
|
||||
timeRange = extractEpochValues(parsed);
|
||||
|
|
@ -813,8 +850,11 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
} catch {
|
||||
logWarn("[CopilotPlus] Failed to parse getTimeRangeMs result:", timeRangeResult);
|
||||
}
|
||||
} else if (timeRangeResult && !timeRangeResult.error) {
|
||||
timeRange = extractEpochValues(timeRangeResult);
|
||||
} else if (timeRangeResult) {
|
||||
const typedResult = timeRangeResult as TimeInfoResult;
|
||||
if (!typedResult.error) {
|
||||
timeRange = extractEpochValues(typedResult);
|
||||
}
|
||||
}
|
||||
logInfo("[CopilotPlus] Executed getTimeRangeMs, result:", timeRange);
|
||||
}
|
||||
|
|
@ -838,7 +878,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
salientTerms: planningResult.salientTerms,
|
||||
timeRange,
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
return this.handleResponse(
|
||||
getApiErrorMessage(error),
|
||||
userMessage,
|
||||
|
|
@ -886,16 +926,22 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
cleanedUserMessage,
|
||||
updateLoadingMessage
|
||||
);
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
// Reset loading message to default
|
||||
updateLoadingMessage?.(LOADING_MESSAGES.DEFAULT);
|
||||
|
||||
// Check if the error is due to abort signal
|
||||
if (error.name === "AbortError" || abortController.signal.aborted) {
|
||||
if (
|
||||
(error instanceof Error && 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));
|
||||
await this.handleError(
|
||||
error,
|
||||
thinkStreamer.processErrorChunk.bind(thinkStreamer) as (message: string) => void
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -920,7 +966,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
const fallbackSources =
|
||||
this.lastCitationSources && this.lastCitationSources.length > 0
|
||||
? this.lastCitationSources
|
||||
: ((sources as any[]) || []).map((source) => ({ title: source.title, path: source.path }));
|
||||
: (sources || []).map((source) => ({ title: source.title, path: source.path }));
|
||||
|
||||
fullAIResponse = addFallbackSources(
|
||||
fullAIResponse,
|
||||
|
|
@ -943,14 +989,14 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
}
|
||||
|
||||
private async executeToolCalls(
|
||||
toolCalls: any[],
|
||||
toolCalls: ToolCallWithExecutor[],
|
||||
updateLoadingMessage?: (message: string) => void
|
||||
): Promise<{
|
||||
toolOutputs: { tool: string; output: any }[];
|
||||
sources: { title: string; path: string; score: number; explanation?: any }[];
|
||||
toolOutputs: { tool: string; output: unknown }[];
|
||||
sources: { title: string; path: string; score: number; explanation?: unknown }[];
|
||||
}> {
|
||||
const toolOutputs = [];
|
||||
const allSources: { title: string; path: string; score: number; explanation?: any }[] = [];
|
||||
const toolOutputs: { tool: string; output: unknown }[] = [];
|
||||
const allSources: { title: string; path: string; score: number; explanation?: unknown }[] = [];
|
||||
|
||||
// TODO: remove this hack until better solution in place (logan, wenzheng)
|
||||
// Skip getFileTree if localSearch is already being called to avoid redundant work
|
||||
|
|
@ -998,17 +1044,32 @@ 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: any[]): string {
|
||||
protected getTimeExpression(toolCalls: ToolCallWithExecutor[]): string {
|
||||
const timeRangeCall = toolCalls.find((call) => call.tool.name === "getTimeRangeMs");
|
||||
return timeRangeCall ? timeRangeCall.args.timeExpression : "";
|
||||
return timeRangeCall ? (timeRangeCall.args.timeExpression as string) : "";
|
||||
}
|
||||
|
||||
private prepareLocalSearchResult(documents: any[], timeExpression: string): string {
|
||||
private prepareLocalSearchResult(documents: unknown[], 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 = documents.filter((doc) => doc.includeInContext !== false);
|
||||
const includedDocs = typedDocs.filter((doc) => doc.includeInContext !== false);
|
||||
|
||||
// Generate quality summary across all docs combined
|
||||
const qualitySummary = generateQualitySummary(includedDocs);
|
||||
|
|
@ -1022,8 +1083,8 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
const timeDominant = isTimeDominantResults(includedDocs);
|
||||
|
||||
// Determine tier split based on result type
|
||||
let tier1Docs: any[];
|
||||
let tier2Docs: any[];
|
||||
let tier1Docs: SearchDoc[];
|
||||
let tier2Docs: SearchDoc[];
|
||||
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));
|
||||
|
|
@ -1045,33 +1106,39 @@ 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((sum, doc) => sum + (doc.content?.length || 0), 0);
|
||||
const totalContentLength = tier1Docs.reduce<number>((sum, doc) => {
|
||||
return sum + (doc.content ? doc.content.length : 0);
|
||||
}, 0);
|
||||
|
||||
// If total content length exceeds threshold, truncate content proportionally
|
||||
let processedDocs = tier1Docs;
|
||||
let processedDocs: SearchDoc[] = 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) => ({
|
||||
...doc,
|
||||
content:
|
||||
doc.content?.slice(0, Math.floor((doc.content?.length || 0) * truncationRatio)) || "",
|
||||
}));
|
||||
processedDocs = tier1Docs.map(
|
||||
(doc): SearchDoc => ({
|
||||
...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 = processedDocs.map((doc, idx) => ({
|
||||
...doc,
|
||||
__sourceId: idx + 1,
|
||||
content: sanitizeContentForCitations(doc.content || ""),
|
||||
}));
|
||||
const withIds: SearchDoc[] = processedDocs.map(
|
||||
(doc, idx): SearchDoc => ({
|
||||
...doc,
|
||||
__sourceId: idx + 1,
|
||||
content: sanitizeContentForCitations((doc.content as string) || ""),
|
||||
})
|
||||
);
|
||||
|
||||
// Split into filter and search docs by isFilterResult flag
|
||||
const filterDocs = withIds.filter((d: any) => d.isFilterResult === true);
|
||||
const searchDocs = withIds.filter((d: any) => d.isFilterResult !== true);
|
||||
const filterDocs = withIds.filter((d) => d.isFilterResult === true);
|
||||
const searchDocs = withIds.filter((d) => 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.
|
||||
|
|
@ -1101,14 +1168,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: any) => ({
|
||||
.map((d) => ({
|
||||
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: any) => {
|
||||
this.lastCitationSources = withIds.slice(0, Math.min(20, withIds.length)).map((d) => {
|
||||
const title = d.title || d.path || "Untitled";
|
||||
return {
|
||||
title,
|
||||
|
|
@ -1144,9 +1211,9 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
): {
|
||||
formattedForLLM: string;
|
||||
formattedForDisplay: string;
|
||||
sources: { title: string; path: string; score: number; explanation?: any }[];
|
||||
sources: { title: string; path: string; score: number; explanation?: unknown }[];
|
||||
} {
|
||||
let sources: { title: string; path: string; score: number; explanation?: any }[] = [];
|
||||
let sources: { title: string; path: string; score: number; explanation?: unknown }[] = [];
|
||||
let formattedForLLM: string;
|
||||
let formattedForDisplay: string;
|
||||
|
||||
|
|
@ -1157,7 +1224,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(toolResult.result);
|
||||
const parsed = JSON.parse(toolResult.result) as { type?: unknown; documents?: unknown };
|
||||
const searchResults =
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
|
|
@ -1172,7 +1239,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);
|
||||
logSearchResultsDebugTable(searchResults as SearchDoc[]);
|
||||
|
||||
// Extract sources with explanation for UI display
|
||||
sources = extractSourcesFromSearchResults(searchResults);
|
||||
|
|
@ -1201,15 +1268,13 @@ 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: any[]): string {
|
||||
private formatAllToolOutputs(toolOutputs: { tool: string; output: unknown }[]): string {
|
||||
if (toolOutputs.length === 0) return "";
|
||||
|
||||
const formattedOutputs = toolOutputs
|
||||
.map((output) => {
|
||||
let content = output.output;
|
||||
if (typeof content !== "string") {
|
||||
content = JSON.stringify(content);
|
||||
}
|
||||
const content: string =
|
||||
typeof output.output === "string" ? output.output : JSON.stringify(output.output);
|
||||
return `<${output.tool}>\n${content}\n</${output.tool}>`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ 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<any[]> {
|
||||
private async constructMessages(
|
||||
userMessage: ChatMessage
|
||||
): Promise<{ role: string; content: string | unknown[] }[]> {
|
||||
// Require envelope for LLM chain
|
||||
if (!userMessage.contextEnvelope) {
|
||||
throw new Error(
|
||||
|
|
@ -32,7 +34,7 @@ export class LLMChainRunner extends BaseChainRunner {
|
|||
debug: false,
|
||||
});
|
||||
|
||||
const messages: any[] = [];
|
||||
const messages: { role: string; content: string | unknown[] }[] = [];
|
||||
|
||||
// Add system message (L1)
|
||||
const systemMessage = baseMessages.find((m) => m.role === "system");
|
||||
|
|
@ -50,7 +52,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: any) => {
|
||||
const updatedContent = userMessage.content.map((item: { type?: string }) => {
|
||||
if (item.type === "text") {
|
||||
return { ...item, text: userMessageContent.content };
|
||||
}
|
||||
|
|
@ -115,9 +117,13 @@ export class LLMChainRunner extends BaseChainRunner {
|
|||
|
||||
// Stream with abort signal
|
||||
const chatStream = await withSuppressedTokenWarnings(() =>
|
||||
this.chainManager.chatModelManager.getChatModel().stream(messages, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
this.chainManager.chatModelManager.getChatModel().stream(
|
||||
// ProviderMessage[] format matches what getChatModel().stream() accepts at runtime
|
||||
messages as never,
|
||||
{
|
||||
signal: abortController.signal,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
for await (const chunk of chatStream) {
|
||||
|
|
@ -125,15 +131,19 @@ export class LLMChainRunner extends BaseChainRunner {
|
|||
logInfo("Stream iteration aborted", { reason: abortController.signal.reason });
|
||||
break;
|
||||
}
|
||||
streamer.processChunk(chunk);
|
||||
streamer.processChunk(chunk as Parameters<typeof streamer.processChunk>[0]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
// Check if the error is due to abort signal
|
||||
if (error.name === "AbortError" || abortController.signal.aborted) {
|
||||
const errorName = error instanceof Error ? error.name : "";
|
||||
if (errorName === "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));
|
||||
await this.handleError(
|
||||
error,
|
||||
streamer.processErrorChunk.bind(streamer) as (message: string) => void
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -138,22 +138,26 @@ export class VaultQAChainRunner extends BaseChainRunner {
|
|||
// Format documents as context with XML tags
|
||||
// Sanitize content to remove pre-existing citation markers
|
||||
|
||||
const context = retrievedDocs
|
||||
.map((doc: any) => {
|
||||
const context = (
|
||||
retrievedDocs as { metadata?: { title?: string; path?: string }; pageContent?: string }[]
|
||||
)
|
||||
.map((doc) => {
|
||||
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: any[] = [];
|
||||
const messages: { role: string; content: string | unknown[] }[] = [];
|
||||
const chatModel = this.chainManager.chatModelManager.getChatModel();
|
||||
|
||||
// Prepare RAG context and citation instructions
|
||||
const sourceEntries: SourceCatalogEntry[] = retrievedDocs
|
||||
const sourceEntries: SourceCatalogEntry[] = (
|
||||
retrievedDocs as { metadata?: { title?: string; path?: string } }[]
|
||||
)
|
||||
.slice(0, Math.max(5, Math.min(20, retrievedDocs.length)))
|
||||
.map((d: any) => ({
|
||||
.map((d) => ({
|
||||
title: d.metadata?.title || d.metadata?.path || "Untitled",
|
||||
path: d.metadata?.path || d.metadata?.title || "",
|
||||
}));
|
||||
|
|
@ -204,12 +208,14 @@ export class VaultQAChainRunner extends BaseChainRunner {
|
|||
|
||||
// Handle multimodal content if present
|
||||
if (userMessage.content && Array.isArray(userMessage.content)) {
|
||||
const updatedContent = userMessage.content.map((item: any) => {
|
||||
if (item.type === "text") {
|
||||
return { ...item, text: enhancedUserContent };
|
||||
const updatedContent = userMessage.content.map(
|
||||
(item: { type?: string }): { type?: string; [key: string]: unknown } => {
|
||||
if (item.type === "text") {
|
||||
return { ...item, text: enhancedUserContent };
|
||||
}
|
||||
return { ...item };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
);
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: updatedContent,
|
||||
|
|
@ -234,9 +240,13 @@ export class VaultQAChainRunner extends BaseChainRunner {
|
|||
|
||||
// Stream with abort signal
|
||||
const chatStream = await withSuppressedTokenWarnings(() =>
|
||||
this.chainManager.chatModelManager.getChatModel().stream(messages, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
this.chainManager.chatModelManager.getChatModel().stream(
|
||||
// ProviderMessage[] format matches what getChatModel().stream() accepts at runtime
|
||||
messages as never,
|
||||
{
|
||||
signal: abortController.signal,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
for await (const chunk of chatStream) {
|
||||
|
|
@ -244,15 +254,18 @@ export class VaultQAChainRunner extends BaseChainRunner {
|
|||
logInfo("VaultQA stream iteration aborted", { reason: abortController.signal.reason });
|
||||
break;
|
||||
}
|
||||
streamer.processChunk(chunk);
|
||||
streamer.processChunk(chunk as Parameters<typeof streamer.processChunk>[0]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
// Check if the error is due to abort signal
|
||||
if (error.name === "AbortError" || abortController.signal.aborted) {
|
||||
if ((error as { name?: string }).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));
|
||||
await this.handleError(
|
||||
error,
|
||||
streamer.processErrorChunk.bind(streamer) as (message: string) => void
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +1,7 @@
|
|||
// Main exports for chain runners
|
||||
export type { ChainRunner } from "./BaseChainRunner";
|
||||
export { BaseChainRunner } from "./BaseChainRunner";
|
||||
export { LLMChainRunner } from "./LLMChainRunner";
|
||||
export { VaultQAChainRunner } from "./VaultQAChainRunner";
|
||||
export { CopilotPlusChainRunner } from "./CopilotPlusChainRunner";
|
||||
export { ProjectChainRunner } from "./ProjectChainRunner";
|
||||
export { AutonomousAgentChainRunner } from "./AutonomousAgentChainRunner";
|
||||
|
||||
// Utility exports (for internal use or testing)
|
||||
export { ThinkBlockStreamer } from "./utils/ThinkBlockStreamer";
|
||||
export {
|
||||
executeSequentialToolCall,
|
||||
getToolDisplayName,
|
||||
getToolEmoji,
|
||||
logToolCall,
|
||||
logToolResult,
|
||||
deduplicateSources,
|
||||
} from "./utils/toolExecution";
|
||||
export type { ToolExecutionResult } from "./utils/toolExecution";
|
||||
export {
|
||||
createToolResultMessage,
|
||||
generateToolCallId,
|
||||
extractNativeToolCalls,
|
||||
} from "./utils/nativeToolCalling";
|
||||
export type { NativeToolCall } from "./utils/nativeToolCalling";
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const MockedToolManager = ToolManager as jest.Mocked<typeof ToolManager>;
|
|||
const MockedToolResultFormatter = ToolResultFormatter as jest.Mocked<typeof ToolResultFormatter>;
|
||||
|
||||
describe("ActionBlockStreamer", () => {
|
||||
let writeFileTool: any;
|
||||
let writeFileTool: unknown;
|
||||
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 }[]) {
|
||||
const outputContents: any[] = [];
|
||||
async function processChunks(chunks: { content: string | null }[]): Promise<unknown[]> {
|
||||
const outputContents: unknown[] = [];
|
||||
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: any[] = [
|
||||
const chunks: { content: string | null }[] = [
|
||||
{ content: "Hello" },
|
||||
{ content: null },
|
||||
{ content: "" },
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export class ActionBlockStreamer {
|
|||
|
||||
constructor(
|
||||
private toolManager: typeof ToolManager,
|
||||
private writeFileTool: any
|
||||
private writeFileTool: unknown
|
||||
) {}
|
||||
|
||||
private findCompleteBlock(str: string) {
|
||||
|
|
@ -32,21 +32,23 @@ export class ActionBlockStreamer {
|
|||
};
|
||||
}
|
||||
|
||||
async *processChunk(chunk: any): AsyncGenerator<any, void, unknown> {
|
||||
async *processChunk(
|
||||
chunk: Record<string, unknown>
|
||||
): AsyncGenerator<Record<string, unknown>, 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) {
|
||||
for (const item of chunk.content as Array<{ type?: string; text?: unknown }>) {
|
||||
if (item.type === "text" && item.text != null) {
|
||||
chunkContent += item.text;
|
||||
chunkContent += typeof item.text === "string" ? item.text : "";
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle standard string content
|
||||
else if (chunk.content != null) {
|
||||
chunkContent = chunk.content;
|
||||
chunkContent = typeof chunk.content === "string" ? chunk.content : "";
|
||||
}
|
||||
|
||||
// Add to buffer
|
||||
|
|
@ -77,10 +79,10 @@ export class ActionBlockStreamer {
|
|||
});
|
||||
|
||||
// Format tool result using ToolResultFormatter for consistency with agent mode
|
||||
const formattedResult = ToolResultFormatter.format("writeFile", result);
|
||||
const formattedResult = ToolResultFormatter.format("writeFile", result as string);
|
||||
yield { ...chunk, content: `\n${formattedResult}\n` };
|
||||
} catch (err: any) {
|
||||
yield { ...chunk, content: `\nError: ${err?.message || err}\n` };
|
||||
} catch (err: unknown) {
|
||||
yield { ...chunk, content: `\nError: ${(err as Error)?.message ?? String(err)}\n` };
|
||||
}
|
||||
|
||||
// Remove processed block from buffer
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export class ThinkBlockStreamer {
|
|||
}
|
||||
}
|
||||
|
||||
private handleClaudeChunk(content: any[]) {
|
||||
private handleClaudeChunk(content: Array<{ type?: string; text?: string; thinking?: string }>) {
|
||||
let textContent = "";
|
||||
let hasThinkingContent = false;
|
||||
for (const item of content) {
|
||||
|
|
@ -157,7 +157,10 @@ export class ThinkBlockStreamer {
|
|||
return hasThinkingContent;
|
||||
}
|
||||
|
||||
private handleDeepseekChunk(chunk: any) {
|
||||
private handleDeepseekChunk(chunk: {
|
||||
content?: string;
|
||||
additional_kwargs?: { reasoning_content?: string };
|
||||
}) {
|
||||
// Handle standard string content
|
||||
if (typeof chunk.content === "string") {
|
||||
this.fullResponse += stripSpecialTokens(chunk.content);
|
||||
|
|
@ -200,7 +203,13 @@ 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: any) {
|
||||
private handleOpenRouterChunk(chunk: {
|
||||
content?: string;
|
||||
additional_kwargs?: {
|
||||
delta?: { reasoning?: string };
|
||||
reasoning_details?: unknown[];
|
||||
};
|
||||
}) {
|
||||
// Only process delta.reasoning (streaming), ignore reasoning_details entirely
|
||||
if (chunk.additional_kwargs?.delta?.reasoning) {
|
||||
// Skip thinking content if excludeThinking is enabled
|
||||
|
|
@ -233,7 +242,14 @@ export class ThinkBlockStreamer {
|
|||
* Accumulate native tool call chunks during streaming.
|
||||
* LangChain providers send tool_call_chunks with incremental data.
|
||||
*/
|
||||
private handleToolCallChunks(chunk: any) {
|
||||
private handleToolCallChunks(chunk: {
|
||||
tool_call_chunks?: Array<{
|
||||
index?: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
args?: string;
|
||||
}>;
|
||||
}) {
|
||||
// Check for tool_call_chunks in the chunk (LangChain streaming format)
|
||||
const toolCallChunks = chunk.tool_call_chunks;
|
||||
if (!toolCallChunks || !Array.isArray(toolCallChunks)) {
|
||||
|
|
@ -241,7 +257,7 @@ export class ThinkBlockStreamer {
|
|||
}
|
||||
|
||||
for (const tc of toolCallChunks) {
|
||||
const idx = tc.index ?? 0;
|
||||
const idx: number = (tc.index as number) ?? 0;
|
||||
const existing = this.toolCallChunks.get(idx) || { name: "", args: "" };
|
||||
|
||||
// Accumulate data from chunk
|
||||
|
|
@ -253,7 +269,17 @@ export class ThinkBlockStreamer {
|
|||
}
|
||||
}
|
||||
|
||||
processChunk(chunk: any) {
|
||||
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[];
|
||||
};
|
||||
}) {
|
||||
// Detect truncation using multi-provider detector
|
||||
const truncationResult = detectTruncation(chunk);
|
||||
if (truncationResult.wasTruncated) {
|
||||
|
|
@ -290,16 +316,17 @@ 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);
|
||||
this.handleDeepseekChunk(chunk as Parameters<typeof this.handleDeepseekChunk>[0]);
|
||||
} else if (isThinkingChunk) {
|
||||
// OpenRouter format with delta.reasoning or reasoning_details
|
||||
this.handleOpenRouterChunk(chunk);
|
||||
this.handleOpenRouterChunk(chunk as Parameters<typeof this.handleOpenRouterChunk>[0]);
|
||||
} else {
|
||||
// Default case: regular content or other formats
|
||||
this.handleDeepseekChunk(chunk);
|
||||
this.handleDeepseekChunk(chunk as Parameters<typeof this.handleDeepseekChunk>[0]);
|
||||
}
|
||||
|
||||
// Handle text-level think tags (e.g., from nvidia/nemotron models)
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ describe("chatHistoryUtils", () => {
|
|||
},
|
||||
];
|
||||
|
||||
const messages: any[] = [];
|
||||
const messages: { role: string; content: string | unknown[] }[] = [];
|
||||
addChatHistoryToMessages(rawHistory, messages);
|
||||
|
||||
expect(messages).toEqual([
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { logInfo } from "@/logger";
|
|||
|
||||
export interface ProcessedMessage {
|
||||
role: "user" | "assistant";
|
||||
content: any; // string or MessageContent[]
|
||||
content: string | unknown[]; // string or MessageContent[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -16,28 +16,29 @@ export interface ProcessedMessage {
|
|||
* @param rawHistory Array of messages from memory.loadMemoryVariables()
|
||||
* @returns Array of processed messages safe for LLM consumption
|
||||
*/
|
||||
export function processRawChatHistory(rawHistory: any[]): ProcessedMessage[] {
|
||||
export function processRawChatHistory(rawHistory: unknown[]): 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 message._getType === "function") {
|
||||
const messageType = message._getType();
|
||||
if (typeof msg._getType === "function") {
|
||||
const messageType = (msg._getType as () => string)();
|
||||
|
||||
// Only process human and AI messages
|
||||
if (messageType === "human") {
|
||||
messages.push({ role: "user", content: message.content });
|
||||
messages.push({ role: "user", content: msg.content as string | unknown[] });
|
||||
} else if (messageType === "ai") {
|
||||
messages.push({ role: "assistant", content: message.content });
|
||||
messages.push({ role: "assistant", content: msg.content as string | unknown[] });
|
||||
}
|
||||
// Skip system messages and unknown types
|
||||
} else if (message.content !== undefined) {
|
||||
} else if (msg.content !== undefined) {
|
||||
// Fallback for other message formats - try to infer role
|
||||
const role = inferMessageRole(message);
|
||||
const role = inferMessageRole(msg);
|
||||
if (role) {
|
||||
messages.push({ role, content: message.content });
|
||||
messages.push({ role, content: msg.content as string | unknown[] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -49,7 +50,7 @@ export function processRawChatHistory(rawHistory: any[]): ProcessedMessage[] {
|
|||
* Try to infer the role from various message format properties
|
||||
* @returns 'user' | 'assistant' | null
|
||||
*/
|
||||
function inferMessageRole(message: any): "user" | "assistant" | null {
|
||||
function inferMessageRole(message: Record<string, unknown>): "user" | "assistant" | null {
|
||||
// Check various properties that might indicate the role
|
||||
if (message.role === "human" || message.role === "user" || message.sender === "user") {
|
||||
return "user";
|
||||
|
|
@ -69,8 +70,8 @@ function inferMessageRole(message: any): "user" | "assistant" | null {
|
|||
* @param messages Target messages array to add to
|
||||
*/
|
||||
export function addChatHistoryToMessages(
|
||||
rawHistory: any[],
|
||||
messages: Array<{ role: string; content: any }>
|
||||
rawHistory: unknown[],
|
||||
messages: Array<{ role: string; content: string | unknown[] }>
|
||||
): void {
|
||||
const processedHistory = processRawChatHistory(rawHistory);
|
||||
for (const msg of processedHistory) {
|
||||
|
|
@ -87,14 +88,17 @@ export interface ChatHistoryEntry {
|
|||
* Extract text content from potentially multimodal message content.
|
||||
* Replaces non-text content (images) with placeholder.
|
||||
*/
|
||||
function extractTextContent(content: any): string {
|
||||
function extractTextContent(content: string | unknown[]): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
} else if (Array.isArray(content)) {
|
||||
// Extract text from multimodal content, skip image_url payloads
|
||||
const textParts = content
|
||||
.filter((item: any) => item.type === "text")
|
||||
.map((item: any) => item.text || "")
|
||||
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 || "")
|
||||
.join(" ");
|
||||
return textParts || "[Image content]";
|
||||
}
|
||||
|
|
@ -229,8 +233,10 @@ export function extractConversationTurns(processedHistory: ProcessedMessage[]):
|
|||
* @returns The processed history that was added
|
||||
*/
|
||||
export async function loadAndAddChatHistory(
|
||||
memory: any,
|
||||
messages: Array<{ role: string; content: any }>
|
||||
memory: {
|
||||
loadMemoryVariables: (vars: Record<string, unknown>) => Promise<{ history?: unknown[] }>;
|
||||
},
|
||||
messages: Array<{ role: string; content: string | unknown[] }>
|
||||
): Promise<ProcessedMessage[]> {
|
||||
const memoryVariables = await memory.loadMemoryVariables({});
|
||||
const rawHistory = memoryVariables.history || [];
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ More content
|
|||
|
||||
it("should handle edge cases", () => {
|
||||
expect(sanitizeContentForCitations("")).toBe("");
|
||||
expect(sanitizeContentForCitations(null as any)).toBe("");
|
||||
expect(sanitizeContentForCitations(undefined as any)).toBe("");
|
||||
expect(sanitizeContentForCitations(null)).toBe("");
|
||||
expect(sanitizeContentForCitations(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -142,8 +142,8 @@ More content
|
|||
|
||||
it("should handle edge cases", () => {
|
||||
expect(hasExistingCitations("")).toBe(false);
|
||||
expect(hasExistingCitations(null as any)).toBe(false);
|
||||
expect(hasExistingCitations(undefined as any)).toBe(false);
|
||||
expect(hasExistingCitations(null)).toBe(false);
|
||||
expect(hasExistingCitations(undefined)).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 as any)).toBe(false);
|
||||
expect(hasInlineCitations(undefined as any)).toBe(false);
|
||||
expect(hasInlineCitations(null)).toBe(false);
|
||||
expect(hasInlineCitations(undefined)).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: any[] = [];
|
||||
const sources: { title?: string; path?: string }[] = [];
|
||||
|
||||
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 as any, [{ title: "Doc" }])).toBe("");
|
||||
expect(addFallbackSources(undefined as any, [{ title: "Doc" }])).toBe("");
|
||||
expect(addFallbackSources(null, [{ title: "Doc" }])).toBe("");
|
||||
expect(addFallbackSources(undefined, [{ title: "Doc" }])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
// ===== CITATION RULES =====
|
||||
|
||||
export const CITATION_RULES = `CITATION RULES:
|
||||
const CITATION_RULES = `CITATION RULES:
|
||||
1. START with [^1] and increment sequentially ([^1], [^2], [^3], etc.) with NO gaps
|
||||
2. BE SELECTIVE: ONLY cite when introducing NEW factual claims, specific data, or direct quotes from sources
|
||||
3. IMPORTANT: Do NOT cite every sentence or bullet point. This creates clutter and poor readability.
|
||||
|
|
@ -21,7 +21,7 @@ export const CITATION_RULES = `CITATION RULES:
|
|||
9. If multiple source chunks come from the same document, cite each relevant chunk separately (e.g., [^1] and [^2] can both be from the same document title)
|
||||
10. End with '#### Sources' section containing: [^n]: [[Title]] (one per line, matching citation order)`;
|
||||
|
||||
export const WEB_CITATION_RULES = `WEB CITATION RULES:
|
||||
const WEB_CITATION_RULES = `WEB CITATION RULES:
|
||||
1. START with [^1] and increment sequentially ([^1], [^2], [^3], etc.) with NO gaps
|
||||
2. Cite ONLY when introducing new factual claims, statistics, or direct quotes from the search results
|
||||
3. After every cited claim, place the corresponding footnote immediately after the sentence ("The study found X [^1]")
|
||||
|
|
@ -106,7 +106,7 @@ const MAX_FALLBACK_SOURCES = 20;
|
|||
* Adds fallback sources to response if citations are missing.
|
||||
*/
|
||||
export function addFallbackSources(
|
||||
response: string,
|
||||
response: string | null | undefined,
|
||||
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): string {
|
||||
export function sanitizeContentForCitations(text: string | null | undefined): string {
|
||||
if (!text) return "";
|
||||
|
||||
// Remove inline footnote refs like [^12]
|
||||
|
|
@ -159,7 +159,7 @@ export function sanitizeContentForCitations(text: string): string {
|
|||
/**
|
||||
* Detects if response already has sources section or footnote definitions.
|
||||
*/
|
||||
export function hasExistingCitations(response: string): boolean {
|
||||
export function hasExistingCitations(response: string | null | undefined): 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): boolean {
|
|||
* 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): boolean {
|
||||
export function hasInlineCitations(response: string | null | undefined): 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 =====
|
||||
|
||||
export interface SourcesSection {
|
||||
interface SourcesSection {
|
||||
mainContent: string;
|
||||
sourcesBlock: string;
|
||||
}
|
||||
|
|
@ -254,7 +254,7 @@ export function extractSourcesSection(content: string): SourcesSection | null {
|
|||
/**
|
||||
* Normalizes sources block by adding line breaks if everything is on one line.
|
||||
*/
|
||||
export function normalizeSourcesBlock(sourcesBlock: string): string {
|
||||
function normalizeSourcesBlock(sourcesBlock: string): string {
|
||||
if (!sourcesBlock.includes("\n")) {
|
||||
// Ensure a break before every [n]
|
||||
sourcesBlock = sourcesBlock.replace(/\s*\[(\d+)\]\s*/g, "\n[$1] ");
|
||||
|
|
@ -268,7 +268,7 @@ export function normalizeSourcesBlock(sourcesBlock: string): string {
|
|||
/**
|
||||
* Parses footnote definitions from sources block.
|
||||
*/
|
||||
export function parseFootnoteDefinitions(sourcesBlock: string): string[] {
|
||||
function parseFootnoteDefinitions(sourcesBlock: string): string[] {
|
||||
return sourcesBlock
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
|
|
@ -278,10 +278,7 @@ export function parseFootnoteDefinitions(sourcesBlock: string): string[] {
|
|||
/**
|
||||
* Builds a citation renumbering map based on first-mention order in content.
|
||||
*/
|
||||
export function buildCitationMap(
|
||||
mainContent: string,
|
||||
footnoteLines: string[]
|
||||
): Map<number, number> {
|
||||
function buildCitationMap(mainContent: string, footnoteLines: string[]): Map<number, number> {
|
||||
const map = new Map<number, number>();
|
||||
const seen = new Set<number>();
|
||||
const firstMention: number[] = [];
|
||||
|
|
@ -326,7 +323,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) => {
|
||||
result = result.replace(/\[\^(\d+)\]/g, (match, n: string) => {
|
||||
const oldN = parseInt(n, 10);
|
||||
const newN = map.get(oldN) ?? oldN;
|
||||
const replacement = `[${newN}]`;
|
||||
|
|
@ -337,7 +334,7 @@ export function normalizeCitations(content: string, map: Map<number, number>): s
|
|||
});
|
||||
|
||||
// Handle multiple citations: [^n, ^m] -> [n, m]
|
||||
result = result.replace(/\[\^(\d+(?:\s*,\s*\^?\d+)*)\]/g, (match, citationList) => {
|
||||
result = result.replace(/\[\^(\d+(?:\s*,\s*\^?\d+)*)\]/g, (match, citationList: string) => {
|
||||
// Split and process each number in the list
|
||||
const processedNumbers = citationList
|
||||
.split(",")
|
||||
|
|
@ -368,10 +365,7 @@ export function normalizeCitations(content: string, map: Map<number, number>): s
|
|||
/**
|
||||
* Converts footnote definitions to simple display items.
|
||||
*/
|
||||
export function convertFootnoteDefinitions(
|
||||
sourcesBlock: string,
|
||||
map: Map<number, number>
|
||||
): string[] {
|
||||
function convertFootnoteDefinitions(sourcesBlock: string, map: Map<number, number>): string[] {
|
||||
const items: string[] = [];
|
||||
sourcesBlock.split("\n").forEach((line) => {
|
||||
const m = line.match(/^\[\^(\d+)\]:\s*(.*)$/);
|
||||
|
|
@ -409,7 +403,7 @@ export function convertFootnoteDefinitions(
|
|||
/**
|
||||
* Consolidates duplicate sources and returns mapping for citation updates.
|
||||
*/
|
||||
export function consolidateDuplicateSources(items: string[]): {
|
||||
function consolidateDuplicateSources(items: string[]): {
|
||||
uniqueItems: string[];
|
||||
consolidationMap: Map<number, number>;
|
||||
} {
|
||||
|
|
@ -456,7 +450,7 @@ export function updateCitationsForConsolidation(
|
|||
): string {
|
||||
if (consolidationMap.size === 0) return content;
|
||||
|
||||
return content.replace(/\[(\d+(?:\s*,\s*\d+)*)\]/g, (_match, nums) => {
|
||||
return content.replace(/\[(\d+(?:\s*,\s*\d+)*)\]/g, (_match, nums: string) => {
|
||||
const parts = nums.split(/\s*,\s*/);
|
||||
const seen = new Set<number>();
|
||||
const unique: number[] = [];
|
||||
|
|
@ -485,7 +479,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, second) => {
|
||||
(match, first: string, second: string) => {
|
||||
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))) {
|
||||
|
|
@ -591,7 +585,7 @@ function buildSourcesDetails(mainContent: string, items: SourcesDisplayItem[]):
|
|||
* These spans provide visual feedback during streaming (styled as pending links)
|
||||
* and are replaced by linkInlineCitations with actual clickable anchors after streaming.
|
||||
*/
|
||||
export function wrapCitationPlaceholders(content: string): string {
|
||||
function wrapCitationPlaceholders(content: string): string {
|
||||
return content.replace(
|
||||
/\[(\d+(?:\s*,\s*\d+)*)\](?!\()/g,
|
||||
'<span class="copilot-citation-ref">[$1]</span>'
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ export interface FinishReasonResult {
|
|||
* @param chunk The streaming chunk from the LLM (AIMessageChunk)
|
||||
* @returns FinishReasonResult with truncation status and details
|
||||
*/
|
||||
export function detectTruncation(chunk: any): FinishReasonResult {
|
||||
export function detectTruncation(chunk: {
|
||||
response_metadata?: Record<string, unknown>;
|
||||
}): FinishReasonResult {
|
||||
const metadata = chunk.response_metadata || {};
|
||||
|
||||
// OpenAI, DeepSeek, Mistral, Groq use "length"
|
||||
|
|
@ -69,7 +71,10 @@ export function detectTruncation(chunk: any): FinishReasonResult {
|
|||
* @param chunk The streaming chunk from the LLM
|
||||
* @returns Token usage object or null if not available
|
||||
*/
|
||||
export function extractTokenUsage(chunk: any): {
|
||||
export function extractTokenUsage(chunk: {
|
||||
response_metadata?: Record<string, unknown>;
|
||||
usage_metadata?: { input_tokens?: number; output_tokens?: number; total_tokens?: number };
|
||||
}): {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
|
|
@ -78,31 +83,47 @@ export function extractTokenUsage(chunk: any): {
|
|||
|
||||
// OpenAI format: tokenUsage with camelCase
|
||||
if (metadata.tokenUsage) {
|
||||
const tu = metadata.tokenUsage as {
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
return {
|
||||
inputTokens: metadata.tokenUsage.promptTokens,
|
||||
outputTokens: metadata.tokenUsage.completionTokens,
|
||||
totalTokens: metadata.tokenUsage.totalTokens,
|
||||
inputTokens: tu.promptTokens,
|
||||
outputTokens: tu.completionTokens,
|
||||
totalTokens: tu.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:
|
||||
metadata.usage.input_tokens ||
|
||||
metadata.usage.inputTokens || // Bedrock camelCase
|
||||
metadata.usage.inputTokenCount || // Bedrock invocationMetrics
|
||||
metadata.usage.prompt_tokens,
|
||||
u.input_tokens ||
|
||||
u.inputTokens || // Bedrock camelCase
|
||||
u.inputTokenCount || // Bedrock invocationMetrics
|
||||
u.prompt_tokens,
|
||||
outputTokens:
|
||||
metadata.usage.output_tokens ||
|
||||
metadata.usage.outputTokens || // Bedrock camelCase
|
||||
metadata.usage.outputTokenCount || // Bedrock invocationMetrics
|
||||
metadata.usage.completion_tokens,
|
||||
u.output_tokens ||
|
||||
u.outputTokens || // Bedrock camelCase
|
||||
u.outputTokenCount || // Bedrock invocationMetrics
|
||||
u.completion_tokens,
|
||||
totalTokens:
|
||||
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),
|
||||
u.total_tokens ||
|
||||
u.totalTokens || // Bedrock camelCase
|
||||
(u.input_tokens || u.inputTokenCount || 0) + (u.output_tokens || u.outputTokenCount || 0),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ describe("Image extraction from content", () => {
|
|||
// Mock the global app object
|
||||
const mockApp = {
|
||||
metadataCache: {
|
||||
getFirstLinkpathDest: jest.fn(),
|
||||
getFirstLinkpathDest: jest.fn() as jest.Mock<{ path: string } | null, [string, string]>,
|
||||
},
|
||||
};
|
||||
|
||||
(global as any).app = mockApp;
|
||||
(window as unknown as { app: typeof mockApp }).app = mockApp;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { ModelAdapterFactory, joinPromptSections } from "./modelAdapter";
|
||||
import { ToolMetadata } from "@/tools/ToolRegistry";
|
||||
import type { BaseChatModel } from "@langchain/core/language_models/chat_models";
|
||||
|
||||
describe("ModelAdapter", () => {
|
||||
describe("enhanceSystemPrompt", () => {
|
||||
|
|
@ -15,7 +16,7 @@ describe("ModelAdapter", () => {
|
|||
});
|
||||
|
||||
it("should include tool instructions when tools are enabled", () => {
|
||||
const mockModel = { modelName: "gpt-4" } as any;
|
||||
const mockModel = { modelName: "gpt-4" } as unknown as BaseChatModel;
|
||||
const adapter = ModelAdapterFactory.createAdapter(mockModel);
|
||||
|
||||
const toolMetadata: ToolMetadata[] = [
|
||||
|
|
@ -38,7 +39,7 @@ describe("ModelAdapter", () => {
|
|||
});
|
||||
|
||||
it("should only include instructions for enabled tools", () => {
|
||||
const mockModel = { modelName: "gpt-4" } as any;
|
||||
const mockModel = { modelName: "gpt-4" } as unknown as BaseChatModel;
|
||||
const adapter = ModelAdapterFactory.createAdapter(mockModel);
|
||||
|
||||
const toolMetadata: ToolMetadata[] = [
|
||||
|
|
@ -64,7 +65,7 @@ describe("ModelAdapter", () => {
|
|||
});
|
||||
|
||||
it("should include base structure elements", () => {
|
||||
const mockModel = { modelName: "gpt-4" } as any;
|
||||
const mockModel = { modelName: "gpt-4" } as unknown as BaseChatModel;
|
||||
const adapter = ModelAdapterFactory.createAdapter(mockModel);
|
||||
|
||||
const enhancedPrompt = adapter.enhanceSystemPrompt(basePrompt, toolDescriptions, [], []);
|
||||
|
|
@ -76,7 +77,7 @@ describe("ModelAdapter", () => {
|
|||
});
|
||||
|
||||
it("should handle GPT-specific enhancements", () => {
|
||||
const mockModel = { modelName: "gpt-4" } as any;
|
||||
const mockModel = { modelName: "gpt-4" } as unknown as BaseChatModel;
|
||||
const adapter = ModelAdapterFactory.createAdapter(mockModel);
|
||||
|
||||
const enhancedPrompt = adapter.enhanceSystemPrompt(basePrompt, toolDescriptions, [], []);
|
||||
|
|
@ -87,7 +88,7 @@ describe("ModelAdapter", () => {
|
|||
});
|
||||
|
||||
it("should handle Claude-specific enhancements", () => {
|
||||
const mockModel = { modelName: "claude-3-7-sonnet" } as any;
|
||||
const mockModel = { modelName: "claude-3-7-sonnet" } as unknown as BaseChatModel;
|
||||
const adapter = ModelAdapterFactory.createAdapter(mockModel);
|
||||
|
||||
const enhancedPrompt = adapter.enhanceSystemPrompt(basePrompt, toolDescriptions, [], []);
|
||||
|
|
@ -97,7 +98,7 @@ describe("ModelAdapter", () => {
|
|||
});
|
||||
|
||||
it("should handle Gemini-specific enhancements", () => {
|
||||
const mockModel = { modelName: "gemini-pro" } as any;
|
||||
const mockModel = { modelName: "gemini-pro" } as unknown as BaseChatModel;
|
||||
const adapter = ModelAdapterFactory.createAdapter(mockModel);
|
||||
|
||||
const enhancedPrompt = adapter.enhanceSystemPrompt(basePrompt, toolDescriptions, [], []);
|
||||
|
|
@ -107,7 +108,7 @@ describe("ModelAdapter", () => {
|
|||
});
|
||||
|
||||
it("should exclude instructions when no metadata provided", () => {
|
||||
const mockModel = { modelName: "gpt-4" } as any;
|
||||
const mockModel = { modelName: "gpt-4" } as unknown as BaseChatModel;
|
||||
const adapter = ModelAdapterFactory.createAdapter(mockModel);
|
||||
|
||||
const enhancedPrompt = adapter.enhanceSystemPrompt(
|
||||
|
|
@ -123,7 +124,7 @@ describe("ModelAdapter", () => {
|
|||
});
|
||||
|
||||
it("should include composer-specific examples for GPT when file tools are enabled", () => {
|
||||
const mockModel = { modelName: "gpt-4" } as any;
|
||||
const mockModel = { modelName: "gpt-4" } as unknown as BaseChatModel;
|
||||
const adapter = ModelAdapterFactory.createAdapter(mockModel);
|
||||
|
||||
const enhancedPrompt = adapter.enhanceSystemPrompt(
|
||||
|
|
@ -141,7 +142,7 @@ describe("ModelAdapter", () => {
|
|||
});
|
||||
|
||||
it("should rebuild enhanceSystemPrompt output from section metadata", () => {
|
||||
const mockModel = { modelName: "gpt-4" } as any;
|
||||
const mockModel = { modelName: "gpt-4" } as unknown as BaseChatModel;
|
||||
const adapter = ModelAdapterFactory.createAdapter(mockModel);
|
||||
|
||||
const sections = adapter.buildSystemPromptSections(basePrompt, toolDescriptions, [], []);
|
||||
|
|
@ -157,7 +158,7 @@ describe("ModelAdapter", () => {
|
|||
});
|
||||
|
||||
it("should enhance file editing messages for GPT", () => {
|
||||
const mockModel = { modelName: "gpt-4" } as any;
|
||||
const mockModel = { modelName: "gpt-4" } as unknown as BaseChatModel;
|
||||
const adapter = ModelAdapterFactory.createAdapter(mockModel);
|
||||
|
||||
const editMessage = "fix the typo in my note";
|
||||
|
|
|
|||
|
|
@ -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): any[];
|
||||
parseToolCalls?(response: string): unknown[];
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
protected isGPT5Model(): boolean {
|
||||
isGPT5Model(): boolean {
|
||||
return this.modelName.includes("gpt-5") || this.modelName.includes("gpt5");
|
||||
}
|
||||
buildSystemPromptSections(
|
||||
|
|
@ -674,7 +674,11 @@ 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 = ((model as any).modelName || (model as any).model || "").toLowerCase();
|
||||
const modelName: string = (
|
||||
(model as { modelName?: string }).modelName ||
|
||||
(model as { model?: string }).model ||
|
||||
""
|
||||
).toLowerCase();
|
||||
|
||||
logInfo(`Creating model adapter for: ${modelName}`);
|
||||
|
||||
|
|
@ -682,7 +686,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 as any).isGPT5Model()) {
|
||||
if (adapter.isGPT5Model()) {
|
||||
logInfo("Using GPTModelAdapter with GPT-5 specific enhancements");
|
||||
} else {
|
||||
logInfo("Using GPTModelAdapter");
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
*/
|
||||
|
||||
import { AIMessage, ToolMessage } from "@langchain/core/messages";
|
||||
import { ToolCall as LangChainToolCall } from "@langchain/core/messages/tool";
|
||||
import { logError } from "@/logger";
|
||||
|
||||
/**
|
||||
|
|
@ -27,37 +26,6 @@ export interface ToolCallChunk {
|
|||
args: string; // JSON string accumulated from chunks
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract native tool calls from an AIMessage.
|
||||
* Returns empty array if no tool calls present.
|
||||
*
|
||||
* @param message - AIMessage from LLM response
|
||||
* @returns Array of standardized tool calls
|
||||
*/
|
||||
export function extractNativeToolCalls(message: AIMessage): NativeToolCall[] {
|
||||
const toolCalls = message.tool_calls;
|
||||
|
||||
if (!toolCalls || toolCalls.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return toolCalls.map((tc: LangChainToolCall) => ({
|
||||
id: tc.id || generateToolCallId(),
|
||||
name: tc.name,
|
||||
args: (tc.args as Record<string, unknown>) || {},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an AIMessage contains tool calls
|
||||
*
|
||||
* @param message - AIMessage to check
|
||||
* @returns true if message has tool calls
|
||||
*/
|
||||
export function hasToolCalls(message: AIMessage): boolean {
|
||||
return (message.tool_calls?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ToolMessage for returning tool execution results to the LLM.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { generatePromptDebugReportForAgent, resolveBasePrompt } from "./promptDebugService";
|
||||
import { PromptSection } from "./modelAdapter";
|
||||
import type ChainManager from "@/LLMProviders/chainManager";
|
||||
import { ModelAdapter, PromptSection } from "./modelAdapter";
|
||||
import { PromptDebugReport } from "./toolPromptDebugger";
|
||||
|
||||
const createAdapter = () => ({
|
||||
|
|
@ -8,7 +9,7 @@ const createAdapter = () => ({
|
|||
basePrompt: string,
|
||||
toolDescriptions: string,
|
||||
toolNames?: string[],
|
||||
toolMetadata?: any[]
|
||||
toolMetadata?: unknown[]
|
||||
): PromptSection[] => [
|
||||
{
|
||||
id: "system",
|
||||
|
|
@ -24,7 +25,7 @@ const createAdapter = () => ({
|
|||
constructor: { name: "TestAdapter" },
|
||||
});
|
||||
|
||||
const createChainContext = (history: any[] = []) => {
|
||||
const createChainContext = (history: unknown[] = []): ChainManager => {
|
||||
const memory = {
|
||||
loadMemoryVariables: jest.fn().mockResolvedValue({ history }),
|
||||
};
|
||||
|
|
@ -36,7 +37,7 @@ const createChainContext = (history: any[] = []) => {
|
|||
userMemoryManager: {
|
||||
getUserMemoryPrompt: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
} as any;
|
||||
} as unknown as ChainManager;
|
||||
};
|
||||
|
||||
describe("promptDebugService", () => {
|
||||
|
|
@ -46,7 +47,7 @@ describe("promptDebugService", () => {
|
|||
|
||||
const report: PromptDebugReport = await generatePromptDebugReportForAgent({
|
||||
chainManager,
|
||||
adapter: adapter as any,
|
||||
adapter: adapter as unknown as ModelAdapter,
|
||||
basePrompt: "BasePrompt",
|
||||
toolDescriptions: "<tool></tool>",
|
||||
toolNames: ["localSearch"],
|
||||
|
|
@ -90,7 +91,7 @@ describe("promptDebugService", () => {
|
|||
userMemoryManager: {
|
||||
getUserMemoryPrompt: jest.fn().mockResolvedValue(memoryPrompt),
|
||||
},
|
||||
} as any;
|
||||
} as unknown as ChainManager;
|
||||
|
||||
const prompt = await resolveBasePrompt(chainManager);
|
||||
expect(prompt).toContain(memoryPrompt);
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ describe("promptPayloadRecorder", () => {
|
|||
});
|
||||
|
||||
it("serializes circular message graphs without throwing", async () => {
|
||||
const circular: any = { role: "system" };
|
||||
const circular: { role: string; self?: unknown } = { role: "system" };
|
||||
circular.self = circular;
|
||||
|
||||
expect(() => recordPromptPayload({ messages: [circular] })).not.toThrow();
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ function safeSerialize(value: unknown): string {
|
|||
|
||||
return JSON.stringify(
|
||||
value,
|
||||
(key, val) => {
|
||||
(key, val: unknown): unknown => {
|
||||
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: any): string => {
|
||||
const getTextContent = (msg: { role?: unknown; content?: unknown }): string => {
|
||||
if (typeof msg.content === "string") {
|
||||
return msg.content;
|
||||
}
|
||||
if (Array.isArray(msg.content)) {
|
||||
const textParts = msg.content
|
||||
.filter((item: any) => item.type === "text")
|
||||
.map((item: any) => item.text);
|
||||
const textParts: string[] = (msg.content as Array<{ type?: string; text?: string }>)
|
||||
.filter((item) => item.type === "text")
|
||||
.map((item): string => 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: any = messageArray[i];
|
||||
const msg = messageArray[i] as { role?: unknown; content?: unknown };
|
||||
const content = getTextContent(msg);
|
||||
|
||||
if (msg.role === "system") {
|
||||
|
|
@ -194,7 +194,9 @@ function buildLayeredViewFromMessages(
|
|||
}
|
||||
|
||||
// Last user message
|
||||
const lastMsg: any = messageArray[messageArray.length - 1];
|
||||
const lastMsg = messageArray[messageArray.length - 1] as
|
||||
| { role?: unknown; content?: unknown }
|
||||
| undefined;
|
||||
if (lastMsg && lastMsg.role === "user") {
|
||||
lines.push("━━━ USER MESSAGE ━━━");
|
||||
lines.push("");
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ import {
|
|||
describe("searchResultUtils", () => {
|
||||
describe("formatSearchResultsForLLM", () => {
|
||||
it("should return empty string for non-array input", () => {
|
||||
expect(formatSearchResultsForLLM(null as any)).toBe("");
|
||||
expect(formatSearchResultsForLLM(undefined as any)).toBe("");
|
||||
expect(formatSearchResultsForLLM("string" as any)).toBe("");
|
||||
expect(formatSearchResultsForLLM({} as any)).toBe("");
|
||||
expect(formatSearchResultsForLLM(null)).toBe("");
|
||||
expect(formatSearchResultsForLLM(undefined)).toBe("");
|
||||
expect(formatSearchResultsForLLM("string")).toBe("");
|
||||
expect(formatSearchResultsForLLM({})).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 as any)).toEqual([]);
|
||||
expect(extractSourcesFromSearchResults(undefined as any)).toEqual([]);
|
||||
expect(extractSourcesFromSearchResults("string" as any)).toEqual([]);
|
||||
expect(extractSourcesFromSearchResults(null)).toEqual([]);
|
||||
expect(extractSourcesFromSearchResults(undefined)).toEqual([]);
|
||||
expect(extractSourcesFromSearchResults("string")).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 as any)).toBe("");
|
||||
expect(formatMetadataOnlyDocuments(undefined as any)).toBe("");
|
||||
expect(formatMetadataOnlyDocuments(null)).toBe("");
|
||||
expect(formatMetadataOnlyDocuments(undefined)).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 any)).toBe(false);
|
||||
expect(isFilterOnlyResults(undefined as any)).toBe(false);
|
||||
expect(isFilterOnlyResults(null as unknown as Array<{ source?: string }>)).toBe(false);
|
||||
expect(isFilterOnlyResults(undefined as unknown as Array<{ source?: string }>)).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 any)).toBe(false);
|
||||
expect(isTimeDominantResults(undefined as any)).toBe(false);
|
||||
expect(isTimeDominantResults(null as unknown as Array<{ source?: string }>)).toBe(false);
|
||||
expect(isTimeDominantResults(undefined as unknown as Array<{ source?: string }>)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when at least one doc has source time-filtered", () => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,32 @@
|
|||
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.
|
||||
*/
|
||||
export interface QualitySummary {
|
||||
interface QualitySummary {
|
||||
high: number; // Count of results with score >= 0.7
|
||||
medium: number; // Count of results with score >= 0.3 and < 0.7
|
||||
low: number; // Count of results with score < 0.3
|
||||
|
|
@ -20,7 +41,7 @@ export interface QualitySummary {
|
|||
* @param searchResults - Array of search results with scores
|
||||
* @returns Quality summary with counts by relevance tier
|
||||
*/
|
||||
export function generateQualitySummary(searchResults: any[]): QualitySummary {
|
||||
export function generateQualitySummary(searchResults: SearchDoc[]): QualitySummary {
|
||||
if (!Array.isArray(searchResults) || searchResults.length === 0) {
|
||||
return { high: 0, medium: 0, low: 0, total: 0, averageScore: 0 };
|
||||
}
|
||||
|
|
@ -77,13 +98,15 @@ 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: any[]): string {
|
||||
export function formatSearchResultsForLLM(searchResults: unknown): string {
|
||||
if (!Array.isArray(searchResults)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Filter documents that should be included in context
|
||||
const includedDocs = searchResults.filter((doc) => doc.includeInContext !== false);
|
||||
const includedDocs = (searchResults as SearchDoc[]).filter(
|
||||
(doc) => doc.includeInContext !== false
|
||||
);
|
||||
|
||||
if (includedDocs.length === 0) {
|
||||
return "No relevant documents found.";
|
||||
|
|
@ -91,15 +114,11 @@ export function formatSearchResultsForLLM(searchResults: any[]): string {
|
|||
|
||||
// Format each document with essential metadata
|
||||
const formattedDocs = includedDocs
|
||||
.map((doc: any, idx: number) => {
|
||||
.map((doc, 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 as any).__sourceId ||
|
||||
(doc as any).collection_name ||
|
||||
(doc as any).source_id ||
|
||||
idx + 1;
|
||||
const sourceId = doc.__sourceId || doc.collection_name || doc.source_id || idx + 1;
|
||||
|
||||
// Safely handle mtime - check validity before converting
|
||||
let modified: string | null = null;
|
||||
|
|
@ -159,13 +178,13 @@ export function formatSearchResultStringForLLM(resultString: string): string {
|
|||
* @returns Sources array with explanation preserved for UI
|
||||
*/
|
||||
export function extractSourcesFromSearchResults(
|
||||
searchResults: any[]
|
||||
): { title: string; path: string; score: number; explanation?: any }[] {
|
||||
searchResults: unknown
|
||||
): { title: string; path: string; score: number; explanation?: unknown }[] {
|
||||
if (!Array.isArray(searchResults)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return searchResults.map((doc: any) => ({
|
||||
return (searchResults as SearchDoc[]).map((doc) => ({
|
||||
title: doc.title || doc.path || "Untitled",
|
||||
path: doc.path || doc.title || "",
|
||||
score: doc.rerank_score || doc.score || 0,
|
||||
|
|
@ -193,19 +212,20 @@ function toIsoString(ts: unknown): string {
|
|||
* Create a concise, single-line summary of an explanation object.
|
||||
* Includes lexical matches, semantic score, folder/graph boosts, and score adjustments.
|
||||
*/
|
||||
export function summarizeExplanation(explanation: any): string {
|
||||
function summarizeExplanation(explanation: unknown): string {
|
||||
if (!explanation) return "";
|
||||
|
||||
const parts: string[] = [];
|
||||
const exp = explanation as Record<string, unknown>;
|
||||
|
||||
try {
|
||||
// Lexical matches summary
|
||||
if (Array.isArray(explanation.lexicalMatches) && explanation.lexicalMatches.length > 0) {
|
||||
if (Array.isArray(exp.lexicalMatches) && exp.lexicalMatches.length > 0) {
|
||||
const fields = new Set<string>();
|
||||
const terms = new Set<string>();
|
||||
for (const m of explanation.lexicalMatches) {
|
||||
if (m?.field) fields.add(String(m.field));
|
||||
if (m?.query) terms.add(String(m.query));
|
||||
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);
|
||||
}
|
||||
const fieldsStr = Array.from(fields).join("/");
|
||||
const termsStr = Array.from(terms).slice(0, 3).join(", ");
|
||||
|
|
@ -213,24 +233,32 @@ export function summarizeExplanation(explanation: any): string {
|
|||
}
|
||||
|
||||
// Semantic score
|
||||
if (typeof explanation.semanticScore === "number" && explanation.semanticScore > 0) {
|
||||
parts.push(`Semantic: ${(explanation.semanticScore * 100).toFixed(1)}%`);
|
||||
if (typeof exp.semanticScore === "number" && exp.semanticScore > 0) {
|
||||
parts.push(`Semantic: ${(exp.semanticScore * 100).toFixed(1)}%`);
|
||||
}
|
||||
|
||||
// Folder boost
|
||||
if (explanation.folderBoost && typeof explanation.folderBoost.boostFactor === "number") {
|
||||
const fb = explanation.folderBoost;
|
||||
if (
|
||||
exp.folderBoost &&
|
||||
typeof (exp.folderBoost as { boostFactor?: number }).boostFactor === "number"
|
||||
) {
|
||||
const fb = exp.folderBoost as { boostFactor: number; folder?: string };
|
||||
const folder = fb.folder || "root";
|
||||
parts.push(`Folder +${fb.boostFactor.toFixed(2)} (${folder})`);
|
||||
}
|
||||
|
||||
// Graph connections (query-aware boost)
|
||||
if (explanation.graphConnections && typeof explanation.graphConnections === "object") {
|
||||
const gc = explanation.graphConnections;
|
||||
if (exp.graphConnections && typeof exp.graphConnections === "object") {
|
||||
const gc = exp.graphConnections as {
|
||||
backlinks?: number;
|
||||
coCitations?: number;
|
||||
sharedTags?: number;
|
||||
score?: number;
|
||||
};
|
||||
const bits: string[] = [];
|
||||
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 ((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 (typeof gc.score === "number") {
|
||||
parts.push(`Graph ${gc.score.toFixed(1)}${bits.length ? ` (${bits.join(", ")})` : ""}`);
|
||||
} else if (bits.length) {
|
||||
|
|
@ -240,21 +268,21 @@ export function summarizeExplanation(explanation: any): string {
|
|||
|
||||
// Legacy graph boost
|
||||
if (
|
||||
explanation.graphBoost &&
|
||||
typeof explanation.graphBoost.boostFactor === "number" &&
|
||||
!explanation.graphConnections
|
||||
exp.graphBoost &&
|
||||
typeof (exp.graphBoost as { boostFactor?: number }).boostFactor === "number" &&
|
||||
!exp.graphConnections
|
||||
) {
|
||||
const gb = explanation.graphBoost;
|
||||
const gb = exp.graphBoost as { boostFactor: number; connections?: number };
|
||||
parts.push(`Graph +${gb.boostFactor.toFixed(2)} (${gb.connections} connections)`);
|
||||
}
|
||||
|
||||
// Score adjustment
|
||||
if (
|
||||
typeof explanation.baseScore === "number" &&
|
||||
typeof explanation.finalScore === "number" &&
|
||||
explanation.baseScore !== explanation.finalScore
|
||||
typeof exp.baseScore === "number" &&
|
||||
typeof exp.finalScore === "number" &&
|
||||
exp.baseScore !== exp.finalScore
|
||||
) {
|
||||
parts.push(`Score: ${explanation.baseScore.toFixed(4)}→${explanation.finalScore.toFixed(4)}`);
|
||||
parts.push(`Score: ${exp.baseScore.toFixed(4)}→${exp.finalScore.toFixed(4)}`);
|
||||
}
|
||||
} catch {
|
||||
// Ignore explanation parsing errors, leave parts as-is
|
||||
|
|
@ -282,8 +310,8 @@ export function summarizeExplanation(explanation: any): string {
|
|||
* @returns Formatted XML string with `<filterResults>` and `<searchResults>` sections
|
||||
*/
|
||||
export function formatSplitSearchResultsForLLM(
|
||||
filterDocs: any[],
|
||||
searchDocs: any[],
|
||||
filterDocs: SearchDoc[],
|
||||
searchDocs: SearchDoc[],
|
||||
startId = 1
|
||||
): string {
|
||||
let currentId = startId;
|
||||
|
|
@ -292,8 +320,8 @@ export function formatSplitSearchResultsForLLM(
|
|||
// Format filter results
|
||||
if (filterDocs.length > 0) {
|
||||
const filterXml = filterDocs
|
||||
.map((doc: any) => {
|
||||
const id = (doc as any).__sourceId || currentId++;
|
||||
.map((doc) => {
|
||||
const id = doc.__sourceId || currentId++;
|
||||
const title = doc.title || "Untitled";
|
||||
const path = doc.path || "";
|
||||
const matchType = doc.matchType || doc.source || "filter";
|
||||
|
|
@ -317,8 +345,8 @@ ${doc.content || ""}
|
|||
// Format search results
|
||||
if (searchDocs.length > 0) {
|
||||
const searchXml = searchDocs
|
||||
.map((doc: any) => {
|
||||
const id = (doc as any).__sourceId || currentId++;
|
||||
.map((doc) => {
|
||||
const id = doc.__sourceId || currentId++;
|
||||
const title = doc.title || "Untitled";
|
||||
const path = doc.path || "";
|
||||
const modified = toIsoString(doc.mtime);
|
||||
|
|
@ -393,16 +421,13 @@ 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: Array<{ title?: string; path?: string; mtime?: number | null; content?: string }>,
|
||||
snippetLength = 300
|
||||
): string {
|
||||
export function formatMetadataOnlyDocuments(docs: unknown, snippetLength = 300): string {
|
||||
if (!Array.isArray(docs) || docs.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const fileElements = docs
|
||||
.map((doc: any) => {
|
||||
const fileElements = (docs as SearchDoc[])
|
||||
.map((doc) => {
|
||||
const title = doc.title || "Untitled";
|
||||
const path = doc.path || "";
|
||||
const modified = toIsoString(doc.mtime);
|
||||
|
|
@ -420,7 +445,7 @@ export function formatMetadataOnlyDocuments(
|
|||
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: any[]): void {
|
||||
export function logSearchResultsDebugTable(searchResults: SearchDoc[]): void {
|
||||
if (!Array.isArray(searchResults) || searchResults.length === 0) {
|
||||
logInfo("Search Results: (none)");
|
||||
return;
|
||||
|
|
@ -435,7 +460,7 @@ export function logSearchResultsDebugTable(searchResults: any[]): void {
|
|||
};
|
||||
|
||||
let includedCount = 0;
|
||||
const rows: Row[] = searchResults.map((doc: any) => {
|
||||
const rows: Row[] = searchResults.map((doc) => {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export interface ErrorMarker {
|
|||
endIndex: number;
|
||||
}
|
||||
|
||||
export interface ParsedMessage {
|
||||
interface ParsedMessage {
|
||||
segments: Array<{
|
||||
type: "text" | "toolCall" | "error";
|
||||
content: string;
|
||||
|
|
@ -45,7 +45,7 @@ function encodeResultForMarker(result: string): string {
|
|||
/**
|
||||
* Decode tool result previously encoded for marker embedding
|
||||
*/
|
||||
export function decodeResultFromMarker(result: string | undefined): string | undefined {
|
||||
function decodeResultFromMarker(result: string | undefined): string | undefined {
|
||||
if (typeof result !== "string") return result;
|
||||
if (!result.startsWith("ENC:")) return result;
|
||||
try {
|
||||
|
|
@ -65,38 +65,6 @@ function buildOmittedResultMessage(toolName: string): string {
|
|||
return `Tool '${toolName}' ${TOOL_RESULT_OMITTED_THRESHOLD_MESSAGE}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* For logging only: decode any encoded tool results embedded in markers
|
||||
*/
|
||||
export function decodeToolCallMarkerResults(message: string): string {
|
||||
if (!message || typeof message !== "string") return message;
|
||||
return message.replace(
|
||||
/<!--TOOL_CALL_END:([^:]+):(ENC:[\s\S]*?)-->/g,
|
||||
(_match, id: string, encoded: string) => {
|
||||
const decoded = decodeResultFromMarker(encoded) || encoded;
|
||||
return `<!--TOOL_CALL_END:${id}:${decoded}-->`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure any TOOL_CALL_END results are encoded. Useful for sanitizing messages
|
||||
* that might contain unencoded results due to legacy or partial updates.
|
||||
*/
|
||||
export function ensureEncodedToolCallMarkerResults(message: string): string {
|
||||
if (!message || typeof message !== "string") return message;
|
||||
return message.replace(
|
||||
/<!--TOOL_CALL_END:([^:]+):([\s\S]*?)-->/g,
|
||||
(_match, id: string, content: string) => {
|
||||
if (content.startsWith("ENC:")) {
|
||||
return _match;
|
||||
}
|
||||
const safe = encodeResultForMarker(content);
|
||||
return `<!--TOOL_CALL_END:${id}:${safe}-->`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse error chunks from a text segment
|
||||
* Format: <errorChunk>error content</errorChunk>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { executeSequentialToolCall } from "./toolExecution";
|
||||
import { executeSequentialToolCall, type ToolCall } 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 any, []);
|
||||
const result = await executeSequentialToolCall(null as unknown as ToolCall, []);
|
||||
|
||||
expect(result).toEqual({
|
||||
toolName: "unknown",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { StructuredTool } from "@langchain/core/tools";
|
||||
import { logError, logInfo, logWarn } from "@/logger";
|
||||
import { checkIsPlusUser, isSelfHostModeValid } from "@/plusUtils";
|
||||
import { getSettings } from "@/settings/model";
|
||||
|
|
@ -14,7 +15,7 @@ export interface ToolCall {
|
|||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolExecutionResult {
|
||||
interface ToolExecutionResult {
|
||||
toolName: string;
|
||||
result: string;
|
||||
success: boolean;
|
||||
|
|
@ -30,7 +31,7 @@ export interface ToolExecutionResult {
|
|||
*/
|
||||
export async function executeSequentialToolCall(
|
||||
toolCall: ToolCall,
|
||||
availableTools: any[],
|
||||
availableTools: Pick<StructuredTool, "name" | "invoke">[],
|
||||
originalUserMessage?: string
|
||||
): Promise<ToolExecutionResult> {
|
||||
const DEFAULT_TOOL_TIMEOUT = 120000; // 120 seconds timeout per tool
|
||||
|
|
@ -49,7 +50,7 @@ export async function executeSequentialToolCall(
|
|||
const tool = availableTools.find((t) => t.name === toolCall.name);
|
||||
|
||||
if (!tool) {
|
||||
const availableToolNames = availableTools.map((t) => t.name).join(", ");
|
||||
const availableToolNames = availableTools.map((t): string => 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.`,
|
||||
|
|
@ -96,7 +97,7 @@ export async function executeSequentialToolCall(
|
|||
result = await Promise.race([
|
||||
ToolManager.callTool(tool, toolArgs),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
window.setTimeout(
|
||||
() => reject(new Error(`Tool execution timed out after ${timeout}ms`)),
|
||||
timeout
|
||||
)
|
||||
|
|
@ -145,7 +146,7 @@ export async function executeSequentialToolCall(
|
|||
/**
|
||||
* Get display name for tool (user-friendly version)
|
||||
*/
|
||||
export function getToolDisplayName(toolName: string): string {
|
||||
function getToolDisplayName(toolName: string): string {
|
||||
// Special handling for localSearch to show the actual search type being used
|
||||
if (toolName === "localSearch") {
|
||||
const settings = getSettings();
|
||||
|
|
@ -183,7 +184,7 @@ export function getToolDisplayName(toolName: string): string {
|
|||
/**
|
||||
* Get emoji for tool display
|
||||
*/
|
||||
export function getToolEmoji(toolName: string): string {
|
||||
function getToolEmoji(toolName: string): string {
|
||||
const emojiMap: Record<string, string> = {
|
||||
localSearch: "🔍",
|
||||
webSearch: "🌐",
|
||||
|
|
@ -210,29 +211,6 @@ export 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
|
||||
*/
|
||||
|
|
@ -283,11 +261,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?: any }[]
|
||||
): { title: string; path: string; score: number; explanation?: any }[] {
|
||||
sources: { title: string; path: string; score: number; explanation?: unknown }[]
|
||||
): { title: string; path: string; score: number; explanation?: unknown }[] {
|
||||
const uniqueSources = new Map<
|
||||
string,
|
||||
{ title: string; path: string; score: number; explanation?: any }
|
||||
{ title: string; path: string; score: number; explanation?: unknown }
|
||||
>();
|
||||
|
||||
for (const source of sources) {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import { processRawChatHistory, processedMessagesToTextOnly } from "./chatHistor
|
|||
/**
|
||||
* Options for building prompt debug sections with annotated provenance.
|
||||
*/
|
||||
export interface BuildPromptDebugSectionsOptions {
|
||||
interface BuildPromptDebugSectionsOptions {
|
||||
systemSections: PromptSection[];
|
||||
rawHistory?: any[];
|
||||
rawHistory?: unknown[];
|
||||
adapterName: string;
|
||||
originalUserMessage: string;
|
||||
enhancedUserMessage: string;
|
||||
|
|
@ -27,9 +27,7 @@ export interface PromptDebugReport {
|
|||
* @param options - Data required to assemble annotated prompt sections.
|
||||
* @returns Prompt sections with provenance metadata.
|
||||
*/
|
||||
export function buildPromptDebugSections(
|
||||
options: BuildPromptDebugSectionsOptions
|
||||
): PromptSection[] {
|
||||
function buildPromptDebugSections(options: BuildPromptDebugSectionsOptions): PromptSection[] {
|
||||
const { systemSections, rawHistory, adapterName, originalUserMessage, enhancedUserMessage } =
|
||||
options;
|
||||
const sections: PromptSection[] = [...systemSections];
|
||||
|
|
@ -76,7 +74,7 @@ export function buildPromptDebugSections(
|
|||
* @param sections - Prompt sections with provenance metadata.
|
||||
* @returns Multiline string with section headers that identify code sources.
|
||||
*/
|
||||
export function formatPromptSectionsWithAnnotations(sections: PromptSection[]): string {
|
||||
function formatPromptSectionsWithAnnotations(sections: PromptSection[]): string {
|
||||
return sections
|
||||
.map((section) => {
|
||||
const header = `[Section: ${section.label} | Source: ${section.source}]`;
|
||||
|
|
|
|||
|
|
@ -42,9 +42,9 @@ describe("escapeXml", () => {
|
|||
});
|
||||
|
||||
it("should handle non-string inputs", () => {
|
||||
expect(escapeXml(null as any)).toBe("");
|
||||
expect(escapeXml(undefined as any)).toBe("");
|
||||
expect(escapeXml(123 as any)).toBe("");
|
||||
expect(escapeXml(null)).toBe("");
|
||||
expect(escapeXml(undefined)).toBe("");
|
||||
expect(escapeXml(123)).toBe("");
|
||||
});
|
||||
|
||||
it("should escape XML entity references", () => {
|
||||
|
|
@ -90,9 +90,9 @@ describe("unescapeXml", () => {
|
|||
});
|
||||
|
||||
it("should handle non-string inputs", () => {
|
||||
expect(unescapeXml(null as any)).toBe("");
|
||||
expect(unescapeXml(undefined as any)).toBe("");
|
||||
expect(unescapeXml(123 as any)).toBe("");
|
||||
expect(unescapeXml(null)).toBe("");
|
||||
expect(unescapeXml(undefined)).toBe("");
|
||||
expect(unescapeXml(123)).toBe("");
|
||||
});
|
||||
|
||||
it("should handle double-escaped ampersand correctly", () => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
* @param str - The string to escape
|
||||
* @returns The escaped string safe for XML content
|
||||
*/
|
||||
export function escapeXml(str: string): string {
|
||||
export function escapeXml(str: unknown): string {
|
||||
if (typeof str !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ export function escapeXml(str: string): string {
|
|||
* @param str - The XML-escaped string to unescape
|
||||
* @returns The unescaped string with original characters restored
|
||||
*/
|
||||
export function unescapeXml(str: string): string {
|
||||
export function unescapeXml(str: unknown): string {
|
||||
if (typeof str !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
|
@ -47,6 +47,6 @@ export function unescapeXml(str: string): string {
|
|||
* @param str - The string to escape for attribute use
|
||||
* @returns The escaped string safe for XML attributes
|
||||
*/
|
||||
export function escapeXmlAttribute(str: string): string {
|
||||
export function escapeXmlAttribute(str: unknown): string {
|
||||
return escapeXml(str);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,16 +23,14 @@ 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";
|
||||
|
|
@ -42,31 +40,41 @@ 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 any).getNumTokens = async (
|
||||
content: string | Array<{ type: string; text?: string }>
|
||||
) => {
|
||||
|
||||
(
|
||||
BaseLanguageModel.prototype as { getNumTokens: (...args: unknown[]) => Promise<number> }
|
||||
).getNumTokens = async (content: string | Array<{ type: string; text?: string }>) => {
|
||||
const text =
|
||||
typeof content === "string"
|
||||
? content
|
||||
: content.map((item: any) => (typeof item === "string" ? item : (item.text ?? ""))).join("");
|
||||
: content.map((item: { type: string; text?: string }): string => item.text ?? "").join("");
|
||||
return Math.ceil(text.length / 4);
|
||||
};
|
||||
|
||||
type ChatConstructorType = {
|
||||
new (config: any): any;
|
||||
new (config: Record<string, unknown>): BaseChatModel;
|
||||
};
|
||||
|
||||
const CHAT_PROVIDER_CONSTRUCTORS = {
|
||||
[ChatModelProviders.OPENAI]: ChatOpenAI,
|
||||
[ChatModelProviders.AZURE_OPENAI]: ChatOpenAI,
|
||||
[ChatModelProviders.ANTHROPIC]: ChatAnthropic,
|
||||
[ChatModelProviders.COHEREAI]: ChatCohere,
|
||||
[ChatModelProviders.COHEREAI]: ChatOpenAI,
|
||||
[ChatModelProviders.GOOGLE]: ChatGoogleGenerativeAI,
|
||||
[ChatModelProviders.XAI]: ChatXAI,
|
||||
[ChatModelProviders.OPENROUTERAI]: ChatOpenRouter,
|
||||
|
|
@ -76,7 +84,7 @@ const CHAT_PROVIDER_CONSTRUCTORS = {
|
|||
[ChatModelProviders.OPENAI_FORMAT]: ChatOpenAI,
|
||||
[ChatModelProviders.SILICONFLOW]: ChatOpenAI,
|
||||
[ChatModelProviders.COPILOT_PLUS]: ChatOpenRouter,
|
||||
[ChatModelProviders.MISTRAL]: ChatMistralAI,
|
||||
[ChatModelProviders.MISTRAL]: ChatOpenAI,
|
||||
[ChatModelProviders.DEEPSEEK]: ChatDeepSeek,
|
||||
[ChatModelProviders.AMAZON_BEDROCK]: BedrockChatModel,
|
||||
[ChatModelProviders.GITHUB_COPILOT]: GitHubCopilotChatModel,
|
||||
|
|
@ -193,7 +201,7 @@ export default class ChatModelManager {
|
|||
|
||||
const modelName = customModel.name;
|
||||
const modelInfo = getModelInfo(modelName);
|
||||
const { isThinkingEnabled } = modelInfo;
|
||||
const { isThinkingEnabled, usesAdaptiveThinking } = modelInfo;
|
||||
const resolvedTemperature = this.getTemperatureForModel(modelInfo, customModel, settings);
|
||||
const maxTokens = customModel.maxTokens ?? settings.maxTokens;
|
||||
|
||||
|
|
@ -240,13 +248,18 @@ export default class ChatModelManager {
|
|||
fetch: customModel.enableCors ? safeFetch : undefined,
|
||||
},
|
||||
...(isThinkingEnabled && {
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budget_tokens: ChatModelManager.ANTHROPIC_THINKING_BUDGET_TOKENS,
|
||||
},
|
||||
// Opus 4.7+ defaults thinking.display to "omitted" so thinking summaries
|
||||
// never reach the UI; force "summarized" for the adaptive branch. Pre-4.7
|
||||
// models default to "summarized" server-side and don't need this.
|
||||
thinking: usesAdaptiveThinking
|
||||
? { type: "adaptive" as const, display: "summarized" as const }
|
||||
: {
|
||||
type: "enabled" as const,
|
||||
budget_tokens: ChatModelManager.ANTHROPIC_THINKING_BUDGET_TOKENS,
|
||||
},
|
||||
}),
|
||||
},
|
||||
[ChatModelProviders.AZURE_OPENAI]: await (async () => {
|
||||
[ChatModelProviders.AZURE_OPENAI]: await (async (): Promise<Record<string, unknown>> => {
|
||||
const azureUrl = normalizeAzureUrl(customModel.baseUrl);
|
||||
return {
|
||||
modelName: customModel.baseUrl
|
||||
|
|
@ -279,30 +292,17 @@ export default class ChatModelManager {
|
|||
};
|
||||
})(),
|
||||
[ChatModelProviders.COHEREAI]: {
|
||||
modelName,
|
||||
apiKey: await getDecryptedKey(customModel.apiKey || settings.cohereApiKey),
|
||||
model: modelName,
|
||||
configuration: {
|
||||
baseURL: customModel.baseUrl || ProviderInfo[ChatModelProviders.COHEREAI].host,
|
||||
fetch: customModel.enableCors ? safeFetch : undefined,
|
||||
},
|
||||
},
|
||||
[ChatModelProviders.GOOGLE]: {
|
||||
apiKey: await getDecryptedKey(customModel.apiKey || settings.googleApiKey),
|
||||
model: modelName,
|
||||
safetySettings: [
|
||||
{
|
||||
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
|
||||
threshold: HarmBlockThreshold.BLOCK_NONE,
|
||||
},
|
||||
{
|
||||
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
|
||||
threshold: HarmBlockThreshold.BLOCK_NONE,
|
||||
},
|
||||
{
|
||||
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
||||
threshold: HarmBlockThreshold.BLOCK_NONE,
|
||||
},
|
||||
{
|
||||
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
|
||||
threshold: HarmBlockThreshold.BLOCK_NONE,
|
||||
},
|
||||
],
|
||||
safetySettings: GOOGLE_SAFETY_SETTINGS_BLOCK_NONE,
|
||||
baseUrl: customModel.baseUrl,
|
||||
},
|
||||
[ChatModelProviders.XAI]: {
|
||||
|
|
@ -344,6 +344,9 @@ 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,
|
||||
|
|
@ -407,9 +410,12 @@ export default class ChatModelManager {
|
|||
},
|
||||
},
|
||||
[ChatModelProviders.MISTRAL]: {
|
||||
model: modelName,
|
||||
modelName,
|
||||
apiKey: await getDecryptedKey(customModel.apiKey || settings.mistralApiKey),
|
||||
serverURL: customModel.baseUrl,
|
||||
configuration: {
|
||||
baseURL: customModel.baseUrl || ProviderInfo[ChatModelProviders.MISTRAL].host,
|
||||
fetch: customModel.enableCors ? safeFetch : undefined,
|
||||
},
|
||||
},
|
||||
[ChatModelProviders.DEEPSEEK]: {
|
||||
modelName: modelName,
|
||||
|
|
@ -435,7 +441,7 @@ export default class ChatModelManager {
|
|||
let selectedProviderConfig =
|
||||
providerConfig[customModel.provider as keyof typeof providerConfig] || {};
|
||||
|
||||
if (customModel.provider === ChatModelProviders.AMAZON_BEDROCK) {
|
||||
if ((customModel.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK) {
|
||||
selectedProviderConfig = await this.buildBedrockConfig(
|
||||
customModel,
|
||||
modelName,
|
||||
|
|
@ -478,7 +484,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(
|
||||
|
|
@ -487,7 +493,7 @@ export default class ChatModelManager {
|
|||
settings
|
||||
);
|
||||
|
||||
const config: any = {
|
||||
const config: Record<string, unknown> = {
|
||||
maxTokens,
|
||||
temperature: resolvedTemperature,
|
||||
};
|
||||
|
|
@ -505,7 +511,7 @@ export default class ChatModelManager {
|
|||
if (
|
||||
modelInfo.isGPT5 &&
|
||||
customModel?.verbosity &&
|
||||
customModel?.provider !== ChatModelProviders.AZURE_OPENAI
|
||||
(customModel?.provider as ChatModelProviders) !== ChatModelProviders.AZURE_OPENAI
|
||||
) {
|
||||
const verbosityValue = customModel.verbosity;
|
||||
// For Responses API, verbosity is nested under 'text' parameter
|
||||
|
|
@ -580,7 +586,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, any> = {};
|
||||
const params: Record<string, unknown> = {};
|
||||
|
||||
// Add topP only if defined
|
||||
if (customModel.topP !== undefined) {
|
||||
|
|
@ -660,7 +666,7 @@ export default class ChatModelManager {
|
|||
* @returns True when the provider requirements are satisfied, otherwise false.
|
||||
*/
|
||||
private hasProviderCredentials(model: CustomModel): boolean {
|
||||
if (model.provider === ChatModelProviders.AMAZON_BEDROCK) {
|
||||
if ((model.provider as ChatModelProviders) === 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
|
||||
|
|
@ -676,8 +682,9 @@ export default class ChatModelManager {
|
|||
}
|
||||
|
||||
getProviderConstructor(model: CustomModel): ChatConstructorType {
|
||||
const constructor: ChatConstructorType =
|
||||
CHAT_PROVIDER_CONSTRUCTORS[model.provider as ChatModelProviders];
|
||||
const constructor: ChatConstructorType = CHAT_PROVIDER_CONSTRUCTORS[
|
||||
model.provider as ChatModelProviders
|
||||
] as unknown as ChatConstructorType;
|
||||
if (!constructor) {
|
||||
console.warn(`Unknown provider: ${model.provider} for model: ${model.name}`);
|
||||
throw new Error(`Unknown provider: ${model.provider} for model: ${model.name}`);
|
||||
|
|
@ -779,8 +786,8 @@ export default class ChatModelManager {
|
|||
const modelInfo = getModelInfo(model.name);
|
||||
if (
|
||||
modelInfo.isGPT5 &&
|
||||
(model.provider === ChatModelProviders.OPENAI ||
|
||||
model.provider === ChatModelProviders.OPENAI_FORMAT)
|
||||
((model.provider as ChatModelProviders) === ChatModelProviders.OPENAI ||
|
||||
(model.provider as ChatModelProviders) === ChatModelProviders.OPENAI_FORMAT)
|
||||
) {
|
||||
logInfo(`Chat model set with Responses API for GPT-5: ${model.name}`);
|
||||
}
|
||||
|
|
@ -799,7 +806,7 @@ export default class ChatModelManager {
|
|||
}
|
||||
if (!selectedModel.hasApiKey) {
|
||||
const errorMessage = `API key is not provided for the model: ${modelKey}.`;
|
||||
if (model.provider === ChatModelProviders.COPILOT_PLUS) {
|
||||
if ((model.provider as ChatModelProviders) === 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."
|
||||
);
|
||||
|
|
@ -811,24 +818,37 @@ export default class ChatModelManager {
|
|||
const modelInfo = getModelInfo(model.name);
|
||||
|
||||
// For GPT-5 models, automatically use Responses API for proper verbosity support
|
||||
const constructorConfig: any = { ...modelConfig };
|
||||
const constructorConfig: Record<string, unknown> = { ...modelConfig };
|
||||
const useCopilotResponses = shouldUseGitHubCopilotResponsesApi(model);
|
||||
if (
|
||||
modelInfo.isGPT5 &&
|
||||
(selectedModel.vendor === ChatModelProviders.OPENAI ||
|
||||
selectedModel.vendor === ChatModelProviders.OPENAI_FORMAT)
|
||||
((selectedModel.vendor as ChatModelProviders) === ChatModelProviders.OPENAI ||
|
||||
(selectedModel.vendor as ChatModelProviders) === 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 === ChatModelProviders.LM_STUDIO && model.useResponsesApi !== false) {
|
||||
if (
|
||||
(model.provider as ChatModelProviders) === 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;
|
||||
|
|
@ -885,25 +905,33 @@ export default class ChatModelManager {
|
|||
const pingMaxTokens = modelInfo.isThinkingEnabled ? 4096 : 30;
|
||||
const tokenConfig = { maxTokens: pingMaxTokens };
|
||||
|
||||
const constructorConfig: any = {
|
||||
const constructorConfig: Record<string, unknown> = {
|
||||
...pingConfig,
|
||||
...tokenConfig,
|
||||
};
|
||||
const useCopilotResponses = shouldUseGitHubCopilotResponsesApi(model);
|
||||
|
||||
if (
|
||||
modelInfo.isGPT5 &&
|
||||
(model.provider === ChatModelProviders.OPENAI ||
|
||||
model.provider === ChatModelProviders.OPENAI_FORMAT)
|
||||
((model.provider as ChatModelProviders) === ChatModelProviders.OPENAI ||
|
||||
(model.provider as ChatModelProviders) === 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 === ChatModelProviders.LM_STUDIO && model.useResponsesApi !== false
|
||||
(model.provider as ChatModelProviders) === ChatModelProviders.LM_STUDIO &&
|
||||
model.useResponsesApi !== false
|
||||
? new ChatLMStudio(constructorConfig)
|
||||
: new (this.getProviderConstructor(modelToTest))(constructorConfig);
|
||||
: useCopilotResponses
|
||||
? new GitHubCopilotResponsesModel(constructorConfig)
|
||||
: new (this.getProviderConstructor(modelToTest))(constructorConfig);
|
||||
await testModel.invoke([{ role: "user", content: "hello" }], {
|
||||
timeout: 8000,
|
||||
});
|
||||
|
|
@ -914,7 +942,7 @@ export default class ChatModelManager {
|
|||
await tryPing(false);
|
||||
return true;
|
||||
} catch (firstError) {
|
||||
console.log("First ping attempt failed, trying with CORS...");
|
||||
logInfo("First ping attempt failed, retrying with CORS enabled.");
|
||||
try {
|
||||
// Second try with CORS
|
||||
await tryPing(true);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
/* 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";
|
||||
|
|
@ -15,13 +14,13 @@ import { BrevilabsClient } from "./brevilabsClient";
|
|||
import { CustomJinaEmbeddings } from "./CustomJinaEmbeddings";
|
||||
import { CustomOpenAIEmbeddings } from "./CustomOpenAIEmbeddings";
|
||||
|
||||
type EmbeddingConstructorType = new (config: any) => Embeddings;
|
||||
type EmbeddingConstructorType = new (config: Record<string, unknown>) => Embeddings;
|
||||
|
||||
const EMBEDDING_PROVIDER_CONSTRUCTORS = {
|
||||
[EmbeddingModelProviders.COPILOT_PLUS]: CustomOpenAIEmbeddings,
|
||||
[EmbeddingModelProviders.COPILOT_PLUS_JINA]: CustomJinaEmbeddings,
|
||||
[EmbeddingModelProviders.OPENAI]: OpenAIEmbeddings,
|
||||
[EmbeddingModelProviders.COHEREAI]: CohereEmbeddings,
|
||||
[EmbeddingModelProviders.COHEREAI]: OpenAIEmbeddings,
|
||||
[EmbeddingModelProviders.GOOGLE]: GoogleGenerativeAIEmbeddings,
|
||||
[EmbeddingModelProviders.AZURE_OPENAI]: AzureOpenAIEmbeddings,
|
||||
[EmbeddingModelProviders.OLLAMA]: OllamaEmbeddings,
|
||||
|
|
@ -117,14 +116,14 @@ export default class EmbeddingManager {
|
|||
}
|
||||
|
||||
static getModelName(embeddingsInstance: Embeddings): string {
|
||||
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;
|
||||
const emb = embeddingsInstance as { model?: string; modelName?: string };
|
||||
if (emb.model) {
|
||||
return emb.model;
|
||||
} else if (emb.modelName) {
|
||||
return emb.modelName;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Embeddings instance missing model or modelName properties: ${embeddingsInstance}`
|
||||
`Embeddings instance missing model or modelName properties: ${JSON.stringify(embeddingsInstance)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -141,7 +140,7 @@ export default class EmbeddingManager {
|
|||
const settings = getSettings();
|
||||
const embeddingModelKey = settings.embeddingModelKey;
|
||||
|
||||
if (!EmbeddingManager.modelMap.hasOwnProperty(embeddingModelKey)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(EmbeddingManager.modelMap, embeddingModelKey)) {
|
||||
throw new CustomError(`No embedding model found for: ${embeddingModelKey}`);
|
||||
}
|
||||
|
||||
|
|
@ -176,13 +175,12 @@ export default class EmbeddingManager {
|
|||
EmbeddingManager.embeddingModel = new selectedModel.EmbeddingConstructor(config);
|
||||
return EmbeddingManager.embeddingModel;
|
||||
} catch (error) {
|
||||
throw new CustomError(
|
||||
`Error creating embedding model: ${embeddingModelKey}. ${error.message}`
|
||||
);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new CustomError(`Error creating embedding model: ${embeddingModelKey}. ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getEmbeddingConfig(customModel: CustomModel): Promise<any> {
|
||||
private async getEmbeddingConfig(customModel: CustomModel): Promise<Record<string, unknown>> {
|
||||
const settings = getSettings();
|
||||
const modelName = customModel.name;
|
||||
|
||||
|
|
@ -241,8 +239,14 @@ export default class EmbeddingManager {
|
|||
},
|
||||
},
|
||||
[EmbeddingModelProviders.COHEREAI]: {
|
||||
model: modelName,
|
||||
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,
|
||||
|
|
@ -323,7 +327,7 @@ export default class EmbeddingManager {
|
|||
await tryPing(false);
|
||||
return true;
|
||||
} catch (firstError) {
|
||||
console.log("First ping attempt failed, trying with CORS...");
|
||||
logInfo("First ping attempt failed, trying with CORS...");
|
||||
try {
|
||||
// Second try with CORS
|
||||
await tryPing(true);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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";
|
||||
|
||||
|
|
@ -16,29 +17,37 @@ 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) => {
|
||||
.map((part: unknown): string => {
|
||||
if (typeof part === "string") return part;
|
||||
if (part && typeof part === "object" && typeof part.text === "string") {
|
||||
return part.text;
|
||||
if (
|
||||
part &&
|
||||
typeof part === "object" &&
|
||||
typeof (part as { text?: unknown }).text === "string"
|
||||
) {
|
||||
return (part as { text: string }).text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
if (typeof content === "object" && typeof (content as any).text === "string") {
|
||||
return (content as any).text;
|
||||
if (typeof content === "object" && typeof (content as { text?: unknown }).text === "string") {
|
||||
return (content as { text: string }).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.
|
||||
|
|
@ -53,6 +62,9 @@ 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.
|
||||
|
|
@ -72,73 +84,28 @@ 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 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;
|
||||
};
|
||||
return buildGitHubCopilotAuthedFetch(provider, baseFetch);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
const baseFetch = fetchImplementation ?? (configuration?.fetch as FetchImplementation) ?? fetch;
|
||||
// scorecard: streaming requires fetch — cannot use requestUrl
|
||||
const baseFetch = fetchImplementation ?? configuration?.fetch ?? fetch;
|
||||
const authedFetch = GitHubCopilotChatModel.buildAuthedFetch(provider, baseFetch);
|
||||
|
||||
super({
|
||||
|
|
@ -152,7 +119,7 @@ export class GitHubCopilotChatModel extends ChatOpenAICompletions {
|
|||
configuration: {
|
||||
...(configuration ?? {}),
|
||||
// Reason: OpenAI SDK appends "/chat/completions" to baseURL automatically
|
||||
baseURL: (configuration?.baseURL as string) ?? COPILOT_API_BASE,
|
||||
baseURL: configuration?.baseURL ?? COPILOT_API_BASE,
|
||||
fetch: authedFetch,
|
||||
},
|
||||
});
|
||||
|
|
@ -175,13 +142,17 @@ 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, any>,
|
||||
rawResponse: any,
|
||||
delta: Record<string, unknown>,
|
||||
rawResponse: unknown,
|
||||
// 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?: any
|
||||
defaultRole?: string
|
||||
): BaseMessageChunk {
|
||||
// Reason: Copilot API omits delta.role when proxying Claude models.
|
||||
// The parent converter uses `delta.role ?? defaultRole` to determine message type.
|
||||
|
|
@ -198,12 +169,22 @@ export class GitHubCopilotChatModel extends ChatOpenAICompletions {
|
|||
// each delta is a single-use streaming chunk.
|
||||
delta.content = normalizeDeltaContent(delta.content);
|
||||
|
||||
return super._convertCompletionsDeltaToBaseMessageChunk(delta, rawResponse, defaultRole);
|
||||
// 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]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
|
|
|||
|
|
@ -68,8 +68,8 @@ export interface CopilotAuthState {
|
|||
* - Token management (access token + copilot token)
|
||||
* - Model listing
|
||||
*
|
||||
* Chat completions are handled by GitHubCopilotChatModel (extends ChatOpenAICompletions),
|
||||
* which uses this provider for token lifecycle via buildCopilotRequestHeaders/getValidCopilotToken.
|
||||
* Chat requests are handled by GitHubCopilotChatModel / GitHubCopilotResponsesModel,
|
||||
* which use 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) => setTimeout(resolve, ms));
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
|
|
@ -660,11 +660,11 @@ export class GitHubCopilotProvider {
|
|||
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeoutId);
|
||||
window.clearTimeout(timeoutId);
|
||||
reject(new AuthCancelledError());
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
|
|
|
|||
115
src/LLMProviders/githubCopilot/GitHubCopilotResponsesModel.ts
Normal file
115
src/LLMProviders/githubCopilot/GitHubCopilotResponsesModel.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
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";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
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";
|
||||
|
|
@ -34,7 +35,7 @@ export default class MemoryManager {
|
|||
chatHistory: chatHistory,
|
||||
});
|
||||
if (this.debug) {
|
||||
console.log("Memory initialized with context turns:", chatContextTurns);
|
||||
logInfo("Memory initialized with context turns:", chatContextTurns);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,13 +44,13 @@ export default class MemoryManager {
|
|||
}
|
||||
|
||||
async clearChatMemory(): Promise<void> {
|
||||
if (this.debug) console.log("Clearing chat memory");
|
||||
if (this.debug) logInfo("Clearing chat memory");
|
||||
await this.memory.clear();
|
||||
}
|
||||
|
||||
async loadMemoryVariables(): Promise<any> {
|
||||
async loadMemoryVariables(): Promise<Record<string, unknown>> {
|
||||
const variables = await this.memory.loadMemoryVariables({});
|
||||
if (this.debug) console.log("Loaded memory variables:", variables);
|
||||
if (this.debug) logInfo("Loaded memory variables:", variables);
|
||||
return variables;
|
||||
}
|
||||
|
||||
|
|
@ -58,16 +59,25 @@ export default class MemoryManager {
|
|||
* The output (assistant response) is compacted to reduce memory bloat from
|
||||
* accumulated tool results (localSearch, readNote, etc.).
|
||||
*/
|
||||
async saveContext(input: any, output: any): Promise<void> {
|
||||
async saveContext(
|
||||
input: Record<string, unknown>,
|
||||
output: Record<string, unknown> | string
|
||||
): Promise<void> {
|
||||
// Compact the output to prevent memory bloat from tool results
|
||||
const compactedOutput =
|
||||
typeof output === "string"
|
||||
? compactAssistantOutput(output)
|
||||
: { ...output, output: compactAssistantOutput(output.output) };
|
||||
: {
|
||||
...output,
|
||||
output: compactAssistantOutput((output as { output: string | unknown[] }).output),
|
||||
};
|
||||
|
||||
if (this.debug) {
|
||||
console.log("Saving to memory - Input:", input, "Output (compacted):", compactedOutput);
|
||||
logInfo("Saving to memory - Input:", input, "Output (compacted):", compactedOutput);
|
||||
}
|
||||
await this.memory.saveContext(input, compactedOutput);
|
||||
await this.memory.saveContext(
|
||||
input,
|
||||
compactedOutput as Parameters<typeof this.memory.saveContext>[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,27 @@
|
|||
import {
|
||||
FailedItem,
|
||||
getChainType,
|
||||
getCurrentProject,
|
||||
isProjectMode,
|
||||
ProjectConfig,
|
||||
setCurrentProject,
|
||||
setProjectLoading,
|
||||
subscribeToChainTypeChange,
|
||||
subscribeToModelKeyChange,
|
||||
subscribeToProjectChange,
|
||||
} from "@/aiParams";
|
||||
import { ContextCache, ProjectContextCache } from "@/cache/projectContextCache";
|
||||
import { ChainType } from "@/chainFactory";
|
||||
import { ChainType } from "@/chainType";
|
||||
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 { getSettings, subscribeToSettingsChange, updateSetting } from "@/settings/model";
|
||||
import { ProjectFileManager } from "@/projects/ProjectFileManager";
|
||||
import { getCachedProjects, subscribeToProjectRecords } from "@/projects/state";
|
||||
import { ProjectFileRecord } from "@/projects/type";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import { FileParserManager, saveConvertedDocOutput } from "@/tools/FileParserManager";
|
||||
import { err2String } from "@/utils";
|
||||
import { isRateLimitError } from "@/utils/rateLimitUtils";
|
||||
|
|
@ -35,7 +40,7 @@ export default class ProjectManager {
|
|||
private readonly projectContextCache: ProjectContextCache;
|
||||
private fileParserManager: FileParserManager;
|
||||
private loadTracker: ProjectLoadTracker;
|
||||
private readonly projectUsageTimestampsManager = new RecentUsageManager<string>();
|
||||
private projectRecordsUnsubscriber?: () => void;
|
||||
|
||||
private constructor(app: App, plugin: CopilotPlugin) {
|
||||
this.app = app;
|
||||
|
|
@ -52,11 +57,11 @@ export default class ProjectManager {
|
|||
this.loadTracker = ProjectLoadTracker.getInstance(this.app);
|
||||
|
||||
// Set up subscriptions
|
||||
subscribeToModelKeyChange(async () => {
|
||||
await this.getCurrentChainManager().createChainWithNewModel();
|
||||
subscribeToModelKeyChange(() => {
|
||||
void this.getCurrentChainManager().createChainWithNewModel();
|
||||
});
|
||||
|
||||
subscribeToChainTypeChange(async () => {
|
||||
subscribeToChainTypeChange(() => {
|
||||
// When switching from other modes to project mode, no need to update the chain.
|
||||
if (isProjectMode()) {
|
||||
return;
|
||||
|
|
@ -64,29 +69,52 @@ export default class ProjectManager {
|
|||
const settings = getSettings();
|
||||
const shouldAutoIndex =
|
||||
settings.enableSemanticSearchV3 &&
|
||||
settings.indexVaultToVectorStore === VAULT_VECTOR_STORE_STRATEGY.ON_MODE_SWITCH &&
|
||||
(settings.indexVaultToVectorStore as VAULT_VECTOR_STORE_STRATEGY) ===
|
||||
VAULT_VECTOR_STORE_STRATEGY.ON_MODE_SWITCH &&
|
||||
(getChainType() === ChainType.VAULT_QA_CHAIN ||
|
||||
getChainType() === ChainType.COPILOT_PLUS_CHAIN);
|
||||
await this.getCurrentChainManager().createChainWithNewModel({
|
||||
void this.getCurrentChainManager().createChainWithNewModel({
|
||||
refreshIndex: shouldAutoIndex,
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to Project changes
|
||||
subscribeToProjectChange(async (project) => {
|
||||
await this.switchProject(project);
|
||||
subscribeToProjectChange((project) => {
|
||||
void this.switchProject(project);
|
||||
});
|
||||
|
||||
// Subscribe to settings changes to monitor projectList changes
|
||||
// Subscribe to project cache changes to monitor project modifications
|
||||
this.setupProjectListChangeMonitor();
|
||||
}
|
||||
|
||||
private setupProjectListChangeMonitor() {
|
||||
subscribeToSettingsChange(async (prev, next) => {
|
||||
if (!prev || !next) return;
|
||||
private previousProjectRecords: ProjectFileRecord[] = [];
|
||||
|
||||
const prevProjects = prev.projectList || [];
|
||||
const nextProjects = next.projectList || [];
|
||||
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;
|
||||
|
||||
// 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)
|
||||
);
|
||||
}
|
||||
|
||||
// Find modified projects
|
||||
for (const nextProject of nextProjects) {
|
||||
|
|
@ -95,15 +123,31 @@ 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
|
||||
await this.compareAndUpdateCache(prevProject, nextProject);
|
||||
void this.compareAndUpdateCache(prevProject, nextProject).catch((err) =>
|
||||
logError("[ProjectManager] compareAndUpdateCache failed", err)
|
||||
);
|
||||
|
||||
// If this is the current project, reload its context and recreate chain
|
||||
if (this.currentProjectId === nextProject.id) {
|
||||
await Promise.all([
|
||||
// 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([
|
||||
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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -140,38 +184,12 @@ export default class ProjectManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Touch the project's usage timestamp in settings with throttled persistence.
|
||||
* Memory is always updated immediately (for UI sorting), but settings writes are throttled.
|
||||
* Touch the project's usage timestamp with throttled persistence.
|
||||
* Delegates to ProjectFileManager for vault file writes.
|
||||
*/
|
||||
private touchProjectUsageTimestamps(project: ProjectConfig): void {
|
||||
// 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);
|
||||
const manager = ProjectFileManager.getInstance(this.app);
|
||||
void manager.touchProjectLastUsed(project.id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -179,10 +197,19 @@ export default class ProjectManager {
|
|||
* This allows UI components to use in-memory values for immediate feedback.
|
||||
*/
|
||||
public getProjectUsageTimestampsManager(): RecentUsageManager<string> {
|
||||
return this.projectUsageTimestampsManager;
|
||||
return ProjectFileManager.getInstance(this.app).getProjectUsageTimestampsManager();
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
@ -196,6 +223,12 @@ 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;
|
||||
|
|
@ -203,9 +236,6 @@ export default class ProjectManager {
|
|||
|
||||
// else
|
||||
const projectId = project.id;
|
||||
if (this.currentProjectId === projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.saveCurrentProjectMessage();
|
||||
this.currentProjectId = projectId; // ensure set currentProjectId
|
||||
|
|
@ -287,7 +317,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);
|
||||
await this.processNonMarkdownFiles(project, projectAllFiles, updatedContextCacheAfterSources);
|
||||
|
||||
logInfo(`[loadProjectContext] Completed for project: ${project.name}.`);
|
||||
return updatedContextCacheAfterSources;
|
||||
|
|
@ -369,7 +399,7 @@ export default class ProjectManager {
|
|||
}
|
||||
|
||||
public async getProjectContext(projectId: string): Promise<string | null> {
|
||||
const project = getSettings().projectList.find((p) => p.id === projectId);
|
||||
const project = getCachedProjects().find((p) => p.id === projectId);
|
||||
if (!project) {
|
||||
logWarn(`[getProjectContext] Project not found for ID: ${projectId}`);
|
||||
return null;
|
||||
|
|
@ -789,7 +819,8 @@ modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`;
|
|||
|
||||
private async processNonMarkdownFiles(
|
||||
project: ProjectConfig,
|
||||
projectAllFiles: TFile[]
|
||||
projectAllFiles: TFile[],
|
||||
contextCache: ContextCache
|
||||
): Promise<void> {
|
||||
const nonMarkdownFiles = projectAllFiles.filter((file) => file.extension !== "md");
|
||||
|
||||
|
|
@ -808,12 +839,28 @@ 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 nonMarkdownFiles) {
|
||||
for (const file of orderedFiles) {
|
||||
const filePath = file.path;
|
||||
if (this.fileParserManager.supportsExtension(file.extension)) {
|
||||
try {
|
||||
|
|
@ -867,7 +914,7 @@ modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`;
|
|||
return;
|
||||
}
|
||||
|
||||
const project = getSettings().projectList.find((p) => p.id === this.currentProjectId);
|
||||
const project = getCachedProjects().find((p) => p.id === this.currentProjectId);
|
||||
if (!project) {
|
||||
logError(`[retryFailedItem] Current project not found: ${this.currentProjectId}`);
|
||||
return;
|
||||
|
|
@ -890,7 +937,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;
|
||||
}
|
||||
|
||||
|
|
@ -991,6 +1038,8 @@ modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`;
|
|||
}
|
||||
|
||||
public onunload(): void {
|
||||
this.projectRecordsUnsubscriber?.();
|
||||
this.projectRecordsUnsubscriber = undefined;
|
||||
this.projectContextCache.cleanup();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { hasSelfHostSearchKey, selfHostWebSearch } from "./selfHostServices";
|
|||
|
||||
const mockGetSettings = jest.fn();
|
||||
jest.mock("@/settings/model", () => ({
|
||||
getSettings: () => mockGetSettings(),
|
||||
getSettings: (): unknown => mockGetSettings(),
|
||||
}));
|
||||
|
||||
jest.mock("@/encryptionService", () => ({
|
||||
|
|
@ -17,9 +17,15 @@ jest.mock("@/logger", () => ({
|
|||
logWarn: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock global fetch
|
||||
// Mock safeFetchNoThrow (the requestUrl-backed wrapper used in place of fetch)
|
||||
const mockFetch = jest.fn();
|
||||
global.fetch = mockFetch;
|
||||
jest.mock("@/utils", () => {
|
||||
const actual = jest.requireActual<Record<string, unknown>>("@/utils");
|
||||
return {
|
||||
...actual,
|
||||
safeFetchNoThrow: (url: string, options?: RequestInit): unknown => mockFetch(url, options),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { type Youtube4llmResponse } from "@/LLMProviders/brevilabsClient";
|
|||
import { getDecryptedKey } from "@/encryptionService";
|
||||
import { logError, logInfo } from "@/logger";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import { safeFetchNoThrow } from "@/utils";
|
||||
|
||||
const FIRECRAWL_SEARCH_URL = "https://api.firecrawl.dev/v2/search";
|
||||
const PERPLEXITY_CHAT_URL = "https://api.perplexity.ai/chat/completions";
|
||||
|
|
@ -45,7 +46,7 @@ export function hasSelfHostSearchKey(): boolean {
|
|||
async function firecrawlSearch(query: string, apiKey: string): Promise<SelfHostWebSearchResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
const response = await fetch(FIRECRAWL_SEARCH_URL, {
|
||||
const response = await safeFetchNoThrow(FIRECRAWL_SEARCH_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
|
|
@ -59,7 +60,9 @@ async function firecrawlSearch(query: string, apiKey: string): Promise<SelfHostW
|
|||
throw new Error(`Firecrawl search failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const json = (await response.json()) as {
|
||||
data?: FirecrawlSearchResult[] | { web?: FirecrawlSearchResult[] };
|
||||
};
|
||||
|
||||
// v2 returns { data: { web: [...] } }, older responses return { data: [...] }
|
||||
const rawData = json?.data;
|
||||
|
|
@ -95,7 +98,7 @@ async function perplexitySonarSearch(
|
|||
query: string,
|
||||
apiKey: string
|
||||
): Promise<SelfHostWebSearchResult> {
|
||||
const response = await fetch(PERPLEXITY_CHAT_URL, {
|
||||
const response = await safeFetchNoThrow(PERPLEXITY_CHAT_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
|
|
@ -112,9 +115,12 @@ async function perplexitySonarSearch(
|
|||
throw new Error(`Perplexity Sonar search failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const json = (await response.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
citations?: unknown;
|
||||
};
|
||||
const content = json?.choices?.[0]?.message?.content ?? "";
|
||||
const citations: string[] = Array.isArray(json?.citations) ? json.citations : [];
|
||||
const citations: string[] = Array.isArray(json?.citations) ? (json.citations as string[]) : [];
|
||||
|
||||
return { content, citations };
|
||||
}
|
||||
|
|
@ -144,7 +150,7 @@ export async function selfHostYoutube4llm(url: string): Promise<Youtube4llmRespo
|
|||
|
||||
const transcriptUrl = `${SUPADATA_TRANSCRIPT_URL}?url=${encodeURIComponent(url)}&mode=auto&text=true`;
|
||||
|
||||
const response = await fetch(transcriptUrl, {
|
||||
const response = await safeFetchNoThrow(transcriptUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
|
|
@ -153,7 +159,7 @@ export async function selfHostYoutube4llm(url: string): Promise<Youtube4llmRespo
|
|||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const json = await response.json();
|
||||
const json = (await response.json()) as { content?: string };
|
||||
const elapsed = Date.now() - startTime;
|
||||
logInfo(`[selfHostYoutube4llm] transcript received in ${elapsed}ms`);
|
||||
return {
|
||||
|
|
@ -163,7 +169,7 @@ export async function selfHostYoutube4llm(url: string): Promise<Youtube4llmRespo
|
|||
}
|
||||
|
||||
if (response.status === 201 || response.status === 202) {
|
||||
const json = await response.json();
|
||||
const json = (await response.json()) as { job_id?: string };
|
||||
const jobId = json.job_id;
|
||||
if (!jobId) {
|
||||
throw new Error("Supadata returned async status but no job_id");
|
||||
|
|
@ -187,9 +193,9 @@ async function pollSupadataJob(
|
|||
const pollUrl = `${SUPADATA_TRANSCRIPT_URL}/${jobId}`;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, SUPADATA_POLL_INTERVAL));
|
||||
await new Promise((resolve) => window.setTimeout(resolve, SUPADATA_POLL_INTERVAL));
|
||||
|
||||
const pollResponse = await fetch(pollUrl, {
|
||||
const pollResponse = await safeFetchNoThrow(pollUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
|
|
@ -198,7 +204,7 @@ async function pollSupadataJob(
|
|||
});
|
||||
|
||||
if (pollResponse.status === 200) {
|
||||
const json = await pollResponse.json();
|
||||
const json = (await pollResponse.json()) as { content?: string };
|
||||
const elapsed = Date.now() - startTime;
|
||||
logInfo(`[selfHostYoutube4llm] async transcript completed in ${elapsed}ms`);
|
||||
return {
|
||||
|
|
|
|||
19
src/__tests__/mockObsidian.ts
Normal file
19
src/__tests__/mockObsidian.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ChainType } from "@/chainFactory";
|
||||
import { ChainType } from "@/chainType";
|
||||
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
|
||||
import { ChatPromptTemplate } from "@langchain/core/prompts";
|
||||
|
||||
|
|
@ -6,6 +6,7 @@ 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(
|
||||
|
|
@ -59,7 +60,7 @@ export const projectContextLoadAtom = atom<ProjectContextLoadState>({
|
|||
total: [],
|
||||
});
|
||||
|
||||
export interface IndexingProgressState {
|
||||
interface IndexingProgressState {
|
||||
isActive: boolean;
|
||||
isPaused: boolean;
|
||||
isCancelled: boolean;
|
||||
|
|
@ -69,7 +70,7 @@ export interface IndexingProgressState {
|
|||
completionStatus: "none" | "success" | "cancelled" | "error";
|
||||
}
|
||||
|
||||
export const indexingProgressAtom = atom<IndexingProgressState>({
|
||||
const indexingProgressAtom = atom<IndexingProgressState>({
|
||||
isActive: false,
|
||||
isPaused: false,
|
||||
isCancelled: false,
|
||||
|
|
@ -128,7 +129,7 @@ export interface ModelConfig {
|
|||
export interface SetChainOptions {
|
||||
prompt?: ChatPromptTemplate;
|
||||
chatModel?: BaseChatModel;
|
||||
noteFile?: any;
|
||||
noteFile?: TFile;
|
||||
abortController?: AbortController;
|
||||
refreshIndex?: boolean;
|
||||
}
|
||||
|
|
@ -236,26 +237,10 @@ export function subscribeToProjectChange(
|
|||
});
|
||||
}
|
||||
|
||||
export function useCurrentProject() {
|
||||
return useAtom(currentProjectAtom, {
|
||||
store: settingsStore,
|
||||
});
|
||||
}
|
||||
|
||||
export function setProjectLoading(loading: boolean) {
|
||||
settingsStore.set(projectLoadingAtom, loading);
|
||||
}
|
||||
|
||||
export function isProjectLoading(): boolean {
|
||||
return settingsStore.get(projectLoadingAtom);
|
||||
}
|
||||
|
||||
export function subscribeToProjectLoadingChange(callback: (loading: boolean) => void): () => void {
|
||||
return settingsStore.sub(projectLoadingAtom, () => {
|
||||
callback(settingsStore.get(projectLoadingAtom));
|
||||
});
|
||||
}
|
||||
|
||||
export function useProjectLoading() {
|
||||
return useAtom(projectLoadingAtom, {
|
||||
store: settingsStore,
|
||||
|
|
@ -274,11 +259,6 @@ export function getSelectedTextContexts(): SelectedTextContext[] {
|
|||
return settingsStore.get(selectedTextContextsAtom);
|
||||
}
|
||||
|
||||
export function addSelectedTextContext(context: SelectedTextContext) {
|
||||
const current = getSelectedTextContexts();
|
||||
setSelectedTextContexts([...current, context]);
|
||||
}
|
||||
|
||||
export function removeSelectedTextContext(id: string) {
|
||||
const current = getSelectedTextContexts();
|
||||
setSelectedTextContexts(current.filter((context) => context.id !== id));
|
||||
|
|
@ -294,13 +274,6 @@ export function useSelectedTextContexts() {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the project context load state from the atom.
|
||||
*/
|
||||
export function getProjectContextLoadState(): Readonly<ProjectContextLoadState> {
|
||||
return settingsStore.get(projectContextLoadAtom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the project context load state in the atom.
|
||||
*/
|
||||
|
|
@ -321,17 +294,6 @@ export function updateProjectContextLoadState<K extends keyof ProjectContextLoad
|
|||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to changes in the project context load state.
|
||||
*/
|
||||
export function subscribeToProjectContextLoadChange(
|
||||
callback: (state: ProjectContextLoadState) => void
|
||||
): () => void {
|
||||
return settingsStore.sub(projectContextLoadAtom, () => {
|
||||
callback(settingsStore.get(projectContextLoadAtom));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the project context load state from the atom.
|
||||
*/
|
||||
|
|
@ -370,7 +332,7 @@ export function updateIndexingProgressState(partial: Partial<IndexingProgressSta
|
|||
// cascading React re-renders from frequent Jotai atom updates.
|
||||
let _lastUpdateTime = 0;
|
||||
let _pendingCount = 0;
|
||||
let _throttleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _throttleTimer: number | null = null;
|
||||
const THROTTLE_INTERVAL_MS = 500;
|
||||
|
||||
/**
|
||||
|
|
@ -381,7 +343,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) {
|
||||
clearTimeout(_throttleTimer);
|
||||
window.clearTimeout(_throttleTimer);
|
||||
_throttleTimer = null;
|
||||
}
|
||||
_lastUpdateTime = 0;
|
||||
|
|
@ -410,13 +372,13 @@ export function throttledUpdateIndexingCount(indexedCount: number): void {
|
|||
// Enough time has passed — write immediately
|
||||
_lastUpdateTime = now;
|
||||
if (_throttleTimer !== null) {
|
||||
clearTimeout(_throttleTimer);
|
||||
window.clearTimeout(_throttleTimer);
|
||||
_throttleTimer = null;
|
||||
}
|
||||
updateIndexingProgressState({ indexedCount: _pendingCount });
|
||||
} else if (_throttleTimer === null) {
|
||||
// Schedule a trailing write
|
||||
_throttleTimer = setTimeout(
|
||||
_throttleTimer = window.setTimeout(
|
||||
() => {
|
||||
_lastUpdateTime = Date.now();
|
||||
_throttleTimer = null;
|
||||
|
|
@ -433,7 +395,7 @@ export function throttledUpdateIndexingCount(indexedCount: number): void {
|
|||
*/
|
||||
export function flushIndexingCount(): void {
|
||||
if (_throttleTimer !== null) {
|
||||
clearTimeout(_throttleTimer);
|
||||
window.clearTimeout(_throttleTimer);
|
||||
_throttleTimer = null;
|
||||
}
|
||||
updateIndexingProgressState({ indexedCount: _pendingCount });
|
||||
|
|
|
|||
6
src/cache/fileCache.ts
vendored
6
src/cache/fileCache.ts
vendored
|
|
@ -1,5 +1,5 @@
|
|||
import { logError, logInfo } from "@/logger";
|
||||
import { MD5 } from "crypto-js";
|
||||
import { md5 } from "@/utils/hash";
|
||||
import { TFile } from "obsidian";
|
||||
|
||||
export interface FileCacheEntry<T> {
|
||||
|
|
@ -8,7 +8,7 @@ export interface FileCacheEntry<T> {
|
|||
}
|
||||
|
||||
export class FileCache<T> {
|
||||
private static instance: FileCache<any>;
|
||||
private static instance: FileCache<unknown>;
|
||||
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).toString();
|
||||
return md5(metadata);
|
||||
}
|
||||
|
||||
private getCachePath(cacheKey: string): string {
|
||||
|
|
|
|||
6
src/cache/pdfCache.ts
vendored
6
src/cache/pdfCache.ts
vendored
|
|
@ -1,6 +1,6 @@
|
|||
import { Pdf4llmResponse } from "@/LLMProviders/brevilabsClient";
|
||||
import { logError, logInfo } from "@/logger";
|
||||
import { MD5 } from "crypto-js";
|
||||
import { md5 } from "@/utils/hash";
|
||||
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).toString();
|
||||
const key = md5(metadata);
|
||||
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);
|
||||
return JSON.parse(cacheContent) as Pdf4llmResponse;
|
||||
}
|
||||
logInfo("Cache miss for PDF:", file.path);
|
||||
return null;
|
||||
|
|
|
|||
50
src/cache/projectContextCache.ts
vendored
50
src/cache/projectContextCache.ts
vendored
|
|
@ -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 { getSettings } from "@/settings/model";
|
||||
import { MD5 } from "crypto-js";
|
||||
import { getCachedProjects } from "@/projects/state";
|
||||
import { md5 } from "@/utils/hash";
|
||||
import { TAbstractFile, TFile, Vault } from "obsidian";
|
||||
import debounce from "lodash.debounce";
|
||||
import { debounce } from "@/utils/debounce";
|
||||
import { Mutex } from "async-mutex";
|
||||
|
||||
export interface ContextCache {
|
||||
|
|
@ -24,6 +24,38 @@ 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.
|
||||
|
|
@ -94,8 +126,7 @@ export class ProjectContextCache {
|
|||
return;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
const projects = settings.projectList || [];
|
||||
const projects = getCachedProjects();
|
||||
|
||||
// Check each project to see if the file matches its patterns
|
||||
for (const project of projects) {
|
||||
|
|
@ -142,7 +173,7 @@ export class ProjectContextCache {
|
|||
|
||||
private getCacheKey(project: ProjectConfig): string {
|
||||
// Use project ID as cache key
|
||||
return MD5(project.id).toString();
|
||||
return md5(project.id);
|
||||
}
|
||||
|
||||
private getCachePath(cacheKey: string): string {
|
||||
|
|
@ -190,7 +221,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);
|
||||
const contextCache = JSON.parse(cacheContent) as ContextCache;
|
||||
// Store in memory cache
|
||||
this.memoryCache.set(cacheKey, contextCache);
|
||||
return contextCache;
|
||||
|
|
@ -259,7 +290,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));
|
||||
const contextCacheCopy = JSON.parse(JSON.stringify(contextCache)) as ContextCache;
|
||||
|
||||
// Store in memory cache
|
||||
this.memoryCache.set(cacheKey, contextCacheCopy);
|
||||
|
|
@ -609,8 +640,7 @@ export class ProjectContextCache {
|
|||
filePath: string
|
||||
): Promise<{ cacheKey: string; content: string } | null> {
|
||||
try {
|
||||
const settings = getSettings();
|
||||
const projects = settings.projectList || [];
|
||||
const projects = getCachedProjects();
|
||||
|
||||
if (projects.length === 0) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,213 +0,0 @@
|
|||
// TODO(logan): This entire file is deprecated since we moved to direct chat model calls in chain runners
|
||||
// Consider removing after verifying no dependencies remain
|
||||
|
||||
import { BaseLanguageModel } from "@langchain/core/language_models/base";
|
||||
import { StringOutputParser } from "@langchain/core/output_parsers";
|
||||
import { ChatPromptTemplate, PromptTemplate } from "@langchain/core/prompts";
|
||||
import { BaseRetriever } from "@langchain/core/retrievers";
|
||||
import { RunnablePassthrough, RunnableSequence } from "@langchain/core/runnables";
|
||||
import { BaseChatMemory } from "@langchain/classic/memory";
|
||||
import { formatDocumentsAsString } from "@langchain/classic/util/document";
|
||||
import { 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;
|
||||
6
src/chainType.ts
Normal file
6
src/chainType.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export enum ChainType {
|
||||
LLM_CHAIN = "llm_chain",
|
||||
VAULT_QA_CHAIN = "vault_qa",
|
||||
COPILOT_PLUS_CHAIN = "copilot_plus",
|
||||
PROJECT_CHAIN = "project",
|
||||
}
|
||||
|
|
@ -44,8 +44,8 @@ describe("updateChatMemory with tool call markers", () => {
|
|||
},
|
||||
];
|
||||
|
||||
const memoryManager: any = new MockMemoryManager();
|
||||
await updateChatMemory(messages, memoryManager);
|
||||
const memoryManager = new MockMemoryManager();
|
||||
await updateChatMemory(messages, memoryManager as never);
|
||||
|
||||
expect(memoryManager.getMemory().saved).toHaveLength(1);
|
||||
expect(memoryManager.getMemory().saved[0].input).toBe("find my piano notes");
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
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, logWarn } from "@/logger";
|
||||
import { logError } 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 } from "obsidian";
|
||||
import { App, Component, MarkdownRenderer, Notice, MarkdownView, Scope } from "obsidian";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { preprocessAIResponse } from "@/utils/markdownPreprocess";
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
import { Root } from "react-dom/client";
|
||||
import { createPluginRoot } from "@/utils/react/createPluginRoot";
|
||||
import { CustomCommand } from "@/commands/type";
|
||||
import { useSettingsValue, updateSetting } from "@/settings/model";
|
||||
import {
|
||||
|
|
@ -180,12 +184,12 @@ function CustomCommandChatModalContent({
|
|||
return command.modelKey || globalModelKey;
|
||||
}, [modelSelectionScope, settings.quickCommandModelKey, command.modelKey, globalModelKey]);
|
||||
|
||||
const [selectedModelKey, setSelectedModelKey] = useState(initialModelKey);
|
||||
const [userSelectedModelKey, setUserSelectedModelKey] = useState(initialModelKey);
|
||||
|
||||
// Handle model change with scope-aware persistence
|
||||
const handleModelChange = useCallback(
|
||||
(newModelKey: string) => {
|
||||
setSelectedModelKey(newModelKey);
|
||||
setUserSelectedModelKey(newModelKey);
|
||||
// Only persist for quick-command scope (shared with Quick Ask)
|
||||
if (modelSelectionScope === "quick-command") {
|
||||
updateSetting("quickCommandModelKey", newModelKey);
|
||||
|
|
@ -206,16 +210,13 @@ function CustomCommandChatModalContent({
|
|||
updateSetting("quickCommandIncludeNoteContext", checked);
|
||||
}, []);
|
||||
|
||||
// Track if we've already shown the fallback notice to avoid repeated notices
|
||||
const didShowFallbackNoticeRef = useRef(false);
|
||||
|
||||
// Safely resolve the selected model with fallback to first enabled model
|
||||
const resolvedModel = useMemo((): CustomModel | null => {
|
||||
try {
|
||||
const model = findCustomModel(selectedModelKey, settings.activeModels);
|
||||
const model = findCustomModel(userSelectedModelKey, settings.activeModels);
|
||||
// Treat disabled models as invalid selections (ModelSelector won't present them)
|
||||
if (!model.enabled) {
|
||||
throw new Error(`Selected model is disabled: ${selectedModelKey}`);
|
||||
throw new Error(`Selected model is disabled: ${userSelectedModelKey}`);
|
||||
}
|
||||
return model;
|
||||
} catch {
|
||||
|
|
@ -223,7 +224,7 @@ function CustomCommandChatModalContent({
|
|||
// Avoid side effects during render; notify/log in the effect below.
|
||||
return settings.activeModels.find((m) => m.enabled) ?? null;
|
||||
}
|
||||
}, [selectedModelKey, settings.activeModels]);
|
||||
}, [userSelectedModelKey, settings.activeModels]);
|
||||
|
||||
// Compute the key for the resolved model
|
||||
const resolvedModelKey = useMemo(() => {
|
||||
|
|
@ -231,23 +232,8 @@ function CustomCommandChatModalContent({
|
|||
return `${resolvedModel.name}|${resolvedModel.provider}`;
|
||||
}, [resolvedModel]);
|
||||
|
||||
// Update selectedModelKey if we had to fall back to a different model
|
||||
useEffect(() => {
|
||||
if (!resolvedModelKey) return;
|
||||
if (resolvedModelKey === selectedModelKey) return;
|
||||
|
||||
// Always keep UI selection consistent with the resolved model
|
||||
setSelectedModelKey(resolvedModelKey);
|
||||
|
||||
// Notify only once per modal lifecycle
|
||||
if (didShowFallbackNoticeRef.current) return;
|
||||
didShowFallbackNoticeRef.current = true;
|
||||
logWarn("Selected model is no longer available. Falling back to a default model.", {
|
||||
selectedModelKey,
|
||||
resolvedModelKey,
|
||||
});
|
||||
new Notice("Selected model is no longer available. Falling back to a default model.");
|
||||
}, [resolvedModelKey, selectedModelKey]);
|
||||
// Effective model key for the UI — falls back to user selection when resolution fails.
|
||||
const effectiveModelKey = resolvedModelKey ?? userSelectedModelKey;
|
||||
|
||||
// Use shared streaming hook
|
||||
const {
|
||||
|
|
@ -274,12 +260,15 @@ function CustomCommandChatModalContent({
|
|||
// Track the last input prompt for saving context on stop
|
||||
const lastInputPromptRef = useRef<string>("");
|
||||
|
||||
// Sync editedText with finalText when finalText changes
|
||||
useEffect(() => {
|
||||
// Sync editedText with finalText when finalText changes. Render-phase tracker
|
||||
// preserves user edits made after the last finalText change until the next change.
|
||||
const [prevFinalText, setPrevFinalText] = useState(finalText);
|
||||
if (prevFinalText !== finalText) {
|
||||
setPrevFinalText(finalText);
|
||||
if (finalText) {
|
||||
setEditedText(finalText);
|
||||
}
|
||||
}, [finalText]);
|
||||
}
|
||||
|
||||
// Compute content state for MenuCommandModal
|
||||
const contentState: ContentState = useMemo(() => {
|
||||
|
|
@ -329,7 +318,7 @@ function CustomCommandChatModalContent({
|
|||
}
|
||||
}
|
||||
|
||||
generateInitialResponse();
|
||||
void generateInitialResponse();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
|
@ -450,7 +439,7 @@ function CustomCommandChatModalContent({
|
|||
followUpValue={followUpValue}
|
||||
onFollowUpChange={setFollowUpValue}
|
||||
onFollowUpSubmit={handleFollowUpSubmit}
|
||||
selectedModel={selectedModelKey}
|
||||
selectedModel={effectiveModelKey}
|
||||
onSelectModel={handleModelChange}
|
||||
onStop={handleStop}
|
||||
onCopy={handleCopy}
|
||||
|
|
@ -483,6 +472,7 @@ 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,
|
||||
|
|
@ -501,7 +491,7 @@ export class CustomCommandChatModal {
|
|||
* the modal is positioned relative to the correct window.
|
||||
*/
|
||||
private resolveWindow(view?: MarkdownView | null): Window {
|
||||
return view?.containerEl?.ownerDocument?.defaultView ?? window;
|
||||
return view?.containerEl?.win ?? window;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -510,7 +500,7 @@ export class CustomCommandChatModal {
|
|||
* appended to the document that owns the triggering view.
|
||||
*/
|
||||
private resolveDocument(view?: MarkdownView | null): Document {
|
||||
return view?.containerEl?.ownerDocument ?? document;
|
||||
return view?.containerEl?.doc ?? activeDocument;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -520,7 +510,11 @@ 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.
|
||||
|
|
@ -528,7 +522,9 @@ 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;
|
||||
|
||||
|
|
@ -583,8 +579,11 @@ 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
|
||||
|
|
@ -634,6 +633,14 @@ 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.
|
||||
|
|
@ -644,7 +651,7 @@ export class CustomCommandChatModal {
|
|||
this.container.className = "copilot-menu-command-modal-container";
|
||||
doc.body.appendChild(this.container);
|
||||
|
||||
this.root = createRoot(this.container);
|
||||
this.root = createPluginRoot(this.container, this.app);
|
||||
|
||||
// Capture ReplaceGuard (replaces captureReplaceSnapshot)
|
||||
const { selectedText, command, systemPrompt, behaviorConfig } = this.configs;
|
||||
|
|
@ -678,7 +685,7 @@ export class CustomCommandChatModal {
|
|||
const { anchorBottom, ...initialPosition } = this.getInitialPosition(activeView);
|
||||
|
||||
const handleInsert = (message: string) => {
|
||||
insertIntoEditor(message);
|
||||
void insertIntoEditor(message);
|
||||
this.close();
|
||||
};
|
||||
|
||||
|
|
@ -720,6 +727,10 @@ 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);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { Label } from "@/components/ui/label";
|
|||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import React, { useState } from "react";
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
import { Root } from "react-dom/client";
|
||||
import { createPluginRoot } from "@/utils/react/createPluginRoot";
|
||||
import { getModelKeyFromModel, useSettingsValue } from "@/settings/model";
|
||||
import { getModelDisplayText } from "@/components/ui/model-display";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -41,7 +42,7 @@ function CustomCommandSettingsModalContent({
|
|||
const [command, setCommand] = useState(initialCommand);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
|
||||
const handleUpdate = (field: keyof CustomCommand, value: any) => {
|
||||
const handleUpdate = (field: keyof CustomCommand, value: CustomCommand[keyof CustomCommand]) => {
|
||||
setCommand((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
|
|
@ -181,7 +182,7 @@ export class CustomCommandSettingsModal extends Modal {
|
|||
app: App,
|
||||
private commands: CustomCommand[],
|
||||
private command: CustomCommand,
|
||||
private onUpdate: (command: CustomCommand) => void
|
||||
private onUpdate: (command: CustomCommand) => void | Promise<void>
|
||||
) {
|
||||
super(app);
|
||||
// https://docs.obsidian.md/Reference/TypeScript+API/Modal/setTitle
|
||||
|
|
@ -191,10 +192,10 @@ export class CustomCommandSettingsModal extends Modal {
|
|||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.root = createRoot(contentEl);
|
||||
this.root = createPluginRoot(contentEl, this.app);
|
||||
|
||||
const handleConfirm = (command: CustomCommand) => {
|
||||
this.onUpdate(command);
|
||||
void this.onUpdate(command);
|
||||
this.close();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { CustomCommand } from "@/commands/type";
|
||||
|
||||
export const LEGACY_SELECTED_TEXT_PLACEHOLDER = "{copilot-selection}";
|
||||
export const COMMAND_NAME_MAX_LENGTH = 50;
|
||||
export const QUICK_COMMAND_CODE_BLOCK = "copilotquickcommand";
|
||||
export const EMPTY_COMMAND: CustomCommand = {
|
||||
title: "",
|
||||
content: "",
|
||||
|
|
|
|||
|
|
@ -50,12 +50,14 @@ export async function createChatChain(
|
|||
|
||||
return RunnableSequence.from([
|
||||
{
|
||||
input: (initialInput) => initialInput.input,
|
||||
input: (initialInput: { input: string }) => initialInput.input,
|
||||
memory: () => memory.loadMemoryVariables({}),
|
||||
},
|
||||
{
|
||||
input: (previousOutput) => previousOutput.input,
|
||||
history: (previousOutput) => previousOutput.memory.history,
|
||||
input: (previousOutput: { input: string; memory: { history: unknown } }) =>
|
||||
previousOutput.input,
|
||||
history: (previousOutput: { input: string; memory: { history: unknown } }) =>
|
||||
previousOutput.memory.history,
|
||||
},
|
||||
chatPrompt,
|
||||
chatModel,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
updateCachedCommands,
|
||||
} from "./state";
|
||||
import { ensureFolderExists } from "@/utils";
|
||||
import { trashFile } from "@/utils/vaultAdapterUtils";
|
||||
|
||||
export class CustomCommandManager {
|
||||
private static instance: CustomCommandManager;
|
||||
|
|
@ -56,20 +57,25 @@ export class CustomCommandManager {
|
|||
// Ensure nested folders are created cross-platform
|
||||
await ensureFolderExists(folderPath);
|
||||
|
||||
let commandFile = app.vault.getAbstractFileByPath(filePath) as TFile;
|
||||
if (!commandFile || !(commandFile instanceof TFile)) {
|
||||
commandFile = await app.vault.create(filePath, command.content);
|
||||
const existingFile = app.vault.getAbstractFileByPath(filePath);
|
||||
let commandFile: TFile;
|
||||
if (existingFile instanceof TFile) {
|
||||
await app.vault.modify(existingFile, command.content);
|
||||
commandFile = existingFile;
|
||||
} else {
|
||||
await app.vault.modify(commandFile, command.content);
|
||||
commandFile = await app.vault.create(filePath, command.content);
|
||||
}
|
||||
|
||||
await app.fileManager.processFrontMatter(commandFile, (frontmatter) => {
|
||||
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] = command.showInContextMenu;
|
||||
frontmatter[COPILOT_COMMAND_SLASH_ENABLED] = command.showInSlashMenu;
|
||||
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ORDER] = command.order;
|
||||
frontmatter[COPILOT_COMMAND_MODEL_KEY] = command.modelKey;
|
||||
frontmatter[COPILOT_COMMAND_LAST_USED] = command.lastUsedMs;
|
||||
});
|
||||
await app.fileManager.processFrontMatter(
|
||||
commandFile,
|
||||
(frontmatter: Record<string, unknown>) => {
|
||||
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] = command.showInContextMenu;
|
||||
frontmatter[COPILOT_COMMAND_SLASH_ENABLED] = command.showInSlashMenu;
|
||||
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ORDER] = command.order;
|
||||
frontmatter[COPILOT_COMMAND_MODEL_KEY] = command.modelKey;
|
||||
frontmatter[COPILOT_COMMAND_LAST_USED] = command.lastUsedMs;
|
||||
}
|
||||
);
|
||||
|
||||
if (!mergedOptions.skipStoreUpdate) {
|
||||
updateCachedCommand(command, command.title);
|
||||
|
|
@ -80,7 +86,7 @@ export class CustomCommandManager {
|
|||
}
|
||||
|
||||
async recordUsage(command: CustomCommand) {
|
||||
this.updateCommand({ ...command, lastUsedMs: Date.now() }, command.title);
|
||||
await this.updateCommand({ ...command, lastUsedMs: Date.now() }, command.title);
|
||||
}
|
||||
|
||||
async updateCommand(command: CustomCommand, prevCommandTitle: string, skipStoreUpdate = false) {
|
||||
|
|
@ -123,13 +129,16 @@ export class CustomCommandManager {
|
|||
|
||||
if (commandFile instanceof TFile) {
|
||||
await app.vault.modify(commandFile, command.content);
|
||||
await app.fileManager.processFrontMatter(commandFile, (frontmatter) => {
|
||||
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] = command.showInContextMenu;
|
||||
frontmatter[COPILOT_COMMAND_SLASH_ENABLED] = command.showInSlashMenu;
|
||||
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ORDER] = command.order;
|
||||
frontmatter[COPILOT_COMMAND_MODEL_KEY] = command.modelKey;
|
||||
frontmatter[COPILOT_COMMAND_LAST_USED] = command.lastUsedMs;
|
||||
});
|
||||
await app.fileManager.processFrontMatter(
|
||||
commandFile,
|
||||
(frontmatter: Record<string, unknown>) => {
|
||||
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] = command.showInContextMenu;
|
||||
frontmatter[COPILOT_COMMAND_SLASH_ENABLED] = command.showInSlashMenu;
|
||||
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ORDER] = command.order;
|
||||
frontmatter[COPILOT_COMMAND_MODEL_KEY] = command.modelKey;
|
||||
frontmatter[COPILOT_COMMAND_LAST_USED] = command.lastUsedMs;
|
||||
}
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
removePendingFileWrite(filePath);
|
||||
|
|
@ -163,7 +172,7 @@ export class CustomCommandManager {
|
|||
deleteCachedCommand(command.title);
|
||||
const file = app.vault.getAbstractFileByPath(filePath);
|
||||
if (file instanceof TFile) {
|
||||
await app.vault.delete(file);
|
||||
await trashFile(app, file);
|
||||
}
|
||||
} finally {
|
||||
removePendingFileWrite(filePath);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from "@/commands/customCommandUtils";
|
||||
import { Editor, Plugin, TFile, Vault } from "obsidian";
|
||||
import { CustomCommandChatModal } from "@/commands/CustomCommandChatModal";
|
||||
import debounce from "lodash.debounce";
|
||||
import { debounce } from "@/utils/debounce";
|
||||
import { CustomCommand } from "@/commands/type";
|
||||
import {
|
||||
deleteCachedCommand,
|
||||
|
|
@ -38,8 +38,9 @@ export class CustomCommandRegister {
|
|||
|
||||
/**
|
||||
* Register all custom commands found in the custom commands folder.
|
||||
* Synchronous: iterates cached commands and registers each.
|
||||
*/
|
||||
private async registerCommands() {
|
||||
private registerCommands() {
|
||||
const commands = getCachedCustomCommands();
|
||||
commands.forEach((command) => {
|
||||
this.registerCommand(command);
|
||||
|
|
@ -106,7 +107,7 @@ export class CustomCommandRegister {
|
|||
return;
|
||||
}
|
||||
const commandId = getCommandId(file.basename);
|
||||
(this.plugin as any).removeCommand(commandId);
|
||||
(this.plugin as unknown as { removeCommand: (id: string) => void }).removeCommand(commandId);
|
||||
deleteCachedCommand(file.basename);
|
||||
};
|
||||
|
||||
|
|
@ -118,7 +119,9 @@ export class CustomCommandRegister {
|
|||
const oldFilename = oldPath.split("/").pop()?.replace(/\.md$/, "");
|
||||
if (oldFilename) {
|
||||
const oldCommandId = getCommandId(oldFilename);
|
||||
(this.plugin as any).removeCommand(oldCommandId);
|
||||
(this.plugin as unknown as { removeCommand: (id: string) => void }).removeCommand(
|
||||
oldCommandId
|
||||
);
|
||||
deleteCachedCommand(oldFilename);
|
||||
}
|
||||
// Register the new command if it's still a custom command file
|
||||
|
|
@ -132,7 +135,7 @@ export class CustomCommandRegister {
|
|||
|
||||
private registerCommand(customCommand: CustomCommand) {
|
||||
const commandId = getCommandId(customCommand.title);
|
||||
(this.plugin as any).removeCommand(commandId);
|
||||
(this.plugin as unknown as { removeCommand: (id: string) => void }).removeCommand(commandId);
|
||||
this.plugin.addCommand({
|
||||
id: commandId,
|
||||
name: customCommand.title,
|
||||
|
|
@ -141,7 +144,9 @@ export class CustomCommandRegister {
|
|||
selectedText: editor.getSelection(),
|
||||
command: customCommand,
|
||||
}).open();
|
||||
CustomCommandManager.getInstance().recordUsage(customCommand);
|
||||
void CustomCommandManager.getInstance()
|
||||
.recordUsage(customCommand)
|
||||
.catch((err) => logError("recordUsage failed", err));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ 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", () => ({
|
||||
|
|
@ -31,7 +32,7 @@ jest.mock("@/logger", () => ({
|
|||
|
||||
// Mock the utility functions
|
||||
jest.mock("@/utils", () => {
|
||||
const actual = jest.requireActual("@/utils");
|
||||
const actual = jest.requireActual<{ stripFrontmatter: unknown }>("@/utils");
|
||||
return {
|
||||
extractTemplateNoteFiles: jest.fn().mockReturnValue([]),
|
||||
getFileContent: jest.fn(),
|
||||
|
|
@ -64,10 +65,10 @@ describe("processedPrompt()", () => {
|
|||
}),
|
||||
},
|
||||
} as unknown as Vault;
|
||||
mockActiveNote = {
|
||||
mockActiveNote = mockTFile({
|
||||
path: "path/to/active/note.md",
|
||||
basename: "Active Note",
|
||||
} as TFile;
|
||||
});
|
||||
});
|
||||
|
||||
it("should add 1 context and selectedText", async () => {
|
||||
|
|
@ -84,7 +85,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).mockResolvedValueOnce([mockActiveNote]);
|
||||
(getNotesFromPath as jest.Mock).mockReturnValueOnce([mockActiveNote]);
|
||||
|
||||
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
|
||||
|
||||
|
|
@ -110,14 +111,14 @@ describe("processedPrompt()", () => {
|
|||
.mockResolvedValueOnce("here is the note content for note0")
|
||||
.mockResolvedValueOnce("note content for note1");
|
||||
|
||||
const { getFileName } = jest.requireMock("@/utils") as any;
|
||||
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
|
||||
getFileName.mockReturnValueOnce("Variable1 Note").mockReturnValueOnce("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;
|
||||
const mockNote1 = mockTFile({ path: "path/to/note1.md", basename: "Variable1 Note" });
|
||||
const mockNote2 = mockTFile({ path: "path/to/note2.md", basename: "Variable2 Note" });
|
||||
(getNotesFromPath as jest.Mock)
|
||||
.mockResolvedValueOnce([mockNote1])
|
||||
.mockResolvedValueOnce([mockNote2]);
|
||||
.mockReturnValueOnce([mockNote1])
|
||||
.mockReturnValueOnce([mockNote2]);
|
||||
|
||||
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
|
||||
|
||||
|
|
@ -174,7 +175,7 @@ describe("processedPrompt()", () => {
|
|||
const selectedText = "";
|
||||
|
||||
(getFileContent as jest.Mock).mockResolvedValue("Content of the active note");
|
||||
const { getFileName } = jest.requireMock("@/utils") as any;
|
||||
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
|
||||
getFileName.mockReturnValue("Active Note");
|
||||
|
||||
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
|
||||
|
|
@ -228,16 +229,16 @@ describe("processedPrompt()", () => {
|
|||
const selectedText = "";
|
||||
|
||||
// Mock note file for the tag
|
||||
const mockNoteForTag = {
|
||||
const mockNoteForTag = mockTFile({
|
||||
path: "path/to/tagged/note.md",
|
||||
basename: "Tagged Note",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
// Mock getNotesFromTags to return our mock note
|
||||
(getNotesFromTags as jest.Mock).mockResolvedValue([mockNoteForTag]);
|
||||
(getNotesFromTags as jest.Mock).mockReturnValue([mockNoteForTag]);
|
||||
|
||||
// Mock getFileName to return the basename
|
||||
const { getFileName } = jest.requireMock("@/utils") as any;
|
||||
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
|
||||
getFileName.mockReturnValue("Tagged Note");
|
||||
|
||||
// Mock getFileContent to return content for the note
|
||||
|
|
@ -256,20 +257,20 @@ describe("processedPrompt()", () => {
|
|||
const selectedText = "";
|
||||
|
||||
// Mock note files for the tags
|
||||
const mockNoteForTag1 = {
|
||||
const mockNoteForTag1 = mockTFile({
|
||||
basename: "Tagged Note 1",
|
||||
path: "path/to/tagged/note1.md",
|
||||
} as TFile;
|
||||
const mockNoteForTag2 = {
|
||||
});
|
||||
const mockNoteForTag2 = mockTFile({
|
||||
basename: "Tagged Note 2",
|
||||
path: "path/to/tagged/note2.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
// Mock getNotesFromTags to return our mock notes
|
||||
(getNotesFromTags as jest.Mock).mockResolvedValue([mockNoteForTag1, mockNoteForTag2]);
|
||||
(getNotesFromTags as jest.Mock).mockReturnValue([mockNoteForTag1, mockNoteForTag2]);
|
||||
|
||||
// Mock getFileName to return the basename
|
||||
const { getFileName } = jest.requireMock("@/utils") as any;
|
||||
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
|
||||
getFileName.mockImplementation((file: TFile) => file.basename);
|
||||
|
||||
// Mock getFileContent to return content for each note
|
||||
|
|
@ -294,7 +295,7 @@ describe("processedPrompt()", () => {
|
|||
it("should process [[note title]] syntax correctly", async () => {
|
||||
const customPrompt = "Content of [[Test Note]] is important.";
|
||||
const selectedText = "";
|
||||
const mockTestNote = { basename: "Test Note", path: "Test Note.md" } as TFile;
|
||||
const mockTestNote = mockTFile({ basename: "Test Note", path: "Test Note.md" });
|
||||
|
||||
// Mock the necessary functions
|
||||
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockTestNote]);
|
||||
|
|
@ -316,20 +317,20 @@ describe("processedPrompt()", () => {
|
|||
const selectedText = "";
|
||||
|
||||
// Mock the necessary functions
|
||||
const mockNoteFile = {
|
||||
const mockNoteFile = mockTFile({
|
||||
basename: "Test Note",
|
||||
path: "Test Note.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNoteFile]);
|
||||
|
||||
const { getFileName } = jest.requireMock("@/utils") as any;
|
||||
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
|
||||
getFileName.mockReturnValue("Test Note");
|
||||
|
||||
(getFileContent as jest.Mock).mockResolvedValue("Test note content");
|
||||
|
||||
// Mock getNotesFromPath to return our mock note
|
||||
(getNotesFromPath as jest.Mock).mockResolvedValue([mockNoteFile]);
|
||||
(getNotesFromPath as jest.Mock).mockReturnValue([mockNoteFile]);
|
||||
|
||||
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
|
||||
|
||||
|
|
@ -353,15 +354,15 @@ describe("processedPrompt()", () => {
|
|||
const selectedText = "";
|
||||
|
||||
// Mock the necessary functions
|
||||
const mockNote1 = {
|
||||
const mockNote1 = mockTFile({
|
||||
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("@/utils") as any;
|
||||
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
|
||||
getFileName.mockImplementation((file: TFile) => file.basename);
|
||||
|
||||
(getFileContent as jest.Mock).mockImplementation((file: TFile) => {
|
||||
|
|
@ -372,7 +373,7 @@ describe("processedPrompt()", () => {
|
|||
});
|
||||
|
||||
// Mock getNotesFromPath to return our mock note
|
||||
(getNotesFromPath as jest.Mock).mockResolvedValue([mockNote1]);
|
||||
(getNotesFromPath as jest.Mock).mockReturnValue([mockNote1]);
|
||||
|
||||
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
|
||||
|
||||
|
|
@ -394,13 +395,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 = { 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;
|
||||
const mockNote1 = mockTFile({ basename: "Note1", path: "Note1.md" });
|
||||
const mockNote2 = mockTFile({ basename: "Note2", path: "Note2.md" });
|
||||
const mockNote3 = mockTFile({ basename: "Note3", path: "Note3.md" });
|
||||
|
||||
// Mock the necessary functions
|
||||
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNote1, mockNote2, mockNote3]);
|
||||
(getNotesFromPath as jest.Mock).mockResolvedValue([]);
|
||||
(getNotesFromPath as jest.Mock).mockReturnValue([]);
|
||||
(getFileContent as jest.Mock).mockImplementation((file: TFile) => {
|
||||
if (file.basename === "Note1") {
|
||||
return "Note1 content";
|
||||
|
|
@ -458,7 +459,7 @@ describe("processedPrompt()", () => {
|
|||
const selectedText = "";
|
||||
|
||||
// Mock getFileName and getFileContent
|
||||
const { getFileName } = jest.requireMock("@/utils") as any;
|
||||
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
|
||||
getFileName.mockReturnValue("Active Note");
|
||||
|
||||
(getFileContent as jest.Mock).mockResolvedValue("Content of the active note");
|
||||
|
|
@ -510,7 +511,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("@/utils") as any;
|
||||
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
|
||||
getFileName.mockReturnValue("Active Note");
|
||||
|
||||
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
|
||||
|
|
@ -629,7 +630,7 @@ describe("sortSlashCommands", () => {
|
|||
});
|
||||
|
||||
const sorted = sortSlashCommands(sampleCommands);
|
||||
expect(sorted.map((c: any) => c.title)).toEqual(["Gamma", "Alpha", "Beta"]);
|
||||
expect(sorted.map((c: { title: string }) => c.title)).toEqual(["Gamma", "Alpha", "Beta"]);
|
||||
});
|
||||
|
||||
it("sorts alphabetically (ALPHABETICAL)", () => {
|
||||
|
|
@ -639,7 +640,7 @@ describe("sortSlashCommands", () => {
|
|||
});
|
||||
|
||||
const sorted = sortSlashCommands(sampleCommands);
|
||||
expect(sorted.map((c: any) => c.title)).toEqual(["Alpha", "Beta", "Gamma"]);
|
||||
expect(sorted.map((c: { title: string }) => c.title)).toEqual(["Alpha", "Beta", "Gamma"]);
|
||||
});
|
||||
|
||||
it("sorts by manual order (MANUAL)", () => {
|
||||
|
|
@ -648,7 +649,7 @@ describe("sortSlashCommands", () => {
|
|||
promptSortStrategy: PromptSortStrategy.MANUAL,
|
||||
});
|
||||
const sorted = sortSlashCommands(sampleCommands);
|
||||
expect(sorted.map((c: any) => c.title)).toEqual(["Alpha", "Beta", "Gamma"]);
|
||||
expect(sorted.map((c: { title: string }) => c.title)).toEqual(["Alpha", "Beta", "Gamma"]);
|
||||
});
|
||||
|
||||
it("returns original order for unknown strategy", () => {
|
||||
|
|
@ -662,14 +663,30 @@ describe("sortSlashCommands", () => {
|
|||
});
|
||||
|
||||
describe("parseCustomCommandFile", () => {
|
||||
let originalApp: any;
|
||||
let mockFile: any;
|
||||
let mockFrontmatter: any;
|
||||
let mockMetadata: any;
|
||||
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;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save and mock global app
|
||||
originalApp = global.app;
|
||||
originalApp = (window as unknown as AppRef).app;
|
||||
mockFrontmatter = {
|
||||
"copilot-command-context-menu-enabled": true,
|
||||
"copilot-command-slash-enabled": false,
|
||||
|
|
@ -678,27 +695,28 @@ describe("parseCustomCommandFile", () => {
|
|||
"copilot-command-last-used": 1234567890,
|
||||
};
|
||||
mockMetadata = { frontmatter: mockFrontmatter };
|
||||
global.app = {
|
||||
const mockedApp: MockAppLike = {
|
||||
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) as any,
|
||||
} as any,
|
||||
} as any;
|
||||
mockFile = {
|
||||
getFileCache: jest.fn().mockReturnValue(mockMetadata),
|
||||
},
|
||||
};
|
||||
(window as unknown as AppRef).app = mockedApp;
|
||||
mockFile = mockTFile({
|
||||
basename: "Test Command",
|
||||
extension: "md",
|
||||
path: "CustomCommands/Test Command.md",
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.app = originalApp;
|
||||
(window as unknown as AppRef).app = originalApp;
|
||||
});
|
||||
|
||||
it("parses a custom command file with frontmatter and content", async () => {
|
||||
|
|
@ -718,8 +736,12 @@ describe("parseCustomCommandFile", () => {
|
|||
});
|
||||
|
||||
it("uses EMPTY_COMMAND defaults if frontmatter is missing", async () => {
|
||||
(global.app.vault as any).read.mockResolvedValue("Prompt content only, no frontmatter.");
|
||||
(global.app.metadataCache as any).getFileCache.mockReturnValue({});
|
||||
(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({});
|
||||
const { parseCustomCommandFile } = await import("@/commands/customCommandUtils");
|
||||
const result = await parseCustomCommandFile(mockFile);
|
||||
expect(result).toEqual({
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ import {
|
|||
COPILOT_COMMAND_SLASH_ENABLED,
|
||||
EMPTY_COMMAND,
|
||||
LEGACY_SELECTED_TEXT_PLACEHOLDER,
|
||||
QUICK_COMMAND_CODE_BLOCK,
|
||||
} from "@/commands/constants";
|
||||
import { CustomCommand } from "@/commands/type";
|
||||
import { logWarn } from "@/logger";
|
||||
import { normalizePath, Notice, TAbstractFile, TFile, Vault, Editor } from "obsidian";
|
||||
import { normalizePath, Notice, TAbstractFile, TFile, Vault } from "obsidian";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import {
|
||||
updateCachedCommands,
|
||||
|
|
@ -146,7 +145,7 @@ export function sortCommandsByOrder(commands: CustomCommand[]): CustomCommand[]
|
|||
});
|
||||
}
|
||||
|
||||
export function sortCommandsByRecency(commands: CustomCommand[]): CustomCommand[] {
|
||||
function sortCommandsByRecency(commands: CustomCommand[]): CustomCommand[] {
|
||||
return sortByStrategy(commands, "recent", {
|
||||
getName: (command) => command.title,
|
||||
getCreatedAtMs: () => 0,
|
||||
|
|
@ -154,7 +153,7 @@ export function sortCommandsByRecency(commands: CustomCommand[]): CustomCommand[
|
|||
});
|
||||
}
|
||||
|
||||
export function sortCommandsByAlphabetical(commands: CustomCommand[]): CustomCommand[] {
|
||||
function sortCommandsByAlphabetical(commands: CustomCommand[]): CustomCommand[] {
|
||||
return sortByStrategy(commands, "name", {
|
||||
getName: (command) => command.title,
|
||||
getCreatedAtMs: () => 0,
|
||||
|
|
@ -166,7 +165,7 @@ export function sortCommandsByAlphabetical(commands: CustomCommand[]): CustomCom
|
|||
* Sort prompts of the slash commands based on the sort strategy.
|
||||
*/
|
||||
export function sortSlashCommands(commands: CustomCommand[]): CustomCommand[] {
|
||||
const sortStrategy = getSettings().promptSortStrategy;
|
||||
const sortStrategy: PromptSortStrategy = getSettings().promptSortStrategy as PromptSortStrategy;
|
||||
switch (sortStrategy) {
|
||||
case PromptSortStrategy.TIMESTAMP:
|
||||
return sortCommandsByRecency(commands);
|
||||
|
|
@ -295,7 +294,7 @@ async function extractVariablesFromPrompt(
|
|||
.slice(1)
|
||||
.split(",")
|
||||
.map((tag) => tag.trim());
|
||||
const noteFiles = await getNotesFromTags(vault, tagNames);
|
||||
const noteFiles = getNotesFromTags(vault, tagNames);
|
||||
const notesContent: string[] = [];
|
||||
for (const file of noteFiles) {
|
||||
const content = await getFileContent(file, vault);
|
||||
|
|
@ -309,7 +308,7 @@ async function extractVariablesFromPrompt(
|
|||
variableResult.content = notesContent.join("\n\n");
|
||||
} else {
|
||||
const processedVariableName = processVariableNameForNotePath(variableName);
|
||||
const noteFiles = await getNotesFromPath(vault, processedVariableName);
|
||||
const noteFiles = getNotesFromPath(vault, processedVariableName);
|
||||
const notesContent: string[] = [];
|
||||
for (const file of noteFiles) {
|
||||
const content = await getFileContent(file, vault);
|
||||
|
|
@ -493,7 +492,7 @@ export function getNextCustomCommandOrder(): number {
|
|||
export async function ensureCommandFrontmatter(file: TFile, command: CustomCommand) {
|
||||
try {
|
||||
addPendingFileWrite(file.path);
|
||||
await app.fileManager.processFrontMatter(file, (frontmatter) => {
|
||||
await app.fileManager.processFrontMatter(file, (frontmatter: Record<string, unknown>) => {
|
||||
if (frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] == null) {
|
||||
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] = command.showInContextMenu;
|
||||
}
|
||||
|
|
@ -514,68 +513,3 @@ export async function ensureCommandFrontmatter(file: TFile, command: CustomComma
|
|||
removePendingFileWrite(file.path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all quick command code blocks from the editor while preserving cursor position and selection
|
||||
* @param editor - The Obsidian editor instance
|
||||
* @returns true if any blocks were removed, false otherwise
|
||||
*/
|
||||
export function removeQuickCommandBlocks(editor: Editor): boolean {
|
||||
// Store original selection positions
|
||||
const originalFrom = editor.getCursor("from");
|
||||
const originalTo = editor.getCursor("to");
|
||||
|
||||
const content = editor.getValue();
|
||||
const lines = content.split("\n");
|
||||
let hasExisting = false;
|
||||
const newLines = [];
|
||||
let removedLinesBeforeFrom = 0;
|
||||
let removedLinesBeforeTo = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
if (lines[i].trim() === `\`\`\`${QUICK_COMMAND_CODE_BLOCK}`) {
|
||||
hasExisting = true;
|
||||
const blockStartLine = i;
|
||||
|
||||
// Skip the opening line
|
||||
i++;
|
||||
// Skip until we find the closing ```
|
||||
while (i < lines.length && lines[i].trim() !== "```") {
|
||||
i++;
|
||||
}
|
||||
// Skip the closing line
|
||||
i++;
|
||||
|
||||
const removedLineCount = i - blockStartLine;
|
||||
|
||||
// Calculate how many lines were removed before the selection positions
|
||||
if (blockStartLine <= originalFrom.line) {
|
||||
removedLinesBeforeFrom += removedLineCount;
|
||||
}
|
||||
if (blockStartLine <= originalTo.line) {
|
||||
removedLinesBeforeTo += removedLineCount;
|
||||
}
|
||||
} else {
|
||||
newLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update editor content and restore selection if we removed existing blocks
|
||||
if (hasExisting) {
|
||||
editor.setValue(newLines.join("\n"));
|
||||
|
||||
// Calculate new selection positions accounting for removed lines
|
||||
const newFromLine = Math.max(0, originalFrom.line - removedLinesBeforeFrom);
|
||||
const newToLine = Math.max(0, originalTo.line - removedLinesBeforeTo);
|
||||
|
||||
// Restore the selection
|
||||
editor.setSelection(
|
||||
{ line: newFromLine, ch: originalFrom.ch },
|
||||
{ line: newToLine, ch: originalTo.ch }
|
||||
);
|
||||
}
|
||||
|
||||
return hasExisting;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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", () => ({
|
||||
|
|
@ -35,10 +36,10 @@ describe("XML Escaping in processPrompt", () => {
|
|||
},
|
||||
} as unknown as Vault;
|
||||
|
||||
mockActiveNote = {
|
||||
mockActiveNote = mockTFile({
|
||||
path: "path/to/active/note.md",
|
||||
basename: "Active Note",
|
||||
} as TFile;
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT escape XML special characters in selected text", async () => {
|
||||
|
|
@ -59,12 +60,12 @@ describe("XML Escaping in processPrompt", () => {
|
|||
it("should NOT escape XML in variable names", async () => {
|
||||
const customPrompt = 'Use {my"variable<>}';
|
||||
|
||||
const mockNote = {
|
||||
const mockNote = mockTFile({
|
||||
basename: 'Note with <special> & "chars"',
|
||||
path: "special.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
(getNotesFromPath as jest.Mock).mockResolvedValue([mockNote]);
|
||||
(getNotesFromPath as jest.Mock).mockReturnValue([mockNote]);
|
||||
(getFileName as jest.Mock).mockReturnValue(mockNote.basename);
|
||||
(getFileContent as jest.Mock).mockResolvedValue('Content with <xml> & special "chars"');
|
||||
|
||||
|
|
@ -102,13 +103,15 @@ describe("XML Escaping in processPrompt", () => {
|
|||
it("should NOT escape XML in note paths and metadata", async () => {
|
||||
const customPrompt = "[[Special Note]]";
|
||||
|
||||
const mockNote = {
|
||||
const mockNote = mockTFile({
|
||||
basename: 'Special & "Note"',
|
||||
path: 'folder<with>/special&chars/"note".md',
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
(getNotesFromPath as jest.Mock).mockResolvedValue([]);
|
||||
jest.requireMock("@/utils").extractTemplateNoteFiles.mockReturnValue([mockNote]);
|
||||
(getNotesFromPath as jest.Mock).mockReturnValue([]);
|
||||
(
|
||||
jest.requireMock("@/utils") as unknown as { extractTemplateNoteFiles: jest.Mock }
|
||||
).extractTemplateNoteFiles.mockReturnValue([mockNote]);
|
||||
(getFileContent as jest.Mock).mockResolvedValue("Content with & and < and >");
|
||||
|
||||
const result = await processPrompt(customPrompt, "", mockVault, mockActiveNote);
|
||||
|
|
@ -126,13 +129,15 @@ describe("XML Escaping in processPrompt", () => {
|
|||
it("should NOT escape XML in tag variables", async () => {
|
||||
const customPrompt = "Notes for {#tag&special}";
|
||||
|
||||
const mockNote = {
|
||||
const mockNote = mockTFile({
|
||||
basename: 'Tagged & "Note"',
|
||||
path: "tagged.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
(getNotesFromPath as jest.Mock).mockResolvedValue([]);
|
||||
jest.requireMock("@/utils").getNotesFromTags.mockResolvedValue([mockNote]);
|
||||
(getNotesFromPath as jest.Mock).mockReturnValue([]);
|
||||
(
|
||||
jest.requireMock("@/utils") as unknown as { getNotesFromTags: jest.Mock }
|
||||
).getNotesFromTags.mockReturnValue([mockNote]);
|
||||
(getFileName as jest.Mock).mockReturnValue(mockNote.basename);
|
||||
(getFileContent as jest.Mock).mockResolvedValue("Content: <tag> & </tag>");
|
||||
|
||||
|
|
|
|||
|
|
@ -34,37 +34,47 @@ import { COMMAND_IDS, COMMAND_ICONS, COMMAND_NAMES, CommandId } from "../constan
|
|||
import { setSelectedTextContexts } from "@/aiParams";
|
||||
|
||||
/**
|
||||
* Add a command to the plugin.
|
||||
* Add a command to the plugin. Supports async callbacks; errors are logged.
|
||||
*/
|
||||
export function addCommand(plugin: CopilotPlugin, id: CommandId, callback: () => void) {
|
||||
function addCommand(plugin: CopilotPlugin, id: CommandId, callback: () => void | Promise<void>) {
|
||||
plugin.addCommand({
|
||||
id,
|
||||
name: COMMAND_NAMES[id],
|
||||
icon: COMMAND_ICONS[id],
|
||||
callback,
|
||||
callback: () => {
|
||||
const result = callback();
|
||||
if (result instanceof Promise) {
|
||||
result.catch((err) => logError(`Command ${id} failed`, err));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an editor command to the plugin.
|
||||
* Add an editor command to the plugin. Supports async callbacks; errors are logged.
|
||||
*/
|
||||
function addEditorCommand(
|
||||
plugin: CopilotPlugin,
|
||||
id: CommandId,
|
||||
callback: (editor: Editor) => void
|
||||
callback: (editor: Editor) => void | Promise<void>
|
||||
) {
|
||||
plugin.addCommand({
|
||||
id,
|
||||
name: COMMAND_NAMES[id],
|
||||
icon: COMMAND_ICONS[id],
|
||||
editorCallback: callback,
|
||||
editorCallback: (editor) => {
|
||||
const result = callback(editor);
|
||||
if (result instanceof Promise) {
|
||||
result.catch((err) => logError(`Editor command ${id} failed`, err));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a check command to the plugin.
|
||||
*/
|
||||
export function addCheckCommand(
|
||||
function addCheckCommand(
|
||||
plugin: CopilotPlugin,
|
||||
id: CommandId,
|
||||
callback: (checking: boolean) => boolean | void
|
||||
|
|
@ -83,7 +93,7 @@ export function registerCommands(
|
|||
next: CopilotSettings
|
||||
) {
|
||||
addEditorCommand(plugin, COMMAND_IDS.COUNT_WORD_AND_TOKENS_SELECTION, async (editor: Editor) => {
|
||||
const selectedText = await editor.getSelection();
|
||||
const selectedText = editor.getSelection();
|
||||
const wordCount = selectedText.split(" ").length;
|
||||
const tokenCount = await plugin.projectManager
|
||||
.getCurrentChainManager()
|
||||
|
|
@ -108,13 +118,13 @@ export function registerCommands(
|
|||
plugin.toggleView();
|
||||
});
|
||||
|
||||
addCommand(plugin, COMMAND_IDS.OPEN_COPILOT_CHAT_WINDOW, () => {
|
||||
plugin.activateView();
|
||||
addCommand(plugin, COMMAND_IDS.OPEN_COPILOT_CHAT_WINDOW, async () => {
|
||||
await plugin.activateView();
|
||||
});
|
||||
|
||||
addCommand(plugin, COMMAND_IDS.NEW_CHAT, () => {
|
||||
addCommand(plugin, COMMAND_IDS.NEW_CHAT, async () => {
|
||||
clearRecordedPromptPayload();
|
||||
plugin.newChat();
|
||||
await plugin.newChat();
|
||||
});
|
||||
|
||||
// Quick Command - opens a modal dialog for quick interactions
|
||||
|
|
@ -296,8 +306,8 @@ export function registerCommands(
|
|||
}
|
||||
});
|
||||
|
||||
addCommand(plugin, COMMAND_IDS.LOAD_COPILOT_CHAT_CONVERSATION, () => {
|
||||
plugin.loadCopilotChatHistory();
|
||||
addCommand(plugin, COMMAND_IDS.LOAD_COPILOT_CHAT_CONVERSATION, async () => {
|
||||
await plugin.loadCopilotChatHistory();
|
||||
});
|
||||
|
||||
addCommand(plugin, COMMAND_IDS.LIST_INDEXED_FILES, async () => {
|
||||
|
|
@ -377,16 +387,16 @@ export function registerCommands(
|
|||
await ensureFolderExists(folderPath);
|
||||
|
||||
const existingFile = plugin.app.vault.getAbstractFileByPath(filePath);
|
||||
if (existingFile) {
|
||||
await plugin.app.vault.modify(existingFile as TFile, content);
|
||||
if (existingFile instanceof TFile) {
|
||||
await plugin.app.vault.modify(existingFile, content);
|
||||
} else {
|
||||
await plugin.app.vault.create(filePath, content);
|
||||
}
|
||||
|
||||
// Open the file
|
||||
const file = plugin.app.vault.getAbstractFileByPath(filePath);
|
||||
if (file) {
|
||||
await plugin.app.workspace.getLeaf().openFile(file as TFile);
|
||||
if (file instanceof TFile) {
|
||||
await plugin.app.workspace.getLeaf().openFile(file);
|
||||
new Notice(`Listed ${indexedFiles.size} indexed files`);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -414,28 +424,30 @@ export function registerCommands(
|
|||
}
|
||||
|
||||
// Map hits to chunks (getDocsByPath returns {document, score} format)
|
||||
const chunks = hits.map((hit: any) => hit.document);
|
||||
const chunks: Record<string, unknown>[] = hits.map(
|
||||
(hit) => hit.document as unknown as Record<string, unknown>
|
||||
);
|
||||
const content = [
|
||||
`# Embedding Debug: ${activeFile.basename}`,
|
||||
"",
|
||||
`**Path:** ${activeFile.path}`,
|
||||
`**Chunks:** ${chunks.length}`,
|
||||
`**Embedding Model:** ${chunks[0]?.embeddingModel || "unknown"}`,
|
||||
`**Embedding Model:** ${(chunks[0]?.embeddingModel as string | undefined) || "unknown"}`,
|
||||
"",
|
||||
...chunks.flatMap((chunk: any, index: number) => {
|
||||
const embedding = chunk.embedding || [];
|
||||
...chunks.flatMap((chunk: Record<string, unknown>, index: number) => {
|
||||
const embedding = (chunk.embedding as number[] | undefined) || [];
|
||||
const preview = embedding
|
||||
.slice(0, 10)
|
||||
.map((v: number) => v.toFixed(6))
|
||||
.join(", ");
|
||||
return [
|
||||
`## Chunk ${index + 1}`,
|
||||
`- **ID:** ${chunk.id}`,
|
||||
`- **Content Preview:** "${(chunk.content || "").substring(0, 200)}..."`,
|
||||
`- **ID:** ${chunk.id as string}`,
|
||||
`- **Content Preview:** "${((chunk.content as string | undefined) || "").substring(0, 200)}..."`,
|
||||
`- **Vector Length:** ${embedding.length}`,
|
||||
`- **Vector Preview:** [${preview}${embedding.length > 10 ? ", ..." : ""}]`,
|
||||
`- **Tags:** ${(chunk.tags || []).join(", ") || "none"}`,
|
||||
`- **Characters:** ${chunk.nchars || 0}`,
|
||||
`- **Tags:** ${((chunk.tags as string[] | undefined) || []).join(", ") || "none"}`,
|
||||
`- **Characters:** ${(chunk.nchars as number | undefined) || 0}`,
|
||||
"",
|
||||
];
|
||||
}),
|
||||
|
|
@ -449,15 +461,15 @@ export function registerCommands(
|
|||
await ensureFolderExists(folderPath);
|
||||
|
||||
const existingFile = plugin.app.vault.getAbstractFileByPath(filePath);
|
||||
if (existingFile) {
|
||||
await plugin.app.vault.modify(existingFile as TFile, content);
|
||||
if (existingFile instanceof TFile) {
|
||||
await plugin.app.vault.modify(existingFile, content);
|
||||
} else {
|
||||
await plugin.app.vault.create(filePath, content);
|
||||
}
|
||||
|
||||
const file = plugin.app.vault.getAbstractFileByPath(filePath);
|
||||
if (file) {
|
||||
await plugin.app.workspace.getLeaf().openFile(file as TFile);
|
||||
if (file instanceof TFile) {
|
||||
await plugin.app.workspace.getLeaf().openFile(file);
|
||||
new Notice(`Embedding debug info for ${chunks.length} chunk(s)`);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -546,7 +558,7 @@ export function registerCommands(
|
|||
setSelectedTextContexts([selectedTextContext]);
|
||||
|
||||
// Open chat window to show the context was added
|
||||
plugin.activateView();
|
||||
await plugin.activateView();
|
||||
});
|
||||
|
||||
// Add web selection to chat context command (manual)
|
||||
|
|
@ -592,7 +604,7 @@ export function registerCommands(
|
|||
setSelectedTextContexts([webSelectedTextContext]);
|
||||
|
||||
// Open chat window to show the context was added
|
||||
plugin.activateView();
|
||||
await plugin.activateView();
|
||||
} catch (error) {
|
||||
logError("Error adding web selection to context:", error);
|
||||
new Notice("Failed to get web selection");
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ 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();
|
||||
|
|
@ -23,7 +24,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) => {
|
||||
await app.fileManager.processFrontMatter(file, (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;
|
||||
|
|
@ -93,7 +94,7 @@ export async function generateDefaultCommands(): Promise<void> {
|
|||
(command) => !existingCommands.some((c) => c.title === command.title)
|
||||
);
|
||||
const newCommands = [...existingCommands, ...defaultCommands];
|
||||
CustomCommandManager.getInstance().updateCommands(newCommands);
|
||||
await CustomCommandManager.getInstance().updateCommands(newCommands);
|
||||
}
|
||||
|
||||
/** Suggests the default commands if the user has not created any commands yet. */
|
||||
|
|
@ -108,7 +109,9 @@ export async function suggestDefaultCommands(): Promise<void> {
|
|||
new ConfirmModal(
|
||||
app,
|
||||
() => {
|
||||
generateDefaultCommands();
|
||||
void generateDefaultCommands().catch((err) =>
|
||||
logError("generateDefaultCommands failed", err)
|
||||
);
|
||||
},
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -18,16 +18,6 @@ export function isFileWritePending(filePath: string) {
|
|||
return pendingFileWritesAtom.has(filePath);
|
||||
}
|
||||
|
||||
export function createCachedCommand(command: CustomCommand): CustomCommand {
|
||||
const commands = customCommandsStore.get(customCommandsAtom);
|
||||
const existingCommand = commands.find((c) => c.title === command.title);
|
||||
if (existingCommand) {
|
||||
return existingCommand;
|
||||
}
|
||||
customCommandsStore.set(customCommandsAtom, [...commands, command]);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function deleteCachedCommand(title: string) {
|
||||
const commands = customCommandsStore.get(customCommandsAtom);
|
||||
customCommandsStore.set(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
getSelectedTextContexts,
|
||||
ProjectConfig,
|
||||
removeSelectedTextContext,
|
||||
setCurrentProject,
|
||||
useChainType,
|
||||
updateIndexingProgressState,
|
||||
useIndexingProgress,
|
||||
|
|
@ -12,7 +11,7 @@ import {
|
|||
useSelectedTextContexts,
|
||||
} from "@/aiParams";
|
||||
import { resetSessionSystemPromptSettings } from "@/system-prompts";
|
||||
import { ChainType } from "@/chainFactory";
|
||||
import { ChainType } from "@/chainType";
|
||||
import { useProjectContextStatus } from "@/hooks/useProjectContextStatus";
|
||||
import { logInfo, logError } from "@/logger";
|
||||
import type { WebTabContext } from "@/types/message";
|
||||
|
|
@ -42,12 +41,15 @@ import { clearRecordedPromptPayload } from "@/LLMProviders/chainRunner/utils/pro
|
|||
import { logFileManager } from "@/logFileManager";
|
||||
import CopilotPlugin from "@/main";
|
||||
import { useIsPlusUser } from "@/plusUtils";
|
||||
import { updateSetting, useSettingsValue } from "@/settings/model";
|
||||
import { ProjectFileManager } from "@/projects/ProjectFileManager";
|
||||
import { useProjects } from "@/projects/state";
|
||||
import { 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";
|
||||
|
|
@ -78,6 +80,7 @@ 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);
|
||||
|
|
@ -85,7 +88,6 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
const [currentChain] = useChainType();
|
||||
const [currentAiMessage, setCurrentAiMessage] = useState("");
|
||||
const [inputMessage, setInputMessage] = useState("");
|
||||
const [latestTokenCount, setLatestTokenCount] = useState<number | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
// Stable ID for streaming message, shared with final persisted message
|
||||
// This allows collapsible UI state (think blocks) to persist across streaming -> history
|
||||
|
|
@ -101,12 +103,6 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
const messageToAdd = shouldAttachId ? { ...message, id: streamingId } : message;
|
||||
|
||||
rawAddMessage(messageToAdd);
|
||||
if (
|
||||
messageToAdd.sender === AI_SENDER &&
|
||||
messageToAdd.responseMetadata?.tokenUsage?.totalTokens
|
||||
) {
|
||||
setLatestTokenCount(messageToAdd.responseMetadata.tokenUsage.totalTokens);
|
||||
}
|
||||
},
|
||||
[rawAddMessage]
|
||||
);
|
||||
|
|
@ -119,8 +115,12 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMessage, setLoadingMessage] = useState(LOADING_MESSAGES.DEFAULT);
|
||||
const [contextNotes, setContextNotes] = useState<TFile[]>([]);
|
||||
const [includeActiveNote, setIncludeActiveNote] = useState(false);
|
||||
const [includeActiveWebTab, setIncludeActiveWebTab] = useState(false);
|
||||
const [includeActiveNote, setIncludeActiveNote] = useState(
|
||||
settings.autoAddActiveContentToContext === true && currentChain !== ChainType.PROJECT_CHAIN
|
||||
);
|
||||
const [includeActiveWebTab, setIncludeActiveWebTab] = useState(
|
||||
settings.autoAddActiveContentToContext === true && currentChain !== ChainType.PROJECT_CHAIN
|
||||
);
|
||||
const [selectedImages, setSelectedImages] = useState<File[]>([]);
|
||||
const [showChatUI, setShowChatUI] = useState(false);
|
||||
const [chatHistoryItems, setChatHistoryItems] = useState<ChatHistoryItem[]>([]);
|
||||
|
|
@ -179,10 +179,11 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
return projectContextStatus === "loading" || projectContextStatus === "error";
|
||||
};
|
||||
|
||||
// Reset user preference when status changes to allow default behavior
|
||||
useEffect(() => {
|
||||
const [prevProjectContextStatus, setPrevProjectContextStatus] = useState(projectContextStatus);
|
||||
if (prevProjectContextStatus !== projectContextStatus) {
|
||||
setPrevProjectContextStatus(projectContextStatus);
|
||||
setProgressCardVisible(null);
|
||||
}, [projectContextStatus]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to show the indexing progress card.
|
||||
|
|
@ -195,12 +196,22 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
return indexingState.isActive || indexingState.completionStatus !== "none";
|
||||
};
|
||||
|
||||
// Allow the card to show whenever new indexing activity or completion is detected
|
||||
useEffect(() => {
|
||||
const [prevIndexingActivity, setPrevIndexingActivity] = useState({
|
||||
isActive: indexingState.isActive,
|
||||
completionStatus: indexingState.completionStatus,
|
||||
});
|
||||
if (
|
||||
prevIndexingActivity.isActive !== indexingState.isActive ||
|
||||
prevIndexingActivity.completionStatus !== indexingState.completionStatus
|
||||
) {
|
||||
setPrevIndexingActivity({
|
||||
isActive: indexingState.isActive,
|
||||
completionStatus: indexingState.completionStatus,
|
||||
});
|
||||
if (indexingState.isActive || indexingState.completionStatus !== "none") {
|
||||
setIndexingCardVisible(null);
|
||||
}
|
||||
}, [indexingState.isActive, indexingState.completionStatus]);
|
||||
}
|
||||
|
||||
const handleIndexingCardClose = useCallback(() => {
|
||||
setIndexingCardVisible(false);
|
||||
|
|
@ -225,11 +236,12 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
await VectorStoreManager.getInstance().cancelIndexing();
|
||||
}, []);
|
||||
|
||||
// Clear token count when chat is cleared or replaced (e.g., loading chat history)
|
||||
useEffect(() => {
|
||||
if (chatHistory.length === 0) {
|
||||
setLatestTokenCount(null);
|
||||
const latestTokenCount = useMemo(() => {
|
||||
for (let i = chatHistory.length - 1; i >= 0; i--) {
|
||||
const m = chatHistory[i];
|
||||
if (m.sender === AI_SENDER) return m.responseMetadata?.tokenUsage?.totalTokens ?? null;
|
||||
}
|
||||
return null;
|
||||
}, [chatHistory]);
|
||||
|
||||
const [previousMode, setPreviousMode] = useState<ChainType | null>(null);
|
||||
|
|
@ -239,13 +251,20 @@ 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: (files) => setSelectedImages((prev) => [...prev, ...files]),
|
||||
onAddImage: handleAddImage,
|
||||
containerRef: chatContainerRef,
|
||||
});
|
||||
|
||||
|
|
@ -276,7 +295,10 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
|
||||
try {
|
||||
// Create message content array
|
||||
const content: any[] = [];
|
||||
type MessageContentItem =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image_url"; image_url: { url: string } };
|
||||
const content: MessageContentItem[] = [];
|
||||
|
||||
// Add text content if present
|
||||
if (inputMessage) {
|
||||
|
|
@ -347,7 +369,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
|
||||
// Autosave if enabled
|
||||
if (settings.autosaveChat) {
|
||||
handleSaveAsNote();
|
||||
await handleSaveAsNote();
|
||||
}
|
||||
|
||||
// Get the LLM message for AI processing
|
||||
|
|
@ -365,7 +387,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
|
||||
// Autosave again after AI response
|
||||
if (settings.autosaveChat) {
|
||||
handleSaveAsNote();
|
||||
await handleSaveAsNote();
|
||||
}
|
||||
} catch (error) {
|
||||
logError("Error sending message:", error);
|
||||
|
|
@ -445,12 +467,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) {
|
||||
console.log("Message regenerated successfully");
|
||||
logInfo("Message regenerated successfully");
|
||||
}
|
||||
|
||||
// Autosave the chat if the setting is enabled
|
||||
if (settings.autosaveChat) {
|
||||
handleSaveAsNote();
|
||||
await handleSaveAsNote();
|
||||
}
|
||||
} catch (error) {
|
||||
logError("Error regenerating message:", error);
|
||||
|
|
@ -527,7 +549,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
|
||||
// Autosave the chat if the setting is enabled
|
||||
if (settings.autosaveChat) {
|
||||
handleSaveAsNote();
|
||||
await handleSaveAsNote();
|
||||
}
|
||||
} catch (error) {
|
||||
logError("Error editing message:", error);
|
||||
|
|
@ -557,71 +579,33 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
}, [onSaveChat, handleSaveAsNote]);
|
||||
|
||||
const handleAddProject = useCallback(
|
||||
(project: ProjectConfig) => {
|
||||
const currentProjects = settings.projectList || [];
|
||||
const existingIndex = currentProjects.findIndex((p) => p.name === project.name);
|
||||
async (project: ProjectConfig) => {
|
||||
const manager = ProjectFileManager.getInstance(plugin.app);
|
||||
await manager.createProject(project);
|
||||
new Notice(`${project.name} added successfully`);
|
||||
|
||||
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
|
||||
// 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.
|
||||
const currentProject = getCurrentProject();
|
||||
if (currentProject?.id === project.id) {
|
||||
// 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`);
|
||||
void reloadCurrentProject(plugin.app);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[settings.projectList]
|
||||
[plugin.app]
|
||||
);
|
||||
|
||||
const handleEditProject = useCallback(
|
||||
(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;
|
||||
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).
|
||||
},
|
||||
[settings.projectList]
|
||||
[plugin.app]
|
||||
);
|
||||
|
||||
const handleRemoveSelectedText = useCallback(
|
||||
|
|
@ -712,7 +696,6 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
// Additional UI state reset specific to this component
|
||||
safeSet.setCurrentAiMessage("");
|
||||
setContextNotes([]);
|
||||
setLatestTokenCount(null); // Clear token count on new chat
|
||||
// Capture web selection URL before clearing for suppression
|
||||
const webSelectionUrl = selectedTextContexts.find((ctx) => ctx.sourceType === "web")?.url;
|
||||
clearSelectedTextContexts();
|
||||
|
|
@ -808,7 +791,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
|
||||
// Event listener for abort stream events
|
||||
useEffect(() => {
|
||||
const handleAbortStream = (event: CustomEvent) => {
|
||||
const handleAbortStream = (event: CustomEvent<{ reason?: ABORT_REASON }>) => {
|
||||
const reason = event.detail?.reason || ABORT_REASON.NEW_CHAT;
|
||||
handleStopGenerating(reason);
|
||||
};
|
||||
|
|
@ -821,10 +804,19 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
};
|
||||
}, [eventTarget, handleStopGenerating]);
|
||||
|
||||
// Use the autoAddActiveContentToContext setting
|
||||
useEffect(() => {
|
||||
const [prevAutoAddTuple, setPrevAutoAddTuple] = useState({
|
||||
autoAdd: settings.autoAddActiveContentToContext,
|
||||
chain: selectedChain,
|
||||
});
|
||||
if (
|
||||
prevAutoAddTuple.autoAdd !== settings.autoAddActiveContentToContext ||
|
||||
prevAutoAddTuple.chain !== selectedChain
|
||||
) {
|
||||
setPrevAutoAddTuple({
|
||||
autoAdd: settings.autoAddActiveContentToContext,
|
||||
chain: selectedChain,
|
||||
});
|
||||
if (settings.autoAddActiveContentToContext !== undefined) {
|
||||
// Only apply the setting if not in Project mode
|
||||
if (selectedChain === ChainType.PROJECT_CHAIN) {
|
||||
setIncludeActiveNote(false);
|
||||
setIncludeActiveWebTab(false);
|
||||
|
|
@ -833,7 +825,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
setIncludeActiveWebTab(settings.autoAddActiveContentToContext);
|
||||
}
|
||||
}
|
||||
}, [settings.autoAddActiveContentToContext, selectedChain]);
|
||||
}
|
||||
|
||||
// Note: pendingMessages loading has been removed as ChatManager now handles
|
||||
// message persistence and loading automatically based on project context
|
||||
|
|
@ -869,7 +861,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
new ContextManageModal(
|
||||
app,
|
||||
(updatedProject) => {
|
||||
handleEditProject(currentProject, updatedProject);
|
||||
void handleEditProject(currentProject, updatedProject);
|
||||
},
|
||||
currentProject
|
||||
).open();
|
||||
|
|
@ -881,17 +873,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={handleIndexingPause}
|
||||
onResume={handleIndexingResume}
|
||||
onStop={handleIndexingStop}
|
||||
onPause={() => void handleIndexingPause()}
|
||||
onResume={() => void handleIndexingResume()}
|
||||
onStop={() => void handleIndexingStop()}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ChatControls
|
||||
onNewChat={handleNewChat}
|
||||
onNewChat={() => void handleNewChat()}
|
||||
onSaveAsNote={() => handleSaveAsNote()}
|
||||
onLoadHistory={handleLoadChatHistory}
|
||||
onLoadHistory={() => void handleLoadChatHistory()}
|
||||
onModeChange={(newMode) => {
|
||||
setPreviousMode(selectedChain);
|
||||
// Hide chat UI when switching to project mode
|
||||
|
|
@ -921,7 +913,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
setIncludeActiveWebTab={setIncludeActiveWebTab}
|
||||
activeWebTab={currentActiveWebTab}
|
||||
selectedImages={selectedImages}
|
||||
onAddImage={(files: File[]) => setSelectedImages((prev) => [...prev, ...files])}
|
||||
onAddImage={handleAddImage}
|
||||
setSelectedImages={setSelectedImages}
|
||||
disableModelSwitch={selectedChain === ChainType.PROJECT_CHAIN}
|
||||
selectedTextContexts={selectedTextContexts}
|
||||
|
|
@ -955,7 +947,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={settings.projectList || []}
|
||||
projects={projects}
|
||||
defaultOpen={true}
|
||||
app={app}
|
||||
plugin={plugin}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ import ChainManager from "@/LLMProviders/chainManager";
|
|||
import Chat from "@/components/Chat";
|
||||
import { ChatViewLayout } from "@/components/chat-components/ChatViewLayout";
|
||||
import { CHAT_VIEWTYPE } from "@/constants";
|
||||
import { AppContext, EventTargetContext } from "@/context";
|
||||
import { EventTargetContext } from "@/context";
|
||||
import CopilotPlugin from "@/main";
|
||||
import { FileParserManager } from "@/tools/FileParserManager";
|
||||
import { createPluginRoot } from "@/utils/react/createPluginRoot";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { ItemView, Platform, WorkspaceLeaf } from "obsidian";
|
||||
import * as React from "react";
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
import { Root } from "react-dom/client";
|
||||
|
||||
export default class CopilotView extends ItemView {
|
||||
private get chainManager(): ChainManager {
|
||||
|
|
@ -22,6 +23,7 @@ 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(
|
||||
|
|
@ -54,7 +56,7 @@ export default class CopilotView extends ItemView {
|
|||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
this.root = createRoot(this.containerEl.children[1]);
|
||||
this.root = createPluginRoot(this.containerEl.children[1], this.app);
|
||||
const handleSaveAsNote = (saveFunction: () => Promise<void>) => {
|
||||
this.handleSaveAsNote = saveFunction;
|
||||
};
|
||||
|
|
@ -67,6 +69,19 @@ 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.
|
||||
|
|
@ -74,7 +89,7 @@ export default class CopilotView extends ItemView {
|
|||
// before we disconnect and rebind.
|
||||
this.registerEvent(
|
||||
this.app.workspace.on("layout-change", () => {
|
||||
requestAnimationFrame(() => this.setupDrawerHideObserver());
|
||||
window.requestAnimationFrame(() => this.setupDrawerHideObserver());
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -94,7 +109,7 @@ export default class CopilotView extends ItemView {
|
|||
this.keyboardObserver?.disconnect();
|
||||
|
||||
const syncKeyboardClass = () => {
|
||||
const drawer = this.containerEl.closest(".workspace-drawer") as HTMLElement | null;
|
||||
const drawer = this.containerEl.closest<HTMLElement>(".workspace-drawer");
|
||||
|
||||
// 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.
|
||||
|
|
@ -109,13 +124,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(
|
||||
document.documentElement.style.getPropertyValue("--keyboard-height") || "0"
|
||||
this.containerEl.doc.documentElement.style.getPropertyValue("--keyboard-height") || "0"
|
||||
);
|
||||
drawer.classList.toggle("copilot-keyboard-open", isCopilotActive && kbHeight > 0);
|
||||
};
|
||||
|
||||
this.keyboardObserver = new MutationObserver(syncKeyboardClass);
|
||||
this.keyboardObserver.observe(document.documentElement, {
|
||||
this.keyboardObserver.observe(this.containerEl.doc.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["style"],
|
||||
});
|
||||
|
|
@ -138,7 +153,7 @@ export default class CopilotView extends ItemView {
|
|||
|
||||
this.drawerHideObserver?.disconnect();
|
||||
|
||||
const drawer = this.containerEl.closest(".workspace-drawer") as HTMLElement | null;
|
||||
const drawer = this.containerEl.closest<HTMLElement>(".workspace-drawer");
|
||||
if (!drawer) return;
|
||||
|
||||
let wasHidden = drawer.classList.contains("is-hidden");
|
||||
|
|
@ -168,20 +183,18 @@ export default class CopilotView extends ItemView {
|
|||
if (!this.root) return;
|
||||
|
||||
this.root.render(
|
||||
<AppContext.Provider value={this.app}>
|
||||
<EventTargetContext.Provider value={this.eventTarget}>
|
||||
<Tooltip.Provider delayDuration={0}>
|
||||
<Chat
|
||||
chainManager={this.chainManager}
|
||||
updateUserMessageHistory={updateUserMessageHistory}
|
||||
fileParserManager={this.fileParserManager}
|
||||
plugin={this.plugin}
|
||||
onSaveChat={handleSaveAsNote}
|
||||
chatUIState={this.plugin.chatUIState}
|
||||
/>
|
||||
</Tooltip.Provider>
|
||||
</EventTargetContext.Provider>
|
||||
</AppContext.Provider>
|
||||
<EventTargetContext.Provider value={this.eventTarget}>
|
||||
<Tooltip.Provider delayDuration={0}>
|
||||
<Chat
|
||||
chainManager={this.chainManager}
|
||||
updateUserMessageHistory={updateUserMessageHistory}
|
||||
fileParserManager={this.fileParserManager}
|
||||
plugin={this.plugin}
|
||||
onSaveChat={handleSaveAsNote}
|
||||
chatUIState={this.plugin.chatUIState}
|
||||
/>
|
||||
</Tooltip.Provider>
|
||||
</EventTargetContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -209,6 +222,8 @@ 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.
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export default function IndexingProgressCard({
|
|||
onStop,
|
||||
}: IndexingProgressCardProps) {
|
||||
const [indexingState] = useIndexingProgress();
|
||||
const autoCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const autoCloseTimerRef = useRef<number | 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) {
|
||||
clearTimeout(autoCloseTimerRef.current);
|
||||
window.clearTimeout(autoCloseTimerRef.current);
|
||||
autoCloseTimerRef.current = null;
|
||||
}
|
||||
|
||||
|
|
@ -44,14 +44,14 @@ export default function IndexingProgressCard({
|
|||
completionStatus !== "none" &&
|
||||
!(completionStatus === "error" && totalFiles === 0);
|
||||
if (shouldAutoClose) {
|
||||
autoCloseTimerRef.current = setTimeout(() => {
|
||||
autoCloseTimerRef.current = window.setTimeout(() => {
|
||||
onClose();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (autoCloseTimerRef.current) {
|
||||
clearTimeout(autoCloseTimerRef.current);
|
||||
window.clearTimeout(autoCloseTimerRef.current);
|
||||
autoCloseTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { type PropsWithChildren, useRef, useState } from "react";
|
|||
const TOLERANCE = 2;
|
||||
// detects text-overflow ellipses being used
|
||||
// ref: https://stackoverflow.com/questions/7738117/html-text-overflow-ellipsis-detection
|
||||
export function isEllipsesActive(
|
||||
function isEllipsesActive(
|
||||
textRef: React.MutableRefObject<HTMLDivElement | null>,
|
||||
lineClamp?: number
|
||||
): boolean {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
|||
import { cn } from "@/lib/utils";
|
||||
import { ReasoningStatus } from "@/LLMProviders/chainRunner/utils/AgentReasoningState";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
/**
|
||||
* Props for the AgentReasoningBlock component
|
||||
|
|
@ -65,13 +65,13 @@ const CopilotSpinner: React.FC = () => {
|
|||
viewBox={`0 0 ${gridSize} ${gridSize}`}
|
||||
className="copilot-spinner"
|
||||
>
|
||||
{sigmaDots.map((dot, index) => {
|
||||
{sigmaDots.map((dot) => {
|
||||
const cx = dot.col * (dotSize + gap) + dotSize / 2;
|
||||
const cy = dot.row * (dotSize + gap) + dotSize / 2;
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
key={`${dot.row}-${dot.col}`}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={dotSize / 2}
|
||||
|
|
@ -106,15 +106,16 @@ export const AgentReasoningBlock: React.FC<AgentReasoningBlockProps> = ({
|
|||
isStreaming,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(status === "reasoning");
|
||||
const [prevStatus, setPrevStatus] = useState(status);
|
||||
|
||||
// Auto-expand when reasoning, auto-collapse when done
|
||||
useEffect(() => {
|
||||
if (status !== prevStatus) {
|
||||
setPrevStatus(status);
|
||||
if (status === "reasoning") {
|
||||
setIsExpanded(true);
|
||||
} else if (status === "collapsed" || status === "complete") {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
}, [status]);
|
||||
}
|
||||
|
||||
// Don't render anything if idle
|
||||
if (status === "idle") {
|
||||
|
|
@ -164,6 +165,7 @@ 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>
|
||||
|
|
@ -174,5 +176,3 @@ export const AgentReasoningBlock: React.FC<AgentReasoningBlockProps> = ({
|
|||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentReasoningBlock;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { TFile } from "obsidian";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { TFile, TFolder } from "obsidian";
|
||||
import { TypeaheadMenuPopover } from "./TypeaheadMenuPopover";
|
||||
import {
|
||||
useAtMentionCategories,
|
||||
|
|
@ -8,11 +8,12 @@ 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: any) => void;
|
||||
onSelect: (category: AtMentionCategory, data: TFile | string | TFolder | WebTabContext) => void;
|
||||
isCopilotPlus?: boolean;
|
||||
currentActiveFile?: TFile | null;
|
||||
}
|
||||
|
|
@ -41,6 +42,8 @@ export function AtMentionTypeahead({
|
|||
}>({
|
||||
mode: "category",
|
||||
});
|
||||
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
|
||||
const [prevResultsLength, setPrevResultsLength] = useState(0);
|
||||
|
||||
const availableCategoryOptions = useAtMentionCategories(isCopilotPlus);
|
||||
|
||||
|
|
@ -56,9 +59,9 @@ export function AtMentionTypeahead({
|
|||
|
||||
// Handle selection
|
||||
const handleSelect = useCallback(
|
||||
(option: any) => {
|
||||
(option: CategoryOption | AtMentionOption) => {
|
||||
// Guard: never select disabled options (defensive check for click events)
|
||||
if (option?.disabled) return;
|
||||
if ((option as { disabled?: boolean })?.disabled) return;
|
||||
|
||||
if (extendedState.mode === "category" && isCategoryOption(option) && !searchQuery) {
|
||||
// Category was selected - switch to search mode for that category
|
||||
|
|
@ -160,8 +163,8 @@ export function AtMentionTypeahead({
|
|||
[selectedIndex, searchResults, handleSelect, onClose, extendedState.mode, searchQuery]
|
||||
);
|
||||
|
||||
// Reset state when menu closes
|
||||
useEffect(() => {
|
||||
if (isOpen !== prevIsOpen) {
|
||||
setPrevIsOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
setSearchQuery("");
|
||||
setSelectedIndex(0);
|
||||
|
|
@ -170,12 +173,12 @@ export function AtMentionTypeahead({
|
|||
selectedCategory: undefined,
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
}
|
||||
|
||||
// Reset selected index when options change
|
||||
useEffect(() => {
|
||||
if (searchResults.length !== prevResultsLength) {
|
||||
setPrevResultsLength(searchResults.length);
|
||||
setSelectedIndex(0);
|
||||
}, [searchResults.length]);
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
|
|
@ -183,7 +186,7 @@ export function AtMentionTypeahead({
|
|||
|
||||
return (
|
||||
<TypeaheadMenuPopover
|
||||
options={searchResults as any[]}
|
||||
options={searchResults}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={handleSelect}
|
||||
onHighlight={handleHighlight}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { AlertCircle, CheckCircle, CircleDashed, FileText, Loader2, X } from "lucide-react";
|
||||
import { Platform, TFile } from "obsidian";
|
||||
import { Platform, TFile, TFolder } 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 "@/chainFactory";
|
||||
import { ChainType } from "@/chainType";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useChainType, useIndexingProgress } from "@/aiParams";
|
||||
import { useProjectContextStatus } from "@/hooks/useProjectContextStatus";
|
||||
|
|
@ -22,6 +22,8 @@ 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;
|
||||
|
|
@ -32,11 +34,11 @@ interface ChatContextMenuProps {
|
|||
contextFolders: string[];
|
||||
contextWebTabs: WebTabContext[];
|
||||
selectedTextContexts?: SelectedTextContext[];
|
||||
onRemoveContext: (category: string, data: any) => void;
|
||||
onRemoveContext: (category: string, data: string) => void;
|
||||
showProgressCard: () => void;
|
||||
showIndexingCard?: () => void;
|
||||
onTypeaheadSelect: (category: string, data: any) => void;
|
||||
lexicalEditorRef?: React.RefObject<any>;
|
||||
onTypeaheadSelect: (category: string, data: TFile | string | TFolder | WebTabContext) => void;
|
||||
lexicalEditorRef?: React.RefObject<{ focus: () => void }>;
|
||||
}
|
||||
|
||||
function ContextSelection({
|
||||
|
|
@ -44,7 +46,7 @@ function ContextSelection({
|
|||
onRemoveContext,
|
||||
}: {
|
||||
selectedText: SelectedTextContext;
|
||||
onRemoveContext: (category: string, data: any) => void;
|
||||
onRemoveContext: (category: string, data: string) => void;
|
||||
}) {
|
||||
// Handle web selected text
|
||||
if (isWebSelectedTextContext(selectedText)) {
|
||||
|
|
@ -104,7 +106,7 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
|
|||
contextUrls,
|
||||
contextFolders,
|
||||
contextWebTabs,
|
||||
selectedTextContexts = [],
|
||||
selectedTextContexts = EMPTY_SELECTED_TEXT_CONTEXTS,
|
||||
onRemoveContext,
|
||||
showProgressCard,
|
||||
showIndexingCard,
|
||||
|
|
@ -123,12 +125,15 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
|
|||
};
|
||||
|
||||
// Simple wrapper that adds focus management to the ContextControl handler
|
||||
const handleTypeaheadSelect = (category: string, data: any) => {
|
||||
const handleTypeaheadSelect = (
|
||||
category: string,
|
||||
data: TFile | string | TFolder | WebTabContext
|
||||
) => {
|
||||
// Delegate to ContextControl handler
|
||||
onTypeaheadSelect(category, data);
|
||||
|
||||
// Return focus to the editor after selection
|
||||
setTimeout(() => {
|
||||
window.setTimeout(() => {
|
||||
if (lexicalEditorRef?.current) {
|
||||
lexicalEditorRef.current.focus();
|
||||
}
|
||||
|
|
@ -139,7 +144,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) => {
|
||||
openFileInWorkspace(file);
|
||||
void openFileInWorkspace(file);
|
||||
};
|
||||
|
||||
const uniqueNotes = React.useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { getCurrentProject, setCurrentProject, setProjectLoading, useChainType } from "@/aiParams";
|
||||
import { ProjectContextCache } from "@/cache/projectContextCache";
|
||||
import { ChainType } from "@/chainFactory";
|
||||
import { ChainType } from "@/chainType";
|
||||
import { ConfirmModal } from "@/components/modals/ConfirmModal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
|
|
@ -13,6 +13,7 @@ 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,
|
||||
|
|
@ -28,7 +29,7 @@ import {
|
|||
Sparkles,
|
||||
SquareArrowOutUpRight,
|
||||
} from "lucide-react";
|
||||
import { Notice } from "obsidian";
|
||||
import { App, Notice } from "obsidian";
|
||||
import React from "react";
|
||||
import {
|
||||
ChatHistoryItem,
|
||||
|
|
@ -37,7 +38,21 @@ import {
|
|||
import { TokenCounter } from "./TokenCounter";
|
||||
import { ChatSettingsPopover } from "@/components/chat-components/ChatSettingsPopover";
|
||||
|
||||
export async function refreshVaultIndex() {
|
||||
/** 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() {
|
||||
try {
|
||||
const { getSettings } = await import("@/settings/model");
|
||||
const settings = getSettings();
|
||||
|
|
@ -58,12 +73,12 @@ export async function refreshVaultIndex() {
|
|||
new Notice("Lexical search builds indexes on demand. No manual indexing required.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error refreshing vault index:", error);
|
||||
logError("Error refreshing vault index:", error);
|
||||
new Notice("Failed to refresh vault index. Check console for details.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function forceReindexVault() {
|
||||
async function forceReindexVault() {
|
||||
try {
|
||||
const { getSettings } = await import("@/settings/model");
|
||||
const settings = getSettings();
|
||||
|
|
@ -84,12 +99,12 @@ export async function forceReindexVault() {
|
|||
new Notice("Lexical search builds indexes on demand. No manual indexing required.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error force reindexing vault:", error);
|
||||
logError("Error force reindexing vault:", error);
|
||||
new Notice("Failed to force reindex vault. Check console for details.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function reloadCurrentProject() {
|
||||
export async function reloadCurrentProject(app: App) {
|
||||
const currentProject = getCurrentProject();
|
||||
if (!currentProject) {
|
||||
new Notice("No project is currently selected to reload.");
|
||||
|
|
@ -108,9 +123,12 @@ export async function reloadCurrentProject() {
|
|||
// 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 any).plugins.getPlugin("copilot");
|
||||
const plugin = (app as unknown as ObsidianAppWithPlugins).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.");
|
||||
|
|
@ -128,7 +146,7 @@ export async function reloadCurrentProject() {
|
|||
}
|
||||
}
|
||||
|
||||
export async function forceRebuildCurrentProjectContext() {
|
||||
async function forceRebuildCurrentProjectContext(app: App) {
|
||||
const currentProject = getCurrentProject();
|
||||
if (!currentProject) {
|
||||
new Notice("No project is currently selected to rebuild.");
|
||||
|
|
@ -155,7 +173,7 @@ export async function forceRebuildCurrentProjectContext() {
|
|||
// 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 any).plugins.getPlugin("copilot");
|
||||
const plugin = (app as unknown as ObsidianAppWithPlugins).plugins.getPlugin("copilot");
|
||||
if (plugin && plugin.projectManager) {
|
||||
await plugin.projectManager.getProjectContext(currentProject.id);
|
||||
new Notice(
|
||||
|
|
@ -210,6 +228,7 @@ export function ChatControls({
|
|||
onOpenSourceFile,
|
||||
latestTokenCount,
|
||||
}: ChatControlsProps) {
|
||||
const app = useApp();
|
||||
const settings = useSettingsValue();
|
||||
const [selectedChain, setSelectedChain] = useChainType();
|
||||
const isPlusUser = useIsPlusUser();
|
||||
|
|
@ -252,14 +271,14 @@ export function ChatControls({
|
|||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
handleModeChange(ChainType.LLM_CHAIN);
|
||||
void handleModeChange(ChainType.LLM_CHAIN);
|
||||
}}
|
||||
>
|
||||
chat (free)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
handleModeChange(ChainType.VAULT_QA_CHAIN);
|
||||
void handleModeChange(ChainType.VAULT_QA_CHAIN);
|
||||
}}
|
||||
>
|
||||
vault QA (free)
|
||||
|
|
@ -267,7 +286,7 @@ export function ChatControls({
|
|||
{isPlusUser ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
handleModeChange(ChainType.COPILOT_PLUS_CHAIN);
|
||||
void handleModeChange(ChainType.COPILOT_PLUS_CHAIN);
|
||||
}}
|
||||
>
|
||||
<div className="tw-flex tw-items-center tw-gap-1">
|
||||
|
|
@ -291,7 +310,7 @@ export function ChatControls({
|
|||
<DropdownMenuItem
|
||||
className="tw-flex tw-items-center tw-gap-1"
|
||||
onSelect={() => {
|
||||
handleModeChange(ChainType.PROJECT_CHAIN);
|
||||
void handleModeChange(ChainType.PROJECT_CHAIN);
|
||||
}}
|
||||
>
|
||||
<LibraryBig className="tw-size-4" />
|
||||
|
|
@ -327,7 +346,12 @@ export function ChatControls({
|
|||
{!settings.autosaveChat && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost2" size="icon" title="Save Chat as Note" onClick={onSaveAsNote}>
|
||||
<Button
|
||||
variant="ghost2"
|
||||
size="icon"
|
||||
title="Save Chat as Note"
|
||||
onClick={() => void onSaveAsNote()}
|
||||
>
|
||||
<Download className="tw-size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -401,14 +425,14 @@ export function ChatControls({
|
|||
<>
|
||||
<DropdownMenuItem
|
||||
className="tw-flex tw-items-center tw-gap-2"
|
||||
onSelect={() => reloadCurrentProject()}
|
||||
onSelect={() => void reloadCurrentProject(app)}
|
||||
>
|
||||
<RefreshCw className="tw-size-4" />
|
||||
Reload Current Project
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="tw-flex tw-items-center tw-gap-2"
|
||||
onSelect={() => forceRebuildCurrentProjectContext()}
|
||||
onSelect={() => void forceRebuildCurrentProjectContext(app)}
|
||||
>
|
||||
<AlertTriangle className="tw-size-4" />
|
||||
Force Rebuild Context
|
||||
|
|
@ -418,7 +442,7 @@ export function ChatControls({
|
|||
<>
|
||||
<DropdownMenuItem
|
||||
className="tw-flex tw-items-center tw-gap-2"
|
||||
onSelect={() => refreshVaultIndex()}
|
||||
onSelect={() => void 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
Loading…
Reference in a new issue