mirror of
https://github.com/cline/cline.git
synced 2026-09-03 12:14:00 +08:00
Compare commits
175 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b092284f63 | |||
| 19c4439877 | |||
| ec01e1f19d | |||
| 4a0a40ead1 | |||
| 124569c391 | |||
| 5a08f977c3 | |||
| 4ec85af1fb | |||
| 00f88050f7 | |||
| c4f73e7752 | |||
| e84b8669a6 | |||
| 5b6eae2905 | |||
| c153077bde | |||
| 1796558733 | |||
| 2f7f8bff20 | |||
| dc9fb4ddb8 | |||
| f9c46f898f | |||
| 2d2eb5dd36 | |||
| 9562671bcc | |||
| 6a2aef27ea | |||
| c7d6173e20 | |||
| 62da7fe720 | |||
| f97f3e47cc | |||
| f47be1a7bb | |||
| 0cd2e69551 | |||
| 4b19010d56 | |||
| 49803bf958 | |||
| 33704dcdc1 | |||
| 78552287ae | |||
| a29689dfd3 | |||
| 2ca4738b59 | |||
| b4a2d5a06d | |||
| 7f2de3956c | |||
| 059b14e494 | |||
| 4d95817eec | |||
| d8c13db683 | |||
| a35e1ff8df | |||
| a0c5276fb1 | |||
| 696a2c4e48 | |||
| e983358dde | |||
| 9ae90c99ad | |||
| 4cd914970e | |||
| 4919424faf | |||
| e65fa89566 | |||
| 935a68fedc | |||
| 5c78f64978 | |||
| 328e49bd3c | |||
| 0f7c0205b6 | |||
| af0cd9bdff | |||
| 647a964853 | |||
| 58305dd6b3 | |||
| 677fc3a446 | |||
| 88c33ee96d | |||
| 23a0d41646 | |||
| 974f698e54 | |||
| 12f16a47e3 | |||
| bc7d6f0c66 | |||
| c8b446ab6c | |||
| 2cc64973fb | |||
| 599c3f6717 | |||
| 1c491bf719 | |||
| 10ac28b9d6 | |||
| 95b2e327a8 | |||
| 53d11c88bb | |||
| 7ef0f6d1b6 | |||
| 9012d6a1fc | |||
| e3d363c56f | |||
| 62a04ad38a | |||
| 1774cf8b79 | |||
| 6133eb4864 | |||
| 087f1d3be3 | |||
| 8cc7759584 | |||
| f9d70ceb6c | |||
| 3c516023c8 | |||
| 4c0c0c947b | |||
| 2ac6af5b08 | |||
| 7e68dd8d98 | |||
| e8bf106965 | |||
| b528640bd5 | |||
| 470778aa2f | |||
| 14f726c5e8 | |||
| 89e1086981 | |||
| c762583d38 | |||
| 73a340c75f | |||
| d3bb6ff456 | |||
| 93cd64f031 | |||
| 36836ad4d6 | |||
| 4ade5daeff | |||
| 24fe5c9c98 | |||
| 18d4a268e2 | |||
| 686f51c248 | |||
| 9aa04a376f | |||
| ca17a06618 | |||
| 48ea04f2ea | |||
| e11ad166e6 | |||
| 6b9c2a1a45 | |||
| bf0bbb4a16 | |||
| a8a24fcb62 | |||
| e570662d89 | |||
| 78f5faf6d2 | |||
| cc28851ba8 | |||
| 0afa4c4fd8 | |||
| 0f0512363f | |||
| ddc91a46c0 | |||
| 11a32d9a3a | |||
| 45ddaf99e1 | |||
| 6f21cb98cc | |||
| 544d4503ac | |||
| a5663e7cf8 | |||
| 09b0873e94 | |||
| e54623bc7e | |||
| 8909ee77fc | |||
| 3a3943623b | |||
| 2446b16828 | |||
| 30f1d72eb5 | |||
| c67edf4b91 | |||
| 07cf5cddad | |||
| eda2a60a69 | |||
| c1b9fcd05c | |||
| d470ba9150 | |||
| ea302ff4f1 | |||
| f11bddc4be | |||
| aceb7e8ddd | |||
| 8ca0568005 | |||
| 42b17cfbaa | |||
| 359dac9f20 | |||
| 3948b952cc | |||
| 867f36dabe | |||
| 822647c3e9 | |||
| fa0c4e5c36 | |||
| 59dc92c65c | |||
| 6972b2a975 | |||
| 973d18f4f3 | |||
| 5ab50a41b1 | |||
| d92292e405 | |||
| d0728c58af | |||
| 30f6a3ca29 | |||
| 6aad12dc01 | |||
| 9cd0a5ed6a | |||
| 311a4d6ee8 | |||
| 4e2f3d9b0e | |||
| bdccc40a0f | |||
| 5d10a0f89a | |||
| a41bb3a70a | |||
| 42e0c74c93 | |||
| ef76c8ba5a | |||
| 80542434c5 | |||
| 69bda35df8 | |||
| e3b87ae56f | |||
| daf88ba19e | |||
| 4759586a17 | |||
| 39359307dc | |||
| 9fa8a7a7a8 | |||
| 7eaf1a7cbc | |||
| 66911b89de | |||
| 0de047116f | |||
| 60cd10eec6 | |||
| 209d0830c6 | |||
| 8f938b34ab | |||
| ca4fdcf3c4 | |||
| a2572ea233 | |||
| b0b9a662e0 | |||
| 6033d2286c | |||
| 6c8accd585 | |||
| 14ed1c088c | |||
| f608b914b8 | |||
| 9f5df9836c | |||
| 0cdbdb057e | |||
| 4846476f6c | |||
| a6f189c5c5 | |||
| ad8e0b19be | |||
| 22b2e50210 | |||
| af0d2f31a7 | |||
| 8313fa099c | |||
| 9f418b10f6 | |||
| 1b56c3fc11 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
feat(bedrock): adding Amazon Nova
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Improve file handling for NextJS folder naming conventions and increase file listing limits. Fix glob pattern interpretation issues with parentheses in folder names
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Handle input too large Anthropic
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix "See more" not showing up for tasks after task un-fold
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix gpt-4.5-preview's supportsPromptCache value to true
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Added a script to create test tasks in dev mode
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
updated move context management out of cline
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Added support for SambaNova QwQ-32B model
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add OpenAI "dynamic" model chatgpt-4o-latest
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
DangerButton.tsx to Tailwind
|
||||
+1
-1
@@ -252,7 +252,7 @@ class Cline {
|
||||
} catch (error) {
|
||||
// 4. Error handling with retry
|
||||
if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
|
||||
await delay(1000)
|
||||
await setTimeoutPromise(1000)
|
||||
this.didAutomaticallyRetryFailedApiRequest = true
|
||||
yield* this.attemptApiRequest(previousApiReqIndex)
|
||||
return
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett
|
||||
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett @saito-sv
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
name: Changeset Release
|
||||
run-name: Changeset Release ${{ github.actor != 'cline-bot' && '- Create PR' || '- Update Changelog' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [closed, opened, labeled]
|
||||
|
||||
env:
|
||||
REPO_PATH: ${{ github.repository }}
|
||||
GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
|
||||
|
||||
jobs:
|
||||
# Job 1: Create version bump PR when changesets are merged to main
|
||||
changeset-pr-version-bump:
|
||||
if: >
|
||||
( github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.base.ref == 'main' &&
|
||||
github.actor != 'cline-bot' ) ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ env.GIT_REF }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm run install:all
|
||||
|
||||
# Check if there are any new changesets to process
|
||||
- name: Check for changesets
|
||||
id: check-changesets
|
||||
run: |
|
||||
NEW_CHANGESETS=$(find .changeset -name "*.md" ! -name "README.md" | wc -l | tr -d ' ')
|
||||
echo "Changesets diff with previous version: $NEW_CHANGESETS"
|
||||
echo "new_changesets=$NEW_CHANGESETS" >> $GITHUB_OUTPUT
|
||||
|
||||
# Create version bump PR using changesets/action if there are new changesets
|
||||
- name: Changeset Pull Request
|
||||
if: steps.check-changesets.outputs.new_changesets != '0'
|
||||
id: changesets
|
||||
uses: changesets/action@e9cc34b540dd3ad1b030c57fd97269e8f6ad905a # v1
|
||||
with:
|
||||
commit: "changeset version bump"
|
||||
title: "Changeset version bump"
|
||||
version: npm run version-packages # This performs the changeset version bump
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Job 2: Process version bump PR created by cline-bot
|
||||
changeset-pr-edit-approve:
|
||||
name: Auto approve and merge Bump version PRs
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
if: >
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.base.ref == 'main' &&
|
||||
github.actor == 'cline-bot' &&
|
||||
contains(github.event.pull_request.title, 'Changeset version bump')
|
||||
steps:
|
||||
- name: Determine checkout ref
|
||||
id: checkout-ref
|
||||
run: |
|
||||
echo "Event action: ${{ github.event.action }}"
|
||||
echo "Actor: ${{ github.actor }}"
|
||||
echo "Head ref: ${{ github.head_ref }}"
|
||||
echo "PR SHA: ${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
if [[ "${{ github.event.action }}" == "opened" && "${{ github.actor }}" == "cline-bot" ]]; then
|
||||
echo "Using branch ref: ${{ github.head_ref }}"
|
||||
echo "git_ref=${{ github.head_ref }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Using SHA ref: ${{ github.event.pull_request.head.sha }}"
|
||||
echo "git_ref=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
ref: ${{ steps.checkout-ref.outputs.git_ref }}
|
||||
|
||||
# Get current and previous versions to edit changelog entry
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(git show HEAD:package.json | jq -r '.version')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
PREV_VERSION=$(git show origin/main:package.json | jq -r '.version')
|
||||
echo "prev_version=$PREV_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION"
|
||||
echo "prev_version=$PREV_VERSION"
|
||||
|
||||
# Update CHANGELOG.md with proper format
|
||||
- name: Update Changelog Format
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-ready') }}
|
||||
env:
|
||||
VERSION: ${{ steps.get_version.outputs.version }}
|
||||
PREV_VERSION: ${{ steps.get_version.outputs.prev_version }}
|
||||
run: python .github/scripts/overwrite_changeset_changelog.py
|
||||
|
||||
# Commit and push changelog updates
|
||||
- name: Push Changelog updates
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-ready') }}
|
||||
run: |
|
||||
git config user.name "cline-bot"
|
||||
git config user.email github-actions@github.com
|
||||
echo "Running git add and commit..."
|
||||
git add CHANGELOG.md
|
||||
git commit -m "Updating CHANGELOG.md format"
|
||||
git status
|
||||
echo "--------------------------------------------------------------------------------"
|
||||
echo "Pushing to remote..."
|
||||
echo "--------------------------------------------------------------------------------"
|
||||
git push
|
||||
|
||||
# Add label to indicate changelog has been formatted
|
||||
- name: Add changelog-ready label
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-ready') }}
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['changelog-ready']
|
||||
});
|
||||
|
||||
# Auto-approve PR only after it has been labeled
|
||||
- name: Auto approve PR
|
||||
if: contains(github.event.pull_request.labels.*.name, 'changelog-ready')
|
||||
uses: hmarr/auto-approve-action@de8bf34d0402c38aa2c8346973342b2cb02c4435 # v4
|
||||
with:
|
||||
review-message: "I'm approving since it's a bump version PR"
|
||||
|
||||
# Auto-merge PR
|
||||
- name: Automerge on PR
|
||||
if: false # Needs enablePullRequestAutoMerge in repo settings to work contains(github.event.pull_request.labels.*.name, 'changelog-ready')
|
||||
run: gh pr merge --auto --merge ${{ github.event.pull_request.number }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,5 +1,52 @@
|
||||
# Changelog
|
||||
|
||||
## [3.8.2]
|
||||
|
||||
- Fix bug where switching to plan/act would result in VS Code LM/OpenRouter model being reset
|
||||
|
||||
## [3.8.0]
|
||||
|
||||
- Add 'Add to Cline' as an option when you right-click in a file or the terminal, making it easier to add context to your current task
|
||||
- Add 'Fix with Cline' code action - when you see a lightbulb icon in your editor, you can now select 'Fix with Cline' to send the code and associated errors for Cline to fix. (Cursor users can also use the 'Quick Fix (CMD + .)' menu to see this option)
|
||||
- Add Account view to display billing and usage history for Cline account users. You can now keep track of credits used and transaction history right in the extension!
|
||||
- Add 'Sort underling provider routing' setting to Cline/OpenRouter allowing you to sort provider used by throughput, price, latency, or the default (combination of price and uptime)
|
||||
- Improve rich MCP display with dynamic image loading and support for GIFs
|
||||
- Add 'Documentation' menu item to easily access Cline's docs
|
||||
- Add OpenRouter's new usage_details feature for more reliable cost reporting
|
||||
- Display total space Cline takes on disk next to 'Delete all Tasks' button in History view
|
||||
- Fix 'Context Window Exceeded' error for OpenRouter/Cline Accounts (additional support coming soon)
|
||||
- Fix bug where OpenRouter model ID would be set to invalid value
|
||||
- Add button to delete MCP servers in a failure state
|
||||
|
||||
## [3.7.1]
|
||||
|
||||
- Fix issue with 'See more' button in task header not showing when starting new tasks
|
||||
- Fix issue with checkpoints using local git commit hooks
|
||||
|
||||
## [3.7.0]
|
||||
|
||||
- Cline now displays selectable options when asking questions or presenting a plan, saving you from having to type out responses!
|
||||
- Add support for a `.clinerules/` directory to load multiple files at once (thanks @ryo-ma!)
|
||||
- Prevent Cline from reading extremely large files into context that would overload context window
|
||||
- Improve checkpoints loading performance and display warning for large projects not suited for checkpoints
|
||||
- Add SambaNova API provider (thanks @saad-noodleseed!)
|
||||
- Add VPC endpoint option for AWS Bedrock profiles (thanks @minorunara!)
|
||||
- Add DeepSeek-R1 to AWS Bedrock (thanks @watany-dev!)
|
||||
|
||||
## [3.6.5]
|
||||
|
||||
- Add 'Delete all Task History' button to History view
|
||||
- Add toggle to disable model switching between Plan/Act modes in Settings (new users default to disabled)
|
||||
- Add temperature option to OpenAI Compatible
|
||||
- Add Kotlin support to tree-sitter parser (thanks @fumiya-kume!)
|
||||
|
||||
## [3.6.3]
|
||||
|
||||
- Improve QwQ support for Alibaba (thanks @meglinge!) and OpenRouter
|
||||
- Improve diff edit prompting to prevent immediately reverting to write_to_file when a model uses search patterns that don't match anything in the file
|
||||
- Fix bug where new checkpoints system would revert file changes when switching between tasks
|
||||
- Fix issue with incorrect token count for some OpenAI compatible providers
|
||||
|
||||
## [3.6.0]
|
||||
|
||||
- Add Cline API as a provider option, allowing new users to sign up and get started with Cline for free
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center"><sub>
|
||||
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a>
|
||||
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
|
||||
</sub></div>
|
||||
|
||||
# Cline – \#1 on OpenRouter
|
||||
|
||||
+1
-116
@@ -1,116 +1 @@
|
||||
# Cline Privacy Policy
|
||||
|
||||
Cline Bot Inc. ("Cline," "we," "our," and/or "us") values the privacy of individuals who use our VS Code extension and related services (collectively, our "Services"). This privacy policy explains how we collect, use, and disclose information from users of our Services.
|
||||
|
||||
## Key Points
|
||||
|
||||
- Cline operates entirely client-side as a VS Code extension
|
||||
- No code or data is collected, stored, or transmitted to Cline's servers
|
||||
- Your data is only sent to your chosen AI provider (e.g., Anthropic, OpenAI) when you explicitly request assistance
|
||||
- All processing happens locally on your machine
|
||||
- API keys are stored securely in VS Code's built-in settings storage
|
||||
|
||||
## Information We Process
|
||||
|
||||
### A. Information You Provide
|
||||
|
||||
- **API Keys**: When you choose to use certain AI model providers (OpenRouter, Anthropic, OpenAI, etc.), you provide API keys. These are stored securely and locally in your VS Code settings.
|
||||
- **Communications**: If you contact us directly (e.g., via Discord or email), we may receive information like your name, email address, and message contents.
|
||||
|
||||
### B. Information Processing
|
||||
|
||||
Cline functions solely as a client-side VS Code extension that facilitates communication between your editor and your chosen AI model provider:
|
||||
|
||||
1. **File Contents**:
|
||||
|
||||
- Only sent to your chosen AI provider when you explicitly request assistance
|
||||
- Never stored or transmitted to Cline's servers
|
||||
- Only the specific files/content you select are included
|
||||
|
||||
2. **Terminal Commands**:
|
||||
|
||||
- Processed entirely locally on your machine
|
||||
- Require explicit user confirmation before execution
|
||||
- No command history is transmitted to Cline
|
||||
|
||||
3. **Browser Integration**:
|
||||
- Screenshots and console logs are processed locally
|
||||
- Temporary data is cleared after task completion
|
||||
|
||||
## Data Security
|
||||
|
||||
1. **Local-Only Processing**:
|
||||
|
||||
- All operations happen on your local machine
|
||||
- No central servers or data collection by default
|
||||
- Anonymous telemetry and usage statistics are only collected if you explicitly opt in
|
||||
- No account creation required
|
||||
|
||||
2. **API Key Security**:
|
||||
|
||||
- Stored using VS Code's secure settings storage system
|
||||
- Never transmitted to Cline's servers
|
||||
- You can remove/modify keys at any time
|
||||
|
||||
3. **User Control**:
|
||||
- Explicit approval required for file changes
|
||||
- Terminal commands require confirmation
|
||||
- Browser actions need explicit permission
|
||||
- You control which AI provider to use
|
||||
|
||||
## Communication with AI Providers
|
||||
|
||||
When you request assistance:
|
||||
|
||||
1. Selected content is sent directly to your chosen AI provider
|
||||
2. No data passes through Cline's servers
|
||||
3. Provider's own privacy policy applies to this communication:
|
||||
- [Anthropic Privacy Policy](https://www.anthropic.com/privacy)
|
||||
- [OpenAI Privacy Policy](https://openai.com/privacy)
|
||||
- [OpenRouter Privacy Policy](https://openrouter.ai/privacy)
|
||||
|
||||
## Error Handling & Debugging
|
||||
|
||||
- Error logs are processed locally
|
||||
- No automatic error reporting to Cline
|
||||
- Optional anonymous telemetry and error reporting via PostHog if you opt in
|
||||
- You control what information to include when manually reporting issues
|
||||
|
||||
## Children's Privacy
|
||||
|
||||
We do not knowingly collect, maintain, or use personal information from children under 18 years of age, and no part of our Service(s) is directed to children. If you learn that a child has provided us with personal information in violation of this Privacy Policy, then you may alert us at support@cline.bot.
|
||||
|
||||
## Changes to Privacy Policy
|
||||
|
||||
We will post any changes to this policy on our GitHub repository. Significant changes will be announced in our Discord community.
|
||||
|
||||
## Security Concerns & Auditing
|
||||
|
||||
- Cline is open source and available for security audit
|
||||
- Our client-side architecture ensures no central point of data collection
|
||||
- You can inspect exactly what data is being sent to AI providers
|
||||
- Enterprise users can implement additional access controls through VS Code
|
||||
|
||||
## Telemetry & Usage Statistics
|
||||
|
||||
If you choose to opt in to anonymous telemetry:
|
||||
|
||||
- Basic usage statistics and error reports are collected via PostHog
|
||||
- A stable, anonymous identifier (VS Code's `machineId`) is used to understand unique usage patterns
|
||||
- This identifier is not linked to any personal information
|
||||
- It helps us understand how features are used across sessions
|
||||
- It cannot be used to identify you personally
|
||||
- All data is anonymized and cannot be linked to individual users
|
||||
- No code content or sensitive information is ever included
|
||||
- You can opt out at any time through:
|
||||
- VS Code Settings > Cline > Enable Telemetry
|
||||
- VS Code Settings > Telemetry > Telemetry Level (setting this to anything other than "all" will disable Cline's telemetry)
|
||||
- Collected data helps us improve the extension's functionality and stability
|
||||
|
||||
## Contact Us
|
||||
|
||||
For privacy-related questions or concerns:
|
||||
|
||||
- Open an issue on our [GitHub repository](https://github.com/cline/cline)
|
||||
- Join our [Discord community](https://discord.gg/cline)
|
||||
- Email: support@cline.bot
|
||||
See [https://cline.bot/privacy](https://cline.bot/privacy) for our privacy policy.
|
||||
@@ -128,6 +128,27 @@ Cline's system prompt, on the other hand, is not user-editable ([here's where yo
|
||||
- Focus on Desired Outcomes: Describe the results you want, not the specific steps.
|
||||
- Test and Iterate: Experiment to find what works best for your workflow.
|
||||
|
||||
|
||||
### Support for Loading Files from the `.clinerules/` Directory
|
||||
All files under the `.clinerules/` directory are recursively loaded, and their contents are merged into clineRulesFileInstructions.
|
||||
|
||||
#### Example 1:
|
||||
```
|
||||
.clinerules/
|
||||
├── .local-clinerules
|
||||
└── .project-clinerules
|
||||
```
|
||||
|
||||
#### Example 2:
|
||||
```
|
||||
.clinerules/
|
||||
├── .clinerules-nextjs
|
||||
├── .clinerules-serverside
|
||||
└── tests/
|
||||
├── .pytest-clinerules
|
||||
└── .jest-clinerules
|
||||
```
|
||||
|
||||
## Prompting Cline 💬
|
||||
|
||||
**Prompting is how you communicate your needs for a given task in the back-and-forth chat with Cline.** Cline understands natural language, so write conversationally.
|
||||
|
||||
@@ -52,6 +52,7 @@ const copyWasmFiles = {
|
||||
"java",
|
||||
"php",
|
||||
"swift",
|
||||
"kotlin",
|
||||
]
|
||||
|
||||
languages.forEach((lang) => {
|
||||
|
||||
@@ -158,5 +158,5 @@ Um zum Projekt beizutragen, beginnen Sie mit unserem [Beitragsleitfaden](CONTRIB
|
||||
|
||||
## Lizenz
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
|
||||
@@ -158,4 +158,4 @@ Para contribuir al proyecto, comience con nuestra [guía de contribución](CONTR
|
||||
|
||||
## Licencia
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
@@ -158,4 +158,4 @@ Clineがタスクを進める中で、拡張機能は各ステップでワーク
|
||||
|
||||
## ライセンス
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 기여자 행동 강령
|
||||
|
||||
## 서약
|
||||
|
||||
우리는 개방적이고 환영하는 환경을 조성하기 위해 노력하며, 기여자 및 유지 관리자로서 모든 사람이 차별과 괴롭힘 없이 프로젝트와 커뮤니티에 참여할 수 있도록 최선을 다할 것을 서약합니다. 이는 연령, 체형, 장애, 민족성, 성적 특성, 성 정체성 및 표현, 경험 수준, 교육 수준, 사회·경제적 지위, 국적, 외모, 인종, 종교, 성 정체성과 성적 지향에 관계없이 모든 사람에게 적용됩니다.
|
||||
|
||||
## 행동 기준
|
||||
|
||||
긍정적인 환경을 조성하기 위한 바람직한 행동의 예시:
|
||||
|
||||
- 환영하고 포용적인 언어 사용하기
|
||||
- 서로 다른 관점과 경험을 존중하기
|
||||
- 건설적인 비판을 우아하게 수용하기
|
||||
- 커뮤니티에 최선이 되는 것에 집중하기
|
||||
- 다른 커뮤니티 구성원들에 대한 공감 보여주기
|
||||
|
||||
참여자가 해서는 안 되는 행동의 예시:
|
||||
|
||||
- 성적인 언어와 이미지 사용, 원치 않는 성적 관심이나 접근
|
||||
- 트롤링, 모욕적/경멸적인 댓글, 개인적 또는 정치적 공격
|
||||
- 공개적 또는 사적인 괴롭힘
|
||||
- 상대방의 동의 없이 개인정보(실제 주소나 전자 주소 등) 공개하기
|
||||
- 전문적 환경에서 부적절하다고 여겨질 수 있는 기타 행위
|
||||
|
||||
## 책임
|
||||
|
||||
프로젝트 유지 관리자는 허용 가능한 행동 기준을 명확히 설명할 책임이 있으며, 부적절한 행동이 발생할 경우 적절하고 공정한 시정 조치를 취해야 합니다.
|
||||
|
||||
프로젝트 유지 관리자는 본 행동 강령에 부합하지 않는 댓글, 커밋, 코드, 위키 수정, 이슈 및 기타 기여를 삭제, 수정 또는 거부할 권리와 책임이 있으며, 부적절하다고 판단되는 행동(위협적이거나, 공격적이거나, 해로운 행위 등)을 한 기여자를 일시적 또는 영구적으로 차단할 권리를 가집니다.
|
||||
|
||||
## 범위
|
||||
|
||||
이 행동 강령은 프로젝트 공간과 개인이 프로젝트나 커뮤니티를 대표하는 공개 공간에서 모두 적용됩니다. 프로젝트 또는 커뮤니티를 대표하는 예로는 공식 프로젝트 이메일 주소 사용, 공식 소셜 미디어 계정을 통한 게시, 온라인 또는 오프라인 행사에서 지정된 대표자로 활동하는 경우 등이 포함됩니다. 프로젝트의 대표성은 프로젝트 유지 관리자가 추가로 정의하고 명확히 할 수 있습니다.
|
||||
|
||||
## 집행
|
||||
|
||||
학대, 괴롭힘 또는 기타 용납할 수 없는 행동은 프로젝트 팀에 hi@cline.bot을 통해 신고 할 수 있습니다. 모든 신고는 검토 및 조사되며, 상황에 따라 필요하고 적절한 조치가 취해질 것입니다. 프로젝트 팀은 사건 신고자의 신원을 보호할 의무가 있습니다. 특정 시행 정책에 대한 추가 세부 사항은 별도로 게시될 수 있습니다.
|
||||
|
||||
행동 강령을 성실히 준수하거나 집행하지 않는 프로젝트 유지관리자는 프로젝트 리더십의 구성원에 의해 일시적 또는 영구적인 제재를 받을 수 있습니다.
|
||||
|
||||
## 출처
|
||||
|
||||
이 행동 강령은 [Contributor Covenant][homepage] 버전 1.4에서 수정되었으며, https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 에서 확인할 수 있습니다.
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
이 행동 강령에 대한 일반적인 질문에 대한 답변은 https://www.contributor-covenant.org/faq 를 참조하시기 바랍니다.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Cline에 기여하기
|
||||
|
||||
Cline에 기여하는 것에 관심을 가져주셔서 감사합니다! 버그 수정, 기능 추가, 문서 개선 등 모든 기여는 Cline을 더욱 스마트하게 만드는 데 기여합니다. 활기차고 환영하는 커뮤니티를 유지하기 위해 모든 구성원은 [행동 강령](CODE_OF_CONDUCT.md)을 준수해야 합니다.
|
||||
|
||||
## 버그와 문제 보고
|
||||
|
||||
버그 보고는 Cline을 모두에게 더 나은 것으로 만드는 데 도움이 됩니다! 새로운 이슈를 생성하기 전에, 중복을 피하기 위해 [기존 이슈를 검색](https://github.com/cline/cline/issues)해 주세요. 버그를 보고할 준비가 되었다면, [이슈 페이지](https://github.com/cline/cline/issues/new/choose)로 이동하여 관련 정보를 작성하기 위한 템플릿을 사용해 주세요.
|
||||
|
||||
<blockquote class='warning-note'>
|
||||
🔐 <b>중요:</b> 보안 취약점을 발견한 경우, <a href="https://github.com/cline/cline/security/advisories/new">GitHub 보안 도구를 사용하여 비공개로 보고</a>해 주세요.
|
||||
</blockquote>
|
||||
|
||||
## 작업 내용 결정하기
|
||||
|
||||
첫 기여를 찾고 계신가요? ["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue)나 ["help wanted"](https://github.com/cline/cline/labels/help%20wanted) 라벨이 붙은 이슈를 확인해 보세요. 이러한 이슈들은 새로운 기여자를 위해 특별히 선정된 작업으로, 도움이 필요한 영역이 표시되어 있습니다!
|
||||
|
||||
또한, [문서](https://github.com/cline/cline/tree/main/docs)에 대한 기여도 환영합니다! 오타 수정, 기존 가이드 개선, 새로운 교육 콘텐츠 작성 등, 커뮤니티 주도의 리소스 저장소를 구축하는 데 여러분의 도움이 필요합니다. `/docs`를 살펴보고 개선이 필요한 부분을 찾아보세요.
|
||||
|
||||
큰 기능에 대해 작업할 계획이 있다면, 먼저 [기능 요청](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop)을 생성하여 이것이 Cline의 비전과 부합하는지 논의하는 것이 좋습니다.
|
||||
|
||||
## 개발 환경 설정
|
||||
|
||||
1. **VS Code 확장 프로그램**
|
||||
|
||||
- 프로젝트를 열면 VS Code가 권장 확장 프로그램 설치를 안내합니다
|
||||
- 개발을 위해 이 확장 프로그램들이 필요하므로, 설치 안내를 수락해 주세요.
|
||||
- 프롬프트를 닫은 경우 확장 프로그램 패널에서 수동으로 설치할 수 있습니다
|
||||
|
||||
2. **로컬 개발**
|
||||
- `npm run install:all`을 실행하여 의존성을 설치합니다
|
||||
- `npm run test`를 실행하여 로컬에서 테스트를 실행합니다
|
||||
- PR을 제출하기 전에 `npm run format:fix`를 실행하여 코드를 포맷팅합니다
|
||||
|
||||
## 코드 작성과 제출
|
||||
|
||||
누구나 Cline에 코드를 기여할 수 있지만, 기여가 원활하게 통합되도록 다음 가이드라인을 따라주세요:
|
||||
|
||||
1. **Pull Request 집중하기**
|
||||
|
||||
- PR은 단일 기능 또는 버그 수정으로 제한해 주세요
|
||||
- 큰 변경사항은 작은 관련 PR로 분할해 주세요
|
||||
- 논리적으로 독립적인 커밋 단위로 나누어 리뷰가 용이하도록 구성하세요.
|
||||
|
||||
2. **코드 품질**
|
||||
|
||||
- `npm run lint`를 실행하여 코드 스타일을 체크합니다
|
||||
- `npm run format`을 실행하여 코드를 자동으로 포맷팅합니다
|
||||
- 모든 PR은 린팅과 포맷팅을 포함한 CI 체크를 통과해야 합니다
|
||||
- 제출 전에 ESLint 경고나 에러를 모두 해결해 주세요
|
||||
- TypeScript 모범 사례를 따르고, 타입 안전성을 유지해 주세요
|
||||
|
||||
3. **테스트**
|
||||
|
||||
- 새로운 기능에는 테스트를 추가해 주세요
|
||||
- `npm test`를 실행하여 모든 테스트가 통과하는지 확인해 주세요
|
||||
- 변경사항이 기존 테스트에 영향을 미치는 경우 해당 테스트를 업데이트해 주세요
|
||||
- 적절한 경우 단위 테스트와 통합 테스트를 모두 포함해 주세요
|
||||
|
||||
4. **Changesets를 활용한 버전 관리**
|
||||
|
||||
- 사용자에게 영향을 미치는 변경 사항이 있는 경우, `npm run changeset`을 실행하여 changeset을 생성해 주세요
|
||||
- 적절한 버전 증가 옵션을 선택하세요:
|
||||
- `major` 호환되지 않는 변경 (1.0.0 → 2.0.0)
|
||||
- `minor` 새로운 기능 추가 (1.0.0 → 1.1.0)
|
||||
- `patch` 버그 수정 (1.0.0 → 1.0.1)
|
||||
- 영향을 설명하는 명확한 변경사항 메시지를 작성해 주세요
|
||||
- 문서 변경만 있는 경우 changeset이 필요하지 않습니다
|
||||
|
||||
5. **커밋 가이드라인**
|
||||
|
||||
- 명확하고 설명적인 커밋 메시지를 작성해 주세요
|
||||
- 컨벤셔널 커밋 형식(예: "feat:", "fix:", "docs:")을 사용해 주세요
|
||||
- 커밋에서 관련 이슈를 #issue-number를 사용하여 참조해 주세요
|
||||
|
||||
6. **제출 전 확인사항**
|
||||
|
||||
- 최신 main에 브랜치를 리베이스해 주세요
|
||||
- 브랜치가 정상적으로 빌드되는지 확인해 주세요
|
||||
- 모든 테스트가 통과하는지 다시 확인해 주세요
|
||||
- 디버그 코드나 콘솔 로그가 없는지 변경사항을 확인해 주세요
|
||||
|
||||
7. **Pull Request 설명**
|
||||
- 변경 내용을 명확하게 설명해 주세요
|
||||
- 변경사항을 테스트하는 방법을 포함해 주세요
|
||||
- 호환되지 않는 변경 사항이 있다면 목록으로 작성해주세요
|
||||
- UI 변경이 있는 경우, 스크린샷을 추가해 주세요
|
||||
|
||||
## 기여 동의서
|
||||
|
||||
Pull Request를 제출함으로써, 귀하의 기여가 프로젝트와 동일한 라이선스([Apache 2.0](/LICENSE)) 에 따라 제공됨에 동의하는 것입니다.
|
||||
|
||||
기억하세요: Cline에 기여하는 것은 코드를 작성하는 것뿐만 아니라, AI 지원 개발의 미래를 형성하는 커뮤니티의 일원이 되는 것입니다. 함께 멋진 것을 만들어봅시다! 🚀
|
||||
@@ -0,0 +1,172 @@
|
||||
# Cline - 최고의 OpenRouter
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tbody>
|
||||
<td align="center">
|
||||
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong>VS Marketplace에서 다운로드</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://discord.gg/cline" target="_blank"><strong>Discord</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://www.reddit.com/r/cline/" target="_blank"><strong>r/cline</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>기능 요청</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://cline.bot/join-us" target="_blank"><strong>채용 정보</strong></a>
|
||||
</td>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Cline을 만나보세요, **CLI** 및 **에디터**를 활용할 수 있는 AI 어시스턴트입니다.
|
||||
|
||||
[Claude 3.7 Sonnet의 에이전트형 코딩 기능](https://www.anthropic.com/claude/sonnet) 덕분에, Cline은 복잡한 소프트웨어 개발 작업을 단계별로 처리할 수 있습니다. 파일 생성과 편집, 대규모 프로젝트 탐색, 브라우저 사용, 터미널 명령 실행(권한 허가 필요) 등의 도구를 사용하여 단순 코드 완성이나 기술 지원을 넘어서는 도움을 제공합니다. Cline은 Model Context Protocol(MCP)를 사용하여 새로운 도구를 만들고 자신의 기능을 확장할 수도 있습니다. 자율적인 AI 스크립트는 일반적으로 샌드박스 환경에서 실행되지만, 이 확장 프로그램은 모든 파일 변경 및 터미널 명령을 승인할 수 있는 사람이 개입가능한 GUI를 제공하여, 에이전트형 AI의 잠재력을 보다 안전하고 쉽게 탐색할 수 있도록 합니다.
|
||||
|
||||
1. 작업을 입력하고, 목업을 기능하는 앱으로 변환하거나 스크린샷으로 버그를 수정합니다.
|
||||
2. Cline은 파일 구조와 소스코드 AST의 분석, 정규식 검색 실행, 관련 파일 읽기부터 시작하여 기존 프로젝트를 파악합니다. 또한, 어떤 정보를 컨텍스트에 추가할지를 신중하게 관리하여, 대규모 복잡한 프로젝트에서도 컨텍스트 윈도우를 과부하시키지 않으면서도 효과적인 지원을 제공합니다.
|
||||
3. Cline이 필요한 정보를 얻은 후 다음과 같은 작업을 할 수 있습니다:
|
||||
- 파일 생성과 편집 + 린터/컴파일러 오류 모니터링을 수행하여 누락된 임포트나 구문 오류 등의 문제를 자동으로 수정합니다.
|
||||
- 터미널에서 명령을 직접 실행하고 작업 중에 출력을 모니터링합니다. 이를 통해 파일 편집 후 개발 서버의 문제에 대응할 수 있습니다.
|
||||
- 웹 개발 작업에서는 헤드리스 브라우저로 사이트를 실행하고, 클릭, 입력, 스크롤, 스크린샷과 콘솔 로그 캡처를 수행하여 런타임 오류나 시각적 버그를 수정합니다.
|
||||
4. 작업이 완료되면 Cline은 `open -a "Google Chrome" index.html`과 같은 터미널 명령을 제공하여 버튼 클릭 한 번으로 결과를 확인할 수 있도록 합니다.
|
||||
|
||||
> [!TIP]
|
||||
> `CMD/CTRL + Shift + P` 단축키를 사용하여 명령 팔레트를 열고 "Cline: Open In New Tab"을 입력하여 에디터의 탭으로 확장 프로그램을 엽니다. 이를 통해 파일 탐색기와 병행하여 Cline을 사용하고 워크스페이스의 변경을 더 명확하게 확인할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
<img align="right" width="340" src="https://github.com/user-attachments/assets/3cf21e04-7ce9-4d22-a7b9-ba2c595e88a4">
|
||||
|
||||
### 어떤 API나 모델이든 사용 가능
|
||||
|
||||
Cline은 OpenRouter, Anthropic, OpenAI, Google Gemini, AWS Bedrock, Azure, GCP Vertex 등의 API 제공자를 지원합니다. 또한 OpenAI 호환 API를 설정하거나 LM Studio/Ollama를 통해 로컬 모델을 사용할 수도 있습니다. OpenRouter를 사용하는 경우, 확장 프로그램에서 최신 모델 목록을 가져와 바로 최신 모델을 사용할 수 있게 합니다.
|
||||
|
||||
또한, Cline은 전체 작업 루프와 개별 요청별로 토큰 사용량과 API 비용을 추적하여, 진행 중인 작업의 비용을 실시간으로 확인할 수 있도록 도와줍니다.
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="370" src="https://github.com/user-attachments/assets/81be79a8-1fdb-4028-9129-5fe055e01e76">
|
||||
|
||||
### 터미널에서 명령 실행
|
||||
|
||||
VSCode v1.93의 새로운 [셸 통합 업데이트](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api) 덕분에, Cline은 터미널에서 명령을 직접 실행하고 출력을 받을 수 있습니다. 이를 통해 패키지 설치나 빌드 스크립트 실행부터 애플리케이션 배포, 데이터베이스 관리, 테스트 실행까지 광범위한 작업을 수행할 수 있습니다. Cline은 개발 환경과 도구 체인에 맞추어 정확하게 작업을 실행합니다.
|
||||
|
||||
개발 서버와 같은 오래 실행되는 프로세스의 경우, "실행 중 계속"(Proceed While Running) 버튼을 사용하여 명령이 백그라운드에서 실행되는 동안 Cline이 작업을 계속할 수 있게 합니다. 작업이 진행되는 동안 Cline은 새로운 터미널 출력을 실시간으로 확인하여, 파일 편집 시 발생하는 컴파일 오류와 같은 문제에 즉시 대응할 수 있습니다.
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="400" src="https://github.com/user-attachments/assets/c5977833-d9b8-491e-90f9-05f9cd38c588">
|
||||
|
||||
### 파일 생성과 편집
|
||||
|
||||
Cline은 에디터 내에서 파일을 생성 및 편집하고 변경의 Diff 뷰로 표시합니다. Diff 뷰 에디터에서 Cline의 변경을 직접 편집하거나 되돌릴 수 있으며, 채팅에서 피드백을 제공하여 만족할 때까지 개선 요청할 수 있습니다. Cline은 린터/컴파일러 오류(누락된 임포트, 구문 오류 등)도 모니터링하고 발생한 문제를 자동으로 수정합니다.
|
||||
|
||||
Cline에 의한 모든 변경은 파일의 타임라인에 기록되어 필요할 때 변경을 추적하고 되돌릴 수 있는 간단한 방법을 제공합니다.
|
||||
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="370" src="https://github.com/user-attachments/assets/bc2e85ba-dfeb-4fe6-9942-7cfc4703cbe5">
|
||||
|
||||
### 브라우저 사용
|
||||
|
||||
Claude 3.5 Sonnet의 새로운 [컴퓨터 사용](https://www.anthropic.com/news/3-5-models-and-computer-use) 기능으로 인해, Cline은 브라우저를 실행하고 요소를 클릭하고 텍스트를 입력하고 스크롤하며 각 단계에서 스크린샷과 콘솔 로그를 캡처할 수 있습니다. 이를 통해 인터랙티브한 디버깅, 엔드투엔드 테스트, 심지어 일반적인 웹 탐색까지 가능해집니다. 이로 인해 오류 로그를 수동으로 복사 & 붙여넣기 할 필요 없이 시각적 버그나 런타임 문제를 자율적으로 수정할 수 있습니다.
|
||||
|
||||
Cline에게 "앱을 테스트해줘"라고 요청하면, `npm run dev`와 같은 명령을 실행하고 로컬에서 실행 중인 개발 서버를 브라우저에서 실행하여 일련의 테스트를 수행하고 모든 것이 정상적으로 작동하는지 확인합니다. [데모는 여기를 참조하세요.](https://x.com/sdrzn/status/1850880547825823989)
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="350" src="https://github.com/user-attachments/assets/ac0efa14-5c1f-4c26-a42d-9d7c56f5fadd">
|
||||
|
||||
### "도구를 추가 해주세요."
|
||||
|
||||
Cline은 [Model Context Protocol](https://github.com/modelcontextprotocol)을 활용하여 커스텀 도구를 생성하고 기능을 확장할 수 있습니다. 기존의 [커뮤니티 서버](https://github.com/modelcontextprotocol/servers)를 사용할 수도 있지만, Cline은 사용자의 워크플로우에 최적화된 도구를 직접 제작하고 설치할 수도 있습니다. "~ 도구를 추가해주세요."라고 요청만 하면, Cline은 새로운 MCP 서버 생성부터 확장 프로그램 내 설치까지 모두 자동으로 처리합니다. 이러한 커스텀 도구는 Cline의 툴키트의 일부가 되어 향후 작업에서 사용할 수 있게 됩니다.
|
||||
|
||||
- "Jira 티켓을 가져오는 도구를 추가해주세요": 티켓 AC를 가져와 Cline에게 작업을 요청
|
||||
- "AWS EC2를 관리하는 도구를 추가해주세요": 서버 메트릭을 확인하고 인스턴스를 확장 또는 축소
|
||||
- "최신 PagerDuty 인시던트를 가져오는 도구를 추가해주세요": 최신 장애 정보를 가져와 Cline에게 버그 수정 요청
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="360" src="https://github.com/user-attachments/assets/7fdf41e6-281a-4b4b-ac19-020b838b6970">
|
||||
|
||||
### 컨텍스트 추가
|
||||
|
||||
**`@url`:** URL을 붙여넣으면 확장이 해당 페이지를 가져와 Markdown으로 변환합니다. 최신 문서를 Cline에게 제공할 때 유용합니다.
|
||||
|
||||
**`@problems`:** Cline이 수정할 워크스페이스 오류와 경고(Problems' panel)를 추가합니다.
|
||||
|
||||
**`@file`:** 파일의 내용을 추가하여, 파일을 읽는 데 API 요청을 허비하지 않고도 Cline이 접근할 수 있도록 합니다. (+ 파일 검색 가능)
|
||||
|
||||
**`@folder`:** 폴더 내 모든 파일을 한 번에 추가하여 워크플로우를 더욱 빠르게 진행할 수 있습니다.
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="350" src="https://github.com/user-attachments/assets/140c8606-d3bf-41b9-9a1f-4dbf0d4c90cb">
|
||||
|
||||
### 체크포인트: 비교 및 복원
|
||||
|
||||
Cline이 작업을 진행하는 동안 확장 프로그램은 각 단계에서 워크스페이스의 스냅샷을 저장합니다. “Compare” 버튼을 사용하여 스냅샷과 현재 워크스페이스의 차이를 확인하고, “Restore” 버튼을 사용하여 해당 시점으로 롤백할 수 있습니다.
|
||||
|
||||
예를 들어, 로컬 웹 서버에서 작업 중일 때 “Restore Workspace Only”을 사용하여 서로 다른 버전의 앱을 신속하게 테스트하고, “Restore Task and Workspace”을 사용하여 계속 진행할 버전을 찾을 수 있습니다. 이를 통해 진행 상황을 잃지 않고 안전하게 다양한 접근 방식을 실험할 수 있습니다.
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
## 기여
|
||||
|
||||
프로젝트에 기여하려면, [기여 가이드](CONTRIBUTING.md)에서 기본 사항을 익히세요. 또한, [Discord](https://discord.gg/cline)에 참여하여 `#contributors` 채널에서 다른 기여자들과 이야기할 수 있습니다. 풀타임 직업을 찾고 있다면, [채용 페이지](https://cline.bot/join-us)에서 열려있는 포지션을 확인하세요.
|
||||
|
||||
<details>
|
||||
<summary>로컬 개발 방법</summary>
|
||||
|
||||
1. 리포지토리를 클론합니다 _(Requires [git-lfs](https://git-lfs.com/))_:
|
||||
```bash
|
||||
git clone https://github.com/cline/cline.git
|
||||
```
|
||||
2. 프로젝트를 VSCode에서 엽니다:
|
||||
```bash
|
||||
code cline
|
||||
```
|
||||
3. 확장 프로그램과 webview-gui의 필요한 의존성을 설치합니다:
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
4. `F5`를 눌러(또는 `Run`->`Start Debugging`), 확장 프로그램이 로드된 새로운 VSCode 창을 엽니다. (프로젝트 빌드에 문제가 있는 경우, [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers)을 설치해야 할 수도 있습니다.)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Pull Request 생성 방법</summary>
|
||||
|
||||
1. PR을 만들기 전, 변경 사항을 기록하는 changeset 항목을 생성:
|
||||
```bash
|
||||
npm run changeset
|
||||
```
|
||||
이후 프롬프트에서 다음 정보를 입력하세요:
|
||||
- 변경 유형 (major, minor, patch)
|
||||
- `major` → 호환되지 않는 변경 (1.0.0 → 2.0.0)
|
||||
- `minor` → 새로운 기능 추가 (1.0.0 → 1.1.0)
|
||||
- `patch` → 버그 수정 (1.0.0 → 1.0.1)
|
||||
- 변경 사항 설명 입력
|
||||
|
||||
2. 변경 사항과 생성된 `.changeset` 파일을 커밋 후 브랜치를 푸시하고 GitHub에서 PR을 생성하세요.
|
||||
|
||||
3. 브랜치를 푸시하고 GitHub에서 PR을 생성하세요. CI가 다음과 같은 작업을 수행합니다:
|
||||
- 테스트 및 코드 검증 실행
|
||||
- Changesetbot이 버전 변경 영향을 보여주는 코멘트를 생성
|
||||
- 브랜치가 메인에 머지되면, Changesetbot이 버전 패키지 PR을 생성
|
||||
- 버전 패키지 PR이 머지되면, 새로운 릴리즈가 게시됨
|
||||
|
||||
</details>
|
||||
|
||||
## 라이센스
|
||||
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](/LICENSE)
|
||||
@@ -158,4 +158,4 @@ Para contribuir com o projeto, comece com nosso [Guia de Contribuição](CONTRIB
|
||||
|
||||
## Licença
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
@@ -158,5 +158,5 @@ Cline 所做的所有更改都会记录在你的文件时间轴中,提供了
|
||||
|
||||
## 许可证
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
|
||||
@@ -158,4 +158,4 @@ Cline 所做的所有更改都會記錄在你的文件時間軸中,提供了
|
||||
|
||||
## 許可證
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
Generated
+3674
-235
File diff suppressed because it is too large
Load Diff
+68
-11
@@ -2,12 +2,8 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.6.0",
|
||||
"version": "3.8.2",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"galleryBanner": {
|
||||
"color": "#617A91",
|
||||
"theme": "dark"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
},
|
||||
@@ -73,7 +69,7 @@
|
||||
{
|
||||
"command": "cline.mcpButtonClicked",
|
||||
"title": "MCP Servers",
|
||||
"icon": "$(extensions)"
|
||||
"icon": "$(server)"
|
||||
},
|
||||
{
|
||||
"command": "cline.historyButtonClicked",
|
||||
@@ -85,6 +81,11 @@
|
||||
"title": "Open in Editor",
|
||||
"icon": "$(link-external)"
|
||||
},
|
||||
{
|
||||
"command": "cline.accountButtonClicked",
|
||||
"title": "Account",
|
||||
"icon": "$(account)"
|
||||
},
|
||||
{
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"title": "Settings",
|
||||
@@ -94,6 +95,32 @@
|
||||
"command": "cline.openInNewTab",
|
||||
"title": "Open In New Tab",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.dev.createTestTasks",
|
||||
"title": "Create Test Tasks",
|
||||
"category": "Cline",
|
||||
"when": "cline.isDevMode"
|
||||
},
|
||||
{
|
||||
"command": "cline.openDocumentation",
|
||||
"title": "Documentation",
|
||||
"icon": "$(book)"
|
||||
},
|
||||
{
|
||||
"command": "cline.addToChat",
|
||||
"title": "Add to Cline",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.addTerminalOutputToChat",
|
||||
"title": "Add to Cline",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.fixWithCline",
|
||||
"title": "Fix with Cline",
|
||||
"category": "Cline"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -119,9 +146,32 @@
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"command": "cline.openDocumentation",
|
||||
"group": "navigation@5",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.accountButtonClicked",
|
||||
"group": "navigation@6",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"group": "navigation@7",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
}
|
||||
],
|
||||
"editor/context": [
|
||||
{
|
||||
"command": "cline.addToChat",
|
||||
"group": "navigation",
|
||||
"when": "editorHasSelection"
|
||||
}
|
||||
],
|
||||
"terminal/context": [
|
||||
{
|
||||
"command": "cline.addTerminalOutputToChat",
|
||||
"group": "navigation"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -160,7 +210,7 @@
|
||||
"cline.enableCheckpoints": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enables extension to save checkpoints of workspace throughout the task."
|
||||
"description": "Enables extension to save checkpoints of workspace throughout the task. Uses git under the hood which may not work well with large workspaces."
|
||||
},
|
||||
"cline.disableBrowserTool": {
|
||||
"type": "boolean",
|
||||
@@ -267,21 +317,27 @@
|
||||
"@anthropic-ai/bedrock-sdk": "^0.12.4",
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.6.4",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.758.0",
|
||||
"@google-cloud/vertexai": "^1.9.3",
|
||||
"@google/generative-ai": "^0.18.0",
|
||||
"@mistralai/mistralai": "^1.5.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.7.0",
|
||||
"@opentelemetry/api": "^1.4.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.39.1",
|
||||
"@opentelemetry/resources": "^1.30.1",
|
||||
"@opentelemetry/sdk-node": "^0.39.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.30.0",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"axios": "^1.7.4",
|
||||
"axios": "^1.8.2",
|
||||
"cheerio": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"clone-deep": "^4.0.1",
|
||||
"default-shell": "^2.2.0",
|
||||
"delay": "^6.0.0",
|
||||
"diff": "^5.2.0",
|
||||
"execa": "^9.5.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -296,6 +352,7 @@
|
||||
"open-graph-scraper": "^6.9.0",
|
||||
"openai": "^4.83.0",
|
||||
"os-name": "^6.0.0",
|
||||
"p-timeout": "^6.1.4",
|
||||
"p-wait-for": "^5.0.2",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"posthog-node": "^4.8.1",
|
||||
|
||||
+5
-1
@@ -9,7 +9,7 @@ import { OllamaHandler } from "./providers/ollama"
|
||||
import { LmStudioHandler } from "./providers/lmstudio"
|
||||
import { GeminiHandler } from "./providers/gemini"
|
||||
import { OpenAiNativeHandler } from "./providers/openai-native"
|
||||
import { ApiStream } from "./transform/stream"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
|
||||
import { DeepSeekHandler } from "./providers/deepseek"
|
||||
import { RequestyHandler } from "./providers/requesty"
|
||||
import { TogetherHandler } from "./providers/together"
|
||||
@@ -20,10 +20,12 @@ import { ClineHandler } from "./providers/cline"
|
||||
import { LiteLlmHandler } from "./providers/litellm"
|
||||
import { AskSageHandler } from "./providers/asksage"
|
||||
import { XAIHandler } from "./providers/xai"
|
||||
import { SambanovaHandler } from "./providers/sambanova"
|
||||
|
||||
export interface ApiHandler {
|
||||
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
|
||||
getModel(): { id: string; info: ModelInfo }
|
||||
getApiStreamUsage?(): Promise<ApiStreamUsageChunk | undefined>
|
||||
}
|
||||
|
||||
export interface SingleCompletionHandler {
|
||||
@@ -71,6 +73,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
|
||||
return new AskSageHandler(options)
|
||||
case "xai":
|
||||
return new XAIHandler(options)
|
||||
case "sambanova":
|
||||
return new SambanovaHandler(options)
|
||||
default:
|
||||
return new AnthropicHandler(options)
|
||||
}
|
||||
|
||||
+472
-13
@@ -2,9 +2,17 @@ import AnthropicBedrock from "@anthropic-ai/bedrock-sdk"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiHandler } from "../"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
import { ApiHandlerOptions, bedrockDefaultModelId, BedrockModelId, bedrockModels, ModelInfo } from "../../shared/api"
|
||||
import { calculateApiCostOpenAI } from "../../utils/cost"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||
import {
|
||||
BedrockRuntimeClient,
|
||||
ConversationRole,
|
||||
ConverseStreamCommand,
|
||||
InvokeModelWithResponseStreamCommand,
|
||||
} from "@aws-sdk/client-bedrock-runtime"
|
||||
|
||||
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
|
||||
export class AwsBedrockHandler implements ApiHandler {
|
||||
@@ -18,19 +26,31 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
// cross region inference requires prefixing the model id with the region
|
||||
let modelId = await this.getModelId()
|
||||
const model = this.getModel()
|
||||
|
||||
// Check if this is an Amazon Nova model
|
||||
if (modelId.includes("amazon.nova")) {
|
||||
yield* this.createNovaMessage(systemPrompt, messages, modelId, model)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a Deepseek model
|
||||
if (modelId.includes("deepseek")) {
|
||||
yield* this.createDeepseekMessage(systemPrompt, messages, modelId, model)
|
||||
return
|
||||
}
|
||||
|
||||
let budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
|
||||
|
||||
// Get model info and message indices for caching
|
||||
const model = this.getModel()
|
||||
const userMsgIndices = messages.reduce((acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), [] as number[])
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
|
||||
// Create anthropic client, using sessions created or renewed after this handler's
|
||||
// initialization, and allowing for session renewal if necessary as well
|
||||
const client = await this.getClient()
|
||||
const client = await this.getAnthropicClient()
|
||||
|
||||
const stream = await client.messages.create({
|
||||
model: modelId,
|
||||
@@ -99,6 +119,20 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
break
|
||||
case "content_block_start":
|
||||
switch (chunk.content_block.type) {
|
||||
case "thinking":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.content_block.thinking || "",
|
||||
}
|
||||
break
|
||||
case "redacted_thinking":
|
||||
// Handle redacted thinking blocks - we still mark it as reasoning
|
||||
// but note that the content is encrypted
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "[Redacted thinking block]",
|
||||
}
|
||||
break
|
||||
case "text":
|
||||
if (chunk.index > 0) {
|
||||
yield {
|
||||
@@ -115,6 +149,12 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
break
|
||||
case "content_block_delta":
|
||||
switch (chunk.delta.type) {
|
||||
case "thinking_delta":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.delta.thinking,
|
||||
}
|
||||
break
|
||||
case "text_delta":
|
||||
yield {
|
||||
type: "text",
|
||||
@@ -139,12 +179,21 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private async getClient(): Promise<AnthropicBedrock> {
|
||||
// Create AWS credentials by executing a an AWS provider chain exactly as the
|
||||
// Anthropic SDK does it, by wrapping the default chain into a temporary process
|
||||
// environment.
|
||||
// Default AWS region
|
||||
private static readonly DEFAULT_REGION = "us-east-1"
|
||||
|
||||
/**
|
||||
* Gets AWS credentials using the provider chain
|
||||
* Centralizes credential retrieval logic for all AWS services
|
||||
*/
|
||||
private async getAwsCredentials(): Promise<{
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
sessionToken?: string
|
||||
}> {
|
||||
// Create AWS credentials by executing an AWS provider chain
|
||||
const providerChain = fromNodeProviderChain()
|
||||
const credentials = await AwsBedrockHandler.withTempEnv(
|
||||
return await AwsBedrockHandler.withTempEnv(
|
||||
() => {
|
||||
AwsBedrockHandler.setEnv("AWS_REGION", this.options.awsRegion)
|
||||
AwsBedrockHandler.setEnv("AWS_ACCESS_KEY_ID", this.options.awsAccessKey)
|
||||
@@ -154,23 +203,54 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
},
|
||||
() => providerChain(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the AWS region to use, with fallback to default
|
||||
*/
|
||||
private getRegion(): string {
|
||||
return this.options.awsRegion || AwsBedrockHandler.DEFAULT_REGION
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a BedrockRuntimeClient with the appropriate credentials
|
||||
*/
|
||||
private async getBedrockClient(): Promise<BedrockRuntimeClient> {
|
||||
const credentials = await this.getAwsCredentials()
|
||||
|
||||
return new BedrockRuntimeClient({
|
||||
region: this.getRegion(),
|
||||
credentials: {
|
||||
accessKeyId: credentials.accessKeyId,
|
||||
secretAccessKey: credentials.secretAccessKey,
|
||||
sessionToken: credentials.sessionToken,
|
||||
},
|
||||
...(this.options.awsBedrockEndpoint && { endpoint: this.options.awsBedrockEndpoint }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an AnthropicBedrock client with the appropriate credentials
|
||||
*/
|
||||
private async getAnthropicClient(): Promise<AnthropicBedrock> {
|
||||
const credentials = await this.getAwsCredentials()
|
||||
|
||||
// Return an AnthropicBedrock client with the resolved/assumed credentials.
|
||||
//
|
||||
// When AnthropicBedrock creates its AWS client, the chain will execute very
|
||||
// fast as the access/secret keys will already be already provided, and have
|
||||
// a higher precedence than the profiles.
|
||||
return new AnthropicBedrock({
|
||||
awsAccessKey: credentials.accessKeyId,
|
||||
awsSecretKey: credentials.secretAccessKey,
|
||||
awsSessionToken: credentials.sessionToken,
|
||||
awsRegion: this.options.awsRegion || "us-east-1",
|
||||
awsRegion: this.getRegion(),
|
||||
...(this.options.awsBedrockEndpoint && { baseURL: this.options.awsBedrockEndpoint }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate model ID, accounting for cross-region inference if enabled
|
||||
*/
|
||||
async getModelId(): Promise<string> {
|
||||
if (this.options.awsUseCrossRegionInference) {
|
||||
let regionPrefix = (this.options.awsRegion || "").slice(0, 3)
|
||||
let regionPrefix = this.getRegion().slice(0, 3)
|
||||
switch (regionPrefix) {
|
||||
case "us-":
|
||||
return `us.${this.getModel().id}`
|
||||
@@ -202,4 +282,383 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a message using the Deepseek R1 model through AWS Bedrock
|
||||
*/
|
||||
private async *createDeepseekMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
modelId: string,
|
||||
model: { id: BedrockModelId; info: ModelInfo },
|
||||
): ApiStream {
|
||||
// Get Bedrock client with proper credentials
|
||||
const client = await this.getBedrockClient()
|
||||
|
||||
// Format prompt for DeepSeek R1 according to documentation
|
||||
const formattedPrompt = this.formatDeepseekR1Prompt(systemPrompt, messages)
|
||||
|
||||
// Prepare the request based on DeepSeek R1's expected format
|
||||
const command = new InvokeModelWithResponseStreamCommand({
|
||||
modelId: modelId,
|
||||
contentType: "application/json",
|
||||
accept: "application/json",
|
||||
body: JSON.stringify({
|
||||
prompt: formattedPrompt,
|
||||
max_tokens: model.info.maxTokens || 8000,
|
||||
temperature: 0,
|
||||
}),
|
||||
})
|
||||
|
||||
// Track token usage
|
||||
const inputTokenEstimate = this.estimateInputTokens(systemPrompt, messages)
|
||||
let outputTokens = 0
|
||||
let isFirstChunk = true
|
||||
let accumulatedTokens = 0
|
||||
const TOKEN_REPORT_THRESHOLD = 100 // Report usage after accumulating this many tokens
|
||||
|
||||
// Execute the streaming request
|
||||
const response = await client.send(command)
|
||||
|
||||
if (response.body) {
|
||||
for await (const chunk of response.body) {
|
||||
if (chunk.chunk?.bytes) {
|
||||
try {
|
||||
// Parse the response chunk
|
||||
const decodedChunk = new TextDecoder().decode(chunk.chunk.bytes)
|
||||
const parsedChunk = JSON.parse(decodedChunk)
|
||||
|
||||
// Report usage on first chunk
|
||||
if (isFirstChunk) {
|
||||
isFirstChunk = false
|
||||
const totalCost = calculateApiCostOpenAI(model.info, inputTokenEstimate, 0, 0, 0)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: inputTokenEstimate,
|
||||
outputTokens: 0,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle DeepSeek R1 response format
|
||||
if (parsedChunk.choices && parsedChunk.choices.length > 0) {
|
||||
// For non-streaming response (full response)
|
||||
const text = parsedChunk.choices[0].text
|
||||
if (text) {
|
||||
const chunkTokens = this.estimateTokenCount(text)
|
||||
outputTokens += chunkTokens
|
||||
accumulatedTokens += chunkTokens
|
||||
|
||||
yield {
|
||||
type: "text",
|
||||
text: text,
|
||||
}
|
||||
|
||||
if (accumulatedTokens >= TOKEN_REPORT_THRESHOLD) {
|
||||
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: accumulatedTokens,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
accumulatedTokens = 0
|
||||
}
|
||||
}
|
||||
} else if (parsedChunk.delta?.text) {
|
||||
// For streaming response (delta updates)
|
||||
const text = parsedChunk.delta.text
|
||||
const chunkTokens = this.estimateTokenCount(text)
|
||||
outputTokens += chunkTokens
|
||||
accumulatedTokens += chunkTokens
|
||||
|
||||
yield {
|
||||
type: "text",
|
||||
text: text,
|
||||
}
|
||||
// Report aggregated token usage only when threshold is reached
|
||||
if (accumulatedTokens >= TOKEN_REPORT_THRESHOLD) {
|
||||
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: accumulatedTokens,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
accumulatedTokens = 0
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing Deepseek response chunk:", error)
|
||||
// Propagate the error by yielding a text response with error information
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Failed to parse Deepseek response: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report any remaining accumulated tokens at the end of the stream
|
||||
if (accumulatedTokens > 0) {
|
||||
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: accumulatedTokens,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
}
|
||||
|
||||
// Add final total cost calculation that includes both input and output tokens
|
||||
const finalTotalCost = calculateApiCostOpenAI(model.info, inputTokenEstimate, outputTokens, 0, 0)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: inputTokenEstimate,
|
||||
outputTokens: outputTokens,
|
||||
totalCost: finalTotalCost,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats prompt for DeepSeek R1 model according to documentation
|
||||
* First uses convertToR1Format to merge consecutive messages with the same role,
|
||||
* then converts to the string format that DeepSeek R1 expects
|
||||
*/
|
||||
private formatDeepseekR1Prompt(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): string {
|
||||
// First use convertToR1Format to merge consecutive messages with the same role
|
||||
const r1Messages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
|
||||
// Then convert to the special string format expected by DeepSeek R1
|
||||
let combinedContent = ""
|
||||
|
||||
for (const message of r1Messages) {
|
||||
let content = ""
|
||||
|
||||
if (message.content) {
|
||||
if (typeof message.content === "string") {
|
||||
content = message.content
|
||||
} else {
|
||||
// Extract text content from message parts
|
||||
content = message.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
combinedContent += message.role === "user" ? "User: " + content + "\n" : "Assistant: " + content + "\n"
|
||||
}
|
||||
|
||||
// Format according to DeepSeek R1's expected prompt format
|
||||
return `<|begin▁of▁sentence|><|User|>${combinedContent}<|Assistant|><think>\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates token count based on text length (approximate)
|
||||
* Note: This is a rough estimation, as the actual token count depends on the tokenizer
|
||||
*/
|
||||
private estimateInputTokens(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): number {
|
||||
// For Deepseek R1, we estimate the token count of the formatted prompt
|
||||
// The formatted prompt includes special tokens and consistent formatting
|
||||
const formattedPrompt = this.formatDeepseekR1Prompt(systemPrompt, messages)
|
||||
return Math.ceil(formattedPrompt.length / 4)
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates token count for a text string
|
||||
*/
|
||||
private estimateTokenCount(text: string): number {
|
||||
// Approximate 4 characters per token
|
||||
return Math.ceil(text.length / 4)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a message using Amazon Nova models through AWS Bedrock
|
||||
* Implements support for Nova Micro, Nova Lite, and Nova Pro models
|
||||
*/
|
||||
private async *createNovaMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
modelId: string,
|
||||
model: { id: BedrockModelId; info: ModelInfo },
|
||||
): ApiStream {
|
||||
// Get Bedrock client with proper credentials
|
||||
const client = await this.getBedrockClient()
|
||||
|
||||
// Format messages for Nova model
|
||||
const formattedMessages = this.formatNovaMessages(messages)
|
||||
|
||||
// Prepare request for Nova model
|
||||
const command = new ConverseStreamCommand({
|
||||
modelId: modelId,
|
||||
messages: formattedMessages,
|
||||
system: systemPrompt ? [{ text: systemPrompt }] : undefined,
|
||||
inferenceConfig: {
|
||||
maxTokens: model.info.maxTokens || 5000,
|
||||
temperature: 0,
|
||||
// topP: 0.9, // Alternative: use topP instead of temperature
|
||||
},
|
||||
})
|
||||
|
||||
// Execute the streaming request and handle response
|
||||
try {
|
||||
const response = await client.send(command)
|
||||
|
||||
if (response.stream) {
|
||||
let hasReportedInputTokens = false
|
||||
|
||||
for await (const chunk of response.stream) {
|
||||
// Handle metadata events with token usage information
|
||||
if (chunk.metadata?.usage) {
|
||||
// Report complete token usage from the model itself
|
||||
const inputTokens = chunk.metadata.usage.inputTokens || 0
|
||||
const outputTokens = chunk.metadata.usage.outputTokens || 0
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalCost: calculateApiCostOpenAI(model.info, inputTokens, outputTokens, 0, 0),
|
||||
}
|
||||
hasReportedInputTokens = true
|
||||
}
|
||||
|
||||
// Handle content delta (text generation)
|
||||
if (chunk.contentBlockDelta?.delta?.text) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.contentBlockDelta.delta.text,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reasoning content if present
|
||||
if (chunk.contentBlockDelta?.delta?.reasoningContent?.text) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.contentBlockDelta.delta.reasoningContent.text,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (chunk.internalServerException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Internal server error: ${chunk.internalServerException.message}`,
|
||||
}
|
||||
} else if (chunk.modelStreamErrorException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Model stream error: ${chunk.modelStreamErrorException.message}`,
|
||||
}
|
||||
} else if (chunk.validationException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Validation error: ${chunk.validationException.message}`,
|
||||
}
|
||||
} else if (chunk.throttlingException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Throttling error: ${chunk.throttlingException.message}`,
|
||||
}
|
||||
} else if (chunk.serviceUnavailableException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Service unavailable: ${chunk.serviceUnavailableException.message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing Nova model response:", error)
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Failed to process Nova response: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats messages for Amazon Nova models according to the SDK specification
|
||||
*/
|
||||
private formatNovaMessages(messages: Anthropic.Messages.MessageParam[]): { role: ConversationRole; content: any[] }[] {
|
||||
return messages.map((message) => {
|
||||
// Determine role (user or assistant)
|
||||
const role = message.role === "user" ? ConversationRole.USER : ConversationRole.ASSISTANT
|
||||
|
||||
// Process content based on type
|
||||
let content: any[] = []
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
// Simple text content
|
||||
content = [{ text: message.content }]
|
||||
} else if (Array.isArray(message.content)) {
|
||||
// Convert Anthropic content format to Nova content format
|
||||
content = message.content
|
||||
.map((item) => {
|
||||
// Text content
|
||||
if (item.type === "text") {
|
||||
return { text: item.text }
|
||||
}
|
||||
|
||||
// Image content
|
||||
if (item.type === "image") {
|
||||
// Handle different image source formats
|
||||
let imageData: Uint8Array
|
||||
let format = "jpeg" // default format
|
||||
|
||||
// Extract format from media_type if available
|
||||
if (item.source.media_type) {
|
||||
// Extract format from media_type (e.g., "image/jpeg" -> "jpeg")
|
||||
const formatMatch = item.source.media_type.match(/image\/(\w+)/)
|
||||
if (formatMatch && formatMatch[1]) {
|
||||
format = formatMatch[1]
|
||||
// Ensure format is one of the allowed values
|
||||
if (!["png", "jpeg", "gif", "webp"].includes(format)) {
|
||||
format = "jpeg" // Default to jpeg if not supported
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get image data
|
||||
try {
|
||||
if (typeof item.source.data === "string") {
|
||||
// Handle base64 encoded data
|
||||
const base64Data = item.source.data.replace(/^data:image\/\w+;base64,/, "")
|
||||
imageData = new Uint8Array(Buffer.from(base64Data, "base64"))
|
||||
} else if (item.source.data && typeof item.source.data === "object") {
|
||||
// Try to convert to Uint8Array
|
||||
imageData = new Uint8Array(Buffer.from(item.source.data as any))
|
||||
} else {
|
||||
console.error("Unsupported image data format")
|
||||
return null // Skip this item if format is not supported
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Could not convert image data to Uint8Array:", error)
|
||||
return null // Skip this item if conversion fails
|
||||
}
|
||||
|
||||
return {
|
||||
image: {
|
||||
format,
|
||||
source: {
|
||||
bytes: imageData,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Return null for unsupported content types
|
||||
return null
|
||||
})
|
||||
.filter(Boolean) // Remove any null items
|
||||
}
|
||||
|
||||
// Return formatted message
|
||||
return {
|
||||
role,
|
||||
content,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+83
-22
@@ -2,13 +2,15 @@ import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
|
||||
import { streamOpenRouterFormatRequest } from "../transform/openrouter-stream"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { createOpenRouterStream } from "../transform/openrouter-stream"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import axios from "axios"
|
||||
import { OpenRouterErrorResponse } from "./types"
|
||||
|
||||
export class ClineHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
lastGenerationId?: string
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
@@ -19,36 +21,95 @@ export class ClineHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const model = this.getModel()
|
||||
const genId = yield* streamOpenRouterFormatRequest(
|
||||
this.lastGenerationId = undefined
|
||||
|
||||
const stream = await createOpenRouterStream(
|
||||
this.client,
|
||||
systemPrompt,
|
||||
messages,
|
||||
model,
|
||||
this.getModel(),
|
||||
this.options.o3MiniReasoningEffort,
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await axios.get(`https://api.cline.bot/v1/generation?id=${genId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.clineApiKey}`,
|
||||
},
|
||||
timeout: 5_000, // this request hangs sometimes
|
||||
})
|
||||
let didOutputUsage: boolean = false
|
||||
|
||||
const generation = response.data
|
||||
console.log("cline generation details:", generation)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: generation?.native_tokens_prompt || 0,
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
for await (const chunk of stream) {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as OpenRouterErrorResponse["error"]
|
||||
console.error(`Cline API Error: ${error?.code} - ${error?.message}`)
|
||||
// Include metadata in the error message if available
|
||||
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
|
||||
throw new Error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
|
||||
}
|
||||
|
||||
if (!this.lastGenerationId && chunk.id) {
|
||||
this.lastGenerationId = chunk.id
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
// Reasoning tokens are returned separately from the content
|
||||
if ("reasoning" in delta && delta.reasoning) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
// @ts-ignore-next-line
|
||||
reasoning: delta.reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: chunk.usage.cost || 0,
|
||||
}
|
||||
didOutputUsage = true
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore if fails
|
||||
console.error("Error fetching cline generation details:", error)
|
||||
}
|
||||
|
||||
// Fallback to generation endpoint if usage chunk not returned
|
||||
if (!didOutputUsage) {
|
||||
const apiStreamUsage = await this.getApiStreamUsage()
|
||||
if (apiStreamUsage) {
|
||||
yield apiStreamUsage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
|
||||
if (this.lastGenerationId) {
|
||||
try {
|
||||
const response = await axios.get(`https://api.cline.bot/v1/generation?id=${this.lastGenerationId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.clineApiKey}`,
|
||||
},
|
||||
timeout: 15_000, // this request hangs sometimes
|
||||
})
|
||||
|
||||
const generation = response.data
|
||||
return {
|
||||
type: "usage",
|
||||
inputTokens: generation?.native_tokens_prompt || 0,
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore if fails
|
||||
console.error("Error fetching cline generation details:", error)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
|
||||
@@ -23,11 +23,18 @@ export class LiteLlmHandler implements ApiHandler {
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
}
|
||||
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
|
||||
const isOminiModel = modelId.includes("o1-mini") || modelId.includes("o3-mini")
|
||||
let temperature: number | undefined = 0
|
||||
|
||||
if (isOminiModel) {
|
||||
temperature = undefined // does not support temperature
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.options.liteLlmModelId || liteLlmDefaultModelId,
|
||||
messages: [systemMessage, ...formattedMessages],
|
||||
temperature: 0,
|
||||
temperature,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
|
||||
@@ -15,7 +15,8 @@ export class OpenAiHandler implements ApiHandler {
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
// Azure API shape slightly differs from the core API shape: https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
|
||||
if (this.options.openAiBaseUrl?.toLowerCase().includes("azure.com")) {
|
||||
// Use azureApiVersion to determine if this is an Azure endpoint, since the URL may not always contain 'azure.com'
|
||||
if (this.options.azureApiVersion || this.options.openAiBaseUrl?.toLowerCase().includes("azure.com")) {
|
||||
this.client = new AzureOpenAI({
|
||||
baseURL: this.options.openAiBaseUrl,
|
||||
apiKey: this.options.openAiApiKey,
|
||||
@@ -39,7 +40,7 @@ export class OpenAiHandler implements ApiHandler {
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
let temperature: number | undefined = 0
|
||||
let temperature: number | undefined = this.options.openAiModelInfo?.temperature ?? openAiModelInfoSaneDefaults.temperature
|
||||
let reasoningEffort: ChatCompletionReasoningEffort | undefined = undefined
|
||||
|
||||
if (isDeepseekReasoner) {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import axios from "axios"
|
||||
import delay from "delay"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import OpenAI from "openai"
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
|
||||
import { streamOpenRouterFormatRequest } from "../transform/openrouter-stream"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
import { withRetry } from "../retry"
|
||||
import { createOpenRouterStream } from "../transform/openrouter-stream"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { OpenRouterErrorResponse } from "./types"
|
||||
|
||||
export class OpenRouterHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
lastGenerationId?: string
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
@@ -28,23 +28,80 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const model = this.getModel()
|
||||
const genId = yield* streamOpenRouterFormatRequest(
|
||||
this.lastGenerationId = undefined
|
||||
|
||||
const stream = await createOpenRouterStream(
|
||||
this.client,
|
||||
systemPrompt,
|
||||
messages,
|
||||
model,
|
||||
this.getModel(),
|
||||
this.options.o3MiniReasoningEffort,
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
)
|
||||
|
||||
if (genId) {
|
||||
await delay(500) // FIXME: necessary delay to ensure generation endpoint is ready
|
||||
let didOutputUsage: boolean = false
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as OpenRouterErrorResponse["error"]
|
||||
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
|
||||
// Include metadata in the error message if available
|
||||
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
|
||||
throw new Error(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`)
|
||||
}
|
||||
|
||||
if (!this.lastGenerationId && chunk.id) {
|
||||
this.lastGenerationId = chunk.id
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
// Reasoning tokens are returned separately from the content
|
||||
if ("reasoning" in delta && delta.reasoning) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
// @ts-ignore-next-line
|
||||
reasoning: delta.reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: chunk.usage.cost || 0,
|
||||
}
|
||||
didOutputUsage = true
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to generation endpoint if usage chunk not returned
|
||||
if (!didOutputUsage) {
|
||||
const apiStreamUsage = await this.getApiStreamUsage()
|
||||
if (apiStreamUsage) {
|
||||
yield apiStreamUsage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
|
||||
if (this.lastGenerationId) {
|
||||
await setTimeoutPromise(500) // FIXME: necessary delay to ensure generation endpoint is ready
|
||||
try {
|
||||
const generationIterator = this.fetchGenerationDetails(genId)
|
||||
const generationIterator = this.fetchGenerationDetails(this.lastGenerationId)
|
||||
const generation = (await generationIterator.next()).value
|
||||
// console.log("OpenRouter generation details:", generation)
|
||||
yield {
|
||||
return {
|
||||
type: "usage",
|
||||
// cacheWriteTokens: 0,
|
||||
// cacheReadTokens: 0,
|
||||
@@ -58,6 +115,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
console.error("Error fetching OpenRouter generation details:", error)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@withRetry({ maxRetries: 4, baseDelay: 250, maxDelay: 1000, retryAllErrors: true })
|
||||
@@ -68,7 +126,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.openRouterApiKey}`,
|
||||
},
|
||||
timeout: 5_000, // this request hangs sometimes
|
||||
timeout: 15_000, // this request hangs sometimes
|
||||
})
|
||||
yield response.data?.data
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, QwenModelId, ModelInfo, qwenDefaultModelId, qwenModels } from "../../shared/api"
|
||||
import {
|
||||
ApiHandlerOptions,
|
||||
ModelInfo,
|
||||
mainlandQwenModels,
|
||||
internationalQwenModels,
|
||||
mainlandQwenDefaultModelId,
|
||||
internationalQwenDefaultModelId,
|
||||
MainlandQwenModelId,
|
||||
InternationalQwenModelId,
|
||||
} from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
@@ -21,15 +30,21 @@ export class QwenHandler implements ApiHandler {
|
||||
})
|
||||
}
|
||||
|
||||
getModel(): { id: QwenModelId; info: ModelInfo } {
|
||||
getModel(): { id: MainlandQwenModelId | InternationalQwenModelId; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in qwenModels) {
|
||||
const id = modelId as QwenModelId
|
||||
return { id, info: qwenModels[id] }
|
||||
}
|
||||
return {
|
||||
id: qwenDefaultModelId,
|
||||
info: qwenModels[qwenDefaultModelId],
|
||||
// Branch based on API line to let poor typescript know what to do
|
||||
if (this.options.qwenApiLine === "china") {
|
||||
return {
|
||||
id: (modelId as MainlandQwenModelId) ?? mainlandQwenDefaultModelId,
|
||||
info: mainlandQwenModels[modelId as MainlandQwenModelId] ?? mainlandQwenModels[mainlandQwenDefaultModelId],
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
id: (modelId as InternationalQwenModelId) ?? internationalQwenDefaultModelId,
|
||||
info:
|
||||
internationalQwenModels[modelId as InternationalQwenModelId] ??
|
||||
internationalQwenModels[internationalQwenDefaultModelId],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiHandlerOptions, ModelInfo, SambanovaModelId, sambanovaDefaultModelId, sambanovaModels } from "../../shared/api"
|
||||
import { ApiHandler } from "../index"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
|
||||
export class SambanovaHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.sambanova.ai/v1",
|
||||
apiKey: this.options.sambanovaApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const model = this.getModel()
|
||||
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
const modelId = model.id.toLowerCase()
|
||||
|
||||
if (modelId.includes("deepseek") || modelId.includes("qwen") || modelId.includes("qwq")) {
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
messages: openAiMessages,
|
||||
temperature: 0,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in sambanovaModels) {
|
||||
const id = modelId as SambanovaModelId
|
||||
return { id, info: sambanovaModels[id] }
|
||||
}
|
||||
return {
|
||||
id: sambanovaDefaultModelId,
|
||||
info: sambanovaModels[sambanovaDefaultModelId],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,21 @@ export class VertexHandler implements ApiHandler {
|
||||
break
|
||||
case "content_block_start":
|
||||
switch (chunk.content_block.type) {
|
||||
case "thinking":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.content_block.thinking || "",
|
||||
}
|
||||
break
|
||||
case "redacted_thinking":
|
||||
// Handle redacted thinking blocks - we still mark it as reasoning
|
||||
// but note that the content is encrypted
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "[Redacted thinking block]",
|
||||
}
|
||||
break
|
||||
|
||||
case "text":
|
||||
if (chunk.index > 0) {
|
||||
yield {
|
||||
@@ -178,6 +193,12 @@ export class VertexHandler implements ApiHandler {
|
||||
break
|
||||
case "content_block_delta":
|
||||
switch (chunk.delta.type) {
|
||||
case "thinking_delta":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.delta.thinking,
|
||||
}
|
||||
break
|
||||
case "text_delta":
|
||||
yield {
|
||||
type: "text",
|
||||
|
||||
@@ -6,14 +6,15 @@ import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { OpenRouterErrorResponse } from "../providers/types"
|
||||
|
||||
export async function* streamOpenRouterFormatRequest(
|
||||
export async function createOpenRouterStream(
|
||||
client: OpenAI,
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
model: { id: string; info: ModelInfo },
|
||||
o3MiniReasoningEffort?: string,
|
||||
thinkingBudgetTokens?: number,
|
||||
): AsyncGenerator<ApiStreamChunk, string | undefined, unknown> {
|
||||
openRouterProviderSorting?: string,
|
||||
) {
|
||||
// Convert Anthropic messages to OpenAI format
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
@@ -98,7 +99,12 @@ export async function* streamOpenRouterFormatRequest(
|
||||
|
||||
let temperature: number | undefined = 0
|
||||
let topP: number | undefined = undefined
|
||||
if (model.id.startsWith("deepseek/deepseek-r1") || model.id === "perplexity/sonar-reasoning") {
|
||||
if (
|
||||
model.id.startsWith("deepseek/deepseek-r1") ||
|
||||
model.id === "perplexity/sonar-reasoning" ||
|
||||
model.id === "qwen/qwq-32b:free" ||
|
||||
model.id === "qwen/qwq-32b"
|
||||
) {
|
||||
// Recommended values from DeepSeek
|
||||
temperature = 0.7
|
||||
topP = 0.95
|
||||
@@ -136,45 +142,13 @@ export async function* streamOpenRouterFormatRequest(
|
||||
top_p: topP,
|
||||
messages: openAiMessages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
transforms: shouldApplyMiddleOutTransform ? ["middle-out"] : undefined,
|
||||
include_reasoning: true,
|
||||
...(model.id === "openai/o3-mini" ? { reasoning_effort: o3MiniReasoningEffort || "medium" } : {}),
|
||||
...(reasoning ? { reasoning } : {}),
|
||||
...(openRouterProviderSorting ? { provider: { sort: openRouterProviderSorting } } : {}),
|
||||
})
|
||||
|
||||
let genId: string | undefined
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as OpenRouterErrorResponse["error"]
|
||||
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
|
||||
// Include metadata in the error message if available
|
||||
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
|
||||
throw new Error(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`)
|
||||
}
|
||||
|
||||
if (!genId && chunk.id) {
|
||||
genId = chunk.id
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
// Reasoning tokens are returned separately from the content
|
||||
if ("reasoning" in delta && delta.reasoning) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
// @ts-ignore-next-line
|
||||
reasoning: delta.reasoning,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return genId
|
||||
return stream
|
||||
}
|
||||
|
||||
+320
-98
@@ -1,6 +1,6 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import cloneDeep from "clone-deep"
|
||||
import delay from "delay"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import fs from "fs/promises"
|
||||
import getFolderSize from "get-folder-size"
|
||||
import os from "os"
|
||||
@@ -22,7 +22,7 @@ import { listFiles } from "../services/glob/list-files"
|
||||
import { regexSearchFiles } from "../services/ripgrep"
|
||||
import { parseSourceCodeForDefinitionsTopLevel } from "../services/tree-sitter"
|
||||
import { ApiConfiguration } from "../shared/api"
|
||||
import { findLast, findLastIndex } from "../shared/array"
|
||||
import { findLast, findLastIndex, parsePartialArrayString } from "../shared/array"
|
||||
import { AutoApprovalSettings } from "../shared/AutoApprovalSettings"
|
||||
import { BrowserSettings } from "../shared/BrowserSettings"
|
||||
import { ChatSettings } from "../shared/ChatSettings"
|
||||
@@ -35,8 +35,10 @@ import {
|
||||
ClineApiReqCancelReason,
|
||||
ClineApiReqInfo,
|
||||
ClineAsk,
|
||||
ClineAskQuestion,
|
||||
ClineAskUseMcpServer,
|
||||
ClineMessage,
|
||||
ClinePlanModeResponse,
|
||||
ClineSay,
|
||||
ClineSayBrowserAction,
|
||||
ClineSayTool,
|
||||
@@ -46,7 +48,7 @@ import { getApiMetrics } from "../shared/getApiMetrics"
|
||||
import { HistoryItem } from "../shared/HistoryItem"
|
||||
import { ClineAskResponse, ClineCheckpointRestore } from "../shared/WebviewMessage"
|
||||
import { calculateApiCostAnthropic } from "../utils/cost"
|
||||
import { fileExistsAtPath } from "../utils/fs"
|
||||
import { fileExistsAtPath, isDirectory } from "../utils/fs"
|
||||
import { arePathsEqual, getReadablePath } from "../utils/path"
|
||||
import { fixModelHtmlEscaping, removeInvalidChars } from "../utils/string"
|
||||
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
|
||||
@@ -55,13 +57,21 @@ import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "./ignore/ClineIgnoreCon
|
||||
import { parseMentions } from "./mentions"
|
||||
import { formatResponse } from "./prompts/responses"
|
||||
import { addUserInstructions, SYSTEM_PROMPT } from "./prompts/system"
|
||||
import { getNextTruncationRange, getTruncatedMessages } from "./sliding-window"
|
||||
import { ContextManager } from "./context-management/ContextManager"
|
||||
import { OpenAiHandler } from "../api/providers/openai"
|
||||
import { ApiStream } from "../api/transform/stream"
|
||||
import { ClineHandler } from "../api/providers/cline"
|
||||
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
|
||||
import { ClineProvider } from "./webview/ClineProvider"
|
||||
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, LanguageDisplay, LanguageKey } from "../shared/Languages"
|
||||
import { telemetryService } from "../services/telemetry/TelemetryService"
|
||||
import { ConversationTelemetryService, TelemetryChatMessage } from "../services/telemetry/ConversationTelemetryService"
|
||||
import pTimeout from "p-timeout"
|
||||
import { GlobalFileNames } from "../global-constants"
|
||||
import {
|
||||
checkIsAnthropicContextWindowError,
|
||||
checkIsOpenRouterContextWindowError,
|
||||
} from "./context-management/context-error-handling"
|
||||
import { AnthropicHandler } from "../api/providers/anthropic"
|
||||
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
|
||||
|
||||
@@ -75,6 +85,7 @@ export class Cline {
|
||||
private terminalManager: TerminalManager
|
||||
private urlContentFetcher: UrlContentFetcher
|
||||
browserSession: BrowserSession
|
||||
contextManager: ContextManager
|
||||
private didEditFile: boolean = false
|
||||
customInstructions?: string
|
||||
autoApprovalSettings: AutoApprovalSettings
|
||||
@@ -136,6 +147,7 @@ export class Cline {
|
||||
this.terminalManager = new TerminalManager()
|
||||
this.urlContentFetcher = new UrlContentFetcher(provider.context)
|
||||
this.browserSession = new BrowserSession(provider.context, browserSettings)
|
||||
this.contextManager = new ContextManager()
|
||||
this.diffViewProvider = new DiffViewProvider(cwd)
|
||||
this.customInstructions = customInstructions
|
||||
this.autoApprovalSettings = autoApprovalSettings
|
||||
@@ -294,13 +306,12 @@ export class Cline {
|
||||
break
|
||||
case "taskAndWorkspace":
|
||||
case "workspace":
|
||||
if (!this.checkpointTracker) {
|
||||
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.providerRef.deref()?.context.globalStorageUri.fsPath,
|
||||
)
|
||||
this.checkpointTrackerErrorMessage = undefined
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Failed to initialize checkpoint tracker:", errorMessage)
|
||||
@@ -410,13 +421,12 @@ export class Cline {
|
||||
}
|
||||
|
||||
// TODO: handle if this is called from outside original workspace, in which case we need to show user error message we cant show diff outside of workspace?
|
||||
if (!this.checkpointTracker) {
|
||||
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.providerRef.deref()?.context.globalStorageUri.fsPath,
|
||||
)
|
||||
this.checkpointTrackerErrorMessage = undefined
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Failed to initialize checkpoint tracker:", errorMessage)
|
||||
@@ -440,21 +450,30 @@ export class Cline {
|
||||
try {
|
||||
if (seeNewChangesSinceLastTaskCompletion) {
|
||||
// Get last task completed
|
||||
const lastTaskCompletedMessage = findLast(
|
||||
const lastTaskCompletedMessageCheckpointHash = findLast(
|
||||
this.clineMessages.slice(0, messageIndex),
|
||||
(m) => m.say === "completion_result",
|
||||
) // ask is only used to relinquish control, its the last say we care about
|
||||
)?.lastCheckpointHash // ask is only used to relinquish control, its the last say we care about
|
||||
// if undefined, then we get diff from beginning of git
|
||||
// if (!lastTaskCompletedMessage) {
|
||||
// console.error("No previous task completion message found")
|
||||
// return
|
||||
// }
|
||||
// This value *should* always exist
|
||||
const firstCheckpointMessageCheckpointHash = this.clineMessages.find(
|
||||
(m) => m.say === "checkpoint_created",
|
||||
)?.lastCheckpointHash
|
||||
|
||||
const previousCheckpointHash = lastTaskCompletedMessageCheckpointHash || firstCheckpointMessageCheckpointHash // either use the diff between the first checkpoint and the task completion, or the diff between the latest two task completions
|
||||
|
||||
if (!previousCheckpointHash) {
|
||||
vscode.window.showErrorMessage("Unexpected error: No checkpoint hash found")
|
||||
relinquishButton()
|
||||
return
|
||||
}
|
||||
|
||||
// Get changed files between current state and commit
|
||||
changedFiles = await this.checkpointTracker?.getDiffSet(
|
||||
lastTaskCompletedMessage?.lastCheckpointHash, // if undefined, then we get diff from beginning of git history, AKA when the task was started
|
||||
hash,
|
||||
)
|
||||
changedFiles = await this.checkpointTracker?.getDiffSet(previousCheckpointHash, hash)
|
||||
if (!changedFiles?.length) {
|
||||
vscode.window.showInformationMessage("No changes found")
|
||||
relinquishButton()
|
||||
@@ -517,13 +536,12 @@ export class Cline {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!this.checkpointTracker) {
|
||||
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.providerRef.deref()?.context.globalStorageUri.fsPath,
|
||||
)
|
||||
this.checkpointTrackerErrorMessage = undefined
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Failed to initialize checkpoint tracker:", errorMessage)
|
||||
@@ -535,12 +553,26 @@ export class Cline {
|
||||
const lastTaskCompletedMessage = findLast(this.clineMessages.slice(0, messageIndex), (m) => m.say === "completion_result")
|
||||
|
||||
try {
|
||||
// Get changed files between current state and commit
|
||||
const changedFiles = await this.checkpointTracker?.getDiffSet(
|
||||
lastTaskCompletedMessage?.lastCheckpointHash, // if undefined, then we get diff from beginning of git history, AKA when the task was started
|
||||
hash,
|
||||
)
|
||||
const changedFilesCount = changedFiles?.length || 0
|
||||
// Get last task completed
|
||||
const lastTaskCompletedMessageCheckpointHash = lastTaskCompletedMessage?.lastCheckpointHash // ask is only used to relinquish control, its the last say we care about
|
||||
// if undefined, then we get diff from beginning of git
|
||||
// if (!lastTaskCompletedMessage) {
|
||||
// console.error("No previous task completion message found")
|
||||
// return
|
||||
// }
|
||||
// This value *should* always exist
|
||||
const firstCheckpointMessageCheckpointHash = this.clineMessages.find(
|
||||
(m) => m.say === "checkpoint_created",
|
||||
)?.lastCheckpointHash
|
||||
|
||||
const previousCheckpointHash = lastTaskCompletedMessageCheckpointHash || firstCheckpointMessageCheckpointHash // either use the diff between the first checkpoint and the task completion, or the diff between the latest two task completions
|
||||
|
||||
if (!previousCheckpointHash) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get count of changed files between current state and commit
|
||||
const changedFilesCount = (await this.checkpointTracker?.getDiffCount(previousCheckpointHash, hash)) || 0
|
||||
if (changedFilesCount > 0) {
|
||||
return true
|
||||
}
|
||||
@@ -1183,7 +1215,7 @@ export class Cline {
|
||||
// for their associated messages to be sent to the webview, maintaining
|
||||
// the correct order of messages (although the webview is smart about
|
||||
// grouping command_output messages despite any gaps anyways)
|
||||
await delay(50)
|
||||
await setTimeoutPromise(50)
|
||||
|
||||
result = result.trim()
|
||||
|
||||
@@ -1272,13 +1304,33 @@ export class Cline {
|
||||
const clineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
|
||||
let clineRulesFileInstructions: string | undefined
|
||||
if (await fileExistsAtPath(clineRulesFilePath)) {
|
||||
try {
|
||||
const ruleFileContent = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
|
||||
if (ruleFileContent) {
|
||||
clineRulesFileInstructions = `# .clinerules\n\nThe following is provided by a root-level .clinerules file where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${ruleFileContent}`
|
||||
if (await isDirectory(clineRulesFilePath)) {
|
||||
try {
|
||||
// Read all files in the .clinerules/ directory.
|
||||
const ruleFiles = await fs
|
||||
.readdir(clineRulesFilePath, { withFileTypes: true, recursive: true })
|
||||
.then((files) => files.filter((file) => file.isFile()))
|
||||
.then((files) => files.map((file) => path.resolve(file.parentPath, file.name)))
|
||||
const ruleFileContent = await Promise.all(
|
||||
ruleFiles.map(async (file) => {
|
||||
const ruleFilePath = path.resolve(clineRulesFilePath, file)
|
||||
const ruleFilePathRelative = path.relative(cwd, ruleFilePath)
|
||||
return `${ruleFilePathRelative}\n` + (await fs.readFile(ruleFilePath, "utf8")).trim()
|
||||
}),
|
||||
).then((contents) => contents.join("\n\n"))
|
||||
clineRulesFileInstructions = `# .clinerules/\n\nThe following is provided by a root-level .clinerules/ directory where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${ruleFileContent}`
|
||||
} catch {
|
||||
console.error(`Failed to read .clinerules directory at ${clineRulesFilePath}`)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const ruleFileContent = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
|
||||
if (ruleFileContent) {
|
||||
clineRulesFileInstructions = `# .clinerules\n\nThe following is provided by a root-level .clinerules file where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${ruleFileContent}`
|
||||
}
|
||||
} catch {
|
||||
console.error(`Failed to read .clinerules file at ${clineRulesFilePath}`)
|
||||
}
|
||||
} catch {
|
||||
console.error(`Failed to read .clinerules file at ${clineRulesFilePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1303,58 +1355,38 @@ export class Cline {
|
||||
)
|
||||
}
|
||||
|
||||
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = this.clineMessages[previousApiReqIndex]
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
let contextWindow = this.api.getModel().info.contextWindow || 128_000
|
||||
// FIXME: hack to get anyone using openai compatible with deepseek to have the proper context window instead of the default 128k. We need a way for the user to specify the context window for models they input through openai compatible
|
||||
if (this.api instanceof OpenAiHandler && this.api.getModel().id.toLowerCase().includes("deepseek")) {
|
||||
contextWindow = 64_000
|
||||
}
|
||||
let maxAllowedSize: number
|
||||
switch (contextWindow) {
|
||||
case 64_000: // deepseek models
|
||||
maxAllowedSize = contextWindow - 27_000
|
||||
break
|
||||
case 128_000: // most models
|
||||
maxAllowedSize = contextWindow - 30_000
|
||||
break
|
||||
case 200_000: // claude models
|
||||
maxAllowedSize = contextWindow - 40_000
|
||||
break
|
||||
default:
|
||||
maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8) // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
|
||||
}
|
||||
|
||||
// This is the most reliable way to know when we're close to hitting the context window.
|
||||
if (totalTokens >= maxAllowedSize) {
|
||||
// Since the user may switch between models with different context windows, truncating half may not be enough (ie if switching from claude 200k to deepseek 64k, half truncation will only remove 100k tokens, but we need to remove much more)
|
||||
// So if totalTokens/2 is greater than maxAllowedSize, we truncate 3/4 instead of 1/2
|
||||
// FIXME: truncating the conversation in a way that is optimal for prompt caching AND takes into account multi-context window complexity is something we need to improve
|
||||
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
|
||||
|
||||
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
|
||||
this.conversationHistoryDeletedRange = getNextTruncationRange(
|
||||
this.apiConversationHistory,
|
||||
this.conversationHistoryDeletedRange,
|
||||
keep,
|
||||
)
|
||||
await this.saveClineMessages() // saves task history item which we use to keep track of conversation history deleted range
|
||||
// await this.overwriteApiConversationHistory(truncatedMessages)
|
||||
}
|
||||
// Capture system prompt for telemetry,
|
||||
// ONLY if user is opted in, in advanced settings
|
||||
if (this.providerRef.deref()?.conversationTelemetryService.isOptedInToConversationTelemetry()) {
|
||||
const systemMessage: TelemetryChatMessage = {
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
ts: Date.now(), // we dont uniquely identify system messages, so we use the timestamp as the id
|
||||
}
|
||||
|
||||
// no need for timeout here, as there's no timestamp to compare to
|
||||
this.providerRef.deref()?.conversationTelemetryService.captureMessage(this.taskId, systemMessage, {
|
||||
apiProvider: this.apiProvider,
|
||||
model: this.api.getModel().id,
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// conversationHistoryDeletedRange is updated only when we're close to hitting the context window, so we don't continuously break the prompt cache
|
||||
const truncatedConversationHistory = getTruncatedMessages(
|
||||
const contextManagementMetadata = this.contextManager.getNewContextMessagesAndMetadata(
|
||||
this.apiConversationHistory,
|
||||
this.clineMessages,
|
||||
this.api,
|
||||
this.conversationHistoryDeletedRange,
|
||||
previousApiReqIndex,
|
||||
)
|
||||
|
||||
let stream = this.api.createMessage(systemPrompt, truncatedConversationHistory)
|
||||
if (contextManagementMetadata.updatedConversationHistoryDeletedRange) {
|
||||
this.conversationHistoryDeletedRange = contextManagementMetadata.conversationHistoryDeletedRange
|
||||
await this.saveClineMessages() // saves task history item which we use to keep track of conversation history deleted range
|
||||
}
|
||||
|
||||
let stream = this.api.createMessage(systemPrompt, contextManagementMetadata.truncatedConversationHistory)
|
||||
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
|
||||
@@ -1366,20 +1398,59 @@ export class Cline {
|
||||
this.isWaitingForFirstChunk = false
|
||||
} catch (error) {
|
||||
const isOpenRouter = this.api instanceof OpenRouterHandler || this.api instanceof ClineHandler
|
||||
if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
|
||||
const isAnthropic = this.api instanceof AnthropicHandler
|
||||
const isOpenRouterContextWindowError = checkIsOpenRouterContextWindowError(error) && isOpenRouter
|
||||
const isAnthropicContextWindowError = checkIsAnthropicContextWindowError(error) && isAnthropic
|
||||
|
||||
if (isAnthropic && isAnthropicContextWindowError && !this.didAutomaticallyRetryFailedApiRequest) {
|
||||
this.conversationHistoryDeletedRange = this.contextManager.getNextTruncationRange(
|
||||
this.apiConversationHistory,
|
||||
this.conversationHistoryDeletedRange,
|
||||
"quarter", // Force aggressive truncation
|
||||
)
|
||||
await this.saveClineMessages()
|
||||
|
||||
this.didAutomaticallyRetryFailedApiRequest = true
|
||||
} else if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
|
||||
if (isOpenRouterContextWindowError) {
|
||||
this.conversationHistoryDeletedRange = this.contextManager.getNextTruncationRange(
|
||||
this.apiConversationHistory,
|
||||
this.conversationHistoryDeletedRange,
|
||||
"quarter", // Force aggressive truncation
|
||||
)
|
||||
await this.saveClineMessages()
|
||||
}
|
||||
|
||||
console.log("first chunk failed, waiting 1 second before retrying")
|
||||
await delay(1000)
|
||||
await setTimeoutPromise(1000)
|
||||
this.didAutomaticallyRetryFailedApiRequest = true
|
||||
} else {
|
||||
// request failed after retrying automatically once, ask user if they want to retry again
|
||||
// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
|
||||
|
||||
if (isOpenRouterContextWindowError || isAnthropicContextWindowError) {
|
||||
const truncatedConversationHistory = this.contextManager.getTruncatedMessages(
|
||||
this.apiConversationHistory,
|
||||
this.conversationHistoryDeletedRange,
|
||||
)
|
||||
|
||||
// If the conversation has more than 3 messages, we can truncate again. If not, then the conversation is bricked.
|
||||
// ToDo: Allow the user to change their input if this is the case.
|
||||
if (truncatedConversationHistory.length > 3) {
|
||||
error = new Error("Context window exceeded. Click retry to truncate the conversation and try again.")
|
||||
this.didAutomaticallyRetryFailedApiRequest = false
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = this.formatErrorWithStatusCode(error)
|
||||
|
||||
const { response } = await this.ask("api_req_failed", errorMessage)
|
||||
|
||||
if (response !== "yesButtonClicked") {
|
||||
// this will never happen since if noButtonClicked, we will clear current task, aborting this instance
|
||||
throw new Error("API request failed")
|
||||
}
|
||||
|
||||
await this.say("api_req_retried")
|
||||
}
|
||||
// delegate generator output from the recursive call
|
||||
@@ -1703,7 +1774,7 @@ export class Cline {
|
||||
`This is likely because the SEARCH block content doesn't match exactly with what's in the file, or if you used multiple SEARCH/REPLACE blocks they may not have been in the order they appear in the file.\n\n` +
|
||||
`The file was reverted to its original state:\n\n` +
|
||||
`<file_content path="${relPath.toPosix()}">\n${this.diffViewProvider.originalContent}\n</file_content>\n\n` +
|
||||
`Try again with a more precise SEARCH block.\n(If you keep running into this error, you may use the write_to_file tool as a workaround.)`,
|
||||
`Try again with fewer/more precise SEARCH blocks.\n(If you run into this error two times in a row, you may use the write_to_file tool as a fallback.)`,
|
||||
),
|
||||
)
|
||||
await this.diffViewProvider.revertChanges()
|
||||
@@ -1793,7 +1864,7 @@ export class Cline {
|
||||
await this.diffViewProvider.open(relPath)
|
||||
}
|
||||
await this.diffViewProvider.update(newContent, true)
|
||||
await delay(300) // wait for diff view to update
|
||||
await setTimeoutPromise(300) // wait for diff view to update
|
||||
this.diffViewProvider.scrollToFirstDiff()
|
||||
// showOmissionWarning(this.diffViewProvider.originalContent || "", newContent)
|
||||
|
||||
@@ -1815,7 +1886,7 @@ export class Cline {
|
||||
telemetryService.captureToolUsage(this.taskId, block.name, true, true)
|
||||
|
||||
// we need an artificial delay to let the diagnostics catch up to the changes
|
||||
await delay(3_500)
|
||||
await setTimeoutPromise(3_500)
|
||||
} else {
|
||||
// If auto-approval is enabled but this tool wasn't auto-approved, send notification
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
@@ -2677,9 +2748,14 @@ export class Cline {
|
||||
}
|
||||
case "ask_followup_question": {
|
||||
const question: string | undefined = block.params.question
|
||||
const optionsRaw: string | undefined = block.params.options
|
||||
const sharedMessage = {
|
||||
question: removeClosingTag("question", question),
|
||||
options: parsePartialArrayString(removeClosingTag("options", optionsRaw)),
|
||||
} satisfies ClineAskQuestion
|
||||
try {
|
||||
if (block.partial) {
|
||||
await this.ask("followup", removeClosingTag("question", question), block.partial).catch(() => {})
|
||||
await this.ask("followup", JSON.stringify(sharedMessage), block.partial).catch(() => {})
|
||||
break
|
||||
} else {
|
||||
if (!question) {
|
||||
@@ -2697,8 +2773,25 @@ export class Cline {
|
||||
})
|
||||
}
|
||||
|
||||
const { text, images } = await this.ask("followup", question, false)
|
||||
await this.say("user_feedback", text ?? "", images)
|
||||
const { text, images } = await this.ask("followup", JSON.stringify(sharedMessage), false)
|
||||
|
||||
// Check if options contains the text response
|
||||
if (optionsRaw && text && parsePartialArrayString(optionsRaw).includes(text)) {
|
||||
// Valid option selected, don't show user message in UI
|
||||
// Update last followup message with selected option
|
||||
const lastFollowupMessage = findLast(this.clineMessages, (m) => m.ask === "followup")
|
||||
if (lastFollowupMessage) {
|
||||
lastFollowupMessage.text = JSON.stringify({
|
||||
...sharedMessage,
|
||||
selected: text,
|
||||
} satisfies ClineAskQuestion)
|
||||
await this.saveClineMessages()
|
||||
}
|
||||
} else {
|
||||
// Option not selected, send user feedback
|
||||
await this.say("user_feedback", text ?? "", images)
|
||||
}
|
||||
|
||||
pushToolResult(formatResponse.toolResult(`<answer>\n${text}\n</answer>`, images))
|
||||
|
||||
break
|
||||
@@ -2711,11 +2804,14 @@ export class Cline {
|
||||
}
|
||||
case "plan_mode_response": {
|
||||
const response: string | undefined = block.params.response
|
||||
const optionsRaw: string | undefined = block.params.options
|
||||
const sharedMessage = {
|
||||
response: removeClosingTag("response", response),
|
||||
options: parsePartialArrayString(removeClosingTag("options", optionsRaw)),
|
||||
} satisfies ClinePlanModeResponse
|
||||
try {
|
||||
if (block.partial) {
|
||||
await this.ask("plan_mode_response", removeClosingTag("response", response), block.partial).catch(
|
||||
() => {},
|
||||
)
|
||||
await this.ask("plan_mode_response", JSON.stringify(sharedMessage), block.partial).catch(() => {})
|
||||
break
|
||||
} else {
|
||||
if (!response) {
|
||||
@@ -2734,7 +2830,7 @@ export class Cline {
|
||||
// }
|
||||
|
||||
this.isAwaitingPlanResponse = true
|
||||
let { text, images } = await this.ask("plan_mode_response", response, false)
|
||||
let { text, images } = await this.ask("plan_mode_response", JSON.stringify(sharedMessage), false)
|
||||
this.isAwaitingPlanResponse = false
|
||||
|
||||
// webview invoke sendMessage will send this marker in order to put webview into the proper state (responding to an ask) and as a flag to extension that the user switched to ACT mode.
|
||||
@@ -2742,6 +2838,25 @@ export class Cline {
|
||||
text = ""
|
||||
}
|
||||
|
||||
// Check if options contains the text response
|
||||
if (optionsRaw && text && parsePartialArrayString(optionsRaw).includes(text)) {
|
||||
// Valid option selected, don't show user message in UI
|
||||
// Update last followup message with selected option
|
||||
const lastPlanMessage = findLast(this.clineMessages, (m) => m.ask === "plan_mode_response")
|
||||
if (lastPlanMessage) {
|
||||
lastPlanMessage.text = JSON.stringify({
|
||||
...sharedMessage,
|
||||
selected: text,
|
||||
} satisfies ClinePlanModeResponse)
|
||||
await this.saveClineMessages()
|
||||
}
|
||||
} else {
|
||||
// Option not selected, send user feedback
|
||||
if (text || images?.length) {
|
||||
await this.say("user_feedback", text ?? "", images)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.didRespondToPlanAskBySwitchingMode) {
|
||||
pushToolResult(
|
||||
formatResponse.toolResult(
|
||||
@@ -2757,10 +2872,6 @@ export class Cline {
|
||||
pushToolResult(formatResponse.toolResult(`<user_message>\n${text}\n</user_message>`, images))
|
||||
}
|
||||
|
||||
if (text || images?.length) {
|
||||
await this.say("user_feedback", text ?? "", images)
|
||||
}
|
||||
|
||||
//
|
||||
break
|
||||
}
|
||||
@@ -3040,13 +3151,16 @@ export class Cline {
|
||||
// use this opportunity to initialize the checkpoint tracker (can be expensive to initialize in the constructor)
|
||||
// FIXME: right now we're letting users init checkpoints for old tasks, but this could be a problem if opening a task in the wrong workspace
|
||||
// isNewTask &&
|
||||
if (!this.checkpointTracker) {
|
||||
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.providerRef.deref()?.context.globalStorageUri.fsPath,
|
||||
this.checkpointTracker = await pTimeout(
|
||||
CheckpointTracker.create(this.taskId, this.providerRef.deref()?.context.globalStorageUri.fsPath),
|
||||
{
|
||||
milliseconds: 15_000,
|
||||
message:
|
||||
"Checkpoints taking too long to initialize. Consider re-opening Cline in a project that uses git, or disabling checkpoints.",
|
||||
},
|
||||
)
|
||||
this.checkpointTrackerErrorMessage = undefined
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Failed to initialize checkpoint tracker:", errorMessage)
|
||||
@@ -3076,6 +3190,39 @@ export class Cline {
|
||||
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, this.apiProvider, this.api.getModel().id, "user")
|
||||
|
||||
// Capture message data for telemetry,
|
||||
// ONLY if user is opted in, in advanced settings
|
||||
if (this.providerRef.deref()?.conversationTelemetryService.isOptedInToConversationTelemetry()) {
|
||||
// Get the last message from apiConversationHistory
|
||||
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
|
||||
|
||||
// Get the corresponding timestamp from clineMessages
|
||||
// The last message in clineMessages should be the one we just added
|
||||
|
||||
const lastClineMessage = this.clineMessages[this.clineMessages.length - 1]
|
||||
const ts = lastClineMessage.ts
|
||||
|
||||
// Send individual message to telemetry
|
||||
this.providerRef.deref()?.conversationTelemetryService.captureMessage(
|
||||
this.taskId,
|
||||
// Add the timestamp to the message object for telemetry
|
||||
{
|
||||
...lastMessage,
|
||||
ts,
|
||||
},
|
||||
{
|
||||
apiProvider: this.apiProvider,
|
||||
model: this.api.getModel().id,
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
},
|
||||
)
|
||||
|
||||
// Send entire conversation history to cleanup endpoint
|
||||
// This ensures deleted messages are properly handled in telemetry
|
||||
this.providerRef.deref()?.conversationTelemetryService.cleanupTask(this.taskId, this.clineMessages)
|
||||
}
|
||||
|
||||
// since we sent off a placeholder api_req_started message to update the webview while waiting to actually start the API request (to load potential details for example), we need to update the text of that message
|
||||
const lastApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started")
|
||||
this.clineMessages[lastApiReqIndex].text = JSON.stringify({
|
||||
@@ -3153,6 +3300,36 @@ export class Cline {
|
||||
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, this.apiProvider, this.api.getModel().id, "assistant")
|
||||
|
||||
// Capture message data for telemetry after assistant response
|
||||
// ONLY if user is opted in, in advanced settings
|
||||
if (this.providerRef.deref()?.conversationTelemetryService.isOptedInToConversationTelemetry()) {
|
||||
// Get the last message from apiConversationHistory
|
||||
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
|
||||
|
||||
// Find the corresponding timestamp from clineMessages
|
||||
// For assistant messages, we need to find the most recent "text" message
|
||||
const lastTextMessage = findLast(this.clineMessages, (m) => m.say === "text")
|
||||
|
||||
// Add the timestamp to the message object for telemetry
|
||||
if (!lastTextMessage) {
|
||||
console.error("No text message found in clineMessages")
|
||||
} else {
|
||||
this.providerRef.deref()?.conversationTelemetryService.captureMessage(
|
||||
this.taskId,
|
||||
{
|
||||
...lastMessage,
|
||||
ts: lastTextMessage.ts,
|
||||
},
|
||||
{
|
||||
apiProvider: this.apiProvider,
|
||||
model: this.api.getModel().id,
|
||||
tokensIn: inputTokens,
|
||||
tokensOut: outputTokens,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature
|
||||
this.didFinishAbortingStream = true
|
||||
}
|
||||
@@ -3174,6 +3351,7 @@ export class Cline {
|
||||
let assistantMessage = ""
|
||||
let reasoningMessage = ""
|
||||
this.isStreaming = true
|
||||
let didReceiveUsageChunk = false
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (!chunk) {
|
||||
@@ -3181,6 +3359,7 @@ export class Cline {
|
||||
}
|
||||
switch (chunk.type) {
|
||||
case "usage":
|
||||
didReceiveUsageChunk = true
|
||||
inputTokens += chunk.inputTokens
|
||||
outputTokens += chunk.outputTokens
|
||||
cacheWriteTokens += chunk.cacheWriteTokens ?? 0
|
||||
@@ -3250,6 +3429,23 @@ export class Cline {
|
||||
this.isStreaming = false
|
||||
}
|
||||
|
||||
// OpenRouter/Cline may not return token usage as part of the stream (since it may abort early), so we fetch after the stream is finished
|
||||
// (updateApiReq below will update the api_req_started message with the usage details. we do this async so it updates the api_req_started message in the background)
|
||||
if (!didReceiveUsageChunk) {
|
||||
this.api.getApiStreamUsage?.().then(async (apiStreamUsage) => {
|
||||
if (apiStreamUsage) {
|
||||
inputTokens += apiStreamUsage.inputTokens
|
||||
outputTokens += apiStreamUsage.outputTokens
|
||||
cacheWriteTokens += apiStreamUsage.cacheWriteTokens ?? 0
|
||||
cacheReadTokens += apiStreamUsage.cacheReadTokens ?? 0
|
||||
totalCost = apiStreamUsage.totalCost
|
||||
}
|
||||
updateApiReqMsg()
|
||||
await this.saveClineMessages()
|
||||
await this.providerRef.deref()?.postStateToWebview()
|
||||
})
|
||||
}
|
||||
|
||||
// need to call here in case the stream was aborted
|
||||
if (this.abort) {
|
||||
throw new Error("Cline instance aborted")
|
||||
@@ -3283,6 +3479,32 @@ export class Cline {
|
||||
content: [{ type: "text", text: assistantMessage }],
|
||||
})
|
||||
|
||||
// Capture message data for telemetry after assistant response,
|
||||
// ONLY if user is opted in, in advanced settings
|
||||
if (this.providerRef.deref()?.conversationTelemetryService.isOptedInToConversationTelemetry()) {
|
||||
// Get the last message from apiConversationHistory
|
||||
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
|
||||
|
||||
// Find the corresponding timestamp from clineMessages
|
||||
const lastClineMessage = this.clineMessages[this.clineMessages.length - 1]
|
||||
|
||||
if (lastClineMessage) {
|
||||
this.providerRef.deref()?.conversationTelemetryService.captureMessage(
|
||||
this.taskId,
|
||||
{
|
||||
...lastMessage,
|
||||
ts: lastClineMessage.ts,
|
||||
},
|
||||
{
|
||||
apiProvider: this.apiProvider,
|
||||
model: this.api.getModel().id,
|
||||
tokensIn: inputTokens,
|
||||
tokensOut: outputTokens,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: this comment is here for future reference - this was a workaround for userMessageContent not getting set to true. It was due to it not recursively calling for partial blocks when didRejectTool, so it would get stuck waiting for a partial block to complete before it could continue.
|
||||
// in case the content blocks finished
|
||||
// it may be the api stream finished after the last parsed content block was executed, so we are able to detect out of bounds and set userMessageContentReady to true (note you should not call presentAssistantMessage since if the last block is completed it will be presented again)
|
||||
@@ -3406,7 +3628,7 @@ export class Cline {
|
||||
|
||||
if (busyTerminals.length > 0 && this.didEditFile) {
|
||||
// || this.didEditFile
|
||||
await delay(300) // delay after saving file to let terminals catch up
|
||||
await setTimeoutPromise(300) // delay after saving file to let terminals catch up
|
||||
}
|
||||
|
||||
// let terminalWasBusy = false
|
||||
@@ -3520,7 +3742,7 @@ export class Cline {
|
||||
details +=
|
||||
"\nIn this mode you should focus on information gathering, asking questions, and architecting a solution. Once you have a plan, use the plan_mode_response tool to engage in a conversational back and forth with the user. Do not use the plan_mode_response tool until you've gathered all the information you need e.g. with read_file or ask_followup_question."
|
||||
details +=
|
||||
'\n(Remember: If it seems the user wants you to use tools only available in Act Mode, you should ask the user to "toggle to Act mode" (use those words) - they will have to manually do this themselves with the Plan/Act toggle button below. You do not have the ability to switch to Act Mode yourself, and must wait for the user to do it themselves once they are satisfied with the plan.)'
|
||||
'\n(Remember: If it seems the user wants you to use tools only available in Act Mode, you should ask the user to "toggle to Act mode" (use those words) - they will have to manually do this themselves with the Plan/Act toggle button below. You do not have the ability to switch to Act Mode yourself, and must wait for the user to do it themselves once they are satisfied with the plan. You also cannot present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.)'
|
||||
} else {
|
||||
details += "\nACT MODE"
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export const toolParamNames = [
|
||||
"arguments",
|
||||
"uri",
|
||||
"question",
|
||||
"options",
|
||||
"response",
|
||||
"result",
|
||||
] as const
|
||||
@@ -58,64 +59,3 @@ export interface ToolUse {
|
||||
params: Partial<Record<ToolParamName, string>>
|
||||
partial: boolean
|
||||
}
|
||||
|
||||
export interface ExecuteCommandToolUse extends ToolUse {
|
||||
name: "execute_command"
|
||||
// Pick<Record<ToolParamName, string>, "command"> makes "command" required, but Partial<> makes it optional
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "command" | "requires_approval">>
|
||||
}
|
||||
|
||||
export interface ReadFileToolUse extends ToolUse {
|
||||
name: "read_file"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path">>
|
||||
}
|
||||
|
||||
export interface WriteToFileToolUse extends ToolUse {
|
||||
name: "write_to_file"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path" | "content">>
|
||||
}
|
||||
|
||||
export interface ReplaceInFileToolUse extends ToolUse {
|
||||
name: "replace_in_file"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path" | "diff">>
|
||||
}
|
||||
|
||||
export interface SearchFilesToolUse extends ToolUse {
|
||||
name: "search_files"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path" | "regex" | "file_pattern">>
|
||||
}
|
||||
|
||||
export interface ListFilesToolUse extends ToolUse {
|
||||
name: "list_files"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path" | "recursive">>
|
||||
}
|
||||
|
||||
export interface ListCodeDefinitionNamesToolUse extends ToolUse {
|
||||
name: "list_code_definition_names"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path">>
|
||||
}
|
||||
|
||||
export interface BrowserActionToolUse extends ToolUse {
|
||||
name: "browser_action"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "action" | "url" | "coordinate" | "text">>
|
||||
}
|
||||
|
||||
export interface UseMcpToolToolUse extends ToolUse {
|
||||
name: "use_mcp_tool"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "tool_name" | "arguments">>
|
||||
}
|
||||
|
||||
export interface AccessMcpResourceToolUse extends ToolUse {
|
||||
name: "access_mcp_resource"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "uri">>
|
||||
}
|
||||
|
||||
export interface AskFollowupQuestionToolUse extends ToolUse {
|
||||
name: "ask_followup_question"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "question">>
|
||||
}
|
||||
|
||||
export interface AttemptCompletionToolUse extends ToolUse {
|
||||
name: "attempt_completion"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "result" | "command">>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ContextManager } from "./ContextManager"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { expect } from "chai"
|
||||
|
||||
describe("ContextManager", () => {
|
||||
function createMessages(count: number): Anthropic.Messages.MessageParam[] {
|
||||
const messages: Anthropic.Messages.MessageParam[] = []
|
||||
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: "Initial task message",
|
||||
})
|
||||
|
||||
let role: "user" | "assistant" = "assistant"
|
||||
for (let i = 1; i < count; i++) {
|
||||
messages.push({
|
||||
role,
|
||||
content: `Message ${i}`,
|
||||
})
|
||||
role = role === "user" ? "assistant" : "user"
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
describe("getNextTruncationRange", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("first truncation with half keep", () => {
|
||||
const messages = createMessages(11)
|
||||
const result = contextManager.getNextTruncationRange(messages)
|
||||
|
||||
expect(result).to.deep.equal([1, 4])
|
||||
})
|
||||
|
||||
it("first truncation with quarter keep", () => {
|
||||
const messages = createMessages(11)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "quarter")
|
||||
|
||||
expect(result).to.deep.equal([1, 6])
|
||||
})
|
||||
|
||||
it("sequential truncation with half keep", () => {
|
||||
const messages = createMessages(21)
|
||||
const firstRange = contextManager.getNextTruncationRange(messages)
|
||||
expect(firstRange).to.deep.equal([1, 10])
|
||||
|
||||
// Pass the previous range for sequential truncation
|
||||
const secondRange = contextManager.getNextTruncationRange(messages, firstRange)
|
||||
expect(secondRange).to.deep.equal([1, 14])
|
||||
})
|
||||
|
||||
it("sequential truncation with quarter keep", () => {
|
||||
const messages = createMessages(41)
|
||||
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "quarter")
|
||||
|
||||
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "quarter")
|
||||
|
||||
expect(secondRange[0]).to.equal(1)
|
||||
expect(secondRange[1]).to.be.greaterThan(firstRange[1])
|
||||
})
|
||||
|
||||
it("ensures the last message in range is a user message", () => {
|
||||
const messages = createMessages(14)
|
||||
const result = contextManager.getNextTruncationRange(messages)
|
||||
|
||||
// Check if the message at the end of range is a user message
|
||||
const lastRemovedMessage = messages[result[1]]
|
||||
expect(lastRemovedMessage.role).to.equal("user")
|
||||
|
||||
// Check if the next message after the range is an assistant message
|
||||
const nextMessage = messages[result[1] + 1]
|
||||
expect(nextMessage.role).to.equal("assistant")
|
||||
})
|
||||
|
||||
it("handles small message arrays", () => {
|
||||
const messages = createMessages(3)
|
||||
const result = contextManager.getNextTruncationRange(messages)
|
||||
|
||||
expect(result).to.deep.equal([1, 0])
|
||||
})
|
||||
|
||||
it("preserves the message structure when truncating", () => {
|
||||
const messages = createMessages(20)
|
||||
const result = contextManager.getNextTruncationRange(messages)
|
||||
|
||||
// Get messages after removing the range
|
||||
const effectiveMessages = [...messages.slice(0, result[0]), ...messages.slice(result[1] + 1)]
|
||||
|
||||
// Check first message and alternating pattern
|
||||
expect(effectiveMessages[0].role).to.equal("user")
|
||||
for (let i = 1; i < effectiveMessages.length; i++) {
|
||||
const expectedRole = i % 2 === 1 ? "assistant" : "user"
|
||||
expect(effectiveMessages[i].role).to.equal(expectedRole)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTruncatedMessages", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("returns original messages when no range is provided", () => {
|
||||
const messages = createMessages(3)
|
||||
|
||||
const result = contextManager.getTruncatedMessages(messages, undefined)
|
||||
expect(result).to.deep.equal(messages)
|
||||
})
|
||||
|
||||
it("correctly removes messages in the specified range", () => {
|
||||
const messages = createMessages(5)
|
||||
|
||||
const range: [number, number] = [1, 3]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(2)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[4])
|
||||
})
|
||||
|
||||
it("works with a range that starts at the first message after task", () => {
|
||||
const messages = createMessages(4)
|
||||
|
||||
const range: [number, number] = [1, 2]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(2)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[3])
|
||||
})
|
||||
|
||||
it("correctly handles removing a range while preserving alternation pattern", () => {
|
||||
const messages = createMessages(5)
|
||||
|
||||
const range: [number, number] = [1, 2]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(3)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[3])
|
||||
expect(result[2]).to.deep.equal(messages[4])
|
||||
|
||||
expect(result[0].role).to.equal("user")
|
||||
expect(result[1].role).to.equal("assistant")
|
||||
expect(result[2].role).to.equal("user")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ClineApiReqInfo, ClineMessage } from "../../shared/ExtensionMessage"
|
||||
import { ApiHandler } from "../../api"
|
||||
import { OpenAiHandler } from "../../api/providers/openai"
|
||||
|
||||
export class ContextManager {
|
||||
getNewContextMessagesAndMetadata(
|
||||
apiConversationHistory: Anthropic.Messages.MessageParam[],
|
||||
clineMessages: ClineMessage[],
|
||||
api: ApiHandler,
|
||||
conversationHistoryDeletedRange: [number, number] | undefined,
|
||||
previousApiReqIndex: number,
|
||||
) {
|
||||
let updatedConversationHistoryDeletedRange = false
|
||||
|
||||
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = clineMessages[previousApiReqIndex]
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
let contextWindow = api.getModel().info.contextWindow || 128_000
|
||||
// FIXME: hack to get anyone using openai compatible with deepseek to have the proper context window instead of the default 128k. We need a way for the user to specify the context window for models they input through openai compatible
|
||||
if (api instanceof OpenAiHandler && api.getModel().id.toLowerCase().includes("deepseek")) {
|
||||
contextWindow = 64_000
|
||||
}
|
||||
let maxAllowedSize: number
|
||||
switch (contextWindow) {
|
||||
case 64_000: // deepseek models
|
||||
maxAllowedSize = contextWindow - 27_000
|
||||
break
|
||||
case 128_000: // most models
|
||||
maxAllowedSize = contextWindow - 30_000
|
||||
break
|
||||
case 200_000: // claude models
|
||||
maxAllowedSize = contextWindow - 40_000
|
||||
break
|
||||
default:
|
||||
maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8) // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
|
||||
}
|
||||
|
||||
// This is the most reliable way to know when we're close to hitting the context window.
|
||||
if (totalTokens >= maxAllowedSize) {
|
||||
// Since the user may switch between models with different context windows, truncating half may not be enough (ie if switching from claude 200k to deepseek 64k, half truncation will only remove 100k tokens, but we need to remove much more)
|
||||
// So if totalTokens/2 is greater than maxAllowedSize, we truncate 3/4 instead of 1/2
|
||||
// FIXME: truncating the conversation in a way that is optimal for prompt caching AND takes into account multi-context window complexity is something we need to improve
|
||||
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
|
||||
|
||||
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
|
||||
conversationHistoryDeletedRange = this.getNextTruncationRange(
|
||||
apiConversationHistory,
|
||||
conversationHistoryDeletedRange,
|
||||
keep,
|
||||
)
|
||||
|
||||
updatedConversationHistoryDeletedRange = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// conversationHistoryDeletedRange is updated only when we're close to hitting the context window, so we don't continuously break the prompt cache
|
||||
const truncatedConversationHistory = this.getTruncatedMessages(apiConversationHistory, conversationHistoryDeletedRange)
|
||||
|
||||
return {
|
||||
conversationHistoryDeletedRange: conversationHistoryDeletedRange,
|
||||
updatedConversationHistoryDeletedRange: updatedConversationHistoryDeletedRange,
|
||||
truncatedConversationHistory: truncatedConversationHistory,
|
||||
}
|
||||
}
|
||||
|
||||
public getNextTruncationRange(
|
||||
apiMessages: Anthropic.Messages.MessageParam[],
|
||||
currentDeletedRange: [number, number] | undefined,
|
||||
keep: "half" | "quarter",
|
||||
): [number, number] {
|
||||
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
|
||||
const rangeStartIndex = 1
|
||||
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
|
||||
|
||||
let messagesToRemove: number
|
||||
if (keep === "half") {
|
||||
// Remove half of remaining user-assistant pairs
|
||||
// We first calculate half of the messages then divide by 2 to get the number of pairs.
|
||||
// After flooring, we multiply by 2 to get the number of messages.
|
||||
// Note that this will also always be an even number.
|
||||
messagesToRemove = Math.floor((apiMessages.length - startOfRest) / 4) * 2 // Keep even number
|
||||
} else {
|
||||
// Remove 3/4 of remaining user-assistant pairs
|
||||
// We calculate 3/4ths of the messages then divide by 2 to get the number of pairs.
|
||||
// After flooring, we multiply by 2 to get the number of messages.
|
||||
// Note that this will also always be an even number.
|
||||
messagesToRemove = Math.floor(((apiMessages.length - startOfRest) * 3) / 4 / 2) * 2
|
||||
}
|
||||
|
||||
let rangeEndIndex = startOfRest + messagesToRemove - 1
|
||||
|
||||
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
|
||||
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
|
||||
if (apiMessages[rangeEndIndex].role !== "user") {
|
||||
rangeEndIndex -= 1
|
||||
}
|
||||
|
||||
// this is an inclusive range that will be removed from the conversation history
|
||||
return [rangeStartIndex, rangeEndIndex]
|
||||
}
|
||||
|
||||
public getTruncatedMessages(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
deletedRange: [number, number] | undefined,
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
if (!deletedRange) {
|
||||
return messages
|
||||
}
|
||||
|
||||
const [start, end] = deletedRange
|
||||
// the range is inclusive - both start and end indices and everything in between will be removed from the final result.
|
||||
// NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
|
||||
return [...messages.slice(0, start), ...messages.slice(end + 1)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function checkIsOpenRouterContextWindowError(error: any): boolean {
|
||||
return error.code === 400 && error.message?.includes("context length")
|
||||
}
|
||||
|
||||
export function checkIsAnthropicContextWindowError(response: any): boolean {
|
||||
return (
|
||||
response?.error?.error?.type === "invalid_request_error" &&
|
||||
response?.error?.error?.message?.includes("prompt is too long")
|
||||
)
|
||||
}
|
||||
@@ -72,7 +72,10 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
|
||||
}
|
||||
}
|
||||
|
||||
for (const mention of mentions) {
|
||||
// Filter out duplicate mentions while preserving order
|
||||
const uniqueMentions = Array.from(new Set(mentions))
|
||||
|
||||
for (const mention of uniqueMentions) {
|
||||
if (mention.startsWith("http")) {
|
||||
let result: string
|
||||
if (launchBrowserError) {
|
||||
|
||||
@@ -216,9 +216,13 @@ Usage:
|
||||
Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
|
||||
Parameters:
|
||||
- question: (required) The question to ask the user. This should be a clear, specific question that addresses the information you need.
|
||||
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible answer. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually.
|
||||
Usage:
|
||||
<ask_followup_question>
|
||||
<question>Your question here</question>
|
||||
<options>
|
||||
Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
|
||||
</options>
|
||||
</ask_followup_question>
|
||||
|
||||
## attempt_completion
|
||||
@@ -239,9 +243,13 @@ Your final result description here
|
||||
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should be used when you need to provide a response to a question or statement from the user about how you plan to accomplish the task. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN MODE then you should not use this tool. Depending on the user's message, you may ask questions to get clarification about the user's request, architect a solution to the task, and to brainstorm ideas with the user. For example, if the user's task is to create a website, you may start by asking some clarifying questions, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT MODE to implement the solution.
|
||||
Parameters:
|
||||
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response.
|
||||
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible choice or path forward in the planning process. This can help guide the discussion and make it easier for the user to provide input on key decisions. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually. Do NOT present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.
|
||||
Usage:
|
||||
<plan_mode_response>
|
||||
<response>Your response here</response>
|
||||
<options>
|
||||
Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
|
||||
</options>
|
||||
</plan_mode_response>
|
||||
|
||||
# Tool Use Examples
|
||||
@@ -813,7 +821,7 @@ You have access to two tools for working with files: **write_to_file** and **rep
|
||||
|
||||
## Important Considerations
|
||||
|
||||
- Using write_to_file requires providing the file’s complete final content.
|
||||
- Using write_to_file requires providing the file's complete final content.
|
||||
- If you only need to make small changes to an existing file, consider using replace_in_file instead to avoid unnecessarily rewriting the entire file.
|
||||
- While write_to_file should not be your default choice, don't hesitate to use it when the situation truly calls for it.
|
||||
|
||||
@@ -826,12 +834,12 @@ You have access to two tools for working with files: **write_to_file** and **rep
|
||||
## When to Use
|
||||
|
||||
- Small, localized changes like updating a few lines, function implementations, changing variable names, modifying a section of text, etc.
|
||||
- Targeted improvements where only specific portions of the file’s content needs to be altered.
|
||||
- Targeted improvements where only specific portions of the file's content needs to be altered.
|
||||
- Especially useful for long files where much of the file will remain unchanged.
|
||||
|
||||
## Advantages
|
||||
|
||||
- More efficient for minor edits, since you don’t need to supply the entire file content.
|
||||
- More efficient for minor edits, since you don't need to supply the entire file content.
|
||||
- Reduces the chance of errors that can occur when overwriting large files.
|
||||
|
||||
# Choosing the Appropriate Tool
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
/*
|
||||
We can't implement a dynamically updating sliding window as it would break prompt cache
|
||||
every time. To maintain the benefits of caching, we need to keep conversation history
|
||||
static. This operation should be performed as infrequently as possible. If a user reaches
|
||||
a 200k context, we can assume that the first half is likely irrelevant to their current task.
|
||||
Therefore, this function should only be called when absolutely necessary to fit within
|
||||
context limits, not as a continuous process.
|
||||
*/
|
||||
// export function truncateHalfConversation(
|
||||
// messages: Anthropic.Messages.MessageParam[],
|
||||
// ): Anthropic.Messages.MessageParam[] {
|
||||
// // API expects messages to be in user-assistant order, and tool use messages must be followed by tool results. We need to maintain this structure while truncating.
|
||||
|
||||
// // Always keep the first Task message (this includes the project's file structure in environment_details)
|
||||
// const truncatedMessages = [messages[0]]
|
||||
|
||||
// // Remove half of user-assistant pairs
|
||||
// const messagesToRemove = Math.floor(messages.length / 4) * 2 // has to be even number
|
||||
|
||||
// const remainingMessages = messages.slice(messagesToRemove + 1) // has to start with assistant message since tool result cannot follow assistant message with no tool use
|
||||
// truncatedMessages.push(...remainingMessages)
|
||||
|
||||
// return truncatedMessages
|
||||
// }
|
||||
|
||||
/*
|
||||
getNextTruncationRange: Calculates the next range of messages to be "deleted"
|
||||
- Takes the full messages array and optional current deleted range
|
||||
- Always preserves the first message (task message)
|
||||
- Removes 1/2 of remaining messages (rounded down to even number) after current deleted range
|
||||
- Returns [startIndex, endIndex] representing inclusive range to delete
|
||||
|
||||
getTruncatedMessages: Constructs the truncated array using the deleted range
|
||||
- Takes full messages array and optional deleted range
|
||||
- Returns new array with messages in deleted range removed
|
||||
- Preserves order and structure of remaining messages
|
||||
|
||||
The range is represented as [startIndex, endIndex] where both indices are inclusive
|
||||
The functions maintain the original array integrity while allowing progressive truncation
|
||||
through the deletedRange parameter
|
||||
|
||||
Usage example:
|
||||
const messages = [user1, assistant1, user2, assistant2, user3, assistant3];
|
||||
let deletedRange = getNextTruncationRange(messages); // [1,2] (assistant1,user2)
|
||||
let truncated = getTruncatedMessages(messages, deletedRange);
|
||||
// [user1, assistant2, user3, assistant3]
|
||||
|
||||
deletedRange = getNextTruncationRange(messages, deletedRange); // [2,3] (assistant2,user3)
|
||||
truncated = getTruncatedMessages(messages, deletedRange);
|
||||
// [user1, assistant3]
|
||||
*/
|
||||
|
||||
export function getNextTruncationRange(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
currentDeletedRange: [number, number] | undefined = undefined,
|
||||
keep: "half" | "quarter" = "half",
|
||||
): [number, number] {
|
||||
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
|
||||
const rangeStartIndex = 1
|
||||
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
|
||||
|
||||
let messagesToRemove: number
|
||||
if (keep === "half") {
|
||||
// Remove half of user-assistant pairs
|
||||
messagesToRemove = Math.floor((messages.length - startOfRest) / 4) * 2 // Keep even number
|
||||
} else {
|
||||
// Remove 3/4 of user-assistant pairs
|
||||
messagesToRemove = Math.floor((messages.length - startOfRest) / 8) * 3 * 2
|
||||
}
|
||||
|
||||
let rangeEndIndex = startOfRest + messagesToRemove - 1
|
||||
|
||||
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
|
||||
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
|
||||
if (messages[rangeEndIndex].role !== "user") {
|
||||
rangeEndIndex -= 1
|
||||
}
|
||||
|
||||
// this is an inclusive range that will be removed from the conversation history
|
||||
return [rangeStartIndex, rangeEndIndex]
|
||||
}
|
||||
|
||||
export function getTruncatedMessages(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
deletedRange: [number, number] | undefined,
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
if (!deletedRange) {
|
||||
return messages
|
||||
}
|
||||
|
||||
const [start, end] = deletedRange
|
||||
// the range is inclusive - both start and end indices and everything in between will be removed from the final result.
|
||||
// NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
|
||||
return [...messages.slice(0, start), ...messages.slice(end + 1)]
|
||||
}
|
||||
+471
-205
@@ -14,9 +14,10 @@ import { fetchOpenGraphData, isImageUrl } from "../../integrations/misc/link-pre
|
||||
import { selectImages } from "../../integrations/misc/process-images"
|
||||
import { getTheme } from "../../integrations/theme/getTheme"
|
||||
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
|
||||
import { ClineAccountService } from "../../services/account/ClineAccountService"
|
||||
import { McpHub } from "../../services/mcp/McpHub"
|
||||
import { UserInfo } from "../../shared/UserInfo"
|
||||
import { ApiProvider, ModelInfo } from "../../shared/api"
|
||||
import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
|
||||
import { findLast } from "../../shared/array"
|
||||
import { AutoApprovalSettings, DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../shared/AutoApprovalSettings"
|
||||
import { BrowserSettings, DEFAULT_BROWSER_SETTINGS } from "../../shared/BrowserSettings"
|
||||
@@ -36,6 +37,10 @@ import { telemetryService } from "../../services/telemetry/TelemetryService"
|
||||
import { TelemetrySetting } from "../../shared/TelemetrySetting"
|
||||
import { cleanupLegacyCheckpoints } from "../../integrations/checkpoints/CheckpointMigration"
|
||||
import CheckpointTracker from "../../integrations/checkpoints/CheckpointTracker"
|
||||
import { getTotalTasksSize } from "../../utils/storage"
|
||||
import { ConversationTelemetryService } from "../../services/telemetry/ConversationTelemetryService"
|
||||
import { GlobalFileNames } from "../../global-constants"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
|
||||
/*
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
@@ -62,12 +67,14 @@ type SecretKey =
|
||||
| "authNonce"
|
||||
| "asksageApiKey"
|
||||
| "xaiApiKey"
|
||||
| "sambanovaApiKey"
|
||||
type GlobalStateKey =
|
||||
| "apiProvider"
|
||||
| "apiModelId"
|
||||
| "awsRegion"
|
||||
| "awsUseCrossRegionInference"
|
||||
| "awsBedrockUsePromptCache"
|
||||
| "awsBedrockEndpoint"
|
||||
| "awsProfile"
|
||||
| "awsUseProfile"
|
||||
| "vertexProjectId"
|
||||
@@ -87,6 +94,7 @@ type GlobalStateKey =
|
||||
| "azureApiVersion"
|
||||
| "openRouterModelId"
|
||||
| "openRouterModelInfo"
|
||||
| "openRouterProviderSorting"
|
||||
| "autoApprovalSettings"
|
||||
| "browserSettings"
|
||||
| "chatSettings"
|
||||
@@ -95,6 +103,7 @@ type GlobalStateKey =
|
||||
| "previousModeApiProvider"
|
||||
| "previousModeModelId"
|
||||
| "previousModeThinkingBudgetTokens"
|
||||
| "previousModeVsCodeLmModelSelector"
|
||||
| "previousModeModelInfo"
|
||||
| "liteLlmBaseUrl"
|
||||
| "liteLlmModelId"
|
||||
@@ -105,14 +114,7 @@ type GlobalStateKey =
|
||||
| "telemetrySetting"
|
||||
| "asksageApiUrl"
|
||||
| "thinkingBudgetTokens"
|
||||
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
uiMessages: "ui_messages.json",
|
||||
openRouterModels: "openrouter_models.json",
|
||||
mcpSettings: "cline_mcp_settings.json",
|
||||
clineRules: ".clinerules",
|
||||
}
|
||||
| "planActSeparateModelsSetting"
|
||||
|
||||
export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
|
||||
@@ -123,7 +125,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
private cline?: Cline
|
||||
workspaceTracker?: WorkspaceTracker
|
||||
mcpHub?: McpHub
|
||||
private latestAnnouncementId = "feb-19-2025" // update to some unique identifier when we add a new announcement
|
||||
accountService?: ClineAccountService
|
||||
private latestAnnouncementId = "march-22-2025" // update to some unique identifier when we add a new announcement
|
||||
conversationTelemetryService: ConversationTelemetryService
|
||||
|
||||
constructor(
|
||||
readonly context: vscode.ExtensionContext,
|
||||
@@ -133,6 +137,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
ClineProvider.activeInstances.add(this)
|
||||
this.workspaceTracker = new WorkspaceTracker(this)
|
||||
this.mcpHub = new McpHub(this)
|
||||
this.accountService = new ClineAccountService(this)
|
||||
this.conversationTelemetryService = new ConversationTelemetryService(this)
|
||||
|
||||
// Clean up legacy checkpoints
|
||||
cleanupLegacyCheckpoints(this.context.globalStorageUri.fsPath, this.outputChannel).catch((error) => {
|
||||
@@ -163,6 +169,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
this.workspaceTracker = undefined
|
||||
this.mcpHub?.dispose()
|
||||
this.mcpHub = undefined
|
||||
this.accountService = undefined
|
||||
this.conversationTelemetryService.shutdown()
|
||||
this.outputChannel.appendLine("Disposed all disposables")
|
||||
ClineProvider.activeInstances.delete(this)
|
||||
}
|
||||
@@ -544,109 +552,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
break
|
||||
case "apiConfiguration":
|
||||
if (message.apiConfiguration) {
|
||||
const {
|
||||
apiProvider,
|
||||
apiModelId,
|
||||
apiKey,
|
||||
openRouterApiKey,
|
||||
awsAccessKey,
|
||||
awsSecretKey,
|
||||
awsSessionToken,
|
||||
awsRegion,
|
||||
awsUseCrossRegionInference,
|
||||
awsBedrockUsePromptCache,
|
||||
awsProfile,
|
||||
awsUseProfile,
|
||||
vertexProjectId,
|
||||
vertexRegion,
|
||||
openAiBaseUrl,
|
||||
openAiApiKey,
|
||||
openAiModelId,
|
||||
openAiModelInfo,
|
||||
ollamaModelId,
|
||||
ollamaBaseUrl,
|
||||
ollamaApiOptionsCtxNum,
|
||||
lmStudioModelId,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
requestyApiKey,
|
||||
requestyModelId,
|
||||
togetherApiKey,
|
||||
togetherModelId,
|
||||
qwenApiKey,
|
||||
mistralApiKey,
|
||||
azureApiVersion,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
vsCodeLmModelSelector,
|
||||
liteLlmBaseUrl,
|
||||
liteLlmModelId,
|
||||
liteLlmApiKey,
|
||||
qwenApiLine,
|
||||
asksageApiKey,
|
||||
asksageApiUrl,
|
||||
xaiApiKey,
|
||||
thinkingBudgetTokens,
|
||||
clineApiKey,
|
||||
} = message.apiConfiguration
|
||||
await this.updateGlobalState("apiProvider", apiProvider)
|
||||
await this.updateGlobalState("apiModelId", apiModelId)
|
||||
await this.storeSecret("apiKey", apiKey)
|
||||
await this.storeSecret("openRouterApiKey", openRouterApiKey)
|
||||
await this.storeSecret("awsAccessKey", awsAccessKey)
|
||||
await this.storeSecret("awsSecretKey", awsSecretKey)
|
||||
await this.storeSecret("awsSessionToken", awsSessionToken)
|
||||
await this.updateGlobalState("awsRegion", awsRegion)
|
||||
await this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference)
|
||||
await this.updateGlobalState("awsBedrockUsePromptCache", awsBedrockUsePromptCache)
|
||||
await this.updateGlobalState("awsProfile", awsProfile)
|
||||
await this.updateGlobalState("awsUseProfile", awsUseProfile)
|
||||
await this.updateGlobalState("vertexProjectId", vertexProjectId)
|
||||
await this.updateGlobalState("vertexRegion", vertexRegion)
|
||||
await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl)
|
||||
await this.storeSecret("openAiApiKey", openAiApiKey)
|
||||
await this.updateGlobalState("openAiModelId", openAiModelId)
|
||||
await this.updateGlobalState("openAiModelInfo", openAiModelInfo)
|
||||
await this.updateGlobalState("ollamaModelId", ollamaModelId)
|
||||
await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl)
|
||||
await this.updateGlobalState("ollamaApiOptionsCtxNum", ollamaApiOptionsCtxNum)
|
||||
await this.updateGlobalState("lmStudioModelId", lmStudioModelId)
|
||||
await this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl)
|
||||
await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl)
|
||||
await this.storeSecret("geminiApiKey", geminiApiKey)
|
||||
await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey)
|
||||
await this.storeSecret("deepSeekApiKey", deepSeekApiKey)
|
||||
await this.storeSecret("requestyApiKey", requestyApiKey)
|
||||
await this.storeSecret("togetherApiKey", togetherApiKey)
|
||||
await this.storeSecret("qwenApiKey", qwenApiKey)
|
||||
await this.storeSecret("mistralApiKey", mistralApiKey)
|
||||
await this.storeSecret("liteLlmApiKey", liteLlmApiKey)
|
||||
await this.storeSecret("xaiApiKey", xaiApiKey)
|
||||
await this.updateGlobalState("azureApiVersion", azureApiVersion)
|
||||
await this.updateGlobalState("openRouterModelId", openRouterModelId)
|
||||
await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
|
||||
await this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector)
|
||||
await this.updateGlobalState("liteLlmBaseUrl", liteLlmBaseUrl)
|
||||
await this.updateGlobalState("liteLlmModelId", liteLlmModelId)
|
||||
await this.updateGlobalState("qwenApiLine", qwenApiLine)
|
||||
await this.updateGlobalState("requestyModelId", requestyModelId)
|
||||
await this.updateGlobalState("togetherModelId", togetherModelId)
|
||||
await this.storeSecret("asksageApiKey", asksageApiKey)
|
||||
await this.updateGlobalState("asksageApiUrl", asksageApiUrl)
|
||||
await this.updateGlobalState("thinkingBudgetTokens", thinkingBudgetTokens)
|
||||
await this.storeSecret("clineApiKey", clineApiKey)
|
||||
if (this.cline) {
|
||||
this.cline.api = buildApiHandler(message.apiConfiguration)
|
||||
}
|
||||
await this.updateApiConfiguration(message.apiConfiguration)
|
||||
}
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "customInstructions":
|
||||
await this.updateCustomInstructions(message.text)
|
||||
break
|
||||
case "autoApprovalSettings":
|
||||
if (message.autoApprovalSettings) {
|
||||
await this.updateGlobalState("autoApprovalSettings", message.autoApprovalSettings)
|
||||
@@ -670,6 +579,13 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
await this.togglePlanActModeWithChatSettings(message.chatSettings, message.chatContent)
|
||||
}
|
||||
break
|
||||
case "optionsResponse":
|
||||
await this.postMessageToWebview({
|
||||
type: "invoke",
|
||||
invoke: "sendMessage",
|
||||
text: message.text,
|
||||
})
|
||||
break
|
||||
// case "relaunchChromeDebugMode":
|
||||
// if (this.cline) {
|
||||
// this.cline.browserSession.relaunchChromeDebugMode()
|
||||
@@ -815,6 +731,14 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
await this.handleSignOut()
|
||||
break
|
||||
}
|
||||
case "showAccountViewClicked": {
|
||||
await this.postMessageToWebview({ type: "action", action: "accountButtonClicked" })
|
||||
break
|
||||
}
|
||||
case "fetchUserCreditsData": {
|
||||
await this.fetchUserCreditsData()
|
||||
break
|
||||
}
|
||||
case "showMcpView": {
|
||||
await this.postMessageToWebview({ type: "action", action: "mcpButtonClicked" })
|
||||
break
|
||||
@@ -905,6 +829,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
break
|
||||
}
|
||||
case "requestTotalTasksSize": {
|
||||
this.refreshTotalTasksSize()
|
||||
break
|
||||
}
|
||||
case "restartMcpServer": {
|
||||
try {
|
||||
await this.mcpHub?.restartConnection(message.text!)
|
||||
@@ -974,13 +902,42 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
break
|
||||
}
|
||||
case "telemetrySetting": {
|
||||
const telemetrySetting = message.text as TelemetrySetting
|
||||
await this.updateGlobalState("telemetrySetting", telemetrySetting)
|
||||
const isOptedIn = telemetrySetting === "enabled"
|
||||
telemetryService.updateTelemetryState(isOptedIn)
|
||||
if (message.telemetrySetting) {
|
||||
await this.updateTelemetrySetting(message.telemetrySetting)
|
||||
}
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
}
|
||||
case "updateSettings": {
|
||||
// api config
|
||||
if (message.apiConfiguration) {
|
||||
await this.updateApiConfiguration(message.apiConfiguration)
|
||||
}
|
||||
|
||||
// custom instructions
|
||||
await this.updateCustomInstructions(message.customInstructionsSetting)
|
||||
|
||||
// telemetry setting
|
||||
if (message.telemetrySetting) {
|
||||
await this.updateTelemetrySetting(message.telemetrySetting)
|
||||
}
|
||||
|
||||
// plan act setting
|
||||
await this.updateGlobalState("planActSeparateModelsSetting", message.planActSeparateModelsSetting)
|
||||
|
||||
// after settings are updated, post state to webview
|
||||
await this.postStateToWebview()
|
||||
|
||||
await this.postMessageToWebview({ type: "didUpdateSettings" })
|
||||
break
|
||||
}
|
||||
case "clearAllTaskHistory": {
|
||||
await this.deleteAllTaskHistory()
|
||||
await this.postStateToWebview()
|
||||
this.refreshTotalTasksSize()
|
||||
this.postMessageToWebview({ type: "relinquishControl" })
|
||||
break
|
||||
}
|
||||
// Add more switch case statements here as more webview message commands
|
||||
// are created within the webview context (i.e. inside media/main.js)
|
||||
}
|
||||
@@ -990,6 +947,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
)
|
||||
}
|
||||
|
||||
async updateTelemetrySetting(telemetrySetting: TelemetrySetting) {
|
||||
await this.updateGlobalState("telemetrySetting", telemetrySetting)
|
||||
const isOptedIn = telemetrySetting === "enabled"
|
||||
telemetryService.updateTelemetryState(isOptedIn)
|
||||
}
|
||||
|
||||
async togglePlanActModeWithChatSettings(chatSettings: ChatSettings, chatContent?: ChatContent) {
|
||||
const didSwitchToActMode = chatSettings.mode === "act"
|
||||
|
||||
@@ -1002,87 +965,100 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
previousModeApiProvider: newApiProvider,
|
||||
previousModeModelId: newModelId,
|
||||
previousModeModelInfo: newModelInfo,
|
||||
previousModeVsCodeLmModelSelector: newVsCodeLmModelSelector,
|
||||
previousModeThinkingBudgetTokens: newThinkingBudgetTokens,
|
||||
planActSeparateModelsSetting,
|
||||
} = await this.getState()
|
||||
|
||||
// Save the last model used in this mode
|
||||
await this.updateGlobalState("previousModeApiProvider", apiConfiguration.apiProvider)
|
||||
await this.updateGlobalState("previousModeThinkingBudgetTokens", apiConfiguration.thinkingBudgetTokens)
|
||||
switch (apiConfiguration.apiProvider) {
|
||||
case "anthropic":
|
||||
case "bedrock":
|
||||
case "vertex":
|
||||
case "gemini":
|
||||
case "asksage":
|
||||
await this.updateGlobalState("previousModeModelId", apiConfiguration.apiModelId)
|
||||
break
|
||||
case "openrouter":
|
||||
case "cline":
|
||||
await this.updateGlobalState("previousModeModelId", apiConfiguration.openRouterModelId)
|
||||
await this.updateGlobalState("previousModeModelInfo", apiConfiguration.openRouterModelInfo)
|
||||
break
|
||||
case "vscode-lm":
|
||||
await this.updateGlobalState("previousModeModelId", apiConfiguration.vsCodeLmModelSelector)
|
||||
break
|
||||
case "openai":
|
||||
await this.updateGlobalState("previousModeModelId", apiConfiguration.openAiModelId)
|
||||
await this.updateGlobalState("previousModeModelInfo", apiConfiguration.openAiModelInfo)
|
||||
break
|
||||
case "ollama":
|
||||
await this.updateGlobalState("previousModeModelId", apiConfiguration.ollamaModelId)
|
||||
break
|
||||
case "lmstudio":
|
||||
await this.updateGlobalState("previousModeModelId", apiConfiguration.lmStudioModelId)
|
||||
break
|
||||
case "litellm":
|
||||
await this.updateGlobalState("previousModeModelId", apiConfiguration.liteLlmModelId)
|
||||
break
|
||||
case "requesty":
|
||||
await this.updateGlobalState("previousModeModelId", apiConfiguration.requestyModelId)
|
||||
break
|
||||
}
|
||||
const shouldSwitchModel = planActSeparateModelsSetting === true
|
||||
|
||||
// Restore the model used in previous mode
|
||||
if (newApiProvider && newModelId) {
|
||||
await this.updateGlobalState("apiProvider", newApiProvider)
|
||||
await this.updateGlobalState("thinkingBudgetTokens", newThinkingBudgetTokens)
|
||||
switch (newApiProvider) {
|
||||
if (shouldSwitchModel) {
|
||||
// Save the last model used in this mode
|
||||
await this.updateGlobalState("previousModeApiProvider", apiConfiguration.apiProvider)
|
||||
await this.updateGlobalState("previousModeThinkingBudgetTokens", apiConfiguration.thinkingBudgetTokens)
|
||||
switch (apiConfiguration.apiProvider) {
|
||||
case "anthropic":
|
||||
case "bedrock":
|
||||
case "vertex":
|
||||
case "gemini":
|
||||
case "asksage":
|
||||
await this.updateGlobalState("apiModelId", newModelId)
|
||||
case "openai-native":
|
||||
case "qwen":
|
||||
case "deepseek":
|
||||
await this.updateGlobalState("previousModeModelId", apiConfiguration.apiModelId)
|
||||
break
|
||||
case "openrouter":
|
||||
case "cline":
|
||||
await this.updateGlobalState("openRouterModelId", newModelId)
|
||||
await this.updateGlobalState("openRouterModelInfo", newModelInfo)
|
||||
await this.updateGlobalState("previousModeModelId", apiConfiguration.openRouterModelId)
|
||||
await this.updateGlobalState("previousModeModelInfo", apiConfiguration.openRouterModelInfo)
|
||||
break
|
||||
case "vscode-lm":
|
||||
await this.updateGlobalState("vsCodeLmModelSelector", newModelId)
|
||||
// Important we don't set modelId to this, as it's an object not string (webview expects model id to be a string)
|
||||
await this.updateGlobalState("previousModeVsCodeLmModelSelector", apiConfiguration.vsCodeLmModelSelector)
|
||||
break
|
||||
case "openai":
|
||||
await this.updateGlobalState("openAiModelId", newModelId)
|
||||
await this.updateGlobalState("openAiModelInfo", newModelInfo)
|
||||
await this.updateGlobalState("previousModeModelId", apiConfiguration.openAiModelId)
|
||||
await this.updateGlobalState("previousModeModelInfo", apiConfiguration.openAiModelInfo)
|
||||
break
|
||||
case "ollama":
|
||||
await this.updateGlobalState("ollamaModelId", newModelId)
|
||||
await this.updateGlobalState("previousModeModelId", apiConfiguration.ollamaModelId)
|
||||
break
|
||||
case "lmstudio":
|
||||
await this.updateGlobalState("lmStudioModelId", newModelId)
|
||||
await this.updateGlobalState("previousModeModelId", apiConfiguration.lmStudioModelId)
|
||||
break
|
||||
case "litellm":
|
||||
await this.updateGlobalState("liteLlmModelId", newModelId)
|
||||
await this.updateGlobalState("previousModeModelId", apiConfiguration.liteLlmModelId)
|
||||
break
|
||||
case "requesty":
|
||||
await this.updateGlobalState("requestyModelId", newModelId)
|
||||
await this.updateGlobalState("previousModeModelId", apiConfiguration.requestyModelId)
|
||||
break
|
||||
}
|
||||
|
||||
if (this.cline) {
|
||||
const { apiConfiguration: updatedApiConfiguration } = await this.getState()
|
||||
this.cline.api = buildApiHandler(updatedApiConfiguration)
|
||||
// Restore the model used in previous mode
|
||||
if (newApiProvider || newModelId || newThinkingBudgetTokens !== undefined || newVsCodeLmModelSelector) {
|
||||
await this.updateGlobalState("apiProvider", newApiProvider)
|
||||
await this.updateGlobalState("thinkingBudgetTokens", newThinkingBudgetTokens)
|
||||
switch (newApiProvider) {
|
||||
case "anthropic":
|
||||
case "bedrock":
|
||||
case "vertex":
|
||||
case "gemini":
|
||||
case "asksage":
|
||||
case "openai-native":
|
||||
case "qwen":
|
||||
case "deepseek":
|
||||
await this.updateGlobalState("apiModelId", newModelId)
|
||||
break
|
||||
case "openrouter":
|
||||
case "cline":
|
||||
await this.updateGlobalState("openRouterModelId", newModelId)
|
||||
await this.updateGlobalState("openRouterModelInfo", newModelInfo)
|
||||
break
|
||||
case "vscode-lm":
|
||||
await this.updateGlobalState("vsCodeLmModelSelector", newVsCodeLmModelSelector)
|
||||
break
|
||||
case "openai":
|
||||
await this.updateGlobalState("openAiModelId", newModelId)
|
||||
await this.updateGlobalState("openAiModelInfo", newModelInfo)
|
||||
break
|
||||
case "ollama":
|
||||
await this.updateGlobalState("ollamaModelId", newModelId)
|
||||
break
|
||||
case "lmstudio":
|
||||
await this.updateGlobalState("lmStudioModelId", newModelId)
|
||||
break
|
||||
case "litellm":
|
||||
await this.updateGlobalState("liteLlmModelId", newModelId)
|
||||
break
|
||||
case "requesty":
|
||||
await this.updateGlobalState("requestyModelId", newModelId)
|
||||
break
|
||||
}
|
||||
|
||||
if (this.cline) {
|
||||
const { apiConfiguration: updatedApiConfiguration } = await this.getState()
|
||||
this.cline.api = buildApiHandler(updatedApiConfiguration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1141,7 +1117,112 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
if (this.cline) {
|
||||
this.cline.customInstructions = instructions || undefined
|
||||
}
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
|
||||
async updateApiConfiguration(apiConfiguration: ApiConfiguration) {
|
||||
const {
|
||||
apiProvider,
|
||||
apiModelId,
|
||||
apiKey,
|
||||
openRouterApiKey,
|
||||
awsAccessKey,
|
||||
awsSecretKey,
|
||||
awsSessionToken,
|
||||
awsRegion,
|
||||
awsUseCrossRegionInference,
|
||||
awsBedrockUsePromptCache,
|
||||
awsBedrockEndpoint,
|
||||
awsProfile,
|
||||
awsUseProfile,
|
||||
vertexProjectId,
|
||||
vertexRegion,
|
||||
openAiBaseUrl,
|
||||
openAiApiKey,
|
||||
openAiModelId,
|
||||
openAiModelInfo,
|
||||
ollamaModelId,
|
||||
ollamaBaseUrl,
|
||||
ollamaApiOptionsCtxNum,
|
||||
lmStudioModelId,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
requestyApiKey,
|
||||
requestyModelId,
|
||||
togetherApiKey,
|
||||
togetherModelId,
|
||||
qwenApiKey,
|
||||
mistralApiKey,
|
||||
azureApiVersion,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
openRouterProviderSorting,
|
||||
vsCodeLmModelSelector,
|
||||
liteLlmBaseUrl,
|
||||
liteLlmModelId,
|
||||
liteLlmApiKey,
|
||||
qwenApiLine,
|
||||
asksageApiKey,
|
||||
asksageApiUrl,
|
||||
xaiApiKey,
|
||||
thinkingBudgetTokens,
|
||||
clineApiKey,
|
||||
sambanovaApiKey,
|
||||
} = apiConfiguration
|
||||
await this.updateGlobalState("apiProvider", apiProvider)
|
||||
await this.updateGlobalState("apiModelId", apiModelId)
|
||||
await this.storeSecret("apiKey", apiKey)
|
||||
await this.storeSecret("openRouterApiKey", openRouterApiKey)
|
||||
await this.storeSecret("awsAccessKey", awsAccessKey)
|
||||
await this.storeSecret("awsSecretKey", awsSecretKey)
|
||||
await this.storeSecret("awsSessionToken", awsSessionToken)
|
||||
await this.updateGlobalState("awsRegion", awsRegion)
|
||||
await this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference)
|
||||
await this.updateGlobalState("awsBedrockUsePromptCache", awsBedrockUsePromptCache)
|
||||
await this.updateGlobalState("awsBedrockEndpoint", awsBedrockEndpoint)
|
||||
await this.updateGlobalState("awsProfile", awsProfile)
|
||||
await this.updateGlobalState("awsUseProfile", awsUseProfile)
|
||||
await this.updateGlobalState("vertexProjectId", vertexProjectId)
|
||||
await this.updateGlobalState("vertexRegion", vertexRegion)
|
||||
await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl)
|
||||
await this.storeSecret("openAiApiKey", openAiApiKey)
|
||||
await this.updateGlobalState("openAiModelId", openAiModelId)
|
||||
await this.updateGlobalState("openAiModelInfo", openAiModelInfo)
|
||||
await this.updateGlobalState("ollamaModelId", ollamaModelId)
|
||||
await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl)
|
||||
await this.updateGlobalState("ollamaApiOptionsCtxNum", ollamaApiOptionsCtxNum)
|
||||
await this.updateGlobalState("lmStudioModelId", lmStudioModelId)
|
||||
await this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl)
|
||||
await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl)
|
||||
await this.storeSecret("geminiApiKey", geminiApiKey)
|
||||
await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey)
|
||||
await this.storeSecret("deepSeekApiKey", deepSeekApiKey)
|
||||
await this.storeSecret("requestyApiKey", requestyApiKey)
|
||||
await this.storeSecret("togetherApiKey", togetherApiKey)
|
||||
await this.storeSecret("qwenApiKey", qwenApiKey)
|
||||
await this.storeSecret("mistralApiKey", mistralApiKey)
|
||||
await this.storeSecret("liteLlmApiKey", liteLlmApiKey)
|
||||
await this.storeSecret("xaiApiKey", xaiApiKey)
|
||||
await this.updateGlobalState("azureApiVersion", azureApiVersion)
|
||||
await this.updateGlobalState("openRouterModelId", openRouterModelId)
|
||||
await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
|
||||
await this.updateGlobalState("openRouterProviderSorting", openRouterProviderSorting)
|
||||
await this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector)
|
||||
await this.updateGlobalState("liteLlmBaseUrl", liteLlmBaseUrl)
|
||||
await this.updateGlobalState("liteLlmModelId", liteLlmModelId)
|
||||
await this.updateGlobalState("qwenApiLine", qwenApiLine)
|
||||
await this.updateGlobalState("requestyModelId", requestyModelId)
|
||||
await this.updateGlobalState("togetherModelId", togetherModelId)
|
||||
await this.storeSecret("asksageApiKey", asksageApiKey)
|
||||
await this.updateGlobalState("asksageApiUrl", asksageApiUrl)
|
||||
await this.updateGlobalState("thinkingBudgetTokens", thinkingBudgetTokens)
|
||||
await this.storeSecret("clineApiKey", clineApiKey)
|
||||
await this.storeSecret("sambanovaApiKey", sambanovaApiKey)
|
||||
if (this.cline) {
|
||||
this.cline.api = buildApiHandler(apiConfiguration)
|
||||
}
|
||||
}
|
||||
|
||||
// MCP
|
||||
@@ -1249,6 +1330,20 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// Account
|
||||
|
||||
async fetchUserCreditsData() {
|
||||
try {
|
||||
await Promise.all([
|
||||
this.accountService?.fetchBalance(),
|
||||
this.accountService?.fetchUsageTransactions(),
|
||||
this.accountService?.fetchPaymentTransactions(),
|
||||
])
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user credits data:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Auth
|
||||
|
||||
public async validateAuthState(state: string | null): Promise<boolean> {
|
||||
@@ -1287,7 +1382,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
|
||||
await this.postStateToWebview()
|
||||
vscode.window.showInformationMessage("Successfully logged in to Cline")
|
||||
// vscode.window.showInformationMessage("Successfully logged in to Cline")
|
||||
} catch (error) {
|
||||
console.error("Failed to handle auth callback:", error)
|
||||
vscode.window.showErrorMessage("Failed to log in to Cline")
|
||||
@@ -1657,6 +1752,104 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
return models
|
||||
}
|
||||
|
||||
// Context menus and code actions
|
||||
|
||||
getFileMentionFromPath(filePath: string) {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
if (!cwd) {
|
||||
return "@/" + filePath
|
||||
}
|
||||
const relativePath = path.relative(cwd, filePath)
|
||||
return "@/" + relativePath
|
||||
}
|
||||
|
||||
// 'Add to Cline' context menu in editor and code action
|
||||
async addSelectedCodeToChat(code: string, filePath: string, languageId: string, diagnostics?: vscode.Diagnostic[]) {
|
||||
// Ensure the sidebar view is visible
|
||||
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
|
||||
await setTimeoutPromise(100)
|
||||
|
||||
// Post message to webview with the selected code
|
||||
const fileMention = this.getFileMentionFromPath(filePath)
|
||||
|
||||
let input = `${fileMention}\n\`\`\`\n${code}\n\`\`\``
|
||||
if (diagnostics) {
|
||||
const problemsString = this.convertDiagnosticsToProblemsString(diagnostics)
|
||||
input += `\nProblems:\n${problemsString}`
|
||||
}
|
||||
|
||||
await this.postMessageToWebview({
|
||||
type: "addToInput",
|
||||
text: input,
|
||||
})
|
||||
|
||||
console.log("addSelectedCodeToChat", code, filePath, languageId)
|
||||
}
|
||||
|
||||
// 'Add to Cline' context menu in Terminal
|
||||
async addSelectedTerminalOutputToChat(output: string, terminalName: string) {
|
||||
// Ensure the sidebar view is visible
|
||||
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
|
||||
await setTimeoutPromise(100)
|
||||
|
||||
// Post message to webview with the selected terminal output
|
||||
// await this.postMessageToWebview({
|
||||
// type: "addSelectedTerminalOutput",
|
||||
// output,
|
||||
// terminalName
|
||||
// })
|
||||
|
||||
await this.postMessageToWebview({
|
||||
type: "addToInput",
|
||||
text: `Terminal output:\n\`\`\`\n${output}\n\`\`\``,
|
||||
})
|
||||
|
||||
console.log("addSelectedTerminalOutputToChat", output, terminalName)
|
||||
}
|
||||
|
||||
// 'Fix with Cline' in code actions
|
||||
async fixWithCline(code: string, filePath: string, languageId: string, diagnostics: vscode.Diagnostic[]) {
|
||||
// Ensure the sidebar view is visible
|
||||
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
|
||||
await setTimeoutPromise(100)
|
||||
|
||||
const fileMention = this.getFileMentionFromPath(filePath)
|
||||
const problemsString = this.convertDiagnosticsToProblemsString(diagnostics)
|
||||
await this.initClineWithTask(
|
||||
`Fix the following code in ${fileMention}\n\`\`\`\n${code}\n\`\`\`\n\nProblems:\n${problemsString}`,
|
||||
)
|
||||
|
||||
console.log("fixWithCline", code, filePath, languageId, diagnostics, problemsString)
|
||||
}
|
||||
|
||||
convertDiagnosticsToProblemsString(diagnostics: vscode.Diagnostic[]) {
|
||||
let problemsString = ""
|
||||
for (const diagnostic of diagnostics) {
|
||||
let label: string
|
||||
switch (diagnostic.severity) {
|
||||
case vscode.DiagnosticSeverity.Error:
|
||||
label = "Error"
|
||||
break
|
||||
case vscode.DiagnosticSeverity.Warning:
|
||||
label = "Warning"
|
||||
break
|
||||
case vscode.DiagnosticSeverity.Information:
|
||||
label = "Information"
|
||||
break
|
||||
case vscode.DiagnosticSeverity.Hint:
|
||||
label = "Hint"
|
||||
break
|
||||
default:
|
||||
label = "Diagnostic"
|
||||
}
|
||||
const line = diagnostic.range.start.line + 1 // VSCode lines are 0-indexed
|
||||
const source = diagnostic.source ? `${diagnostic.source} ` : ""
|
||||
problemsString += `\n- [${source}${label}] Line ${line}: ${diagnostic.message}`
|
||||
}
|
||||
problemsString = problemsString.trim()
|
||||
return problemsString
|
||||
}
|
||||
|
||||
// Task history
|
||||
|
||||
async getTaskWithId(id: string): Promise<{
|
||||
@@ -1707,46 +1900,78 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
await downloadTask(historyItem.ts, apiConversationHistory)
|
||||
}
|
||||
|
||||
async deleteAllTaskHistory() {
|
||||
await this.clearTask()
|
||||
await this.updateGlobalState("taskHistory", undefined)
|
||||
try {
|
||||
// Remove all contents of tasks directory
|
||||
const taskDirPath = path.join(this.context.globalStorageUri.fsPath, "tasks")
|
||||
if (await fileExistsAtPath(taskDirPath)) {
|
||||
await fs.rm(taskDirPath, { recursive: true, force: true })
|
||||
}
|
||||
// Remove checkpoints directory contents
|
||||
const checkpointsDirPath = path.join(this.context.globalStorageUri.fsPath, "checkpoints")
|
||||
if (await fileExistsAtPath(checkpointsDirPath)) {
|
||||
await fs.rm(checkpointsDirPath, { recursive: true, force: true })
|
||||
}
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(
|
||||
`Encountered error while deleting task history, there may be some files left behind. Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
// await this.postStateToWebview()
|
||||
}
|
||||
|
||||
async refreshTotalTasksSize() {
|
||||
getTotalTasksSize(this.context.globalStorageUri.fsPath)
|
||||
.then((newTotalSize) => {
|
||||
this.postMessageToWebview({
|
||||
type: "totalTasksSize",
|
||||
totalTasksSize: newTotalSize,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error calculating total tasks size:", error)
|
||||
})
|
||||
}
|
||||
|
||||
async deleteTaskWithId(id: string) {
|
||||
console.info("deleteTaskWithId: ", id)
|
||||
|
||||
if (id === this.cline?.taskId) {
|
||||
await this.clearTask()
|
||||
console.debug("cleared task")
|
||||
}
|
||||
|
||||
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id)
|
||||
|
||||
// Delete checkpoints
|
||||
console.info("deleting checkpoints")
|
||||
const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
|
||||
const historyItem = taskHistory.find((item) => item.id === id)
|
||||
//console.log("historyItem: ", historyItem)
|
||||
if (historyItem) {
|
||||
try {
|
||||
await CheckpointTracker.deleteCheckpoints(id, historyItem, this.context.globalStorageUri.fsPath)
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete checkpoints for task ${id}:`, error)
|
||||
try {
|
||||
if (id === this.cline?.taskId) {
|
||||
await this.clearTask()
|
||||
console.debug("cleared task")
|
||||
}
|
||||
|
||||
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id)
|
||||
|
||||
const updatedTaskHistory = await this.deleteTaskFromState(id)
|
||||
|
||||
// Delete the task files
|
||||
const apiConversationHistoryFileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
|
||||
if (apiConversationHistoryFileExists) {
|
||||
await fs.unlink(apiConversationHistoryFilePath)
|
||||
}
|
||||
const uiMessagesFileExists = await fileExistsAtPath(uiMessagesFilePath)
|
||||
if (uiMessagesFileExists) {
|
||||
await fs.unlink(uiMessagesFilePath)
|
||||
}
|
||||
const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
|
||||
if (await fileExistsAtPath(legacyMessagesFilePath)) {
|
||||
await fs.unlink(legacyMessagesFilePath)
|
||||
}
|
||||
|
||||
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
|
||||
|
||||
if (updatedTaskHistory.length === 0) {
|
||||
await this.deleteAllTaskHistory()
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug(`Error deleting task:`, error)
|
||||
}
|
||||
|
||||
await this.deleteTaskFromState(id)
|
||||
|
||||
// Delete the task files
|
||||
const apiConversationHistoryFileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
|
||||
if (apiConversationHistoryFileExists) {
|
||||
await fs.unlink(apiConversationHistoryFilePath)
|
||||
}
|
||||
const uiMessagesFileExists = await fileExistsAtPath(uiMessagesFilePath)
|
||||
if (uiMessagesFileExists) {
|
||||
await fs.unlink(uiMessagesFilePath)
|
||||
}
|
||||
const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
|
||||
if (await fileExistsAtPath(legacyMessagesFilePath)) {
|
||||
await fs.unlink(legacyMessagesFilePath)
|
||||
}
|
||||
|
||||
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
|
||||
this.refreshTotalTasksSize()
|
||||
}
|
||||
|
||||
async deleteTaskFromState(id: string) {
|
||||
@@ -1757,6 +1982,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
|
||||
// Notify the webview that the task has been deleted
|
||||
await this.postStateToWebview()
|
||||
|
||||
return updatedTaskHistory
|
||||
}
|
||||
|
||||
async postStateToWebview() {
|
||||
@@ -1776,6 +2003,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
userInfo,
|
||||
mcpMarketplaceEnabled,
|
||||
telemetrySetting,
|
||||
planActSeparateModelsSetting,
|
||||
} = await this.getState()
|
||||
|
||||
return {
|
||||
@@ -1786,7 +2014,10 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
currentTaskItem: this.cline?.taskId ? (taskHistory || []).find((item) => item.id === this.cline?.taskId) : undefined,
|
||||
checkpointTrackerErrorMessage: this.cline?.checkpointTrackerErrorMessage,
|
||||
clineMessages: this.cline?.clineMessages || [],
|
||||
taskHistory: (taskHistory || []).filter((item) => item.ts && item.task).sort((a, b) => b.ts - a.ts),
|
||||
taskHistory: (taskHistory || [])
|
||||
.filter((item) => item.ts && item.task)
|
||||
.sort((a, b) => b.ts - a.ts)
|
||||
.slice(0, 100), // for now we're only getting the latest 100 tasks, but a better solution here is to only pass in 3 for recent task history, and then get the full task history on demand when going to the task history view (maybe with pagination?)
|
||||
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
|
||||
platform: process.platform as Platform,
|
||||
autoApprovalSettings,
|
||||
@@ -1795,6 +2026,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
userInfo,
|
||||
mcpMarketplaceEnabled,
|
||||
telemetrySetting,
|
||||
planActSeparateModelsSetting,
|
||||
vscMachineId: vscode.env.machineId,
|
||||
}
|
||||
}
|
||||
@@ -1863,6 +2095,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
awsRegion,
|
||||
awsUseCrossRegionInference,
|
||||
awsBedrockUsePromptCache,
|
||||
awsBedrockEndpoint,
|
||||
awsProfile,
|
||||
awsUseProfile,
|
||||
vertexProjectId,
|
||||
@@ -1889,6 +2122,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
azureApiVersion,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
openRouterProviderSorting,
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
taskHistory,
|
||||
@@ -1902,6 +2136,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
previousModeApiProvider,
|
||||
previousModeModelId,
|
||||
previousModeModelInfo,
|
||||
previousModeVsCodeLmModelSelector,
|
||||
previousModeThinkingBudgetTokens,
|
||||
qwenApiLine,
|
||||
liteLlmApiKey,
|
||||
@@ -1910,6 +2145,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
asksageApiUrl,
|
||||
xaiApiKey,
|
||||
thinkingBudgetTokens,
|
||||
sambanovaApiKey,
|
||||
planActSeparateModelsSettingRaw,
|
||||
] = await Promise.all([
|
||||
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
|
||||
this.getGlobalState("apiModelId") as Promise<string | undefined>,
|
||||
@@ -1922,6 +2159,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
this.getGlobalState("awsRegion") as Promise<string | undefined>,
|
||||
this.getGlobalState("awsUseCrossRegionInference") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("awsBedrockUsePromptCache") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("awsBedrockEndpoint") as Promise<string | undefined>,
|
||||
this.getGlobalState("awsProfile") as Promise<string | undefined>,
|
||||
this.getGlobalState("awsUseProfile") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("vertexProjectId") as Promise<string | undefined>,
|
||||
@@ -1948,6 +2186,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
this.getGlobalState("azureApiVersion") as Promise<string | undefined>,
|
||||
this.getGlobalState("openRouterModelId") as Promise<string | undefined>,
|
||||
this.getGlobalState("openRouterModelInfo") as Promise<ModelInfo | undefined>,
|
||||
this.getGlobalState("openRouterProviderSorting") as Promise<string | undefined>,
|
||||
this.getGlobalState("lastShownAnnouncementId") as Promise<string | undefined>,
|
||||
this.getGlobalState("customInstructions") as Promise<string | undefined>,
|
||||
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
|
||||
@@ -1961,6 +2200,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
this.getGlobalState("previousModeApiProvider") as Promise<ApiProvider | undefined>,
|
||||
this.getGlobalState("previousModeModelId") as Promise<string | undefined>,
|
||||
this.getGlobalState("previousModeModelInfo") as Promise<ModelInfo | undefined>,
|
||||
this.getGlobalState("previousModeVsCodeLmModelSelector") as Promise<vscode.LanguageModelChatSelector | undefined>,
|
||||
this.getGlobalState("previousModeThinkingBudgetTokens") as Promise<number | undefined>,
|
||||
this.getGlobalState("qwenApiLine") as Promise<string | undefined>,
|
||||
this.getSecret("liteLlmApiKey") as Promise<string | undefined>,
|
||||
@@ -1969,6 +2209,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
this.getGlobalState("asksageApiUrl") as Promise<string | undefined>,
|
||||
this.getSecret("xaiApiKey") as Promise<string | undefined>,
|
||||
this.getGlobalState("thinkingBudgetTokens") as Promise<number | undefined>,
|
||||
this.getSecret("sambanovaApiKey") as Promise<string | undefined>,
|
||||
this.getGlobalState("planActSeparateModelsSetting") as Promise<boolean | undefined>,
|
||||
])
|
||||
|
||||
let apiProvider: ApiProvider
|
||||
@@ -1991,6 +2233,24 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
|
||||
const mcpMarketplaceEnabled = vscode.workspace.getConfiguration("cline").get<boolean>("mcpMarketplace.enabled", true)
|
||||
|
||||
// Plan/Act separate models setting is a boolean indicating whether the user wants to use different models for plan and act. Existing users expect this to be enabled, while we want new users to opt in to this being disabled by default.
|
||||
// On win11 state sometimes initializes as empty string instead of undefined
|
||||
let planActSeparateModelsSetting: boolean | undefined = undefined
|
||||
if (planActSeparateModelsSettingRaw === true || planActSeparateModelsSettingRaw === false) {
|
||||
planActSeparateModelsSetting = planActSeparateModelsSettingRaw
|
||||
} else {
|
||||
// default to true for existing users
|
||||
if (storedApiProvider) {
|
||||
planActSeparateModelsSetting = true
|
||||
} else {
|
||||
// default to false for new users
|
||||
planActSeparateModelsSetting = false
|
||||
}
|
||||
// this is a special case where it's a new state, but we want it to default to different values for existing and new users.
|
||||
// persist so next time state is retrieved it's set to the correct value.
|
||||
await this.updateGlobalState("planActSeparateModelsSetting", planActSeparateModelsSetting)
|
||||
}
|
||||
|
||||
return {
|
||||
apiConfiguration: {
|
||||
apiProvider,
|
||||
@@ -2004,6 +2264,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
awsRegion,
|
||||
awsUseCrossRegionInference,
|
||||
awsBedrockUsePromptCache,
|
||||
awsBedrockEndpoint,
|
||||
awsProfile,
|
||||
awsUseProfile,
|
||||
vertexProjectId,
|
||||
@@ -2031,6 +2292,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
azureApiVersion,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
openRouterProviderSorting,
|
||||
vsCodeLmModelSelector,
|
||||
o3MiniReasoningEffort,
|
||||
thinkingBudgetTokens,
|
||||
@@ -2040,6 +2302,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
asksageApiKey,
|
||||
asksageApiUrl,
|
||||
xaiApiKey,
|
||||
sambanovaApiKey,
|
||||
},
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
@@ -2051,9 +2314,11 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
previousModeApiProvider,
|
||||
previousModeModelId,
|
||||
previousModeModelInfo,
|
||||
previousModeVsCodeLmModelSelector,
|
||||
previousModeThinkingBudgetTokens,
|
||||
mcpMarketplaceEnabled,
|
||||
telemetrySetting: telemetrySetting || "unset",
|
||||
planActSeparateModelsSetting,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2185,6 +2450,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
"liteLlmApiKey",
|
||||
"asksageApiKey",
|
||||
"xaiApiKey",
|
||||
"sambanovaApiKey",
|
||||
]
|
||||
for (const key of secretKeys) {
|
||||
await this.storeSecret(key, undefined)
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import * as vscode from "vscode"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import { ClineMessage } from "../../shared/ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Registers development-only commands for task manipulation.
|
||||
* These are only activated in development mode.
|
||||
*/
|
||||
export function registerTaskCommands(context: vscode.ExtensionContext, provider: ClineProvider): vscode.Disposable[] {
|
||||
return [
|
||||
vscode.commands.registerCommand("cline.dev.createTestTasks", async () => {
|
||||
const count = await vscode.window.showInputBox({
|
||||
title: "Test Tasks",
|
||||
prompt: "How many test tasks to create?",
|
||||
value: "10",
|
||||
})
|
||||
|
||||
if (!count) {
|
||||
return
|
||||
}
|
||||
|
||||
const tasksCount = parseInt(count)
|
||||
const globalStoragePath = context.globalStorageUri.fsPath
|
||||
const tasksDir = path.join(globalStoragePath, "tasks")
|
||||
|
||||
vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: `Creating ${tasksCount} test tasks...`,
|
||||
cancellable: false,
|
||||
},
|
||||
async (progress) => {
|
||||
for (let i = 0; i < tasksCount; i++) {
|
||||
// Generate a timestamp to ensure unique IDs
|
||||
const timestamp = Date.now() + i
|
||||
const taskId = `${timestamp}`
|
||||
const taskDir = path.join(tasksDir, taskId)
|
||||
|
||||
await fs.mkdir(taskDir, { recursive: true })
|
||||
|
||||
// Generate a task prompt
|
||||
const taskName = getRandomTaskName(i)
|
||||
|
||||
// Create realistic message sequence
|
||||
const messages = createRealisticMessageSequence(timestamp, taskName, i)
|
||||
|
||||
// Create API conversation history file
|
||||
await fs.writeFile(
|
||||
path.join(taskDir, "api_conversation_history.json"),
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `<task>\n${taskName}\n</task>` }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `I'll help you ${taskName.toLowerCase()}. Let me break this down into steps.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
// Create UI messages file with realistic message sequence
|
||||
await fs.writeFile(path.join(taskDir, "ui_messages.json"), JSON.stringify(messages, null, 2))
|
||||
|
||||
// Create history item to be shown in the HistoryView
|
||||
const historyItem: HistoryItem = {
|
||||
id: taskId,
|
||||
ts: timestamp,
|
||||
task: taskName,
|
||||
tokensIn: Math.floor(100 + Math.random() * 900), // Random token count from 100-1000
|
||||
tokensOut: Math.floor(200 + Math.random() * 1800), // Random token count from 200-2000
|
||||
cacheWrites: i % 3 === 0 ? Math.floor(50 + Math.random() * 150) : undefined, // Only add cache writes to every 3rd task
|
||||
cacheReads: i % 3 === 0 ? Math.floor(20 + Math.random() * 80) : undefined, // Only add cache reads to every 3rd task
|
||||
totalCost: Number((0.0001 + Math.random() * 0.01).toFixed(5)), // Random cost from $0.0001 to $0.0101
|
||||
size: 1024 * 1024, // 1MB
|
||||
}
|
||||
|
||||
// Update task history in global state
|
||||
await provider.updateTaskHistory(historyItem)
|
||||
|
||||
progress.report({ increment: 100 / tasksCount })
|
||||
}
|
||||
|
||||
// Update the UI to show the new tasks
|
||||
await provider.postStateToWebview()
|
||||
|
||||
vscode.window.showInformationMessage(`Created ${tasksCount} test tasks`)
|
||||
},
|
||||
)
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a realistic sequence of messages that would occur in a typical task
|
||||
*/
|
||||
function createRealisticMessageSequence(baseTimestamp: number, taskPrompt: string, taskIndex: number): ClineMessage[] {
|
||||
// Use an incrementing timestamp to ensure messages appear in sequence
|
||||
let timestamp = baseTimestamp
|
||||
const getNextTimestamp = () => {
|
||||
timestamp += 1000 // Add 1 second between messages
|
||||
return timestamp
|
||||
}
|
||||
|
||||
// Variables to make different test tasks look unique
|
||||
const fileName = getRandomFileName(taskIndex)
|
||||
const commitHash = `commit${taskIndex}${Math.floor(Math.random() * 1000000).toString(16)}`
|
||||
|
||||
// Create a realistic message sequence
|
||||
const messages: ClineMessage[] = [
|
||||
// Initial task message - uses "say" with "text" which is the format used in Cline.ts
|
||||
{
|
||||
ts: baseTimestamp,
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: taskPrompt,
|
||||
},
|
||||
|
||||
// API request started
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify({
|
||||
request: `<task>\n${taskPrompt}\n</task>`,
|
||||
tokensIn: Math.floor(100 + Math.random() * 200),
|
||||
tokensOut: Math.floor(300 + Math.random() * 500),
|
||||
}),
|
||||
},
|
||||
|
||||
// Reasoning message
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "reasoning",
|
||||
text: `I'll approach this task by breaking it down into manageable steps. First, I'll analyze the requirements, then create a plan, and finally implement the solution systematically.`,
|
||||
},
|
||||
|
||||
// Text response
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: `I'll help you with this task. Let me start by creating the necessary files and implementing the core functionality.`,
|
||||
},
|
||||
]
|
||||
|
||||
// Add task-specific messages based on index modulo to create variety
|
||||
const messageType = taskIndex % 5
|
||||
|
||||
if (messageType === 0 || messageType === 2) {
|
||||
// Tool use - file operations
|
||||
messages.push({
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "newFileCreated",
|
||||
path: fileName,
|
||||
content: `// Sample code for ${taskPrompt}`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (messageType === 1 || messageType === 3) {
|
||||
// Command execution
|
||||
messages.push(
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "ask",
|
||||
ask: "command",
|
||||
text: `ls -la`,
|
||||
},
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "command_output",
|
||||
text: `total 24\ndrwxr-xr-x 3 user staff 96 Mar 10 12:34 .\ndrwxr-xr-x 8 user staff 256 Mar 10 12:30 ..\n-rw-r--r-- 1 user staff 158 Mar 10 12:34 ${fileName}`,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (messageType === 2 || messageType === 4) {
|
||||
// Browser actions
|
||||
messages.push(
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "ask",
|
||||
ask: "browser_action_launch",
|
||||
text: `https://example.com`,
|
||||
},
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "browser_action_result",
|
||||
text: JSON.stringify({
|
||||
logs: "Page loaded successfully",
|
||||
screenshot:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
}),
|
||||
},
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "browser_action",
|
||||
text: JSON.stringify({
|
||||
action: "close",
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Add checkpoint
|
||||
messages.push({
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "checkpoint_created",
|
||||
lastCheckpointHash: commitHash,
|
||||
})
|
||||
|
||||
// Add completion result (all tasks end with this)
|
||||
messages.push({
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "completion_result",
|
||||
text: `I've completed the task to ${taskPrompt.toLowerCase()}. The implementation includes all the required functionality and meets the specifications. ${"x".repeat(1024 * 1024)}`, // 1MB file
|
||||
lastCheckpointHash: commitHash,
|
||||
})
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a random task name for test data
|
||||
*/
|
||||
function getRandomTaskName(index: number): string {
|
||||
const tasks = [
|
||||
"Create a simple todo application",
|
||||
"Build a weather forecast widget",
|
||||
"Implement a markdown parser",
|
||||
"Design a responsive landing page",
|
||||
"Develop a currency converter",
|
||||
"Create a file upload component",
|
||||
"Build a data visualization dashboard",
|
||||
"Implement a search functionality",
|
||||
"Create a user authentication system",
|
||||
"Design a dark mode toggle",
|
||||
"Build a countdown timer",
|
||||
"Create a drag and drop interface",
|
||||
"Implement form validation",
|
||||
"Design a multi-step wizard",
|
||||
"Create a notification system",
|
||||
]
|
||||
|
||||
return tasks[index % tasks.length] + ` (Test ${index + 1})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a random file name for test data
|
||||
*/
|
||||
function getRandomFileName(index: number): string {
|
||||
const files = [
|
||||
"index.html",
|
||||
"styles.css",
|
||||
"script.js",
|
||||
"app.jsx",
|
||||
"main.ts",
|
||||
"utils.py",
|
||||
"config.json",
|
||||
"server.js",
|
||||
"data.csv",
|
||||
"README.md",
|
||||
]
|
||||
|
||||
return files[index % files.length]
|
||||
}
|
||||
+171
-4
@@ -1,6 +1,6 @@
|
||||
// The module 'vscode' contains the VS Code extensibility API
|
||||
// Import the module and reference it with the alias vscode in your code below
|
||||
import delay from "delay"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "./core/webview/ClineProvider"
|
||||
import { Logger } from "./services/logging/Logger"
|
||||
@@ -32,6 +32,8 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
const sidebarProvider = new ClineProvider(context, outputChannel)
|
||||
|
||||
vscode.commands.executeCommand("setContext", "cline.isDevMode", IS_DEV && IS_DEV === "true")
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewViewProvider(ClineProvider.sideBarId, sidebarProvider, {
|
||||
webviewOptions: { retainContextWhenHidden: true },
|
||||
@@ -88,7 +90,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
tabProvider.resolveWebviewView(panel)
|
||||
|
||||
// Lock the editor group so clicking on files doesn't open them over the panel
|
||||
await delay(100)
|
||||
await setTimeoutPromise(100)
|
||||
await vscode.commands.executeCommand("workbench.action.lockEditorGroup")
|
||||
}
|
||||
|
||||
@@ -115,14 +117,20 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.accountLoginClicked", () => {
|
||||
vscode.commands.registerCommand("cline.accountButtonClicked", () => {
|
||||
sidebarProvider.postMessageToWebview({
|
||||
type: "action",
|
||||
action: "accountLoginClicked",
|
||||
action: "accountButtonClicked",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.openDocumentation", () => {
|
||||
vscode.env.openExternal(vscode.Uri.parse("https://docs.cline.bot/"))
|
||||
}),
|
||||
)
|
||||
|
||||
/*
|
||||
We use the text document content provider API to show the left side for diff view by creating a virtual document for the original content. This makes it readonly so users know to edit the right side if they want to keep their changes.
|
||||
|
||||
@@ -187,6 +195,165 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
}
|
||||
context.subscriptions.push(vscode.window.registerUriHandler({ handleUri }))
|
||||
|
||||
// Register size testing commands in development mode
|
||||
if (IS_DEV && IS_DEV === "true") {
|
||||
// Use dynamic import to avoid loading the module in production
|
||||
import("./dev/commands/tasks")
|
||||
.then((module) => {
|
||||
const devTaskCommands = module.registerTaskCommands(context, sidebarProvider)
|
||||
context.subscriptions.push(...devTaskCommands)
|
||||
Logger.log("Cline dev task commands registered")
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.log("Failed to register dev task commands: " + error)
|
||||
})
|
||||
}
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.addToChat", async (range?: vscode.Range, diagnostics?: vscode.Diagnostic[]) => {
|
||||
const editor = vscode.window.activeTextEditor
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use provided range if available, otherwise use current selection
|
||||
// (vscode command passes an argument in the first param by default, so we need to ensure it's a Range object)
|
||||
const textRange = range instanceof vscode.Range ? range : editor.selection
|
||||
const selectedText = editor.document.getText(textRange)
|
||||
|
||||
if (!selectedText) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the file path and language ID
|
||||
const filePath = editor.document.uri.fsPath
|
||||
const languageId = editor.document.languageId
|
||||
|
||||
// Send to sidebar provider
|
||||
await sidebarProvider.addSelectedCodeToChat(
|
||||
selectedText,
|
||||
filePath,
|
||||
languageId,
|
||||
Array.isArray(diagnostics) ? diagnostics : undefined,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.addTerminalOutputToChat", async () => {
|
||||
const terminal = vscode.window.activeTerminal
|
||||
if (!terminal) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save current clipboard content
|
||||
const tempCopyBuffer = await vscode.env.clipboard.readText()
|
||||
|
||||
try {
|
||||
// Copy the *existing* terminal selection (without selecting all)
|
||||
await vscode.commands.executeCommand("workbench.action.terminal.copySelection")
|
||||
|
||||
// Get copied content
|
||||
let terminalContents = (await vscode.env.clipboard.readText()).trim()
|
||||
|
||||
// Restore original clipboard content
|
||||
await vscode.env.clipboard.writeText(tempCopyBuffer)
|
||||
|
||||
if (!terminalContents) {
|
||||
// No terminal content was copied (either nothing selected or some error)
|
||||
return
|
||||
}
|
||||
|
||||
// [Optional] Any additional logic to process multi-line content can remain here
|
||||
// For example:
|
||||
/*
|
||||
const lines = terminalContents.split("\n")
|
||||
const lastLine = lines.pop()?.trim()
|
||||
if (lastLine) {
|
||||
let i = lines.length - 1
|
||||
while (i >= 0 && !lines[i].trim().startsWith(lastLine)) {
|
||||
i--
|
||||
}
|
||||
terminalContents = lines.slice(Math.max(i, 0)).join("\n")
|
||||
}
|
||||
*/
|
||||
|
||||
// Send to sidebar provider
|
||||
await sidebarProvider.addSelectedTerminalOutputToChat(terminalContents, terminal.name)
|
||||
} catch (error) {
|
||||
// Ensure clipboard is restored even if an error occurs
|
||||
await vscode.env.clipboard.writeText(tempCopyBuffer)
|
||||
console.error("Error getting terminal contents:", error)
|
||||
vscode.window.showErrorMessage("Failed to get terminal contents")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Register code action provider
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerCodeActionsProvider(
|
||||
"*",
|
||||
new (class implements vscode.CodeActionProvider {
|
||||
public static readonly providedCodeActionKinds = [vscode.CodeActionKind.QuickFix]
|
||||
|
||||
provideCodeActions(
|
||||
document: vscode.TextDocument,
|
||||
range: vscode.Range,
|
||||
context: vscode.CodeActionContext,
|
||||
): vscode.CodeAction[] {
|
||||
// Expand range to include surrounding 3 lines
|
||||
const expandedRange = new vscode.Range(
|
||||
Math.max(0, range.start.line - 3),
|
||||
0,
|
||||
Math.min(document.lineCount - 1, range.end.line + 3),
|
||||
document.lineAt(Math.min(document.lineCount - 1, range.end.line + 3)).text.length,
|
||||
)
|
||||
|
||||
const addAction = new vscode.CodeAction("Add to Cline", vscode.CodeActionKind.QuickFix)
|
||||
addAction.command = {
|
||||
command: "cline.addToChat",
|
||||
title: "Add to Cline",
|
||||
arguments: [expandedRange, context.diagnostics],
|
||||
}
|
||||
|
||||
const fixAction = new vscode.CodeAction("Fix with Cline", vscode.CodeActionKind.QuickFix)
|
||||
fixAction.command = {
|
||||
command: "cline.fixWithCline",
|
||||
title: "Fix with Cline",
|
||||
arguments: [expandedRange, context.diagnostics],
|
||||
}
|
||||
|
||||
// Only show actions when there are errors
|
||||
if (context.diagnostics.length > 0) {
|
||||
return [addAction, fixAction]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
})(),
|
||||
{
|
||||
providedCodeActionKinds: [vscode.CodeActionKind.QuickFix],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Register the command handler
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.fixWithCline", async (range: vscode.Range, diagnostics: any[]) => {
|
||||
const editor = vscode.window.activeTextEditor
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedText = editor.document.getText(range)
|
||||
const filePath = editor.document.uri.fsPath
|
||||
const languageId = editor.document.languageId
|
||||
|
||||
// Send to sidebar provider with diagnostics
|
||||
await sidebarProvider.fixWithCline(selectedText, filePath, languageId, diagnostics)
|
||||
}),
|
||||
)
|
||||
|
||||
return createClineAPI(outputChannel, sidebarProvider)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// NOTE: These are here temporarily until we find a better home for them
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
uiMessages: "ui_messages.json",
|
||||
openRouterModels: "openrouter_models.json",
|
||||
mcpSettings: "cline_mcp_settings.json",
|
||||
clineRules: ".clinerules",
|
||||
}
|
||||
@@ -2,16 +2,9 @@ import fs from "fs/promises"
|
||||
import { globby } from "globby"
|
||||
import * as path from "path"
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { getLfsPatterns, writeExcludesFile } from "./CheckpointExclusions"
|
||||
import { getWorkingDirectory, hashWorkingDir } from "./CheckpointUtils"
|
||||
|
||||
interface StorageProvider {
|
||||
context: {
|
||||
globalStorageUri: { fsPath: string }
|
||||
}
|
||||
}
|
||||
import { telemetryService } from "../../services/telemetry/TelemetryService"
|
||||
|
||||
interface CheckpointAddResult {
|
||||
success: boolean
|
||||
@@ -26,7 +19,6 @@ interface CheckpointAddResult {
|
||||
* - Git repository initialization and configuration
|
||||
* - Git settings management (user, LFS, etc.)
|
||||
* - Worktree configuration and management
|
||||
* - Task-specific branch management (creation, switching, deletion)
|
||||
* - Managing nested git repositories during checkpoint operations
|
||||
* - File staging and checkpoint creation
|
||||
* - Shadow git repository maintenance and cleanup
|
||||
@@ -62,7 +54,7 @@ export class GitOperations {
|
||||
* - Unable to create initial commit
|
||||
* - LFS pattern setup fails
|
||||
*/
|
||||
public async initShadowGit(gitPath: string, cwd: string): Promise<string> {
|
||||
public async initShadowGit(gitPath: string, cwd: string, taskId: string): Promise<string> {
|
||||
console.info(`Initializing shadow git`)
|
||||
|
||||
// If repo exists, just verify worktree
|
||||
@@ -73,10 +65,15 @@ export class GitOperations {
|
||||
throw new Error("Checkpoints can only be used in the original workspace: " + worktree.value)
|
||||
}
|
||||
console.warn(`Using existing shadow git at ${gitPath}`)
|
||||
|
||||
// shadow git repo already exists, but update the excludes just in case
|
||||
await writeExcludesFile(gitPath, await getLfsPatterns(this.cwd))
|
||||
|
||||
return gitPath
|
||||
}
|
||||
|
||||
// Initialize new repo
|
||||
const startTime = performance.now()
|
||||
const checkpointsDir = path.dirname(gitPath)
|
||||
console.warn(`Creating new shadow git in ${checkpointsDir}`)
|
||||
|
||||
@@ -93,12 +90,14 @@ export class GitOperations {
|
||||
const lfsPatterns = await getLfsPatterns(cwd)
|
||||
await writeExcludesFile(gitPath, lfsPatterns)
|
||||
|
||||
// Stage all files for initial commit (important so main branch is created with all files in initial commit, and branches created from it will take up less space)
|
||||
await this.addCheckpointFiles(git)
|
||||
|
||||
// Initial commit only on first repo creation
|
||||
await git.commit("initial commit", { "--allow-empty": null })
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized", durationMs)
|
||||
|
||||
console.warn(`Shadow git initialization completed`)
|
||||
|
||||
return gitPath
|
||||
@@ -124,166 +123,6 @@ export class GitOperations {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a shadow Git repository exists for the current workspace.
|
||||
* (checkpoints/{workspaceHash}/.git).
|
||||
*
|
||||
* @param provider - The ClineProvider instance for accessing VS Code functionality
|
||||
* @returns Promise<boolean> True if a branch-per-task shadow git exists, false otherwise
|
||||
*/
|
||||
// public static async doesShadowGitExist(provider?: StorageProvider): Promise<boolean> {
|
||||
// const globalStoragePath = provider?.context.globalStorageUri.fsPath
|
||||
// if (!globalStoragePath) {
|
||||
// return false
|
||||
// }
|
||||
|
||||
// // Check branch-per-task path for newer tasks
|
||||
// const workingDir = await getWorkingDirectory()
|
||||
// const cwdHash = hashWorkingDir(workingDir)
|
||||
// const gitPath = path.join(globalStoragePath, "checkpoints", cwdHash, ".git")
|
||||
// const exists = await fileExistsAtPath(gitPath)
|
||||
// if (exists) {
|
||||
// console.info("Found existing shadow git")
|
||||
// }
|
||||
// return exists
|
||||
// }
|
||||
|
||||
/**
|
||||
* Deletes a branch in the git repository, handling cases where the branch is currently checked out.
|
||||
* If the branch to be deleted is currently checked out, the method will:
|
||||
* 1. Save the current worktree configuration
|
||||
* 2. Temporarily unset the worktree to prevent workspace modifications
|
||||
* 3. Force switch to master/main branch
|
||||
* 4. Delete the target branch
|
||||
* 5. Restore the worktree configuration
|
||||
*
|
||||
* @param git - SimpleGit instance to use for operations
|
||||
* @param branchName - Name of the branch to delete
|
||||
* @param checkpointsDir - Directory containing the git repository
|
||||
* @throws Error if:
|
||||
* - Branch deletion fails
|
||||
* - Unable to switch to master/main branch after 3 retries
|
||||
* - Git operations fail during the process
|
||||
*/
|
||||
public static async deleteBranchForGit(git: SimpleGit, branchName: string): Promise<void> {
|
||||
// Check if branch exists
|
||||
const branches = await git.branchLocal()
|
||||
if (!branches.all.includes(branchName)) {
|
||||
console.error(`Task branch ${branchName} does not exist, nothing to delete`)
|
||||
return // Branch doesn't exist, nothing to delete
|
||||
}
|
||||
|
||||
// First, if we're on the branch to be deleted, switch to master/main
|
||||
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
|
||||
console.info(`Current branch: ${currentBranch}, target branch to delete: ${branchName}`)
|
||||
|
||||
if (currentBranch === branchName) {
|
||||
console.debug("Currently on branch to be deleted, switching to master/main first")
|
||||
// Save the current worktree config
|
||||
const worktree = await git.getConfig("core.worktree")
|
||||
console.debug(`Saved current worktree config: ${worktree.value}`)
|
||||
|
||||
try {
|
||||
await git.raw(["config", "--unset", "core.worktree"])
|
||||
|
||||
// Force discard all changes before we delete the branch
|
||||
await git.reset(["--hard"])
|
||||
await git.clean("f", ["-d"]) // Clean mode 'f' for force, -d for directories
|
||||
|
||||
// Determine default branch (master or main)
|
||||
const defaultBranch = branches.all.includes("main") ? "main" : "master"
|
||||
console.debug(`Using ${defaultBranch} as default branch`)
|
||||
|
||||
// Switch to default branch and delete target branch
|
||||
console.debug(`Attempting to force switch to ${defaultBranch} branch`)
|
||||
await git.checkout([defaultBranch, "--force"])
|
||||
|
||||
// Verify the switch completed, sometimes this takes a second
|
||||
let retries = 3
|
||||
while (retries > 0) {
|
||||
const newBranch = await git.revparse(["--abbrev-ref", "HEAD"])
|
||||
if (newBranch === defaultBranch) {
|
||||
console.debug(`Successfully switched to ${defaultBranch} branch`)
|
||||
break
|
||||
}
|
||||
retries--
|
||||
if (retries === 0) {
|
||||
throw new Error(`Failed to switch to ${defaultBranch} branch`)
|
||||
}
|
||||
}
|
||||
|
||||
console.info(`Deleting branch: ${branchName}`)
|
||||
await git.raw(["branch", "-D", branchName])
|
||||
console.debug(`Successfully deleted branch: ${branchName}`)
|
||||
} finally {
|
||||
// Restore the worktree config
|
||||
if (worktree.value) {
|
||||
await git.addConfig("core.worktree", worktree.value)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If we're not on the branch, we can safely delete it
|
||||
console.info(`Directly deleting branch ${branchName}`)
|
||||
await git.raw(["branch", "-D", branchName])
|
||||
console.debug(`Successfully deleted branch: ${branchName}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Static method to delete a task's branch using stored workspace path.
|
||||
* 1. First attempts to delete branch-per-task checkpoint if it exists
|
||||
*
|
||||
* @param taskId - The ID of the task whose branch should be deleted
|
||||
* @param historyItem - The history item containing the shadow git config
|
||||
* @param globalStoragePath - Path to VS Code's global storage
|
||||
* @throws Error if:
|
||||
* - Global storage path is invalid
|
||||
* - Branch deletion fails
|
||||
*/
|
||||
public static async deleteTaskBranchStatic(
|
||||
taskId: string,
|
||||
historyItem: HistoryItem,
|
||||
globalStoragePath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.debug("Starting static task branch deletion process...")
|
||||
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage uri is invalid")
|
||||
}
|
||||
|
||||
// Handle both active and inactive tasks
|
||||
let workingDir: string
|
||||
if (historyItem.shadowGitConfigWorkTree) {
|
||||
workingDir = historyItem.shadowGitConfigWorkTree
|
||||
} else {
|
||||
workingDir = await getWorkingDirectory()
|
||||
}
|
||||
|
||||
const gitPath = path.join(globalStoragePath, "checkpoints", hashWorkingDir(workingDir), ".git")
|
||||
|
||||
if (await fileExistsAtPath(gitPath)) {
|
||||
console.debug(`Found branch-per-task git repository at ${gitPath}`)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
const branchName = `task-${taskId}`
|
||||
|
||||
// Check if the branch exists
|
||||
const branches = await git.branchLocal()
|
||||
if (branches.all.includes(branchName)) {
|
||||
console.info(`Found branch ${branchName} to delete`)
|
||||
await GitOperations.deleteBranchForGit(git, branchName)
|
||||
return
|
||||
}
|
||||
console.warn(`Branch ${branchName} not found in branch-per-task repository`)
|
||||
}
|
||||
|
||||
console.info("No checkpoints found to delete")
|
||||
} catch (error) {
|
||||
console.error("Failed to delete task branch:", error)
|
||||
throw new Error(`Failed to delete task branch: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's
|
||||
* requirement of using submodules for nested repos.
|
||||
@@ -324,47 +163,6 @@ export class GitOperations {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to or creates a task-specific branch in the shadow Git repository.
|
||||
* For branch-per-task checkpoints, this ensures we're on the correct task branch before operations.
|
||||
*
|
||||
* The method performs the following:
|
||||
* 1. Gets the shadow git path and initializes simple-git
|
||||
* 2. Constructs the branch name using the task ID
|
||||
* 3. Checks if the branch exists:
|
||||
* - If not, creates a new branch
|
||||
* - If yes, switches to the existing branch
|
||||
* 4. Verifies the branch switch completed successfully
|
||||
*
|
||||
* Branch naming convention:
|
||||
* task-{taskId}
|
||||
*
|
||||
* @param taskId - The ID of the task whose branch to switch to
|
||||
* @param gitPath - Path to the .git directory
|
||||
* @returns Promise<void>
|
||||
* @throws Error if branch operations fail or git commands error
|
||||
*/
|
||||
public async switchToTaskBranch(taskId: string, gitPath: string): Promise<void> {
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
const branchName = `task-${taskId}`
|
||||
|
||||
// Update excludes when creating a new branch for a new task
|
||||
await writeExcludesFile(gitPath, await getLfsPatterns(this.cwd))
|
||||
|
||||
// Create new task-specific branch, or switch to one if it already exists.
|
||||
const branches = await git.branchLocal()
|
||||
if (!branches.all.includes(branchName)) {
|
||||
console.info(`Creating new task branch: ${branchName}`)
|
||||
await git.checkoutLocalBranch(branchName)
|
||||
} else {
|
||||
console.info(`Switching to existing task branch: ${branchName}`)
|
||||
await git.checkout(branchName)
|
||||
}
|
||||
|
||||
// const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
|
||||
console.info(`Current Checkpoint branch after switch: ${branchName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds files to the shadow git repository while handling nested git repos.
|
||||
* Uses git commands to list files and stages them for commit.
|
||||
@@ -386,6 +184,7 @@ export class GitOperations {
|
||||
* - Nested git repo handling fails
|
||||
*/
|
||||
public async addCheckpointFiles(git: SimpleGit): Promise<CheckpointAddResult> {
|
||||
const startTime = performance.now()
|
||||
try {
|
||||
// Update exclude patterns before each commit
|
||||
await this.renameNestedGitRepos(true)
|
||||
@@ -393,6 +192,8 @@ export class GitOperations {
|
||||
|
||||
try {
|
||||
await git.add(".")
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
console.debug(`Checkpoint add operation completed in ${durationMs}ms`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Checkpoint add operation failed:", error)
|
||||
|
||||
@@ -2,9 +2,9 @@ import fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import simpleGit from "simple-git"
|
||||
import * as vscode from "vscode"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import { telemetryService } from "../../services/telemetry/TelemetryService"
|
||||
import { GitOperations } from "./CheckpointGitOperations"
|
||||
import { getShadowGitPath, hashWorkingDir, getWorkingDirectory } from "./CheckpointUtils"
|
||||
import { getShadowGitPath, getWorkingDirectory, hashWorkingDir } from "./CheckpointUtils"
|
||||
|
||||
/**
|
||||
* CheckpointTracker Module
|
||||
@@ -33,9 +33,9 @@ import { getShadowGitPath, hashWorkingDir, getWorkingDirectory } from "./Checkpo
|
||||
* - Handles cleanup and resource disposal
|
||||
*
|
||||
* Checkpoint Architecture:
|
||||
* - Uses a branch-per-task model to consolidate shadow git repositories
|
||||
* - Each task gets its own branch within a single shadow git per workspace
|
||||
* - Automatically cleans up by deleting task branches when tasks are removed
|
||||
* - Unique shadow git repository for each workspace
|
||||
* - Workspaces are identified by name, and hashed to a unique number
|
||||
* - All commits for a workspace are stored in one shadow git, under a single branch
|
||||
*/
|
||||
|
||||
class CheckpointTracker {
|
||||
@@ -64,7 +64,7 @@ class CheckpointTracker {
|
||||
|
||||
/**
|
||||
* Creates a new CheckpointTracker instance for tracking changes in a task.
|
||||
* Handles initialization of the shadow git repository and branch setup.
|
||||
* Handles initialization of the shadow git repository.
|
||||
*
|
||||
* @param taskId - Unique identifier for the task to track
|
||||
* @param globalStoragePath - the globalStorage path
|
||||
@@ -78,11 +78,9 @@ class CheckpointTracker {
|
||||
* Key operations:
|
||||
* - Validates git installation and settings
|
||||
* - Creates/initializes shadow git repository
|
||||
* - Sets up task-specific branch for new checkpoints
|
||||
*
|
||||
* Configuration:
|
||||
* - Respects 'cline.enableCheckpoints' VS Code setting
|
||||
* - Uses branch-per-task architecture for new checkpoints
|
||||
*/
|
||||
public static async create(taskId: string, globalStoragePath: string | undefined): Promise<CheckpointTracker | undefined> {
|
||||
if (!globalStoragePath) {
|
||||
@@ -90,6 +88,7 @@ class CheckpointTracker {
|
||||
}
|
||||
try {
|
||||
console.info(`Creating new CheckpointTracker for task ${taskId}`)
|
||||
const startTime = performance.now()
|
||||
|
||||
// Check if checkpoints are disabled in VS Code settings
|
||||
const enableCheckpoints = vscode.workspace.getConfiguration("cline").get<boolean>("enableCheckpoints") ?? true
|
||||
@@ -110,10 +109,12 @@ class CheckpointTracker {
|
||||
|
||||
const newTracker = new CheckpointTracker(globalStoragePath, taskId, workingDir, cwdHash)
|
||||
|
||||
// Branch-per-task structure
|
||||
const gitPath = await getShadowGitPath(newTracker.globalStoragePath, newTracker.taskId, newTracker.cwdHash)
|
||||
await newTracker.gitOperations.initShadowGit(gitPath, workingDir)
|
||||
await newTracker.gitOperations.switchToTaskBranch(newTracker.taskId, gitPath)
|
||||
await newTracker.gitOperations.initShadowGit(gitPath, workingDir, taskId)
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized", durationMs)
|
||||
|
||||
return newTracker
|
||||
} catch (error) {
|
||||
console.error("Failed to create CheckpointTracker:", error)
|
||||
@@ -126,33 +127,31 @@ class CheckpointTracker {
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Creates commit with checkpoint files in shadow git repo
|
||||
* - For new tasks, switches to task-specific branch first
|
||||
* - Caches the created commit hash
|
||||
*
|
||||
* Commit structure:
|
||||
* - Branch-per-task: "checkpoint-{cwdHash}-{taskId}"
|
||||
* - Commit message: "checkpoint-{cwdHash}-{taskId}"
|
||||
* - Always allows empty commits
|
||||
*
|
||||
* Dependencies:
|
||||
* - Requires initialized shadow git (getShadowGitPath)
|
||||
* - For new checkpoints, requires task branch setup
|
||||
* - Uses addCheckpointFiles to stage changes using 'git add .'
|
||||
* - Relies on git's native exclusion handling via the exclude file
|
||||
*
|
||||
* @returns Promise<string | undefined> The created commit hash, or undefined if:
|
||||
* - Shadow git access fails
|
||||
* - Branch switch fails
|
||||
* - Staging files fails
|
||||
* - Commit creation fails
|
||||
* @throws Error if unable to:
|
||||
* - Access shadow git path
|
||||
* - Initialize simple-git
|
||||
* - Switch branches
|
||||
* - Stage or commit files
|
||||
*/
|
||||
public async commit(): Promise<string | undefined> {
|
||||
try {
|
||||
console.info(`Creating new checkpoint commit for task ${this.taskId}`)
|
||||
const startTime = performance.now()
|
||||
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
@@ -165,9 +164,14 @@ class CheckpointTracker {
|
||||
console.info(`Creating checkpoint commit with message: ${commitMessage}`)
|
||||
const result = await git.commit(commitMessage, {
|
||||
"--allow-empty": null,
|
||||
"--no-verify": null,
|
||||
})
|
||||
const commitHash = result.commit || ""
|
||||
console.warn(`Checkpoint commit created.`)
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "commit_created", durationMs)
|
||||
|
||||
return commitHash
|
||||
} catch (error) {
|
||||
console.error("Failed to create checkpoint:", {
|
||||
@@ -222,7 +226,6 @@ class CheckpointTracker {
|
||||
*
|
||||
* Dependencies:
|
||||
* - Requires initialized shadow git (getShadowGitPath)
|
||||
* - For new checkpoints, requires task branch setup
|
||||
* - Must be called with a valid commit hash from this task's history
|
||||
*
|
||||
* @param commitHash - The hash of the checkpoint commit to reset to
|
||||
@@ -230,17 +233,20 @@ class CheckpointTracker {
|
||||
* @throws Error if unable to:
|
||||
* - Access shadow git path
|
||||
* - Initialize simple-git
|
||||
* - Switch to task branch
|
||||
* - Reset to target commit
|
||||
*/
|
||||
public async resetHead(commitHash: string): Promise<void> {
|
||||
console.info(`Resetting to checkpoint: ${commitHash}`)
|
||||
const startTime = performance.now()
|
||||
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
console.debug(`Using shadow git at: ${gitPath}`)
|
||||
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
|
||||
await git.reset(["--hard", commitHash]) // Hard reset to target commit
|
||||
console.debug(`Successfully reset to checkpoint: ${commitHash}`)
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "restored", durationMs)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -257,7 +263,7 @@ class CheckpointTracker {
|
||||
* @returns Array of file changes with before/after content
|
||||
*/
|
||||
public async getDiffSet(
|
||||
lhsHash?: string,
|
||||
lhsHash: string,
|
||||
rhsHash?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
@@ -267,40 +273,17 @@ class CheckpointTracker {
|
||||
after: string
|
||||
}>
|
||||
> {
|
||||
const startTime = performance.now()
|
||||
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
console.info(`Getting diff between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
|
||||
|
||||
// If lhsHash is missing, iteratively check up to 5 commits to find the first one with tracked files
|
||||
let baseHash = lhsHash
|
||||
if (!baseHash) {
|
||||
// Ensure we're on the correct task branch before getting commits
|
||||
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
|
||||
|
||||
// Verify which branch we're on after switching
|
||||
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
|
||||
console.info(`Getting commits from branch: ${currentBranch}`)
|
||||
|
||||
try {
|
||||
// Get all commits that match the checkpoint pattern for this specific task
|
||||
const commitPattern = `checkpoint-${this.cwdHash}-${this.taskId}`
|
||||
const branchCommits = await git.log(["--grep", commitPattern, "--reverse"])
|
||||
if (!branchCommits.all.length) {
|
||||
throw new Error("No commits found in the branch.")
|
||||
}
|
||||
// Get the first commit that matches our task's checkpoint pattern
|
||||
baseHash = branchCommits.all[0].hash
|
||||
} catch (error) {
|
||||
console.error("Failed to get branch commits:", error)
|
||||
throw new Error("Failed to determine branch history")
|
||||
}
|
||||
}
|
||||
|
||||
// Stage all changes so that untracked files appear in diff summary
|
||||
await this.gitOperations.addCheckpointFiles(git)
|
||||
|
||||
const diffRange = rhsHash ? `${baseHash}..${rhsHash}` : baseHash
|
||||
const diffRange = rhsHash ? `${lhsHash}..${rhsHash}` : lhsHash
|
||||
console.info(`Diff range: ${diffRange}`)
|
||||
const diffSummary = await git.diffSummary([diffRange])
|
||||
|
||||
@@ -311,7 +294,7 @@ class CheckpointTracker {
|
||||
|
||||
let beforeContent = ""
|
||||
try {
|
||||
beforeContent = await git.show([`${baseHash}:${filePath}`])
|
||||
beforeContent = await git.show([`${lhsHash}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in older commit => remains empty
|
||||
}
|
||||
@@ -339,22 +322,38 @@ class CheckpointTracker {
|
||||
})
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "diff_generated", durationMs)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all checkpoint data for a given task.
|
||||
* Returns the number of files changed between two commits.
|
||||
*
|
||||
* @param taskId - The ID of the task whose checkpoints should be deleted
|
||||
* @param historyItem - The history item containing the shadow git config for this task
|
||||
* @param globalStoragePath - the globalStorage path
|
||||
* @throws Error if deletion fails
|
||||
* @param lhsHash - The commit to compare from (older commit)
|
||||
* @param rhsHash - The commit to compare to (newer commit).
|
||||
* If omitted, we compare to the working directory.
|
||||
* @returns The number of files changed between the commits
|
||||
*/
|
||||
public static async deleteCheckpoints(taskId: string, historyItem: HistoryItem, globalStoragePath: string): Promise<void> {
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage uri is invalid")
|
||||
}
|
||||
await GitOperations.deleteTaskBranchStatic(taskId, historyItem, globalStoragePath)
|
||||
public async getDiffCount(lhsHash: string, rhsHash?: string): Promise<number> {
|
||||
const startTime = performance.now()
|
||||
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
console.info(`Getting diff count between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
|
||||
|
||||
// Stage all changes so that untracked files appear in diff summary
|
||||
await this.gitOperations.addCheckpointFiles(git)
|
||||
|
||||
const diffRange = rhsHash ? `${lhsHash}..${rhsHash}` : lhsHash
|
||||
const diffSummary = await git.diffSummary([diffRange])
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "diff_generated", durationMs)
|
||||
|
||||
return diffSummary.files.length
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,8 @@ import os from "os"
|
||||
|
||||
/**
|
||||
* Gets the path to the shadow Git repository in globalStorage.
|
||||
* For new checkpoints, uses the consolidated branch-per-task structure.
|
||||
*
|
||||
* Branch-per-task path structure:
|
||||
* Checkpoints path structure:
|
||||
* globalStorage/
|
||||
* checkpoints/
|
||||
* {cwdHash}/
|
||||
|
||||
@@ -158,7 +158,6 @@ export class DiffViewProvider {
|
||||
await updatedDocument.save()
|
||||
}
|
||||
|
||||
// await delay(100)
|
||||
// get text after save in case there is any auto-formatting done by the editor
|
||||
const postSaveContent = updatedDocument.getText()
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import pdf from "pdf-parse/lib/pdf-parse"
|
||||
import mammoth from "mammoth"
|
||||
import fs from "fs/promises"
|
||||
import { isBinaryFile } from "isbinaryfile"
|
||||
import { getFileSizeInKB } from "../../utils/fs"
|
||||
|
||||
export async function extractTextFromFile(filePath: string): Promise<string> {
|
||||
try {
|
||||
@@ -22,6 +23,11 @@ export async function extractTextFromFile(filePath: string): Promise<string> {
|
||||
default:
|
||||
const isBinary = await isBinaryFile(filePath).catch(() => false)
|
||||
if (!isBinary) {
|
||||
// If file is over 300KB, throw an error
|
||||
const fileSizeInKB = await getFileSizeInKB(filePath)
|
||||
if (fileSizeInKB > 300) {
|
||||
throw new Error(`File is too large to read into context.`)
|
||||
}
|
||||
return await fs.readFile(filePath, "utf8")
|
||||
} else {
|
||||
throw new Error(`Cannot read text for file type: ${fileExtension}`)
|
||||
|
||||
@@ -63,7 +63,6 @@ export async function fetchOpenGraphData(url: string): Promise<OpenGraphData> {
|
||||
type: data.ogType,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching Open Graph data for ${url}:`, error)
|
||||
// Return basic information based on the URL
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
@@ -100,8 +99,7 @@ export async function isImageUrl(url: string): Promise<boolean> {
|
||||
const contentType = response.headers["content-type"]
|
||||
return contentType && contentType.startsWith("image/")
|
||||
} catch (error) {
|
||||
console.error(`Error checking if URL is an image: ${url}`, error)
|
||||
// If we can't determine, fall back to checking the file extension
|
||||
return /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url)
|
||||
return /\.(jpg|jpeg|png|gif|webp|bmp|svg|tiff|tif|avif)$/i.test(url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import axios, { AxiosRequestConfig, AxiosResponse } from "axios"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import type { BalanceResponse, PaymentTransaction, UsageTransaction } from "../../shared/ClineAccount"
|
||||
|
||||
export class ClineAccountService {
|
||||
private readonly baseUrl = "https://api.cline.bot/v1"
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
|
||||
constructor(provider: ClineProvider) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's Cline Account key from the apiConfiguration
|
||||
*/
|
||||
private async getClineApiKey(): Promise<string | undefined> {
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { apiConfiguration } = await provider.getStateToPostToWebview()
|
||||
return apiConfiguration?.clineApiKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to make authenticated requests to the Cline API
|
||||
* @param endpoint The API endpoint to call (without the base URL)
|
||||
* @param config Additional axios request configuration
|
||||
* @returns The API response data
|
||||
* @throws Error if the API key is not found or the request fails
|
||||
*/
|
||||
private async authenticatedRequest<T>(endpoint: string, config: AxiosRequestConfig = {}): Promise<T> {
|
||||
const clineApiKey = await this.getClineApiKey()
|
||||
|
||||
if (!clineApiKey) {
|
||||
throw new Error("Cline API key not found")
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}${endpoint}`
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
...config,
|
||||
headers: {
|
||||
Authorization: `Bearer ${clineApiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
...config.headers,
|
||||
},
|
||||
}
|
||||
|
||||
const response: AxiosResponse<T> = await axios.get(url, requestConfig)
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error(`Invalid response from ${endpoint} API`)
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's current credit balance
|
||||
*/
|
||||
async fetchBalance(): Promise<BalanceResponse | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<BalanceResponse>("/user/credits/balance")
|
||||
|
||||
// Post to webview
|
||||
await this.providerRef.deref()?.postMessageToWebview({
|
||||
type: "userCreditsBalance",
|
||||
userCreditsBalance: data,
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch balance:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's usage transactions
|
||||
*/
|
||||
async fetchUsageTransactions(): Promise<UsageTransaction[] | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<UsageTransaction[]>("/user/credits/usage")
|
||||
|
||||
// Post to webview
|
||||
await this.providerRef.deref()?.postMessageToWebview({
|
||||
type: "userCreditsUsage",
|
||||
userCreditsUsage: data,
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch usage transactions:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's payment transactions
|
||||
*/
|
||||
async fetchPaymentTransactions(): Promise<PaymentTransaction[] | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<PaymentTransaction[]>("/user/credits/payments")
|
||||
|
||||
// Post to webview
|
||||
await this.providerRef.deref()?.postMessageToWebview({
|
||||
type: "userCreditsPayments",
|
||||
userCreditsPayments: data,
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch payment transactions:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Browser, Page, ScreenshotOptions, TimeoutError, launch } from "puppetee
|
||||
// @ts-ignore
|
||||
import PCR from "puppeteer-chromium-resolver"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import delay from "delay"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { BrowserActionResult } from "../../shared/ExtensionMessage"
|
||||
import { BrowserSettings } from "../../shared/BrowserSettings"
|
||||
@@ -295,7 +295,7 @@ export class BrowserSession {
|
||||
}
|
||||
|
||||
lastHTMLSize = currentHTMLSize
|
||||
await delay(checkDurationMsecs)
|
||||
await setTimeoutPromise(checkDurationMsecs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ export class BrowserSession {
|
||||
this.currentMousePosition = coordinate
|
||||
|
||||
// Small delay to check if click triggered any network activity
|
||||
await delay(100)
|
||||
await setTimeoutPromise(100)
|
||||
|
||||
if (hasNetworkActivity) {
|
||||
// If we detected network activity, wait for navigation/loading
|
||||
@@ -346,7 +346,7 @@ export class BrowserSession {
|
||||
behavior: "auto",
|
||||
})
|
||||
})
|
||||
await delay(300)
|
||||
await setTimeoutPromise(300)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@ export class BrowserSession {
|
||||
behavior: "auto",
|
||||
})
|
||||
})
|
||||
await delay(300)
|
||||
await setTimeoutPromise(300)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as path from "path"
|
||||
import { arePathsEqual } from "../../utils/path"
|
||||
|
||||
export async function listFiles(dirPath: string, recursive: boolean, limit: number): Promise<[string[], boolean]> {
|
||||
// First resolve the path normally - path.resolve doesn't care about glob special characters
|
||||
const absolutePath = path.resolve(dirPath)
|
||||
// Do not allow listing files in root or home directory, which cline tends to want to do when the user's prompt is vague.
|
||||
const root = process.platform === "win32" ? path.parse(absolutePath).root : "/"
|
||||
@@ -48,6 +49,7 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb
|
||||
}
|
||||
|
||||
// * globs all files in one dir, ** globs files in nested directories
|
||||
// For non-recursive listing, we still use a simple pattern
|
||||
const filePaths = recursive ? await globbyLevelByLevel(limit, options) : (await globby("*", options)).slice(0, limit)
|
||||
|
||||
return [filePaths, filePaths.length >= limit]
|
||||
@@ -80,7 +82,11 @@ async function globbyLevelByLevel(limit: number, options?: Options) {
|
||||
}
|
||||
results.add(file)
|
||||
if (file.endsWith("/")) {
|
||||
queue.push(`${file}*`)
|
||||
// Escape parentheses in the path to prevent glob pattern interpretation
|
||||
// This is crucial for NextJS folder naming conventions which use parentheses like (auth), (dashboard)
|
||||
// Without escaping, glob treats parentheses as special pattern grouping characters
|
||||
const escapedFile = file.replace(/\(/g, "\\(").replace(/\)/g, "\\)")
|
||||
queue.push(`${escapedFile}*`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
ReadResourceResultSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import chokidar, { FSWatcher } from "chokidar"
|
||||
import delay from "delay"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { z } from "zod"
|
||||
import { ClineProvider, GlobalFileNames } from "../../core/webview/ClineProvider"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import {
|
||||
DEFAULT_MCP_TIMEOUT_SECONDS,
|
||||
McpMode,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { arePathsEqual } from "../../utils/path"
|
||||
import { secondsToMs } from "../../utils/time"
|
||||
import { GlobalFileNames } from "../../global-constants"
|
||||
export type McpConnection = {
|
||||
server: McpServer
|
||||
client: Client
|
||||
@@ -414,7 +415,7 @@ export class McpHub {
|
||||
connection.server.status = "connecting"
|
||||
connection.server.error = ""
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
await delay(500) // artificial delay to show user that server is restarting
|
||||
await setTimeoutPromise(500) // artificial delay to show user that server is restarting
|
||||
try {
|
||||
await this.deleteConnection(serverName)
|
||||
// Try to connect again using existing config
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { context, SpanKind, trace } from "@opentelemetry/api"
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"
|
||||
import { Resource } from "@opentelemetry/resources"
|
||||
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"
|
||||
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
|
||||
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
|
||||
export type TelemetryChatMessage = {
|
||||
role: "user" | "assistant" | "system"
|
||||
ts: number
|
||||
content: Anthropic.Messages.MessageParam["content"]
|
||||
}
|
||||
|
||||
interface ConversationMetadata {
|
||||
apiProvider?: string
|
||||
model?: string
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
}
|
||||
|
||||
const { IS_DEV } = process.env
|
||||
|
||||
/**
|
||||
Cline Telemetry (currently only available in DEV builds)
|
||||
|
||||
Advanced Setting to opt-in to LLM observability, allowing you to share message data, code, and more extensive telemetry to help improve prompts used in Cline, train our models, and understand failure states more accurately.
|
||||
|
||||
"cline.conversationTelemetry": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"markdownDescription": "Share message data, code, and more extensive telemetry. This data may be used to improve prompts used in Cline, train models, and understand failure states more accurately. [Learn more](https://docs.cline.bot/more-info/llm-observability)"
|
||||
}
|
||||
*/
|
||||
|
||||
export class ConversationTelemetryService {
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
private distinctId: string = vscode.env.machineId
|
||||
private apiEndpoint: string = "https://api.cline.bot/v1/traces"
|
||||
private tracerProvider: NodeTracerProvider | undefined
|
||||
private tracer: any
|
||||
private messageIndices: Map<string, number> = new Map()
|
||||
|
||||
constructor(provider: ClineProvider) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
}
|
||||
|
||||
private async getClineApiKey(): Promise<string | undefined> {
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
return undefined
|
||||
}
|
||||
const { apiConfiguration } = await provider.getStateToPostToWebview()
|
||||
return apiConfiguration?.clineApiKey
|
||||
}
|
||||
|
||||
public isOptedInToConversationTelemetry(): boolean {
|
||||
// First check global telemetry level - telemetry should only be enabled when level is "all"
|
||||
const telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
|
||||
const isGlobalTelemetryEnabled = telemetryLevel === "all"
|
||||
|
||||
// User has to manually opt in to conversation telemetry in Advanced Settings
|
||||
const isConversationTelemetryEnabled =
|
||||
vscode.workspace.getConfiguration("cline").get<boolean>("conversationTelemetry") ?? false
|
||||
|
||||
// Currently only enabled in dev environment
|
||||
const isDevEnvironment = !!IS_DEV
|
||||
|
||||
return isDevEnvironment && isGlobalTelemetryEnabled && isConversationTelemetryEnabled
|
||||
}
|
||||
|
||||
private async initializeTracer() {
|
||||
try {
|
||||
// Create a resource that identifies our service
|
||||
const resource = new Resource({
|
||||
[ATTR_SERVICE_NAME]: "cline-extension",
|
||||
[ATTR_SERVICE_VERSION]: "1.0.0",
|
||||
})
|
||||
|
||||
const clineApiKey = await this.getClineApiKey()
|
||||
|
||||
console.log("[ConversationTelemetry] Initializing OpenTelemetry tracer...")
|
||||
|
||||
// Configure the OTLP exporter
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
// Add API key to headers if available
|
||||
if (clineApiKey) {
|
||||
headers["Authorization"] = `Bearer ${clineApiKey}`
|
||||
}
|
||||
|
||||
const exporter = new OTLPTraceExporter({
|
||||
url: this.apiEndpoint,
|
||||
headers,
|
||||
})
|
||||
|
||||
// Create the span processor
|
||||
const spanProcessor = new SimpleSpanProcessor(exporter as any)
|
||||
|
||||
// Create the trace provider with the span processor in the config
|
||||
this.tracerProvider = new NodeTracerProvider({
|
||||
resource,
|
||||
spanProcessors: [spanProcessor as any],
|
||||
})
|
||||
|
||||
// Register the provider
|
||||
this.tracerProvider.register()
|
||||
|
||||
// Get a tracer
|
||||
this.tracer = trace.getTracer("cline-conversation-tracer")
|
||||
|
||||
console.log("[ConversationTelemetry] OpenTelemetry tracer initialized successfully")
|
||||
} catch (error) {
|
||||
console.error("[ConversationTelemetry] Failed to initialize OpenTelemetry tracer:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a message in the conversation as an OpenTelemetry span
|
||||
* ONLY HAPPENS IF USER IS OPTED INTO CONVERSATION TELEMETRY IN ADVANCED SETTINGS
|
||||
*/
|
||||
public async captureMessage(taskId: string, message: TelemetryChatMessage, metadata: ConversationMetadata) {
|
||||
// Do NOT capture message if user has not explicitly opted in
|
||||
if (!this.isOptedInToConversationTelemetry()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.tracer) {
|
||||
await this.initializeTracer()
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert taskId to a valid trace ID (must be 32 hex chars)
|
||||
const traceId = this.generateTraceIdFromTimestamp(taskId)
|
||||
|
||||
// Convert message timestamp to a valid span ID (must be 16 hex chars)
|
||||
if (!message.ts && message.ts !== 0) {
|
||||
throw new Error("Message timestamp is required")
|
||||
}
|
||||
|
||||
const timestamp = message.ts
|
||||
const spanId = this.generateSpanIdFromTimestamp(timestamp)
|
||||
|
||||
// Create a span context with our IDs
|
||||
const spanContext = trace.setSpanContext(context.active(), {
|
||||
traceId,
|
||||
spanId,
|
||||
isRemote: false,
|
||||
traceFlags: 1, // Sampled
|
||||
})
|
||||
|
||||
// Start a new span with the context
|
||||
const span = this.tracer.startSpan(
|
||||
`message.${message.role}`,
|
||||
{
|
||||
kind: SpanKind.CLIENT,
|
||||
startTime: this.millisecondsToHrTime(timestamp), // Convert to nanoseconds
|
||||
},
|
||||
spanContext,
|
||||
)
|
||||
|
||||
// Get the message index for this task
|
||||
const messageIndex = this.getNextMessageIndex(taskId)
|
||||
|
||||
// Add attributes to the span
|
||||
span.setAttribute("task.id", taskId)
|
||||
span.setAttribute("user.id", this.distinctId)
|
||||
span.setAttribute("message.role", message.role)
|
||||
span.setAttribute("message.timestamp", timestamp)
|
||||
span.setAttribute("message.index", messageIndex)
|
||||
|
||||
const c = message.content
|
||||
|
||||
// Add Braintrust-compatible attributes
|
||||
span.setAttribute("gen_ai.request.model", metadata.model)
|
||||
|
||||
if (message.role === "user") {
|
||||
span.setAttribute("gen_ai.prompt", this.extractContent(message))
|
||||
} else if (message.role === "assistant") {
|
||||
span.setAttribute("gen_ai.completion", this.extractContent(message))
|
||||
span.setAttribute("gen_ai.usage.prompt_tokens", metadata.tokensIn)
|
||||
span.setAttribute("gen_ai.usage.completion_tokens", metadata.tokensOut)
|
||||
} else if (message.role === "system") {
|
||||
span.setAttribute("gen_ai.system_prompt", this.extractContent(message))
|
||||
}
|
||||
|
||||
// Add custom metadata in Braintrust format
|
||||
span.setAttribute("braintrust.metadata.api_provider", metadata.apiProvider)
|
||||
span.setAttribute("braintrust.metadata.ts", message.ts)
|
||||
|
||||
// End the span immediately since messages are discrete events
|
||||
span.end(this.millisecondsToHrTime(timestamp)) // Convert to nanoseconds
|
||||
|
||||
console.log(`[ConversationTelemetry] Captured ${message.role} message for task ${taskId}`, { span })
|
||||
} catch (error) {
|
||||
console.error("[ConversationTelemetry] Error capturing message:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a decimal timestamp to a valid trace ID (32 hex chars)
|
||||
*/
|
||||
private generateTraceIdFromTimestamp(timestamp: string): string {
|
||||
// Pad with zeros and convert to hex
|
||||
const hex = BigInt(timestamp).toString(16).padStart(32, "0")
|
||||
return hex.substring(0, 32) // Ensure it's exactly 32 chars
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts milliseconds to high-resolution time format expected by OpenTelemetry
|
||||
* Returns [seconds, nanoseconds]
|
||||
*/
|
||||
private millisecondsToHrTime(milliseconds: number): [number, number] {
|
||||
return [
|
||||
Math.floor(milliseconds / 1000), // seconds
|
||||
(milliseconds % 1000) * 1000000, // nanoseconds (remainder in ms * 10^6)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a decimal timestamp to a valid span ID (16 hex chars)
|
||||
*/
|
||||
private generateSpanIdFromTimestamp(timestamp: number): string {
|
||||
// Pad with zeros and convert to hex
|
||||
const hex = BigInt(timestamp).toString(16).padStart(16, "0")
|
||||
return hex.substring(0, 16) // Ensure it's exactly 16 chars
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract content from different message formats
|
||||
*/
|
||||
private extractContent(message: TelemetryChatMessage): string {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content
|
||||
}
|
||||
|
||||
return message.content
|
||||
.map((block) => (block.type === "text" ? block.text : null))
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Track message indices per task
|
||||
*/
|
||||
private getNextMessageIndex(taskId: string): number {
|
||||
const currentIndex = this.messageIndices.get(taskId) || 0
|
||||
this.messageIndices.set(taskId, currentIndex + 1)
|
||||
return currentIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends conversation data to cleanup endpoint to remove deleted messages from telemetry
|
||||
* ONLY HAPPENS IF USER IS OPTED INTO CONVERSATION TELEMETRY IN ADVANCED SETTINGS
|
||||
*/
|
||||
public async cleanupTask(taskId: string, conversationData: any): Promise<void> {
|
||||
// Do NOT send data if user has not explicitly opted in
|
||||
if (!this.isOptedInToConversationTelemetry()) {
|
||||
return
|
||||
}
|
||||
|
||||
const clineApiKey = await this.getClineApiKey()
|
||||
if (!clineApiKey) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Configure the headers with API key
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
// Add API key to headers
|
||||
headers["Authorization"] = `Bearer ${clineApiKey}`
|
||||
|
||||
// Send the data to the cleanup endpoint
|
||||
const cleanupEndpoint = `${this.apiEndpoint.replace("/traces", "/traces/cleanup")}`
|
||||
|
||||
// Use fetch API to send the data
|
||||
const response = await fetch(cleanupEndpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
taskId: taskId,
|
||||
conversationData,
|
||||
userId: this.distinctId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to send cleanup data: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
console.log(`[ConversationTelemetry] Cleanup data sent for task ${taskId}`)
|
||||
} catch (error) {
|
||||
console.error("[ConversationTelemetry] Error sending cleanup data:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the tracer provider
|
||||
*/
|
||||
public async shutdown(): Promise<void> {
|
||||
if (this.tracerProvider) {
|
||||
await this.tracerProvider.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PostHog } from "posthog-node"
|
||||
import * as vscode from "vscode"
|
||||
import { version as extensionVersion } from "../../../package.json"
|
||||
|
||||
/**
|
||||
* PostHogClient handles telemetry event tracking for the Cline extension
|
||||
@@ -23,7 +24,7 @@ class PostHogClient {
|
||||
TOKEN_USAGE: "task.tokens",
|
||||
// Tracks switches between plan and act modes
|
||||
MODE_SWITCH: "task.mode",
|
||||
// Tracks usage of the git-based checkpoint system (create/restore/delete)
|
||||
// Tracks usage of the git-based checkpoint system (shadow_git_initialized, commit_created, branch_created, branch_deleted_active, branch_deleted_inactive, restored)
|
||||
CHECKPOINT_USED: "task.checkpoint_used",
|
||||
// Tracks when tools (like file operations, commands) are used
|
||||
TOOL_USED: "task.tool_used",
|
||||
@@ -65,6 +66,8 @@ class PostHogClient {
|
||||
private distinctId: string = vscode.env.machineId
|
||||
/** Whether telemetry is currently enabled based on user and VSCode settings */
|
||||
private telemetryEnabled: boolean = false
|
||||
/** Current version of the extension */
|
||||
private readonly version: string = extensionVersion
|
||||
|
||||
/**
|
||||
* Private constructor to enforce singleton pattern
|
||||
@@ -120,7 +123,12 @@ class PostHogClient {
|
||||
public capture(event: { event: string; properties?: any }): void {
|
||||
// Only send events if telemetry is enabled
|
||||
if (this.telemetryEnabled) {
|
||||
this.client.capture({ distinctId: this.distinctId, event: event.event, properties: event.properties })
|
||||
// Include extension version in all event properties
|
||||
const propertiesWithVersion = {
|
||||
...event.properties,
|
||||
extension_version: this.version,
|
||||
}
|
||||
this.client.capture({ distinctId: this.distinctId, event: event.event, properties: propertiesWithVersion })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,14 +257,20 @@ class PostHogClient {
|
||||
/**
|
||||
* Records interactions with the git-based checkpoint system
|
||||
* @param taskId Unique identifier for the task
|
||||
* @param action The type of checkpoint action (created/restored/deleted)
|
||||
* @param action The type of checkpoint action
|
||||
* @param durationMs Optional duration of the operation in milliseconds
|
||||
*/
|
||||
public captureCheckpointUsage(taskId: string, action: "created" | "restored" | "deleted") {
|
||||
public captureCheckpointUsage(
|
||||
taskId: string,
|
||||
action: "shadow_git_initialized" | "commit_created" | "restored" | "diff_generated",
|
||||
durationMs?: number,
|
||||
) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.TASK.CHECKPOINT_USED,
|
||||
properties: {
|
||||
taskId,
|
||||
action,
|
||||
durationMs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ function separateFiles(allFiles: string[]): {
|
||||
"java",
|
||||
"php",
|
||||
"swift",
|
||||
// Kotlin
|
||||
"kt",
|
||||
].map((e) => `.${e}`)
|
||||
const filesToParse = allFiles.filter((file) => extensions.includes(path.extname(file))).slice(0, 50) // 50 files max
|
||||
const remainingFiles = allFiles.filter((file) => !filesToParse.includes(file))
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
javaQuery,
|
||||
phpQuery,
|
||||
swiftQuery,
|
||||
kotlinQuery,
|
||||
} from "./queries"
|
||||
|
||||
export interface LanguageParser {
|
||||
@@ -120,6 +121,10 @@ export async function loadRequiredLanguageParsers(filesToParse: string[]): Promi
|
||||
language = await loadLanguage("swift")
|
||||
query = language.query(swiftQuery)
|
||||
break
|
||||
case "kt":
|
||||
language = await loadLanguage("kotlin")
|
||||
query = language.query(kotlinQuery)
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported language: ${ext}`)
|
||||
}
|
||||
|
||||
@@ -10,3 +10,4 @@ export { default as cQuery } from "./c"
|
||||
export { default as csharpQuery } from "./c-sharp"
|
||||
export { default as goQuery } from "./go"
|
||||
export { default as swiftQuery } from "./swift"
|
||||
export { default as kotlinQuery } from "./kotlin"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export default `
|
||||
(class_declaration
|
||||
name: (simple_identifier) @name.definition.class) @definition.class
|
||||
|
||||
(function_declaration
|
||||
name: (simple_identifier) @name.definition.function) @definition.function
|
||||
|
||||
(interface_declaration
|
||||
name: (simple_identifier) @name.definition.interface) @definition.interface
|
||||
|
||||
(object_declaration
|
||||
name: (simple_identifier) @name.definition.object) @definition.object
|
||||
|
||||
(property_declaration
|
||||
name: (simple_identifier) @name.definition.property) @definition.property
|
||||
|
||||
(enum_declaration
|
||||
name: (simple_identifier) @name.definition.enum) @definition.enum
|
||||
|
||||
(typealias_declaration
|
||||
name: (simple_identifier) @name.definition.typealias) @definition.typealias
|
||||
)`
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface BalanceResponse {
|
||||
currentBalance: number
|
||||
}
|
||||
|
||||
export interface UsageTransaction {
|
||||
spentAt: string
|
||||
credits: string
|
||||
modelProvider: string
|
||||
model: string
|
||||
promptTokens: string
|
||||
completionTokens: string
|
||||
}
|
||||
|
||||
export interface PaymentTransaction {
|
||||
paidAt: string
|
||||
amountCents: string
|
||||
credits: string
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { ChatSettings } from "./ChatSettings"
|
||||
import { HistoryItem } from "./HistoryItem"
|
||||
import { McpServer, McpMarketplaceCatalog, McpMarketplaceItem, McpDownloadResponse } from "./mcp"
|
||||
import { TelemetrySetting } from "./TelemetrySetting"
|
||||
import type { BalanceResponse, UsageTransaction, PaymentTransaction } from "../shared/ClineAccount"
|
||||
|
||||
// webview will hold state
|
||||
export interface ExtensionMessage {
|
||||
@@ -33,6 +34,12 @@ export interface ExtensionMessage {
|
||||
| "commitSearchResults"
|
||||
| "openGraphData"
|
||||
| "isImageUrlResult"
|
||||
| "didUpdateSettings"
|
||||
| "userCreditsBalance"
|
||||
| "userCreditsUsage"
|
||||
| "userCreditsPayments"
|
||||
| "totalTasksSize"
|
||||
| "addToInput"
|
||||
text?: string
|
||||
action?:
|
||||
| "chatButtonClicked"
|
||||
@@ -42,6 +49,7 @@ export interface ExtensionMessage {
|
||||
| "didBecomeVisible"
|
||||
| "accountLoginClicked"
|
||||
| "accountLogoutClicked"
|
||||
| "accountButtonClicked"
|
||||
invoke?: Invoke
|
||||
state?: ExtensionState
|
||||
images?: string[]
|
||||
@@ -68,6 +76,10 @@ export interface ExtensionMessage {
|
||||
}
|
||||
url?: string
|
||||
isImage?: boolean
|
||||
userCreditsBalance?: BalanceResponse
|
||||
userCreditsUsage?: UsageTransaction[]
|
||||
userCreditsPayments?: PaymentTransaction[]
|
||||
totalTasksSize?: number | null
|
||||
}
|
||||
|
||||
export type Invoke = "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
|
||||
@@ -77,26 +89,27 @@ export type Platform = "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sun
|
||||
export const DEFAULT_PLATFORM = "unknown"
|
||||
|
||||
export interface ExtensionState {
|
||||
version: string
|
||||
apiConfiguration?: ApiConfiguration
|
||||
customInstructions?: string
|
||||
uriScheme?: string
|
||||
currentTaskItem?: HistoryItem
|
||||
checkpointTrackerErrorMessage?: string
|
||||
clineMessages: ClineMessage[]
|
||||
taskHistory: HistoryItem[]
|
||||
shouldShowAnnouncement: boolean
|
||||
autoApprovalSettings: AutoApprovalSettings
|
||||
browserSettings: BrowserSettings
|
||||
chatSettings: ChatSettings
|
||||
checkpointTrackerErrorMessage?: string
|
||||
clineMessages: ClineMessage[]
|
||||
currentTaskItem?: HistoryItem
|
||||
customInstructions?: string
|
||||
mcpMarketplaceEnabled?: boolean
|
||||
planActSeparateModelsSetting: boolean
|
||||
platform: Platform
|
||||
shouldShowAnnouncement: boolean
|
||||
taskHistory: HistoryItem[]
|
||||
telemetrySetting: TelemetrySetting
|
||||
uriScheme?: string
|
||||
userInfo?: {
|
||||
displayName: string | null
|
||||
email: string | null
|
||||
photoURL: string | null
|
||||
}
|
||||
mcpMarketplaceEnabled?: boolean
|
||||
telemetrySetting: TelemetrySetting
|
||||
version: string
|
||||
vscMachineId: string
|
||||
}
|
||||
|
||||
@@ -197,6 +210,18 @@ export interface ClineAskUseMcpServer {
|
||||
uri?: string
|
||||
}
|
||||
|
||||
export interface ClinePlanModeResponse {
|
||||
response: string
|
||||
options?: string[]
|
||||
selected?: string
|
||||
}
|
||||
|
||||
export interface ClineAskQuestion {
|
||||
question: string
|
||||
options?: string[]
|
||||
selected?: string
|
||||
}
|
||||
|
||||
export interface ClineApiReqInfo {
|
||||
request?: string
|
||||
tokensIn?: number
|
||||
|
||||
@@ -4,11 +4,11 @@ import { BrowserSettings } from "./BrowserSettings"
|
||||
import { ChatSettings } from "./ChatSettings"
|
||||
import { UserInfo } from "./UserInfo"
|
||||
import { ChatContent } from "./ChatContent"
|
||||
import { TelemetrySetting } from "./TelemetrySetting"
|
||||
|
||||
export interface WebviewMessage {
|
||||
type:
|
||||
| "apiConfiguration"
|
||||
| "customInstructions"
|
||||
| "webviewDidLaunch"
|
||||
| "newTask"
|
||||
| "askResponse"
|
||||
@@ -45,6 +45,7 @@ export interface WebviewMessage {
|
||||
| "getLatestState"
|
||||
| "accountLoginClicked"
|
||||
| "accountLogoutClicked"
|
||||
| "showAccountViewClicked"
|
||||
| "authStateChanged"
|
||||
| "authCallback"
|
||||
| "fetchMcpMarketplace"
|
||||
@@ -59,6 +60,11 @@ export interface WebviewMessage {
|
||||
| "fetchOpenGraphData"
|
||||
| "checkIsImageUrl"
|
||||
| "invoke"
|
||||
| "updateSettings"
|
||||
| "clearAllTaskHistory"
|
||||
| "fetchUserCreditsData"
|
||||
| "optionsResponse"
|
||||
| "requestTotalTasksSize"
|
||||
// | "relaunchChromeDebugMode"
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
@@ -83,6 +89,9 @@ export interface WebviewMessage {
|
||||
customToken?: string
|
||||
// For openInBrowser
|
||||
url?: string
|
||||
planActSeparateModelsSetting?: boolean
|
||||
telemetrySetting?: TelemetrySetting
|
||||
customInstructionsSetting?: string
|
||||
}
|
||||
|
||||
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
|
||||
|
||||
+386
-6
@@ -18,6 +18,7 @@ export type ApiProvider =
|
||||
| "litellm"
|
||||
| "asksage"
|
||||
| "xai"
|
||||
| "sambanova"
|
||||
|
||||
export interface ApiHandlerOptions {
|
||||
apiModelId?: string
|
||||
@@ -30,6 +31,7 @@ export interface ApiHandlerOptions {
|
||||
openRouterApiKey?: string
|
||||
openRouterModelId?: string
|
||||
openRouterModelInfo?: ModelInfo
|
||||
openRouterProviderSorting?: string
|
||||
awsAccessKey?: string
|
||||
awsSecretKey?: string
|
||||
awsSessionToken?: string
|
||||
@@ -38,12 +40,13 @@ export interface ApiHandlerOptions {
|
||||
awsBedrockUsePromptCache?: boolean
|
||||
awsUseProfile?: boolean
|
||||
awsProfile?: string
|
||||
awsBedrockEndpoint?: string
|
||||
vertexProjectId?: string
|
||||
vertexRegion?: string
|
||||
openAiBaseUrl?: string
|
||||
openAiApiKey?: string
|
||||
openAiModelId?: string
|
||||
openAiModelInfo?: ModelInfo
|
||||
openAiModelInfo?: OpenAiCompatibleModelInfo
|
||||
ollamaModelId?: string
|
||||
ollamaBaseUrl?: string
|
||||
ollamaApiOptionsCtxNum?: string
|
||||
@@ -66,6 +69,7 @@ export interface ApiHandlerOptions {
|
||||
asksageApiKey?: string
|
||||
xaiApiKey?: string
|
||||
thinkingBudgetTokens?: number
|
||||
sambanovaApiKey?: string
|
||||
}
|
||||
|
||||
export type ApiConfiguration = ApiHandlerOptions & {
|
||||
@@ -87,6 +91,10 @@ export interface ModelInfo {
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface OpenAiCompatibleModelInfo extends ModelInfo {
|
||||
temperature?: number
|
||||
}
|
||||
|
||||
// Anthropic
|
||||
// https://docs.anthropic.com/en/docs/about-claude/models // prices updated 2025-01-02
|
||||
export type AnthropicModelId = keyof typeof anthropicModels
|
||||
@@ -151,6 +159,33 @@ export const anthropicModels = {
|
||||
export type BedrockModelId = keyof typeof bedrockModels
|
||||
export const bedrockDefaultModelId: BedrockModelId = "anthropic.claude-3-7-sonnet-20250219-v1:0"
|
||||
export const bedrockModels = {
|
||||
"amazon.nova-pro-v1:0": {
|
||||
maxTokens: 5000,
|
||||
contextWindow: 300_000,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.8,
|
||||
outputPrice: 3.2,
|
||||
},
|
||||
"amazon.nova-lite-v1:0": {
|
||||
maxTokens: 5000,
|
||||
contextWindow: 300_000,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.06,
|
||||
outputPrice: 0.24,
|
||||
},
|
||||
"amazon.nova-micro-v1:0": {
|
||||
maxTokens: 5000,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: false,
|
||||
supportsComputerUse: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.035,
|
||||
outputPrice: 0.14,
|
||||
},
|
||||
"anthropic.claude-3-7-sonnet-20250219-v1:0": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
@@ -215,6 +250,14 @@ export const bedrockModels = {
|
||||
inputPrice: 0.25,
|
||||
outputPrice: 1.25,
|
||||
},
|
||||
"deepseek.r1-v1:0": {
|
||||
maxTokens: 8_000,
|
||||
contextWindow: 64_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1.35,
|
||||
outputPrice: 5.4,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// OpenRouter
|
||||
@@ -389,13 +432,14 @@ export const vertexModels = {
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
export const openAiModelInfoSaneDefaults: ModelInfo = {
|
||||
export const openAiModelInfoSaneDefaults: OpenAiCompatibleModelInfo = {
|
||||
maxTokens: -1,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
temperature: 0,
|
||||
}
|
||||
|
||||
// Gemini
|
||||
@@ -561,11 +605,19 @@ export const openAiNativeModels = {
|
||||
outputPrice: 0.6,
|
||||
cacheReadsPrice: 0.075,
|
||||
},
|
||||
"gpt-4.5-preview": {
|
||||
"chatgpt-4o-latest": {
|
||||
maxTokens: 16_384,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 5,
|
||||
outputPrice: 15,
|
||||
},
|
||||
"gpt-4.5-preview": {
|
||||
maxTokens: 16_384,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 75,
|
||||
outputPrice: 150,
|
||||
},
|
||||
@@ -605,9 +657,11 @@ export const deepSeekModels = {
|
||||
|
||||
// Qwen
|
||||
// https://bailian.console.aliyun.com/
|
||||
export type QwenModelId = keyof typeof qwenModels
|
||||
export const qwenDefaultModelId: QwenModelId = "qwen-coder-plus-latest"
|
||||
export const qwenModels = {
|
||||
export type MainlandQwenModelId = keyof typeof mainlandQwenModels
|
||||
export type InternationalQwenModelId = keyof typeof internationalQwenModels
|
||||
export const internationalQwenDefaultModelId: InternationalQwenModelId = "qwen-coder-plus-latest"
|
||||
export const mainlandQwenDefaultModelId: MainlandQwenModelId = "qwen-coder-plus-latest"
|
||||
export const internationalQwenModels = {
|
||||
"qwen2.5-coder-32b-instruct": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 131_072,
|
||||
@@ -810,6 +864,229 @@ export const qwenModels = {
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
export const mainlandQwenModels = {
|
||||
"qwen2.5-coder-32b-instruct": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.002,
|
||||
outputPrice: 0.006,
|
||||
cacheWritesPrice: 0.002,
|
||||
cacheReadsPrice: 0.006,
|
||||
},
|
||||
"qwen2.5-coder-14b-instruct": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.002,
|
||||
outputPrice: 0.006,
|
||||
cacheWritesPrice: 0.002,
|
||||
cacheReadsPrice: 0.006,
|
||||
},
|
||||
"qwen2.5-coder-7b-instruct": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.001,
|
||||
outputPrice: 0.002,
|
||||
cacheWritesPrice: 0.001,
|
||||
cacheReadsPrice: 0.002,
|
||||
},
|
||||
"qwen2.5-coder-3b-instruct": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 32_768,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.0,
|
||||
outputPrice: 0.0,
|
||||
cacheWritesPrice: 0.0,
|
||||
cacheReadsPrice: 0.0,
|
||||
},
|
||||
"qwen2.5-coder-1.5b-instruct": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 32_768,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.0,
|
||||
outputPrice: 0.0,
|
||||
cacheWritesPrice: 0.0,
|
||||
cacheReadsPrice: 0.0,
|
||||
},
|
||||
"qwen2.5-coder-0.5b-instruct": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 32_768,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.0,
|
||||
outputPrice: 0.0,
|
||||
cacheWritesPrice: 0.0,
|
||||
cacheReadsPrice: 0.0,
|
||||
},
|
||||
"qwen-coder-plus-latest": {
|
||||
maxTokens: 129_024,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3.5,
|
||||
outputPrice: 7,
|
||||
cacheWritesPrice: 3.5,
|
||||
cacheReadsPrice: 7,
|
||||
},
|
||||
"qwen-plus-latest": {
|
||||
maxTokens: 129_024,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.8,
|
||||
outputPrice: 2,
|
||||
cacheWritesPrice: 0.8,
|
||||
cacheReadsPrice: 0.2,
|
||||
},
|
||||
"qwen-turbo-latest": {
|
||||
maxTokens: 1_000_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.8,
|
||||
outputPrice: 2,
|
||||
cacheWritesPrice: 0.8,
|
||||
cacheReadsPrice: 2,
|
||||
},
|
||||
"qwen-max-latest": {
|
||||
maxTokens: 30_720,
|
||||
contextWindow: 32_768,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 2.4,
|
||||
outputPrice: 9.6,
|
||||
cacheWritesPrice: 2.4,
|
||||
cacheReadsPrice: 9.6,
|
||||
},
|
||||
"qwq-plus-latest": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 131_071,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.0,
|
||||
outputPrice: 0.0,
|
||||
cacheWritesPrice: 0.0,
|
||||
cacheReadsPrice: 0.0,
|
||||
},
|
||||
"qwq-plus": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 131_071,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.0,
|
||||
outputPrice: 0.0,
|
||||
cacheWritesPrice: 0.0,
|
||||
cacheReadsPrice: 0.0,
|
||||
},
|
||||
"qwen-coder-plus": {
|
||||
maxTokens: 129_024,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3.5,
|
||||
outputPrice: 7,
|
||||
cacheWritesPrice: 3.5,
|
||||
cacheReadsPrice: 7,
|
||||
},
|
||||
"qwen-plus": {
|
||||
maxTokens: 129_024,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.8,
|
||||
outputPrice: 2,
|
||||
cacheWritesPrice: 0.8,
|
||||
cacheReadsPrice: 0.2,
|
||||
},
|
||||
"qwen-turbo": {
|
||||
maxTokens: 1_000_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.3,
|
||||
outputPrice: 0.6,
|
||||
cacheWritesPrice: 0.3,
|
||||
cacheReadsPrice: 0.6,
|
||||
},
|
||||
"qwen-max": {
|
||||
maxTokens: 30_720,
|
||||
contextWindow: 32_768,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 2.4,
|
||||
outputPrice: 9.6,
|
||||
cacheWritesPrice: 2.4,
|
||||
cacheReadsPrice: 9.6,
|
||||
},
|
||||
"deepseek-v3": {
|
||||
maxTokens: 8_000,
|
||||
contextWindow: 64_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0.28,
|
||||
cacheWritesPrice: 0.14,
|
||||
cacheReadsPrice: 0.014,
|
||||
},
|
||||
"deepseek-r1": {
|
||||
maxTokens: 8_000,
|
||||
contextWindow: 64_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0,
|
||||
outputPrice: 2.19,
|
||||
cacheWritesPrice: 0.55,
|
||||
cacheReadsPrice: 0.14,
|
||||
},
|
||||
"qwen-vl-max": {
|
||||
maxTokens: 30_720,
|
||||
contextWindow: 32_768,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3,
|
||||
outputPrice: 9,
|
||||
cacheWritesPrice: 3,
|
||||
cacheReadsPrice: 9,
|
||||
},
|
||||
"qwen-vl-max-latest": {
|
||||
maxTokens: 129_024,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3,
|
||||
outputPrice: 9,
|
||||
cacheWritesPrice: 3,
|
||||
cacheReadsPrice: 9,
|
||||
},
|
||||
"qwen-vl-plus": {
|
||||
maxTokens: 6_000,
|
||||
contextWindow: 8_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1.5,
|
||||
outputPrice: 4.5,
|
||||
cacheWritesPrice: 1.5,
|
||||
cacheReadsPrice: 4.5,
|
||||
},
|
||||
"qwen-vl-plus-latest": {
|
||||
maxTokens: 129_024,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1.5,
|
||||
outputPrice: 4.5,
|
||||
cacheWritesPrice: 1.5,
|
||||
cacheReadsPrice: 4.5,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// Mistral
|
||||
// https://docs.mistral.ai/getting-started/models/models_overview/
|
||||
export type MistralModelId = keyof typeof mistralModels
|
||||
@@ -847,6 +1124,14 @@ export const mistralModels = {
|
||||
inputPrice: 0.1,
|
||||
outputPrice: 0.1,
|
||||
},
|
||||
"mistral-small-latest": {
|
||||
maxTokens: 131_000,
|
||||
contextWindow: 131_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.1,
|
||||
outputPrice: 0.3,
|
||||
},
|
||||
"mistral-small-2501": {
|
||||
maxTokens: 32_000,
|
||||
contextWindow: 32_000,
|
||||
@@ -1028,3 +1313,98 @@ export const xaiModels = {
|
||||
description: "X AI's Grok Beta model (legacy) with 131K context window",
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// SambaNova
|
||||
// https://docs.sambanova.ai/cloud/docs/get-started/supported-models
|
||||
export type SambanovaModelId = keyof typeof sambanovaModels
|
||||
export const sambanovaDefaultModelId: SambanovaModelId = "Meta-Llama-3.3-70B-Instruct"
|
||||
export const sambanovaModels = {
|
||||
"Meta-Llama-3.3-70B-Instruct": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"DeepSeek-R1-Distill-Llama-70B": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 32_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"Llama-3.1-Swallow-70B-Instruct-v0.3": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"Llama-3.1-Swallow-8B-Instruct-v0.3": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"Meta-Llama-3.1-405B-Instruct": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"Meta-Llama-3.1-8B-Instruct": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"Meta-Llama-3.2-1B-Instruct": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"Qwen2.5-72B-Instruct": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"Qwen2.5-Coder-32B-Instruct": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"QwQ-32B-Preview": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"QwQ-32B": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.5,
|
||||
outputPrice: 1.0,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
@@ -20,3 +20,37 @@ export function findLast<T>(array: Array<T>, predicate: (value: T, index: number
|
||||
const index = findLastIndex(array, predicate)
|
||||
return index === -1 ? undefined : array[index]
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a partial or complete stringified array into an actual array.
|
||||
* Handles both complete JSON strings and incomplete array strings.
|
||||
* Splits on the specific tokens: [" ", " "]
|
||||
* @param arrayString A string representation of an array, which may be incomplete
|
||||
* @returns Array of strings parsed from the input
|
||||
*/
|
||||
export function parsePartialArrayString(arrayString: string): string[] {
|
||||
try {
|
||||
// Try parsing as complete JSON first
|
||||
return JSON.parse(arrayString)
|
||||
} catch {
|
||||
// If JSON parsing fails, handle as partial string
|
||||
const trimmed = arrayString.trim()
|
||||
if (!trimmed.startsWith('["')) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Remove leading ["
|
||||
let content = trimmed.slice(2)
|
||||
// Remove trailing "] if it exists
|
||||
content = content.replace(/"]$/, "")
|
||||
if (!content) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Split on ", " token and handle the parts
|
||||
return content
|
||||
.split('", "')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
+21
-1
@@ -3,7 +3,7 @@ import { after, describe, it } from "mocha"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import "should"
|
||||
import { createDirectoriesForFile, fileExistsAtPath } from "./fs"
|
||||
import { createDirectoriesForFile, fileExistsAtPath, isDirectory } from "./fs"
|
||||
|
||||
describe("Filesystem Utilities", () => {
|
||||
const tmpDir = path.join(os.tmpdir(), "cline-test-" + Math.random().toString(36).slice(2))
|
||||
@@ -68,4 +68,24 @@ describe("Filesystem Utilities", () => {
|
||||
exists.should.be.true()
|
||||
})
|
||||
})
|
||||
describe("isDirectory", () => {
|
||||
it("should return true for directories", async () => {
|
||||
await fs.mkdir(tmpDir, { recursive: true })
|
||||
const isDir = await isDirectory(tmpDir)
|
||||
isDir.should.be.true()
|
||||
})
|
||||
|
||||
it("should return false for files", async () => {
|
||||
const testFile = path.join(tmpDir, "test.txt")
|
||||
await fs.writeFile(testFile, "test")
|
||||
const isDir = await isDirectory(testFile)
|
||||
isDir.should.be.false()
|
||||
})
|
||||
|
||||
it("should return false for non-existent paths", async () => {
|
||||
const nonExistentPath = path.join(tmpDir, "does-not-exist")
|
||||
const isDir = await isDirectory(nonExistentPath)
|
||||
isDir.should.be.false()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,3 +45,32 @@ export async function fileExistsAtPath(filePath: string): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the path is a directory
|
||||
* @param filePath - The path to check.
|
||||
* @returns A promise that resolves to true if the path is a directory, false otherwise.
|
||||
*/
|
||||
export async function isDirectory(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stats = await fs.stat(filePath)
|
||||
return stats.isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the size of a file in kilobytes
|
||||
* @param filePath - Path to the file to check
|
||||
* @returns Promise<number> - Size of the file in KB, or 0 if file doesn't exist
|
||||
*/
|
||||
export async function getFileSizeInKB(filePath: string): Promise<number> {
|
||||
try {
|
||||
const stats = await fs.stat(filePath)
|
||||
const fileSizeInKB = stats.size / 1000 // Convert bytes to KB (decimal) - matches OS file size display
|
||||
return fileSizeInKB
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import path from "path"
|
||||
import getFolderSize from "get-folder-size"
|
||||
|
||||
/**
|
||||
* Gets the total size of tasks and checkpoints directories
|
||||
* @param storagePath The base storage path (typically globalStorageUri.fsPath)
|
||||
* @returns The total size in bytes, or null if calculation fails
|
||||
*/
|
||||
export async function getTotalTasksSize(storagePath: string): Promise<number | null> {
|
||||
const tasksDir = path.join(storagePath, "tasks")
|
||||
const checkpointsDir = path.join(storagePath, "checkpoints")
|
||||
|
||||
try {
|
||||
const tasksSize = await getFolderSize.loose(tasksDir)
|
||||
const checkpointsSize = await getFolderSize.loose(checkpointsDir)
|
||||
return tasksSize + checkpointsSize
|
||||
} catch (error) {
|
||||
console.error("Failed to calculate total task size:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
Generated
+550
-13
@@ -21,6 +21,7 @@
|
||||
"posthog-js": "^1.224.0",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-countup": "^6.5.3",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-remark": "^2.1.0",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
@@ -31,6 +32,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@tailwindcss/vite": "^4.0.12",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -45,9 +47,10 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.14.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"tailwindcss": "^4.0.12",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.18.2",
|
||||
"vite": "^6.1.1",
|
||||
"vite": "^6.2.1",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
},
|
||||
@@ -125,9 +128,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
|
||||
"integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
|
||||
"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
@@ -2438,6 +2441,244 @@
|
||||
"@swc/counter": "^0.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.12.tgz",
|
||||
"integrity": "sha512-a6J11K1Ztdln9OrGfoM75/hChYPcHYGNYimqciMrvKXRmmPaS8XZTHhdvb5a3glz4Kd4ZxE1MnuFE2c0fGGmtg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"enhanced-resolve": "^5.18.1",
|
||||
"jiti": "^2.4.2",
|
||||
"tailwindcss": "4.0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.12.tgz",
|
||||
"integrity": "sha512-DWb+myvJB9xJwelwT9GHaMc1qJj6MDXRDR0CS+T8IdkejAtu8ctJAgV4r1drQJLPeS7mNwq2UHW2GWrudTf63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.0.12",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.0.12",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.0.12",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.0.12",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.12",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.0.12",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.0.12",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.0.12",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.0.12",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.0.12",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.12.tgz",
|
||||
"integrity": "sha512-dAXCaemu3mHLXcA5GwGlQynX8n7tTdvn5i1zAxRvZ5iC9fWLl5bGnjZnzrQqT7ttxCvRwdVf3IHUnMVdDBO/kQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.12.tgz",
|
||||
"integrity": "sha512-vPNI+TpJQ7sizselDXIJdYkx9Cu6JBdtmRWujw9pVIxW8uz3O2PjgGGzL/7A0sXI8XDjSyRChrUnEW9rQygmJQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.12.tgz",
|
||||
"integrity": "sha512-RL/9jM41Fdq4Efr35C5wgLx98BirnrfwuD+zgMFK6Ir68HeOSqBhW9jsEeC7Y/JcGyPd3MEoJVIU4fAb7YLg7A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.12.tgz",
|
||||
"integrity": "sha512-7WzWiax+LguJcMEimY0Q4sBLlFXu1tYxVka3+G2M9KmU/3m84J3jAIV4KZWnockbHsbb2XgrEjtlJKVwHQCoRA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.12.tgz",
|
||||
"integrity": "sha512-X9LRC7jjE1QlfIaBbXjY0PGeQP87lz5mEfLSVs2J1yRc9PSg1tEPS9NBqY4BU9v5toZgJgzKeaNltORyTs22TQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.12.tgz",
|
||||
"integrity": "sha512-i24IFNq2402zfDdoWKypXz0ZNS2G4NKaA82tgBlE2OhHIE+4mg2JDb5wVfyP6R+MCm5grgXvurcIcKWvo44QiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.12.tgz",
|
||||
"integrity": "sha512-LmOdshJBfAGIBG0DdBWhI0n5LTMurnGGJCHcsm9F//ISfsHtCnnYIKgYQui5oOz1SUCkqsMGfkAzWyNKZqbGNw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.12.tgz",
|
||||
"integrity": "sha512-OSK667qZRH30ep8RiHbZDQfqkXjnzKxdn0oRwWzgCO8CoTxV+MvIkd0BWdQbYtYuM1wrakARV/Hwp0eA/qzdbw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.12.tgz",
|
||||
"integrity": "sha512-uylhWq6OWQ8krV8Jk+v0H/3AZKJW6xYMgNMyNnUbbYXWi7hIVdxRKNUB5UvrlC3RxtgsK5EAV2i1CWTRsNcAnA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.12.tgz",
|
||||
"integrity": "sha512-XDLnhMoXZEEOir1LK43/gHHwK84V1GlV8+pAncUAIN2wloeD+nNciI9WRIY/BeFTqES22DhTIGoilSO39xDb2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.12.tgz",
|
||||
"integrity": "sha512-I/BbjCLpKDQucvtn6rFuYLst1nfFwSMYyPzkx/095RE+tuzk5+fwXuzQh7T3fIBTcbn82qH/sFka7yPGA50tLw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.12.tgz",
|
||||
"integrity": "sha512-JM3gp601UJiryIZ9R2bSqalzcOy15RCybQ1Q+BJqDEwVyo4LkWKeqQAcrpHapWXY31OJFTuOUVBFDWMhzHm2Bg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "4.0.12",
|
||||
"@tailwindcss/oxide": "4.0.12",
|
||||
"lightningcss": "^1.29.1",
|
||||
"tailwindcss": "4.0.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.2.0 || ^6"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
||||
@@ -3937,6 +4178,12 @@
|
||||
"layout-base": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/countup.js": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/countup.js/-/countup.js-2.8.0.tgz",
|
||||
"integrity": "sha512-f7xEhX0awl4NOElHulrl4XRfKoNH3rB+qfNSZZyjSZhaAoUk6elvhH+MNxMmlmuUJ2/QNTWPSA7U4mNtIAKljQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -4585,6 +4832,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
|
||||
"integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/devlop": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
||||
@@ -4620,6 +4877,20 @@
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.18.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
|
||||
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/error-stack-parser": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
|
||||
@@ -5582,15 +5853,13 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
||||
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
@@ -5749,6 +6018,245 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.29.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz",
|
||||
"integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-darwin-arm64": "1.29.2",
|
||||
"lightningcss-darwin-x64": "1.29.2",
|
||||
"lightningcss-freebsd-x64": "1.29.2",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.29.2",
|
||||
"lightningcss-linux-arm64-gnu": "1.29.2",
|
||||
"lightningcss-linux-arm64-musl": "1.29.2",
|
||||
"lightningcss-linux-x64-gnu": "1.29.2",
|
||||
"lightningcss-linux-x64-musl": "1.29.2",
|
||||
"lightningcss-win32-arm64-msvc": "1.29.2",
|
||||
"lightningcss-win32-x64-msvc": "1.29.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.29.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz",
|
||||
"integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.29.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz",
|
||||
"integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.29.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz",
|
||||
"integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.29.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz",
|
||||
"integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.29.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz",
|
||||
"integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.29.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz",
|
||||
"integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.29.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz",
|
||||
"integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.29.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz",
|
||||
"integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.29.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz",
|
||||
"integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.29.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz",
|
||||
"integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/local-pkg": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.0.0.tgz",
|
||||
@@ -6594,6 +7102,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-countup": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/react-countup/-/react-countup-6.5.3.tgz",
|
||||
"integrity": "sha512-udnqVQitxC7QWADSPDOxVWULkLvKUWrDapn5i53HE4DPRVgs+Y5rr4bo25qEl8jSh+0l2cToJgGMx+clxPM3+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"countup.js": "^2.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
@@ -7298,6 +7818,23 @@
|
||||
"integrity": "sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.12.tgz",
|
||||
"integrity": "sha512-bT0hJo91FtncsAMSsMzUkoo/iEU0Xs5xgFgVC9XmdM9bw5MhZuQFjPNl6wxAE0SiQF/YTZJa+PndGWYSDtuxAg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
|
||||
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.37.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz",
|
||||
@@ -7841,9 +8378,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz",
|
||||
"integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.1.tgz",
|
||||
"integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"posthog-js": "^1.224.0",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-countup": "^6.5.3",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-remark": "^2.1.0",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
@@ -35,6 +36,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@tailwindcss/vite": "^4.0.12",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -49,9 +51,10 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.14.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"tailwindcss": "^4.0.12",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.18.2",
|
||||
"vite": "^6.1.1",
|
||||
"vite": "^6.2.1",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ const AppContent = () => {
|
||||
setShowMcp(true)
|
||||
setShowAccount(false)
|
||||
break
|
||||
case "accountLoginClicked":
|
||||
case "accountButtonClicked":
|
||||
setShowSettings(false)
|
||||
setShowHistory(false)
|
||||
setShowMcp(false)
|
||||
@@ -96,6 +96,7 @@ const AppContent = () => {
|
||||
showHistoryView={() => {
|
||||
setShowSettings(false)
|
||||
setShowMcp(false)
|
||||
setShowAccount(false)
|
||||
setShowHistory(true)
|
||||
}}
|
||||
isHidden={showSettings || showHistory || showMcp || showAccount}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SVGProps } from "react"
|
||||
|
||||
const ClineLogoWhite = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="47" height="50" viewBox="0 0 47 50" fill="none" {...props}>
|
||||
<path
|
||||
d="M46.4075 28.1192L43.5011 22.3166V18.9747C43.5011 13.4354 39.0302 8.94931 33.5162 8.94931H28.5491C28.9086 8.21513 29.106 7.3898 29.106 6.5189C29.106 3.44039 26.6149 0.949219 23.5363 0.949219C20.4578 0.949219 17.9667 3.44039 17.9667 6.5189C17.9667 7.3898 18.1641 8.21513 18.5236 8.94931H13.5565C8.04249 8.94931 3.57155 13.4354 3.57155 18.9747V22.3166L0.604424 28.104C0.305687 28.6863 0.305687 29.3799 0.604424 29.9622L3.57155 35.6838V39.0256C3.57155 44.5649 8.04249 49.0511 13.5565 49.0511H33.5162C39.0302 49.0511 43.5011 44.5649 43.5011 39.0256V35.6838L46.4024 29.942C46.691 29.3698 46.691 28.6964 46.4075 28.1192ZM20.4983 32.8483C20.4983 35.3648 18.4578 37.4053 15.9413 37.4053C13.4248 37.4053 11.3843 35.3648 11.3843 32.8483V24.747C11.3843 22.2305 13.4248 20.19 15.9413 20.19C18.4578 20.19 20.4983 22.2305 20.4983 24.747V32.8483ZM35.182 32.8483C35.182 35.3648 33.1415 37.4053 30.625 37.4053C28.1085 37.4053 26.068 35.3648 26.068 32.8483V24.747C26.068 22.2305 28.1085 20.19 30.625 20.19C33.1415 20.19 35.182 22.2305 35.182 24.747V32.8483Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
export default ClineLogoWhite
|
||||
@@ -1,8 +1,12 @@
|
||||
import { VSCodeButton, VSCodeDivider } from "@vscode/webview-ui-toolkit/react"
|
||||
import { memo } from "react"
|
||||
import { VSCodeButton, VSCodeDivider, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { memo, useEffect, useState } from "react"
|
||||
import { useFirebaseAuth } from "../../context/FirebaseAuthContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import VSCodeButtonLink from "../common/VSCodeButtonLink"
|
||||
import ClineLogoWhite from "../../assets/ClineLogoWhite"
|
||||
import CountUp from "react-countup"
|
||||
import CreditsHistoryTable from "./CreditsHistoryTable"
|
||||
import { UsageTransaction, PaymentTransaction } from "../../../../src/shared/ClineAccount"
|
||||
|
||||
type AccountViewProps = {
|
||||
onDone: () => void
|
||||
@@ -10,38 +14,13 @@ type AccountViewProps = {
|
||||
|
||||
const AccountView = ({ onDone }: AccountViewProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: "10px 0px 0px 20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "17px",
|
||||
paddingRight: 17,
|
||||
}}>
|
||||
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Cline Account</h3>
|
||||
<div className="fixed inset-0 flex flex-col overflow-hidden pt-[10px] pl-[20px]">
|
||||
<div className="flex justify-between items-center mb-[17px] pr-[17px]">
|
||||
<h3 className="text-[var(--vscode-foreground)] m-0">Account</h3>
|
||||
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
overflowY: "scroll",
|
||||
paddingRight: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}>
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<div className="flex-grow overflow-hidden pr-[8px] flex flex-col">
|
||||
<div className="h-full mb-[5px]">
|
||||
<ClineAccountView />
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,6 +30,37 @@ const AccountView = ({ onDone }: AccountViewProps) => {
|
||||
|
||||
export const ClineAccountView = () => {
|
||||
const { user, handleSignOut } = useFirebaseAuth()
|
||||
const [balance, setBalance] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [usageData, setUsageData] = useState<UsageTransaction[]>([])
|
||||
const [paymentsData, setPaymentsData] = useState<PaymentTransaction[]>([])
|
||||
|
||||
// Listen for balance and transaction data updates from the extension
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "userCreditsBalance" && message.userCreditsBalance) {
|
||||
setBalance(message.userCreditsBalance.currentBalance)
|
||||
} else if (message.type === "userCreditsUsage" && message.userCreditsUsage) {
|
||||
setUsageData(message.userCreditsUsage.usageTransactions)
|
||||
} else if (message.type === "userCreditsPayments" && message.userCreditsPayments) {
|
||||
setPaymentsData(message.userCreditsPayments.paymentTransactions)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
|
||||
// Fetch all account data when component mounts
|
||||
if (user) {
|
||||
setIsLoading(true)
|
||||
vscode.postMessage({ type: "fetchUserCreditsData" })
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage)
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const handleLogin = () => {
|
||||
vscode.postMessage({ type: "accountLoginClicked" })
|
||||
@@ -63,107 +73,96 @@ export const ClineAccountView = () => {
|
||||
handleSignOut()
|
||||
}
|
||||
return (
|
||||
<div style={{ maxWidth: "600px" }}>
|
||||
<div className="h-full flex flex-col">
|
||||
{user ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
border: "1px solid var(--vscode-input-border)",
|
||||
borderRadius: "2px",
|
||||
backgroundColor: "var(--vscode-dropdown-background)",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
}}>
|
||||
{user.photoURL ? (
|
||||
<img
|
||||
src={user.photoURL}
|
||||
alt="Profile"
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--vscode-button-background)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "20px",
|
||||
color: "var(--vscode-button-foreground)",
|
||||
}}>
|
||||
{user.displayName?.[0] || user.email?.[0] || "?"}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
}}>
|
||||
{user.displayName && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
fontWeight: "bold",
|
||||
color: "var(--vscode-foreground)",
|
||||
}}>
|
||||
{user.displayName}
|
||||
<div className="flex flex-col pr-3 h-full">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex items-center mb-6 flex-wrap gap-y-4">
|
||||
{user.photoURL ? (
|
||||
<img src={user.photoURL} alt="Profile" className="size-16 rounded-full mr-4" />
|
||||
) : (
|
||||
<div className="size-16 rounded-full bg-[var(--vscode-button-background)] flex items-center justify-center text-2xl text-[var(--vscode-button-foreground)] mr-4">
|
||||
{user.displayName?.[0] || user.email?.[0] || "?"}
|
||||
</div>
|
||||
)}
|
||||
{user.email && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
{user.email}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<VSCodeButtonLink
|
||||
href="https://app.cline.bot/account"
|
||||
appearance="primary"
|
||||
style={{
|
||||
transform: "scale(0.85)",
|
||||
transformOrigin: "left center",
|
||||
width: "fit-content",
|
||||
marginTop: 2,
|
||||
marginBottom: -2,
|
||||
marginRight: -8,
|
||||
}}>
|
||||
Account
|
||||
</VSCodeButtonLink>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
transform: "scale(0.85)",
|
||||
transformOrigin: "left center",
|
||||
width: "fit-content",
|
||||
marginTop: 2,
|
||||
marginBottom: -2,
|
||||
}}>
|
||||
Log out
|
||||
</VSCodeButton>
|
||||
|
||||
<div className="flex flex-col">
|
||||
{user.displayName && (
|
||||
<h2 className="text-[var(--vscode-foreground)] m-0 mb-1 text-lg font-medium">
|
||||
{user.displayName}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{user.email && (
|
||||
<div className="text-sm text-[var(--vscode-descriptionForeground)]">{user.email}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex gap-2 flex-col min-[225px]:flex-row">
|
||||
<div className="w-full min-[225px]:w-1/2">
|
||||
<VSCodeButtonLink href="https://app.cline.bot/credits" appearance="primary" className="w-full">
|
||||
Dashboard
|
||||
</VSCodeButtonLink>
|
||||
</div>
|
||||
<VSCodeButton appearance="secondary" onClick={handleLogout} className="w-full min-[225px]:w-1/2">
|
||||
Log out
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
|
||||
<VSCodeDivider className="w-full my-6" />
|
||||
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<div className="text-sm text-[var(--vscode-descriptionForeground)] mb-3">CURRENT BALANCE</div>
|
||||
|
||||
<div className="text-4xl font-bold text-[var(--vscode-foreground)] mb-6 flex items-center gap-2">
|
||||
{isLoading ? (
|
||||
<div className="text-[var(--vscode-descriptionForeground)]">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
<span>$</span>
|
||||
<CountUp end={balance} duration={0.66} decimals={2} />
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
className="mt-1"
|
||||
onClick={() => vscode.postMessage({ type: "fetchUserCreditsData" })}>
|
||||
<span className="codicon codicon-refresh"></span>
|
||||
</VSCodeButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<VSCodeButtonLink href="https://app.cline.bot/credits/#buy" className="w-full">
|
||||
Add Credits
|
||||
</VSCodeButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VSCodeDivider className="mt-6 mb-3 w-full" />
|
||||
|
||||
<div className="flex-grow flex flex-col min-h-0 pb-[0px]">
|
||||
<CreditsHistoryTable isLoading={isLoading} usageData={usageData} paymentsData={paymentsData} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{}}>
|
||||
<VSCodeButton onClick={handleLogin} style={{ marginTop: 0 }}>
|
||||
Sign Up with Cline
|
||||
<div className="flex flex-col items-center pr-3 max-w-[400px]">
|
||||
<ClineLogoWhite className="size-16 mb-4" />
|
||||
|
||||
<p style={{}}>
|
||||
Sign up for an account to get access to the latest models, billing dashboard to view usage and credits,
|
||||
and more upcoming features.
|
||||
</p>
|
||||
|
||||
<VSCodeButton onClick={handleLogin} className="w-full mb-4">
|
||||
Sign up with Cline
|
||||
</VSCodeButton>
|
||||
|
||||
<p className="text-[var(--vscode-descriptionForeground)] text-xs text-center m-0">
|
||||
By continuing, you agree to the <VSCodeLink href="https://cline.bot/tos">Terms of Service</VSCodeLink> and{" "}
|
||||
<VSCodeLink href="https://cline.bot/privacy">Privacy Policy.</VSCodeLink>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { VSCodeDataGrid, VSCodeDataGridRow, VSCodeDataGridCell } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useState } from "react"
|
||||
import { TabButton } from "../mcp/McpView"
|
||||
import { UsageTransaction, PaymentTransaction } from "../../../../src/shared/ClineAccount"
|
||||
import { formatDollars, formatTimestamp } from "../../utils/format"
|
||||
|
||||
interface CreditsHistoryTableProps {
|
||||
isLoading: boolean
|
||||
usageData: UsageTransaction[]
|
||||
paymentsData: PaymentTransaction[]
|
||||
}
|
||||
|
||||
const CreditsHistoryTable = ({ isLoading, usageData, paymentsData }: CreditsHistoryTableProps) => {
|
||||
const [activeTab, setActiveTab] = useState<"usage" | "payments">("usage")
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-grow h-full">
|
||||
{/* Tabs container */}
|
||||
<div className="flex border-b border-[var(--vscode-panel-border)]">
|
||||
<TabButton isActive={activeTab === "usage"} onClick={() => setActiveTab("usage")}>
|
||||
USAGE HISTORY
|
||||
</TabButton>
|
||||
<TabButton isActive={activeTab === "payments"} onClick={() => setActiveTab("payments")}>
|
||||
PAYMENTS HISTORY
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
{/* Content container */}
|
||||
<div className="mt-[15px] mb-[0px] rounded-md overflow-auto flex-grow">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center p-4">
|
||||
<div className="text-[var(--vscode-descriptionForeground)]">Loading...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === "usage" && (
|
||||
<>
|
||||
{usageData.length > 0 ? (
|
||||
<VSCodeDataGrid>
|
||||
<VSCodeDataGridRow row-type="header">
|
||||
<VSCodeDataGridCell cell-type="columnheader" grid-column="1">
|
||||
Date
|
||||
</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell cell-type="columnheader" grid-column="2">
|
||||
Model
|
||||
</VSCodeDataGridCell>
|
||||
{/* <VSCodeDataGridCell cell-type="columnheader" grid-column="3">
|
||||
Tokens Used
|
||||
</VSCodeDataGridCell> */}
|
||||
<VSCodeDataGridCell cell-type="columnheader" grid-column="3">
|
||||
Credits Used
|
||||
</VSCodeDataGridCell>
|
||||
</VSCodeDataGridRow>
|
||||
|
||||
{usageData.map((row, index) => (
|
||||
<VSCodeDataGridRow key={index}>
|
||||
<VSCodeDataGridCell grid-column="1">
|
||||
{formatTimestamp(row.spentAt)}
|
||||
</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell grid-column="2">{`${row.modelProvider}/${row.model}`}</VSCodeDataGridCell>
|
||||
{/* <VSCodeDataGridCell grid-column="3">{`${row.promptTokens} → ${row.completionTokens}`}</VSCodeDataGridCell> */}
|
||||
<VSCodeDataGridCell grid-column="3">{`$${Number(row.credits).toFixed(7)}`}</VSCodeDataGridCell>
|
||||
</VSCodeDataGridRow>
|
||||
))}
|
||||
</VSCodeDataGrid>
|
||||
) : (
|
||||
<div className="flex justify-center items-center p-4">
|
||||
<div className="text-[var(--vscode-descriptionForeground)]">No usage history</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "payments" && (
|
||||
<>
|
||||
{paymentsData.length > 0 ? (
|
||||
<VSCodeDataGrid>
|
||||
<VSCodeDataGridRow row-type="header">
|
||||
<VSCodeDataGridCell cell-type="columnheader" grid-column="1">
|
||||
Date
|
||||
</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell cell-type="columnheader" grid-column="2">
|
||||
Total Cost
|
||||
</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell cell-type="columnheader" grid-column="3">
|
||||
Credits
|
||||
</VSCodeDataGridCell>
|
||||
</VSCodeDataGridRow>
|
||||
|
||||
{paymentsData.map((row, index) => (
|
||||
<VSCodeDataGridRow key={index}>
|
||||
<VSCodeDataGridCell grid-column="1">
|
||||
{formatTimestamp(row.paidAt)}
|
||||
</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell grid-column="2">{`$${formatDollars(parseInt(row.amountCents))}`}</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell grid-column="3">{`${row.credits}`}</VSCodeDataGridCell>
|
||||
</VSCodeDataGridRow>
|
||||
))}
|
||||
</VSCodeDataGrid>
|
||||
) : (
|
||||
<div className="flex justify-center items-center p-4">
|
||||
<div className="text-[var(--vscode-descriptionForeground)]">No payment history</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreditsHistoryTable
|
||||
@@ -31,34 +31,22 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
|
||||
</h3>
|
||||
<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
|
||||
<li>
|
||||
<b>Introducing MCP Marketplace:</b> Discover and install the best MCP servers right from the extension, with
|
||||
new servers added regularly! Get started by going to the{" "}
|
||||
<span className="codicon codicon-extensions" style={{ marginRight: "4px", fontSize: 10 }}></span>
|
||||
<VSCodeLink
|
||||
onClick={() => {
|
||||
vscode.postMessage({ type: "showMcpView" })
|
||||
}}>
|
||||
MCP Servers tab
|
||||
</VSCodeLink>
|
||||
.
|
||||
<b>Add to Cline:</b> Right-click selected text in any file or terminal to quickly add context to your current
|
||||
task! Plus, when you see a lightbulb icon, select 'Fix with Cline' to have Cline fix errors in your code.
|
||||
</li>
|
||||
<li>
|
||||
<b>Mermaid diagrams in Plan mode!</b> Cline can now visualize his plans using flowcharts, sequences,
|
||||
entity-relationships, and more. When he explains his approach using mermaid, you'll see a diagram right in
|
||||
chat that you can click to expand.
|
||||
<b>Billing Dashboard:</b> Track your remaining credits and transaction history right in the extension with a{" "}
|
||||
<span className="codicon codicon-account" style={{ fontSize: 11 }}></span> Cline account!
|
||||
</li>
|
||||
<li>
|
||||
Use <code>@terminal</code> to reference terminal contents, and <code>@git</code> to reference working changes
|
||||
and commits!
|
||||
<b>Faster Inference:</b> Cline/OpenRouter users can sort underlying providers used by throughput, price, and
|
||||
latency. Sorting by throughput will output faster generations (at a higher cost).
|
||||
</li>
|
||||
<li>
|
||||
New visual indicator for checkpoints after edits & commands, and automatic checkpoint at the start of each
|
||||
task.
|
||||
<b>Enhanced MCP Support:</b> Dynamic image loading with GIF support, and a new delete button to clean up
|
||||
failed servers.
|
||||
</li>
|
||||
</ul>
|
||||
<VSCodeLink href="https://x.com/sdrzn/status/1892262424881090721" style={{ display: "inline" }}>
|
||||
See a demo of the changes here!
|
||||
</VSCodeLink>
|
||||
{/*<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
|
||||
<li>
|
||||
OpenRouter now supports prompt caching! They also have much higher rate limits than other providers,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from "react"
|
||||
|
||||
interface ChatErrorBoundaryProps {
|
||||
children: React.ReactNode
|
||||
errorTitle?: string
|
||||
errorBody?: string
|
||||
height?: string
|
||||
}
|
||||
|
||||
interface ChatErrorBoundaryState {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable error boundary component specifically designed for chat widgets.
|
||||
* It provides a consistent error UI with customizable title and body text.
|
||||
*/
|
||||
export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, ChatErrorBoundaryState> {
|
||||
constructor(props: ChatErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("Error in ChatErrorBoundary:", error.message)
|
||||
console.error("Component stack:", errorInfo.componentStack)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { errorTitle, errorBody, height } = this.props
|
||||
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
height: height || "auto",
|
||||
maxWidth: "512px",
|
||||
overflow: "auto",
|
||||
border: "1px solid var(--vscode-editorError-foreground)",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "var(--vscode-inputValidation-errorBackground, rgba(255, 0, 0, 0.1))",
|
||||
}}>
|
||||
<h3 style={{ margin: "0 0 8px 0" }}>{errorTitle || "Something went wrong displaying this content"}</h3>
|
||||
<p style={{ margin: "0" }}>{errorBody || `Error: ${this.state.error?.message || "Unknown error"}`}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A demo component that throws an error after a delay.
|
||||
* This is useful for testing error boundaries during development
|
||||
*/
|
||||
interface ErrorAfterDelayProps {
|
||||
numSecondsToWait?: number
|
||||
}
|
||||
|
||||
interface ErrorAfterDelayState {
|
||||
tickCount: number
|
||||
}
|
||||
|
||||
export class ErrorAfterDelay extends React.Component<ErrorAfterDelayProps, ErrorAfterDelayState> {
|
||||
private intervalID: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(props: ErrorAfterDelayProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
tickCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const secondsToWait = this.props.numSecondsToWait ?? 5
|
||||
|
||||
this.intervalID = setInterval(() => {
|
||||
if (this.state.tickCount >= secondsToWait) {
|
||||
if (this.intervalID) {
|
||||
clearInterval(this.intervalID)
|
||||
}
|
||||
// Error boundaries don't catch async code :(
|
||||
// So this only works by throwing inside of a setState
|
||||
this.setState(() => {
|
||||
throw new Error("This is an error for testing the error boundary")
|
||||
})
|
||||
} else {
|
||||
this.setState({
|
||||
tickCount: this.state.tickCount + 1,
|
||||
})
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.intervalID) {
|
||||
clearInterval(this.intervalID)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
// Add a small visual indicator that this component will cause an error
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
background: "rgba(255, 0, 0, 0.5)",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
padding: "2px 5px",
|
||||
fontSize: "12px",
|
||||
borderRadius: "0 0 0 4px",
|
||||
zIndex: 100,
|
||||
}}>
|
||||
Error in {this.state.tickCount}/{this.props.numSecondsToWait ?? 5} seconds
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatErrorBoundary
|
||||
@@ -5,8 +5,10 @@ import { useEvent, useSize } from "react-use"
|
||||
import styled from "styled-components"
|
||||
import {
|
||||
ClineApiReqInfo,
|
||||
ClineAskQuestion,
|
||||
ClineAskUseMcpServer,
|
||||
ClineMessage,
|
||||
ClinePlanModeResponse,
|
||||
ClineSayTool,
|
||||
COMPLETION_RESULT_CHANGES_FLAG,
|
||||
ExtensionMessage,
|
||||
@@ -15,18 +17,19 @@ import { COMMAND_OUTPUT_STRING, COMMAND_REQ_APP_STRING } from "../../../../src/s
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { findMatchingResourceOrTemplate, getMcpServerDisplayName } from "../../utils/mcp"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { CheckmarkControl } from "../common/CheckmarkControl"
|
||||
import { CheckpointControls, CheckpointOverlay } from "../common/CheckpointControls"
|
||||
import CodeAccordian, { cleanPathPrefix } from "../common/CodeAccordian"
|
||||
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
import MarkdownBlock from "../common/MarkdownBlock"
|
||||
import SuccessButton from "../common/SuccessButton"
|
||||
import Thumbnails from "../common/Thumbnails"
|
||||
import McpResourceRow from "../mcp/McpResourceRow"
|
||||
import McpToolRow from "../mcp/McpToolRow"
|
||||
import { highlightMentions } from "./TaskHeader"
|
||||
import CreditLimitError from "./CreditLimitError"
|
||||
import { CheckmarkControl } from "../common/CheckmarkControl"
|
||||
import McpResponseDisplay from "../mcp/McpResponseDisplay"
|
||||
import CreditLimitError from "./CreditLimitError"
|
||||
import { OptionsButtons } from "./OptionsButtons"
|
||||
import { highlightMentions } from "./TaskHeader"
|
||||
import SuccessButton from "../common/SuccessButton"
|
||||
|
||||
const ChatRowContainer = styled.div`
|
||||
padding: 10px 6px 10px 15px;
|
||||
@@ -912,10 +915,12 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
backgroundColor: "rgba(255, 191, 0, 0.1)",
|
||||
backgroundColor: "var(--vscode-textBlockQuote-background)",
|
||||
padding: 8,
|
||||
borderRadius: 3,
|
||||
fontSize: 12,
|
||||
color: "var(--vscode-foreground)",
|
||||
opacity: 0.8,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
@@ -924,24 +929,15 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
marginBottom: 4,
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-error"
|
||||
className="codicon codicon-warning"
|
||||
style={{
|
||||
marginRight: 8,
|
||||
fontSize: 18,
|
||||
color: "#FFA500",
|
||||
fontSize: 14,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}></i>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: "#FFA500",
|
||||
}}>
|
||||
Diff Edit Failed
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
This usually happens when the model uses search patterns that don't match anything in the
|
||||
file. Retrying...
|
||||
<span style={{ fontWeight: 500 }}>Diff Edit Mismatch</span>
|
||||
</div>
|
||||
<div>The model used search patterns that don't match anything in the file. Retrying...</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@@ -1024,8 +1020,8 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
cursor: seeNewChangesDisabled ? "wait" : "pointer",
|
||||
width: "100%",
|
||||
}}>
|
||||
<i className="codicon codicon-new-file" style={{ marginRight: 6 }} />
|
||||
See new changes
|
||||
@@ -1183,6 +1179,19 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
return null // Don't render anything when we get a completion_result ask without text
|
||||
}
|
||||
case "followup":
|
||||
let question: string | undefined
|
||||
let options: string[] | undefined
|
||||
let selected: string | undefined
|
||||
try {
|
||||
const parsedMessage = JSON.parse(message.text || "{}") as ClineAskQuestion
|
||||
question = parsedMessage.question
|
||||
options = parsedMessage.options
|
||||
selected = parsedMessage.selected
|
||||
} catch (e) {
|
||||
// legacy messages would pass question directly
|
||||
question = message.text
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{title && (
|
||||
@@ -1192,16 +1201,39 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
</div>
|
||||
)}
|
||||
<div style={{ paddingTop: 10 }}>
|
||||
<Markdown markdown={message.text} />
|
||||
<Markdown markdown={question} />
|
||||
<OptionsButtons
|
||||
options={options}
|
||||
selected={selected}
|
||||
isActive={isLast && lastModifiedMessage?.ask === "followup"}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
case "plan_mode_response":
|
||||
case "plan_mode_response": {
|
||||
let response: string | undefined
|
||||
let options: string[] | undefined
|
||||
let selected: string | undefined
|
||||
try {
|
||||
const parsedMessage = JSON.parse(message.text || "{}") as ClinePlanModeResponse
|
||||
response = parsedMessage.response
|
||||
options = parsedMessage.options
|
||||
selected = parsedMessage.selected
|
||||
} catch (e) {
|
||||
// legacy messages would pass response directly
|
||||
response = message.text
|
||||
}
|
||||
return (
|
||||
<div style={{}}>
|
||||
<Markdown markdown={message.text} />
|
||||
<Markdown markdown={response} />
|
||||
<OptionsButtons
|
||||
options={options}
|
||||
selected={selected}
|
||||
isActive={isLast && lastModifiedMessage?.ask === "plan_mode_response"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1132,6 +1132,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
</ModelContainer>
|
||||
</ButtonGroup>
|
||||
<Tooltip
|
||||
style={{ zIndex: 1000 }}
|
||||
visible={shownTooltipMode !== null}
|
||||
tipText={`In ${shownTooltipMode === "act" ? "Act" : "Plan"} mode, Cline will ${shownTooltipMode === "act" ? "complete the task immediately" : "gather information to architect a plan"}`}
|
||||
hintText={`Toggle w/ ${metaKeyChar}+Shift+A`}>
|
||||
|
||||
@@ -453,6 +453,18 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
setSelectedImages((prevImages) => [...prevImages, ...newImages].slice(0, MAX_IMAGES_PER_MESSAGE))
|
||||
}
|
||||
break
|
||||
case "addToInput":
|
||||
setInputValue((prevValue) => {
|
||||
const newText = message.text ?? ""
|
||||
return prevValue ? `${prevValue}\n${newText}` : newText
|
||||
})
|
||||
// Add scroll to bottom after state update
|
||||
setTimeout(() => {
|
||||
if (textAreaRef.current) {
|
||||
textAreaRef.current.scrollTop = textAreaRef.current.scrollHeight
|
||||
}
|
||||
}, 0)
|
||||
break
|
||||
case "invoke":
|
||||
switch (message.invoke!) {
|
||||
case "sendMessage":
|
||||
|
||||
@@ -30,7 +30,7 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({ currentBalance, tot
|
||||
</div>
|
||||
|
||||
<VSCodeButtonLink
|
||||
href="https://app.cline.bot/credits"
|
||||
href="https://app.cline.bot/credits/#buy"
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: "8px",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import styled from "styled-components"
|
||||
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
|
||||
const OptionButton = styled.button<{ isSelected?: boolean; isNotSelectable?: boolean }>`
|
||||
padding: 8px 12px;
|
||||
background: ${(props) => (props.isSelected ? "var(--vscode-focusBorder)" : CODE_BLOCK_BG_COLOR)};
|
||||
color: ${(props) => (props.isSelected ? "white" : "var(--vscode-input-foreground)")};
|
||||
border: 1px solid var(--vscode-editorGroup-border);
|
||||
border-radius: 2px;
|
||||
cursor: ${(props) => (props.isNotSelectable ? "default" : "pointer")};
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
|
||||
${(props) =>
|
||||
!props.isNotSelectable &&
|
||||
`
|
||||
&:hover {
|
||||
background: var(--vscode-focusBorder);
|
||||
color: white;
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
export const OptionsButtons = ({
|
||||
options,
|
||||
selected,
|
||||
isActive,
|
||||
}: {
|
||||
options?: string[]
|
||||
selected?: string
|
||||
isActive?: boolean
|
||||
}) => {
|
||||
if (!options?.length) return null
|
||||
|
||||
const hasSelected = selected !== undefined && options.includes(selected)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
paddingTop: 15,
|
||||
// marginTop: "22px",
|
||||
}}>
|
||||
{/* <div style={{ color: "var(--vscode-descriptionForeground)", fontSize: "11px", textTransform: "uppercase" }}>
|
||||
SELECT ONE:
|
||||
</div> */}
|
||||
{options.map((option, index) => (
|
||||
<OptionButton
|
||||
key={index}
|
||||
isSelected={option === selected}
|
||||
isNotSelectable={hasSelected || !isActive}
|
||||
onClick={() => {
|
||||
if (hasSelected || !isActive) {
|
||||
return
|
||||
}
|
||||
vscode.postMessage({
|
||||
type: "optionsResponse",
|
||||
text: option,
|
||||
})
|
||||
}}>
|
||||
{option}
|
||||
</OptionButton>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const { apiConfiguration, currentTaskItem, checkpointTrackerErrorMessage } = useExtensionState()
|
||||
const [isTaskExpanded, setIsTaskExpanded] = useState(true)
|
||||
const [isTaskExpanded, setIsTaskExpanded] = useState(false)
|
||||
const [isTextExpanded, setIsTextExpanded] = useState(false)
|
||||
const [showSeeMore, setShowSeeMore] = useState(false)
|
||||
const textContainerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -43,6 +43,22 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
const { selectedModelInfo } = useMemo(() => normalizeApiConfiguration(apiConfiguration), [apiConfiguration])
|
||||
const contextWindow = selectedModelInfo?.contextWindow
|
||||
|
||||
// Open task header when checkpoint tracker error message is set
|
||||
const prevErrorMessageRef = useRef(checkpointTrackerErrorMessage)
|
||||
useEffect(() => {
|
||||
if (checkpointTrackerErrorMessage !== prevErrorMessageRef.current) {
|
||||
setIsTaskExpanded(true)
|
||||
prevErrorMessageRef.current = checkpointTrackerErrorMessage
|
||||
}
|
||||
}, [checkpointTrackerErrorMessage])
|
||||
|
||||
// Reset isTextExpanded when task is collapsed
|
||||
useEffect(() => {
|
||||
if (!isTaskExpanded) {
|
||||
setIsTextExpanded(false)
|
||||
}
|
||||
}, [isTaskExpanded])
|
||||
|
||||
/*
|
||||
When dealing with event listeners in React components that depend on state variables, we face a challenge. We want our listener to always use the most up-to-date version of a callback function that relies on current state, but we don't want to constantly add and remove event listeners as that function updates. This scenario often arises with resize listeners or other window events. Simply adding the listener in a useEffect with an empty dependency array risks using stale state, while including the callback in the dependencies can lead to unnecessary re-registrations of the listener. There are react hook libraries that provide a elegant solution to this problem by utilizing the useRef hook to maintain a reference to the latest callback function without triggering re-renders or effect re-runs. This approach ensures that our event listener always has access to the most current state while minimizing performance overhead and potential memory leaks from multiple listener registrations.
|
||||
Sources
|
||||
@@ -85,19 +101,22 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
}, [isTextExpanded, windowHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (textRef.current && textContainerRef.current) {
|
||||
let textContainerHeight = textContainerRef.current.clientHeight
|
||||
if (!textContainerHeight) {
|
||||
textContainerHeight = textContainerRef.current.getBoundingClientRect().height
|
||||
}
|
||||
const isOverflowing = textRef.current.scrollHeight > textContainerHeight
|
||||
// necessary to show see more button again if user resizes window to expand and then back to collapse
|
||||
if (!isOverflowing) {
|
||||
setIsTextExpanded(false)
|
||||
}
|
||||
setShowSeeMore(isOverflowing)
|
||||
if (isTaskExpanded && textRef.current && textContainerRef.current) {
|
||||
// Use requestAnimationFrame to ensure DOM is fully updated
|
||||
requestAnimationFrame(() => {
|
||||
// Check if refs are still valid
|
||||
if (textRef.current && textContainerRef.current) {
|
||||
let textContainerHeight = textContainerRef.current.clientHeight
|
||||
if (!textContainerHeight) {
|
||||
textContainerHeight = textContainerRef.current.getBoundingClientRect().height
|
||||
}
|
||||
const isOverflowing = textRef.current.scrollHeight > textContainerHeight
|
||||
|
||||
setShowSeeMore(isOverflowing)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [task.text, windowWidth])
|
||||
}, [task.text, windowWidth, isTaskExpanded])
|
||||
|
||||
const isCostAvailable = useMemo(() => {
|
||||
const openAiCompatHasPricing =
|
||||
@@ -386,49 +405,52 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shouldShowPromptCacheInfo && (cacheReads !== undefined || cacheWrites !== undefined) && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
flexWrap: "wrap",
|
||||
}}>
|
||||
<span style={{ fontWeight: "bold" }}>Cache:</span>
|
||||
<span
|
||||
{shouldShowPromptCacheInfo &&
|
||||
(cacheReads !== undefined ||
|
||||
cacheWrites !== undefined ||
|
||||
apiConfiguration?.apiProvider === "anthropic") && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
gap: "4px",
|
||||
flexWrap: "wrap",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-database"
|
||||
<span style={{ fontWeight: "bold" }}>Cache:</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "-1px",
|
||||
}}
|
||||
/>
|
||||
+{formatLargeNumber(cacheWrites || 0)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-arrow-right"
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-database"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "-1px",
|
||||
}}
|
||||
/>
|
||||
+{formatLargeNumber(cacheWrites || 0)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: 0,
|
||||
}}
|
||||
/>
|
||||
{formatLargeNumber(cacheReads || 0)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-arrow-right"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: 0,
|
||||
}}
|
||||
/>
|
||||
{formatLargeNumber(cacheReads || 0)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{ContextWindowComponent}
|
||||
{isCostAvailable && (
|
||||
<div
|
||||
@@ -461,7 +483,25 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
}}>
|
||||
<i className="codicon codicon-warning" />
|
||||
<span>
|
||||
{checkpointTrackerErrorMessage}
|
||||
{checkpointTrackerErrorMessage.replace(/disabling checkpoints\.$/, "")}
|
||||
{checkpointTrackerErrorMessage.endsWith("disabling checkpoints.") && (
|
||||
<>
|
||||
<a
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openExtensionSettings",
|
||||
text: "enableCheckpoints",
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
color: "inherit",
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
}}>
|
||||
disabling checkpoints.
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{checkpointTrackerErrorMessage.includes("Git must be installed to use checkpoints.") && (
|
||||
<>
|
||||
{" "}
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import styled from "styled-components"
|
||||
|
||||
const StyledButton = styled(VSCodeButton)`
|
||||
--danger-button-bg: #c42b2b;
|
||||
--danger-button-hover: #a82424;
|
||||
--danger-button-active: #8f1f1f;
|
||||
|
||||
background-color: var(--danger-button-bg) !important;
|
||||
border-color: var(--danger-button-bg) !important;
|
||||
color: #ffffff !important;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--danger-button-hover) !important;
|
||||
border-color: var(--danger-button-hover) !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--danger-button-active) !important;
|
||||
border-color: var(--danger-button-active) !important;
|
||||
}
|
||||
`
|
||||
|
||||
interface DangerButtonProps extends React.ComponentProps<typeof VSCodeButton> {}
|
||||
|
||||
const DangerButton: React.FC<DangerButtonProps> = (props) => {
|
||||
return <StyledButton {...props} />
|
||||
return (
|
||||
<VSCodeButton
|
||||
{...props}
|
||||
className={`
|
||||
!bg-[#c42b2b]
|
||||
!border-[#c42b2b]
|
||||
!text-white
|
||||
hover:!bg-[#a82424]
|
||||
hover:!border-[#a82424]
|
||||
active:!bg-[#8f1f1f]
|
||||
active:!border-[#8f1f1f]
|
||||
${props.className || ""}
|
||||
`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default DangerButton
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import styled from "styled-components"
|
||||
|
||||
const StyledButton = styled(VSCodeButton)`
|
||||
--success-button-bg: #176f2c;
|
||||
--success-button-hover: #197f31;
|
||||
--success-button-active: #156528;
|
||||
interface SuccessButtonTWProps extends React.ComponentProps<typeof VSCodeButton> {}
|
||||
|
||||
background-color: var(--success-button-bg) !important;
|
||||
border-color: var(--success-button-bg) !important;
|
||||
color: #ffffff !important;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--success-button-hover) !important;
|
||||
border-color: var(--success-button-hover) !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--success-button-active) !important;
|
||||
border-color: var(--success-button-active) !important;
|
||||
}
|
||||
`
|
||||
|
||||
interface SuccessButtonProps extends React.ComponentProps<typeof VSCodeButton> {}
|
||||
|
||||
const SuccessButton: React.FC<SuccessButtonProps> = (props) => {
|
||||
return <StyledButton {...props} />
|
||||
const SuccessButtonTW: React.FC<SuccessButtonTWProps> = (props) => {
|
||||
return (
|
||||
<VSCodeButton
|
||||
{...props}
|
||||
className={`
|
||||
!bg-[#176f2c]
|
||||
!border-[#176f2c]
|
||||
!text-white
|
||||
hover:!bg-[#197f31]
|
||||
hover:!border-[#197f31]
|
||||
active:!bg-[#156528]
|
||||
active:!border-[#156528]
|
||||
${props.className || ""}
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default SuccessButton
|
||||
export default SuccessButtonTW
|
||||
|
||||
@@ -29,12 +29,12 @@ const TelemetryBanner = () => {
|
||||
|
||||
const handleAllow = () => {
|
||||
setHasChosen(true)
|
||||
vscode.postMessage({ type: "telemetrySetting", text: "enabled" satisfies TelemetrySetting })
|
||||
vscode.postMessage({ type: "telemetrySetting", telemetrySetting: "enabled" satisfies TelemetrySetting })
|
||||
}
|
||||
|
||||
const handleDeny = () => {
|
||||
setHasChosen(true)
|
||||
vscode.postMessage({ type: "telemetrySetting", text: "disabled" satisfies TelemetrySetting })
|
||||
vscode.postMessage({ type: "telemetrySetting", telemetrySetting: "disabled" satisfies TelemetrySetting })
|
||||
}
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
|
||||
@@ -13,10 +13,11 @@ interface TooltipProps {
|
||||
hintText: string
|
||||
tipText: string
|
||||
children: React.ReactNode
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
// add styled component for tooltip
|
||||
const TooltipBody = styled.div`
|
||||
const TooltipBody = styled.div<Pick<TooltipProps, "style">>`
|
||||
position: absolute;
|
||||
background-color: ${getAsVar(VSC_SIDEBAR_BACKGROUND)};
|
||||
color: ${getAsVar(VSC_DESCRIPTION_FOREGROUND)};
|
||||
@@ -24,7 +25,7 @@ const TooltipBody = styled.div`
|
||||
border-radius: 5px;
|
||||
bottom: 100%;
|
||||
left: -180%;
|
||||
z-index: 10;
|
||||
z-index: ${(props) => props.style?.zIndex ?? 10};
|
||||
white-space: wrap;
|
||||
max-width: 200px;
|
||||
border: 1px solid ${getAsVar(VSC_INPUT_BORDER)};
|
||||
@@ -39,12 +40,12 @@ const Hint = styled.div`
|
||||
margin-top: 2px;
|
||||
`
|
||||
|
||||
const Tooltip: React.FC<TooltipProps> = ({ visible, tipText, hintText, children }) => {
|
||||
const Tooltip: React.FC<TooltipProps> = ({ visible, tipText, hintText, children, style }) => {
|
||||
return (
|
||||
<div style={{ position: "relative", display: "inline-block" }}>
|
||||
{children}
|
||||
{visible && (
|
||||
<TooltipBody>
|
||||
<TooltipBody style={style}>
|
||||
{tipText}
|
||||
{hintText && <Hint>{hintText}</Hint>}
|
||||
</TooltipBody>
|
||||
|
||||
@@ -2,10 +2,13 @@ import { VSCodeButton, VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@v
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { Virtuoso } from "react-virtuoso"
|
||||
import { memo, useMemo, useState, useEffect } from "react"
|
||||
import { memo, useMemo, useState, useEffect, useCallback } from "react"
|
||||
import Fuse, { FuseResult } from "fuse.js"
|
||||
import { formatLargeNumber } from "../../utils/format"
|
||||
import { formatSize } from "../../utils/size"
|
||||
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
|
||||
import { useEvent } from "react-use"
|
||||
import DangerButton from "../common/DangerButton"
|
||||
|
||||
type HistoryViewProps = {
|
||||
onDone: () => void
|
||||
@@ -14,10 +17,23 @@ type HistoryViewProps = {
|
||||
type SortOption = "newest" | "oldest" | "mostExpensive" | "mostTokens" | "mostRelevant"
|
||||
|
||||
const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
const { taskHistory } = useExtensionState()
|
||||
const { taskHistory, totalTasksSize } = useExtensionState()
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [sortOption, setSortOption] = useState<SortOption>("newest")
|
||||
const [lastNonRelevantSort, setLastNonRelevantSort] = useState<SortOption | null>("newest")
|
||||
const [deleteAllDisabled, setDeleteAllDisabled] = useState(false)
|
||||
|
||||
const handleMessage = useCallback((event: MessageEvent<ExtensionMessage>) => {
|
||||
if (event.data.type === "relinquishControl") {
|
||||
setDeleteAllDisabled(false)
|
||||
}
|
||||
}, [])
|
||||
useEvent("message", handleMessage)
|
||||
|
||||
// Request total tasks size when component mounts
|
||||
useEffect(() => {
|
||||
vscode.postMessage({ type: "requestTotalTasksSize" })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) {
|
||||
@@ -447,6 +463,21 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 10px",
|
||||
borderTop: "1px solid var(--vscode-panel-border)",
|
||||
}}>
|
||||
<DangerButton
|
||||
style={{ width: "100%" }}
|
||||
disabled={deleteAllDisabled || taskHistory.length === 0}
|
||||
onClick={() => {
|
||||
setDeleteAllDisabled(true)
|
||||
vscode.postMessage({ type: "clearAllTaskHistory" })
|
||||
}}>
|
||||
Delete All History{totalTasksSize !== null ? ` (${formatSize(totalTasksSize)})` : ""}
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import DOMPurify from "dompurify"
|
||||
import { getSafeHostname, formatUrlForOpening, checkIfImageUrl } from "./McpRichUtil"
|
||||
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
|
||||
|
||||
interface ImagePreviewProps {
|
||||
url: string
|
||||
}
|
||||
|
||||
// Use a class component to ensure complete isolation between instances
|
||||
class ImagePreview extends React.Component<
|
||||
ImagePreviewProps,
|
||||
{
|
||||
loading: boolean
|
||||
error: string | null
|
||||
fetchStartTime: number
|
||||
}
|
||||
> {
|
||||
private imgRef = React.createRef<HTMLImageElement>()
|
||||
private timeoutId: NodeJS.Timeout | null = null
|
||||
private heartbeatId: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(props: ImagePreviewProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
loading: true,
|
||||
error: null,
|
||||
fetchStartTime: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Track aspect ratio for proper display
|
||||
private aspectRatio: number = 1
|
||||
|
||||
componentDidMount() {
|
||||
// Set up a timeout to handle cases where the image never loads or errors
|
||||
this.timeoutId = setTimeout(() => {
|
||||
console.log(`Image load timeout for ${this.props.url}`)
|
||||
if (this.state.loading) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: `Timeout loading image: ${this.props.url}`,
|
||||
})
|
||||
}
|
||||
}, 15000)
|
||||
|
||||
// Set up a heartbeat to update the UI with elapsed time
|
||||
this.heartbeatId = setInterval(() => {
|
||||
if (this.state.loading) {
|
||||
this.forceUpdate() // Just update the component to show new elapsed time
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
// First, check the content type to verify it's actually an image
|
||||
this.checkContentType(this.props.url)
|
||||
}
|
||||
|
||||
// Check if the URL is an image using content type verification
|
||||
checkContentType(url: string) {
|
||||
// Always verify content type, even for URLs that look like images by extension
|
||||
checkIfImageUrl(url)
|
||||
.then((isImage) => {
|
||||
if (isImage) {
|
||||
console.log(`URL is confirmed as image: ${url}`)
|
||||
this.loadImage(url)
|
||||
} else {
|
||||
console.log(`URL is not an image: ${url}`)
|
||||
this.handleImageError()
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(`Error checking if URL is an image: ${error}`)
|
||||
// Don't fallback to direct image loading on error
|
||||
// Instead, report the error so the URL can be handled as a non-image
|
||||
this.handleImageError()
|
||||
})
|
||||
}
|
||||
|
||||
// Load the image after content type check or as fallback
|
||||
loadImage(url: string) {
|
||||
const isSvg = /\.svg(\?.*)?$/i.test(url)
|
||||
|
||||
// For SVG files, we don't need to calculate aspect ratio as they're vector-based
|
||||
if (isSvg) {
|
||||
console.log(`SVG image detected, skipping aspect ratio calculation: ${url}`)
|
||||
// Default aspect ratio for SVGs
|
||||
this.aspectRatio = 1
|
||||
this.handleImageLoad()
|
||||
return
|
||||
}
|
||||
|
||||
// Create a test image to check if the URL loads and get dimensions
|
||||
const testImg = new Image()
|
||||
|
||||
testImg.onload = () => {
|
||||
console.log(`Test image loaded successfully: ${url}`)
|
||||
|
||||
// Calculate aspect ratio for proper display
|
||||
if (testImg.width > 0 && testImg.height > 0) {
|
||||
this.aspectRatio = testImg.width / testImg.height
|
||||
}
|
||||
|
||||
this.handleImageLoad()
|
||||
}
|
||||
|
||||
testImg.onerror = () => {
|
||||
console.log(`Test image failed to load: ${url}`)
|
||||
this.handleImageError()
|
||||
}
|
||||
|
||||
// Force CORS mode to be anonymous to avoid CORS issues
|
||||
testImg.crossOrigin = "anonymous"
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.cleanup()
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId)
|
||||
this.timeoutId = null
|
||||
}
|
||||
|
||||
if (this.heartbeatId) {
|
||||
clearInterval(this.heartbeatId)
|
||||
this.heartbeatId = null
|
||||
}
|
||||
}
|
||||
|
||||
// Handle image load event
|
||||
handleImageLoad = () => {
|
||||
console.log(`Image loaded successfully: ${this.props.url}`)
|
||||
this.setState({ loading: false })
|
||||
this.cleanup()
|
||||
}
|
||||
|
||||
// Handle image error event
|
||||
handleImageError = () => {
|
||||
console.log(`Image failed to load: ${this.props.url}`)
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: `Failed to load image: ${this.props.url}`,
|
||||
})
|
||||
this.cleanup()
|
||||
}
|
||||
|
||||
render() {
|
||||
const { url } = this.props
|
||||
const { loading, error, fetchStartTime } = this.state
|
||||
|
||||
// Calculate elapsed time for loading state
|
||||
const elapsedSeconds = loading ? Math.floor((Date.now() - fetchStartTime) / 1000) : 0
|
||||
|
||||
// Fallback display while loading
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className="image-preview-loading"
|
||||
style={{
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
height: "128px",
|
||||
maxWidth: "512px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", marginBottom: "8px" }}>
|
||||
<div
|
||||
className="loading-spinner"
|
||||
style={{
|
||||
marginRight: "8px",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
border: "2px solid rgba(127, 127, 127, 0.3)",
|
||||
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
borderRadius: "50%",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
Loading image from {getSafeHostname(url)}...
|
||||
</div>
|
||||
{elapsedSeconds > 3 && (
|
||||
<div style={{ fontSize: "11px", color: "var(--vscode-descriptionForeground)" }}>
|
||||
{elapsedSeconds > 60
|
||||
? `Waiting for ${Math.floor(elapsedSeconds / 60)}m ${elapsedSeconds % 60}s...`
|
||||
: `Waiting for ${elapsedSeconds}s...`}
|
||||
</div>
|
||||
)}
|
||||
{/* Hidden image that we'll use to detect load/error events */}
|
||||
{/\.svg(\?.*)?$/i.test(url) ? (
|
||||
<object
|
||||
type="image/svg+xml"
|
||||
data={DOMPurify.sanitize(url)}
|
||||
style={{ display: "none" }}
|
||||
onLoad={this.handleImageLoad}
|
||||
onError={this.handleImageError}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={DOMPurify.sanitize(url)}
|
||||
alt=""
|
||||
ref={this.imgRef}
|
||||
onLoad={this.handleImageLoad}
|
||||
onError={this.handleImageError}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle error state
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="image-preview-error"
|
||||
style={{
|
||||
padding: "12px",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(url),
|
||||
})
|
||||
}}>
|
||||
<div style={{ fontWeight: "bold" }}>Failed to load image</div>
|
||||
<div style={{ fontSize: "12px", marginTop: "4px" }}>{getSafeHostname(url)}</div>
|
||||
<div style={{ fontSize: "11px", marginTop: "8px", color: "var(--vscode-textLink-foreground)" }}>
|
||||
Click to open in browser
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render the image
|
||||
return (
|
||||
<div
|
||||
className="image-preview"
|
||||
style={{
|
||||
margin: "10px 0",
|
||||
maxWidth: "100%",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(formatUrlForOpening(url)),
|
||||
})
|
||||
}}>
|
||||
{/\.svg(\?.*)?$/i.test(url) ? (
|
||||
// Special handling for SVG images
|
||||
<object
|
||||
type="image/svg+xml"
|
||||
data={DOMPurify.sanitize(url)}
|
||||
style={{
|
||||
width: "85%",
|
||||
height: "auto",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
aria-label={`SVG from ${getSafeHostname(url)}`}>
|
||||
{/* Fallback if object tag fails */}
|
||||
<img
|
||||
src={DOMPurify.sanitize(url)}
|
||||
alt={`SVG from ${getSafeHostname(url)}`}
|
||||
style={{
|
||||
width: "85%",
|
||||
height: "auto",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
/>
|
||||
</object>
|
||||
) : (
|
||||
<img
|
||||
src={DOMPurify.sanitize(url)}
|
||||
alt={`Image from ${getSafeHostname(url)}`}
|
||||
style={{
|
||||
width: "85%",
|
||||
height: "auto",
|
||||
borderRadius: "4px",
|
||||
// Use contain only for very extreme aspect ratios, otherwise use cover
|
||||
objectFit: this.aspectRatio > 3 || this.aspectRatio < 0.33 ? "contain" : "cover",
|
||||
}}
|
||||
loading="eager"
|
||||
onLoad={(e) => {
|
||||
// Double-check aspect ratio from the actual loaded image
|
||||
const img = e.currentTarget
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
const newAspectRatio = img.naturalWidth / img.naturalHeight
|
||||
|
||||
// Update object-fit based on actual aspect ratio
|
||||
// Use contain only for very extreme aspect ratios, otherwise use cover
|
||||
if (newAspectRatio > 3 || newAspectRatio < 0.33) {
|
||||
img.style.objectFit = "contain"
|
||||
} else {
|
||||
img.style.objectFit = "cover"
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a wrapper component that memoizes the ImagePreview to prevent unnecessary re-renders
|
||||
const MemoizedImagePreview = React.memo(
|
||||
(props: ImagePreviewProps) => <ImagePreview {...props} />,
|
||||
(prevProps, nextProps) => prevProps.url === nextProps.url, // Only re-render if URL changes
|
||||
)
|
||||
|
||||
// Wrap the ImagePreview component with an error boundary
|
||||
const ImagePreviewWithErrorBoundary: React.FC<ImagePreviewProps> = (props) => {
|
||||
return (
|
||||
<ChatErrorBoundary errorTitle="Something went wrong displaying this image">
|
||||
<MemoizedImagePreview {...props} />
|
||||
</ChatErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImagePreviewWithErrorBoundary
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import DOMPurify from "dompurify"
|
||||
import { getSafeHostname, normalizeRelativeUrl } from "./McpRichUtil"
|
||||
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
|
||||
|
||||
interface OpenGraphData {
|
||||
title?: string
|
||||
@@ -15,174 +17,376 @@ interface LinkPreviewProps {
|
||||
url: string
|
||||
}
|
||||
|
||||
const LinkPreview: React.FC<LinkPreviewProps> = ({ url }) => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [ogData, setOgData] = useState<OpenGraphData | null>(null)
|
||||
// Error types for better UI feedback
|
||||
type ErrorType = "timeout" | "network" | "general" | null
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOpenGraphData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
// Use a class component to ensure complete isolation between instances
|
||||
class LinkPreview extends React.Component<
|
||||
LinkPreviewProps,
|
||||
{
|
||||
loading: boolean
|
||||
error: ErrorType
|
||||
errorMessage: string | null
|
||||
ogData: OpenGraphData | null
|
||||
hasCompletedFetch: boolean // Track if fetch has completed (success or error)
|
||||
fetchStartTime: number // Track when the fetch started
|
||||
}
|
||||
> {
|
||||
private messageListener: ((event: MessageEvent) => void) | null = null
|
||||
private timeoutId: NodeJS.Timeout | null = null
|
||||
private heartbeatId: NodeJS.Timeout | null = null
|
||||
|
||||
// Send a message to the extension to fetch Open Graph data
|
||||
vscode.postMessage({
|
||||
type: "fetchOpenGraphData",
|
||||
text: url,
|
||||
})
|
||||
constructor(props: LinkPreviewProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
loading: true,
|
||||
error: null,
|
||||
errorMessage: null,
|
||||
ogData: null,
|
||||
hasCompletedFetch: false,
|
||||
fetchStartTime: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Set up a listener for the response
|
||||
const messageListener = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "openGraphData" && message.url === url) {
|
||||
setOgData(message.openGraphData)
|
||||
setLoading(false)
|
||||
window.removeEventListener("message", messageListener)
|
||||
}
|
||||
}
|
||||
componentDidMount() {
|
||||
// Only fetch if we haven't completed a fetch yet
|
||||
if (!this.state.hasCompletedFetch) {
|
||||
this.fetchOpenGraphData()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", messageListener)
|
||||
componentWillUnmount() {
|
||||
this.cleanup()
|
||||
}
|
||||
|
||||
// Clean up the listener if the component unmounts
|
||||
return () => {
|
||||
window.removeEventListener("message", messageListener)
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to fetch preview data")
|
||||
setLoading(false)
|
||||
}
|
||||
// Prevent updates if fetch has completed
|
||||
shouldComponentUpdate(nextProps: LinkPreviewProps, nextState: any) {
|
||||
// If URL changes, allow update
|
||||
if (nextProps.url !== this.props.url) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Fetch Open Graph data immediately when component mounts
|
||||
fetchOpenGraphData()
|
||||
}, [url])
|
||||
// If we've completed a fetch and state hasn't changed, prevent update
|
||||
if (
|
||||
this.state.hasCompletedFetch &&
|
||||
this.state.loading === nextState.loading &&
|
||||
this.state.error === nextState.error &&
|
||||
this.state.ogData === nextState.ogData
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Fallback display while loading
|
||||
if (loading) {
|
||||
return true
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
// Clean up event listeners and timeouts
|
||||
if (this.messageListener) {
|
||||
window.removeEventListener("message", this.messageListener)
|
||||
this.messageListener = null
|
||||
}
|
||||
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId)
|
||||
this.timeoutId = null
|
||||
}
|
||||
|
||||
if (this.heartbeatId) {
|
||||
clearInterval(this.heartbeatId)
|
||||
this.heartbeatId = null
|
||||
}
|
||||
}
|
||||
|
||||
private fetchOpenGraphData() {
|
||||
try {
|
||||
// Record fetch start time
|
||||
const startTime = Date.now()
|
||||
this.setState({ fetchStartTime: startTime })
|
||||
|
||||
// Send a message to the extension to fetch Open Graph data
|
||||
vscode.postMessage({
|
||||
type: "fetchOpenGraphData",
|
||||
text: this.props.url,
|
||||
})
|
||||
|
||||
// Set up a listener for the response
|
||||
this.messageListener = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "openGraphData" && message.url === this.props.url) {
|
||||
// Check if there was an error in the response
|
||||
if (message.error) {
|
||||
this.setState({
|
||||
error: "network",
|
||||
errorMessage: message.error,
|
||||
loading: false,
|
||||
hasCompletedFetch: true,
|
||||
})
|
||||
} else {
|
||||
this.setState({
|
||||
ogData: message.openGraphData,
|
||||
loading: false,
|
||||
hasCompletedFetch: true, // Mark as completed
|
||||
})
|
||||
}
|
||||
this.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", this.messageListener)
|
||||
|
||||
// Instead of a fixed timeout, use a heartbeat to update the loading message
|
||||
// with the elapsed time, but don't actually timeout
|
||||
this.heartbeatId = setInterval(() => {
|
||||
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000)
|
||||
if (elapsedSeconds > 0) {
|
||||
this.forceUpdate() // Just update the component to show new elapsed time
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
this.setState({
|
||||
error: "general",
|
||||
errorMessage: err instanceof Error ? err.message : "Unknown error occurred",
|
||||
loading: false,
|
||||
hasCompletedFetch: true, // Mark as completed on error
|
||||
})
|
||||
this.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { url } = this.props
|
||||
const { loading, error, errorMessage, ogData, fetchStartTime } = this.state
|
||||
|
||||
// Calculate elapsed time for loading state
|
||||
const elapsedSeconds = loading ? Math.floor((Date.now() - fetchStartTime) / 1000) : 0
|
||||
|
||||
// Fallback display while loading
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className="link-preview-loading"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
height: "128px",
|
||||
maxWidth: "512px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", marginBottom: "8px" }}>
|
||||
<div
|
||||
className="loading-spinner"
|
||||
style={{
|
||||
marginRight: "8px",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
border: "2px solid rgba(127, 127, 127, 0.3)",
|
||||
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
borderRadius: "50%",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
Loading preview for {getSafeHostname(url)}...
|
||||
</div>
|
||||
{elapsedSeconds > 5 && (
|
||||
<div style={{ fontSize: "11px", color: "var(--vscode-descriptionForeground)" }}>
|
||||
{elapsedSeconds > 60
|
||||
? `Waiting for ${Math.floor(elapsedSeconds / 60)}m ${elapsedSeconds % 60}s...`
|
||||
: `Waiting for ${elapsedSeconds}s...`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle different error states with specific messages
|
||||
if (error) {
|
||||
let errorDisplay = "Unable to load preview"
|
||||
|
||||
if (error === "timeout") {
|
||||
errorDisplay = "Preview request timed out"
|
||||
} else if (error === "network") {
|
||||
errorDisplay = "Network error loading preview"
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="link-preview-error"
|
||||
style={{
|
||||
padding: "12px",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
height: "128px",
|
||||
maxWidth: "512px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(url),
|
||||
})
|
||||
}}>
|
||||
<div style={{ fontWeight: "bold" }}>{errorDisplay}</div>
|
||||
<div style={{ fontSize: "12px", marginTop: "4px" }}>{getSafeHostname(url)}</div>
|
||||
{errorMessage && <div style={{ fontSize: "11px", marginTop: "4px", opacity: 0.8 }}>{errorMessage}</div>}
|
||||
<div style={{ fontSize: "11px", marginTop: "8px", color: "var(--vscode-textLink-foreground)" }}>
|
||||
Click to open in browser
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Create a fallback object if ogData is null
|
||||
const data = ogData || {
|
||||
title: getSafeHostname(url),
|
||||
description: "No description available",
|
||||
siteName: getSafeHostname(url),
|
||||
url: url,
|
||||
}
|
||||
|
||||
// Render the Open Graph preview
|
||||
return (
|
||||
<div
|
||||
className="link-preview-loading"
|
||||
className="link-preview"
|
||||
style={{
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
height: "128px",
|
||||
maxWidth: "512px",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(url),
|
||||
})
|
||||
}}>
|
||||
{data.image && (
|
||||
<div className="link-preview-image" style={{ width: "128px", height: "128px", flexShrink: 0 }}>
|
||||
<img
|
||||
src={DOMPurify.sanitize(normalizeRelativeUrl(data.image, url))}
|
||||
alt=""
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain", // Use contain for link preview thumbnails to handle logos
|
||||
objectPosition: "center", // Center the image
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
// Check aspect ratio to determine if we should use contain or cover
|
||||
const img = e.currentTarget
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
const aspectRatio = img.naturalWidth / img.naturalHeight
|
||||
|
||||
// Use contain for extreme aspect ratios (logos), cover for photos
|
||||
if (aspectRatio > 2.5 || aspectRatio < 0.4) {
|
||||
img.style.objectFit = "contain"
|
||||
} else {
|
||||
img.style.objectFit = "cover"
|
||||
}
|
||||
}
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.log(`Image could not be loaded: ${data.image}`)
|
||||
// Hide the broken image
|
||||
;(e.target as HTMLImageElement).style.display = "none"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="loading-spinner"
|
||||
className="link-preview-content"
|
||||
style={{
|
||||
marginRight: "8px",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
border: "2px solid rgba(127, 127, 127, 0.3)",
|
||||
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
borderRadius: "50%",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
Loading preview for {new URL(url).hostname}...
|
||||
flex: 1,
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
height: "100%", // Ensure full height
|
||||
}}>
|
||||
{/* Top section with title and URL - top aligned */}
|
||||
<div className="link-preview-top">
|
||||
<div
|
||||
className="link-preview-title"
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
marginBottom: "4px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.title || "No title"}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="link-preview-url"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
marginBottom: "8px", // Increased for better separation
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.siteName || getSafeHostname(url)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description with space-around in the remaining space */}
|
||||
<div
|
||||
className="link-preview-description-container"
|
||||
style={{
|
||||
flex: 1, // Take up remaining space
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-around", // Space around in the remaining area
|
||||
}}>
|
||||
<div
|
||||
className="link-preview-description"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground, rgba(204, 204, 204, 0.7))",
|
||||
overflow: "hidden",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.description || "No description available"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a fallback object if ogData is null
|
||||
const data = ogData || {
|
||||
title: new URL(url).hostname,
|
||||
description: "No description available",
|
||||
siteName: new URL(url).hostname,
|
||||
url: url,
|
||||
}
|
||||
// Create a wrapper component that memoizes the LinkPreview to prevent unnecessary re-renders
|
||||
const MemoizedLinkPreview = React.memo(
|
||||
(props: LinkPreviewProps) => <LinkPreview {...props} />,
|
||||
(prevProps, nextProps) => prevProps.url === nextProps.url, // Only re-render if URL changes
|
||||
)
|
||||
|
||||
// Render the Open Graph preview
|
||||
// Wrap the LinkPreview component with an error boundary
|
||||
const LinkPreviewWithErrorBoundary: React.FC<LinkPreviewProps> = (props) => {
|
||||
return (
|
||||
<div
|
||||
className="link-preview"
|
||||
style={{
|
||||
display: "flex",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(url),
|
||||
})
|
||||
}}>
|
||||
{data.image && (
|
||||
<div className="link-preview-image" style={{ width: "128px", height: "128px", flexShrink: 0 }}>
|
||||
<img
|
||||
src={DOMPurify.sanitize(data.image)}
|
||||
alt=""
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="link-preview-content"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div
|
||||
className="link-preview-title"
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
marginBottom: "4px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.title || "No title"}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="link-preview-url"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
marginBottom: "8px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.siteName || new URL(url).hostname}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="link-preview-description"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground, rgba(204, 204, 204, 0.7))",
|
||||
overflow: "hidden",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.description || "No description available"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChatErrorBoundary errorTitle="Something went wrong displaying this link preview">
|
||||
<MemoizedLinkPreview {...props} />
|
||||
</ChatErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default LinkPreview
|
||||
export default LinkPreviewWithErrorBoundary
|
||||
|
||||
@@ -1,180 +1,23 @@
|
||||
import React, { useEffect, useState, useCallback } from "react"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import LinkPreview from "./LinkPreview"
|
||||
import ImagePreview from "./ImagePreview"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import DOMPurify from "dompurify"
|
||||
import styled from "styled-components"
|
||||
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
import DOMPurify from "dompurify"
|
||||
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
|
||||
import {
|
||||
safeCreateUrl,
|
||||
isUrl,
|
||||
getSafeHostname,
|
||||
isLocalhostUrl,
|
||||
normalizeRelativeUrl,
|
||||
formatUrlForOpening,
|
||||
checkIfImageUrl,
|
||||
} from "./McpRichUtil"
|
||||
|
||||
// We'll use the backend isImageUrl function for HEAD requests
|
||||
// This is a client-side fallback for data URLs and obvious image extensions
|
||||
const isImageUrlSync = (str: string): boolean => {
|
||||
// Check for data URLs which are definitely images
|
||||
if (str.startsWith("data:image/")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for common image file extensions
|
||||
return str.match(/\.(jpg|jpeg|png|gif|webp)$/i) !== null
|
||||
}
|
||||
|
||||
export const isUrl = (str: string): boolean => {
|
||||
// Basic URL validation
|
||||
const urlPattern = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/[^\s]*)?$/
|
||||
return urlPattern.test(str)
|
||||
}
|
||||
|
||||
// Function to check if a URL is an image using HEAD request
|
||||
export const checkIfImageUrl = async (url: string): Promise<boolean> => {
|
||||
// For data URLs, we can check synchronously
|
||||
if (url.startsWith("data:image/")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// For http/https URLs, we need to send a message to the extension
|
||||
if (url.startsWith("http")) {
|
||||
try {
|
||||
// Create a promise that will resolve when we get a response
|
||||
return new Promise((resolve) => {
|
||||
// Set up a one-time listener for the response
|
||||
const messageListener = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "isImageUrlResult" && message.url === url) {
|
||||
window.removeEventListener("message", messageListener)
|
||||
resolve(message.isImage)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", messageListener)
|
||||
|
||||
// Send the request to the extension
|
||||
vscode.postMessage({
|
||||
type: "checkIsImageUrl",
|
||||
text: url,
|
||||
})
|
||||
|
||||
// Set a timeout to avoid hanging indefinitely
|
||||
setTimeout(() => {
|
||||
window.removeEventListener("message", messageListener)
|
||||
// Fall back to extension check
|
||||
resolve(isImageUrlSync(url))
|
||||
}, 3000)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error checking if URL is an image:", error)
|
||||
return isImageUrlSync(url)
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to extension check for other URLs
|
||||
return isImageUrlSync(url)
|
||||
}
|
||||
|
||||
// No longer needed as our regex directly extracts the URL part
|
||||
|
||||
// Helper to ensure URL is in a format that can be opened
|
||||
export const formatUrlForOpening = (url: string): string => {
|
||||
// If it's a data URI, return as is
|
||||
if (url.startsWith("data:image/")) {
|
||||
return url
|
||||
}
|
||||
|
||||
// If it's a regular URL but doesn't have a protocol, add https://
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
return `https://${url}`
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
// Find all URLs (both image and regular) in an object
|
||||
export const findUrls = async (obj: any): Promise<{ imageUrls: string[]; regularUrls: string[] }> => {
|
||||
const imageUrls: string[] = []
|
||||
const regularUrls: string[] = []
|
||||
const pendingChecks: Promise<void>[] = []
|
||||
|
||||
if (typeof obj === "object" && obj !== null) {
|
||||
for (const value of Object.values(obj)) {
|
||||
if (typeof value === "string") {
|
||||
// First check with synchronous method
|
||||
if (isImageUrlSync(value)) {
|
||||
imageUrls.push(value)
|
||||
} else if (isUrl(value)) {
|
||||
// For URLs that don't obviously look like images, we'll check asynchronously
|
||||
const checkPromise = checkIfImageUrl(value).then((isImage) => {
|
||||
if (isImage) {
|
||||
imageUrls.push(value)
|
||||
} else {
|
||||
regularUrls.push(value)
|
||||
}
|
||||
})
|
||||
pendingChecks.push(checkPromise)
|
||||
}
|
||||
} else if (typeof value === "object") {
|
||||
const nestedUrlsPromise = findUrls(value).then((nestedUrls) => {
|
||||
imageUrls.push(...nestedUrls.imageUrls)
|
||||
regularUrls.push(...nestedUrls.regularUrls)
|
||||
})
|
||||
pendingChecks.push(nestedUrlsPromise)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all async checks to complete
|
||||
await Promise.all(pendingChecks)
|
||||
|
||||
return { imageUrls, regularUrls }
|
||||
}
|
||||
|
||||
// Extract URLs from text using regex
|
||||
export const extractUrlsFromText = async (text: string): Promise<{ imageUrls: string[]; regularUrls: string[] }> => {
|
||||
const imageUrls: string[] = []
|
||||
const regularUrls: string[] = []
|
||||
const pendingChecks: Promise<void>[] = []
|
||||
|
||||
// Match URLs with image: prefix and extract just the URL part
|
||||
const imageMatches = text.match(/image:\s*(https?:\/\/[^\s]+)/g)
|
||||
if (imageMatches) {
|
||||
// Extract just the URL part from matches with image: prefix
|
||||
const extractedUrls = imageMatches
|
||||
.map((match) => {
|
||||
const urlMatch = /image:\s*(https?:\/\/[^\s]+)/.exec(match)
|
||||
return urlMatch ? urlMatch[1] : null
|
||||
})
|
||||
.filter(Boolean) as string[]
|
||||
|
||||
imageUrls.push(...extractedUrls)
|
||||
}
|
||||
|
||||
// Match all URLs (including those that might be in the middle of paragraphs)
|
||||
const urlMatches = text.match(/https?:\/\/[^\s]+/g)
|
||||
if (urlMatches) {
|
||||
// Filter out URLs that are already in imageUrls
|
||||
const filteredUrls = urlMatches.filter((url) => !imageUrls.includes(url))
|
||||
|
||||
// Check each URL to see if it's an image
|
||||
for (const url of filteredUrls) {
|
||||
// First check with synchronous method
|
||||
if (isImageUrlSync(url)) {
|
||||
imageUrls.push(url)
|
||||
} else {
|
||||
// For URLs that don't obviously look like images, we'll check asynchronously
|
||||
const checkPromise = checkIfImageUrl(url).then((isImage) => {
|
||||
if (isImage) {
|
||||
imageUrls.push(url)
|
||||
} else {
|
||||
regularUrls.push(url)
|
||||
}
|
||||
})
|
||||
pendingChecks.push(checkPromise)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all async checks to complete
|
||||
await Promise.all(pendingChecks)
|
||||
|
||||
return { imageUrls, regularUrls }
|
||||
}
|
||||
// Maximum number of URLs to process in total, per response
|
||||
export const MAX_URLS = 50
|
||||
|
||||
const ResponseHeader = styled.div`
|
||||
display: flex;
|
||||
@@ -271,7 +114,7 @@ interface McpResponseDisplayProps {
|
||||
// Represents a URL found in the text with its position and metadata
|
||||
interface UrlMatch {
|
||||
url: string // The actual URL
|
||||
fullMatch: string // The full matched text (including any prefix like "image:")
|
||||
fullMatch: string // The full matched text
|
||||
index: number // Position in the text
|
||||
isImage: boolean // Whether this URL is an image
|
||||
isProcessed: boolean // Whether we've already processed this URL (to avoid duplicates)
|
||||
@@ -282,59 +125,159 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
|
||||
const [displayMode, setDisplayMode] = useState<"rich" | "plain">(() => {
|
||||
// Get saved preference from localStorage, default to 'rich'
|
||||
const savedMode = localStorage.getItem("mcpDisplayMode")
|
||||
return (savedMode === "plain" ? "plain" : "rich") as "rich" | "plain"
|
||||
return savedMode === "plain" ? "plain" : "rich"
|
||||
})
|
||||
const [urlMatches, setUrlMatches] = useState<UrlMatch[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Add a counter state for forcing re-renders to make toggling run smoother
|
||||
const [forceUpdateCounter, setForceUpdateCounter] = useState(0)
|
||||
|
||||
const toggleDisplayMode = useCallback(() => {
|
||||
const newMode = displayMode === "rich" ? "plain" : "rich"
|
||||
|
||||
// Force an immediate re-render
|
||||
setForceUpdateCounter((prev) => prev + 1)
|
||||
|
||||
// Update display mode and save preference
|
||||
setDisplayMode(newMode)
|
||||
localStorage.setItem("mcpDisplayMode", newMode)
|
||||
|
||||
// If switching to plain mode, cancel any ongoing processing
|
||||
if (newMode === "plain") {
|
||||
console.log("Switching to plain mode - cancelling URL processing")
|
||||
setUrlMatches([]) // Clear any existing matches when switching to plain mode
|
||||
} else {
|
||||
// If switching to rich mode, the useEffect will re-run and fetch data
|
||||
console.log("Switching to rich mode - will start URL processing")
|
||||
}
|
||||
}, [displayMode])
|
||||
|
||||
// Find all URLs in the text and determine if they're images
|
||||
useEffect(() => {
|
||||
// Skip all processing if in plain mode
|
||||
if (displayMode === "plain") {
|
||||
setIsLoading(false)
|
||||
setUrlMatches([]) // Clear any existing matches when in plain mode
|
||||
return
|
||||
}
|
||||
|
||||
// Use a direct boolean for cancellation that's scoped to this effect run
|
||||
let processingCanceled = false
|
||||
|
||||
const processResponse = async () => {
|
||||
console.log("Processing MCP response for URL extraction")
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const text = responseText || ""
|
||||
const matches: UrlMatch[] = []
|
||||
|
||||
const urlRegex = /https?:\/\/[^\s]+/g
|
||||
const urlRegex = /https?:\/\/[^\s<>"']+/g
|
||||
let urlMatch: RegExpExecArray | null
|
||||
let urlCount = 0
|
||||
|
||||
while ((urlMatch = urlRegex.exec(text)) !== null) {
|
||||
// First pass: Extract all URLs and immediately make them available for rendering
|
||||
while ((urlMatch = urlRegex.exec(text)) !== null && urlCount < MAX_URLS) {
|
||||
// Get the original URL from the match - never modify the original URL text
|
||||
const url = urlMatch[0]
|
||||
const fullMatch = url
|
||||
|
||||
// Skip invalid URLs
|
||||
if (!isUrl(url)) {
|
||||
console.log("Skipping invalid URL:", url)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip localhost URLs to prevent security issues
|
||||
if (isLocalhostUrl(url)) {
|
||||
console.log("Skipping localhost URL:", url)
|
||||
continue
|
||||
}
|
||||
|
||||
matches.push({
|
||||
url,
|
||||
fullMatch,
|
||||
fullMatch: url,
|
||||
index: urlMatch.index,
|
||||
isImage: false, // Will check later
|
||||
isProcessed: false,
|
||||
})
|
||||
|
||||
urlCount++
|
||||
}
|
||||
|
||||
// Check if URLs are images
|
||||
for (const match of matches) {
|
||||
match.isImage = await checkIfImageUrl(match.url)
|
||||
console.log(`Found ${matches.length} URLs in text, will check if they are images`)
|
||||
|
||||
// Set matches immediately so UI can start rendering with loading states
|
||||
setUrlMatches(matches.sort((a, b) => a.index - b.index))
|
||||
|
||||
// Mark loading as complete to show content immediately
|
||||
setIsLoading(false)
|
||||
|
||||
// Process image checks in the background - one at a time to avoid network flooding
|
||||
const processImageChecks = async () => {
|
||||
console.log(`Starting sequential URL processing for ${matches.length} URLs`)
|
||||
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
// Skip already processed URLs (from extension check)
|
||||
if (matches[i].isProcessed) continue
|
||||
|
||||
// Check if processing has been canceled (switched to plain mode)
|
||||
if (processingCanceled) {
|
||||
console.log("URL processing canceled - display mode changed to plain")
|
||||
return
|
||||
}
|
||||
|
||||
const match = matches[i]
|
||||
console.log(`Processing URL ${i + 1} of ${matches.length}: ${match.url}`)
|
||||
|
||||
try {
|
||||
// Process each URL individually
|
||||
const isImage = await checkIfImageUrl(match.url)
|
||||
|
||||
// Skip if processing has been canceled
|
||||
if (processingCanceled) return
|
||||
|
||||
// Update the match in place
|
||||
match.isImage = isImage
|
||||
match.isProcessed = true
|
||||
|
||||
// Update state after each URL to show progress
|
||||
// Create a new array to ensure React detects the state change
|
||||
setUrlMatches([...matches])
|
||||
} catch (err) {
|
||||
console.log(`URL check error: ${match.url}`, err)
|
||||
match.isProcessed = true
|
||||
|
||||
// Update state even on error
|
||||
if (!processingCanceled) {
|
||||
setUrlMatches([...matches])
|
||||
}
|
||||
}
|
||||
|
||||
// Delay between URL processing to avoid overwhelming the network
|
||||
if (!processingCanceled && i < matches.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`URL processing complete. Found ${matches.filter((m) => m.isImage).length} image URLs`)
|
||||
}
|
||||
|
||||
// Sort by position in the text
|
||||
matches.sort((a, b) => a.index - b.index)
|
||||
|
||||
setUrlMatches(matches)
|
||||
// Start the background processing
|
||||
processImageChecks()
|
||||
} catch (error) {
|
||||
console.error("Error processing MCP response:", error)
|
||||
} finally {
|
||||
setError("Failed to process response content. Switch to plain text mode to view safely.")
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
processResponse()
|
||||
}, [responseText])
|
||||
|
||||
// Cleanup function to cancel processing if component unmounts or dependencies change
|
||||
return () => {
|
||||
processingCanceled = true
|
||||
console.log("Cleaning up URL processing")
|
||||
}
|
||||
}, [responseText, displayMode, forceUpdateCounter])
|
||||
|
||||
// Function to render content based on display mode
|
||||
const renderContent = () => {
|
||||
@@ -343,15 +286,26 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
|
||||
return <UrlText>{responseText}</UrlText>
|
||||
}
|
||||
|
||||
// Show error message if there was an error
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<div style={{ color: "var(--vscode-errorForeground)", marginBottom: "10px" }}>{error}</div>
|
||||
<UrlText>{responseText}</UrlText>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// For rich display mode, show the text with embedded content
|
||||
if (displayMode === "rich" && !isLoading) {
|
||||
if (!isLoading) {
|
||||
// We already know displayMode is "rich" if we get here
|
||||
// Create an array of text segments and embedded content
|
||||
const segments: JSX.Element[] = []
|
||||
let lastIndex = 0
|
||||
let segmentIndex = 0
|
||||
|
||||
// Reset the processed flag for all URLs
|
||||
const processedUrls = new Set<string>()
|
||||
// Track embed count for logging
|
||||
let embedCount = 0
|
||||
|
||||
// Add the text before the first URL
|
||||
if (urlMatches.length === 0) {
|
||||
@@ -375,38 +329,51 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
|
||||
const urlEndIndex = index + fullMatch.length
|
||||
|
||||
// Add embedded content after the URL
|
||||
// For images, use the ImagePreview component
|
||||
if (match.isImage) {
|
||||
segments.push(
|
||||
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
|
||||
<img
|
||||
src={DOMPurify.sanitize(url)}
|
||||
alt={`Image for ${url}`}
|
||||
style={{
|
||||
width: "85%",
|
||||
height: "auto",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
const formattedUrl = formatUrlForOpening(url)
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(formattedUrl),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
} else if (!processedUrls.has(url)) {
|
||||
// For non-image URLs, only show the preview once
|
||||
segments.push(
|
||||
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
|
||||
<LinkPreview url={formatUrlForOpening(url)} />
|
||||
<div key={`embed-image-${url}-${segmentIndex++}`}>
|
||||
{/* Use formatUrlForOpening for network calls but preserve original URL in display */}
|
||||
<ImagePreview url={formatUrlForOpening(url)} />
|
||||
</div>,
|
||||
)
|
||||
embedCount++
|
||||
// console.log(`Added image embed for ${url}, embed count: ${embedCount}`);
|
||||
} else if (match.isProcessed) {
|
||||
// For non-image URLs or URLs we haven't processed yet, show link preview
|
||||
try {
|
||||
// Skip localhost URLs
|
||||
if (!isLocalhostUrl(url)) {
|
||||
// Use a unique key that includes the URL to ensure each preview is isolated
|
||||
segments.push(
|
||||
<div key={`embed-${url}-${segmentIndex++}`} style={{ margin: "10px 0" }}>
|
||||
{/* Already using formatUrlForOpening for link previews */}
|
||||
<LinkPreview url={formatUrlForOpening(url)} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
// Mark this URL as processed
|
||||
processedUrls.add(url)
|
||||
embedCount++
|
||||
// console.log(`Added link preview for ${url}, embed count: ${embedCount}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Link preview could not be created")
|
||||
// Show error message for failed link preview
|
||||
segments.push(
|
||||
<div
|
||||
key={`embed-error-${segmentIndex++}`}
|
||||
style={{
|
||||
margin: "10px 0",
|
||||
padding: "8px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
border: "1px solid var(--vscode-editorError-foreground)",
|
||||
borderRadius: "4px",
|
||||
height: "128px", // Fixed height
|
||||
overflow: "auto", // Allow scrolling if content overflows
|
||||
}}>
|
||||
Failed to create preview for: {url}
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update lastIndex for next segment
|
||||
@@ -442,7 +409,7 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
|
||||
</ResponseContainer>
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Error parsing MCP response:", error)
|
||||
console.log("Error rendering MCP response - falling back to plain text")
|
||||
return (
|
||||
<ResponseContainer>
|
||||
<ResponseHeader>
|
||||
@@ -457,4 +424,13 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
|
||||
}
|
||||
}
|
||||
|
||||
export default McpResponseDisplay
|
||||
// Wrap the entire McpResponseDisplay component with an error boundary
|
||||
const McpResponseDisplayWithErrorBoundary: React.FC<McpResponseDisplayProps> = (props) => {
|
||||
return (
|
||||
<ChatErrorBoundary>
|
||||
<McpResponseDisplay {...props} />
|
||||
</ChatErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default McpResponseDisplayWithErrorBoundary
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { vscode } from "../../utils/vscode"
|
||||
|
||||
// Safely create a URL object with error handling and ensure HTTPS
|
||||
export const safeCreateUrl = (url: string): URL | null => {
|
||||
try {
|
||||
// Convert HTTP to HTTPS for security
|
||||
if (url.startsWith("http://")) {
|
||||
url = url.replace("http://", "https://")
|
||||
}
|
||||
|
||||
return new URL(url)
|
||||
} catch (e) {
|
||||
// If the URL doesn't have a protocol, add https://
|
||||
if (!url.startsWith("https://")) {
|
||||
try {
|
||||
return new URL(`https://${url}`)
|
||||
} catch (e) {
|
||||
console.log(`Invalid URL: ${url}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
console.log(`Invalid URL: ${url}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a string is a valid URL
|
||||
export const isUrl = (str: string): boolean => {
|
||||
return safeCreateUrl(str) !== null
|
||||
}
|
||||
|
||||
// Get hostname safely
|
||||
export const getSafeHostname = (url: string): string => {
|
||||
try {
|
||||
const urlObj = safeCreateUrl(url)
|
||||
return urlObj ? urlObj.hostname : "unknown-host"
|
||||
} catch (e) {
|
||||
return "unknown-host"
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a URL is a localhost URL by examining the hostname
|
||||
export const isLocalhostUrl = (url: string): boolean => {
|
||||
try {
|
||||
const hostname = getSafeHostname(url)
|
||||
return (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "0.0.0.0" ||
|
||||
hostname.startsWith("192.168.") ||
|
||||
hostname.startsWith("10.") ||
|
||||
hostname.endsWith(".local")
|
||||
)
|
||||
} catch (e) {
|
||||
// If we can't parse the URL, assume it's not localhost
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Function to normalize relative URLs by combining with a base URL
|
||||
export const normalizeRelativeUrl = (relativeUrl: string, baseUrl: string): string => {
|
||||
// If it's already an absolute URL or a data URL, return as is
|
||||
if (relativeUrl.startsWith("http://") || relativeUrl.startsWith("https://") || relativeUrl.startsWith("data:")) {
|
||||
return relativeUrl
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the base URL
|
||||
const baseUrlObj = safeCreateUrl(baseUrl)
|
||||
if (!baseUrlObj) {
|
||||
return relativeUrl // If we can't parse the base URL, return original
|
||||
}
|
||||
|
||||
// Handle different types of relative paths
|
||||
if (relativeUrl.startsWith("//")) {
|
||||
// Protocol-relative URL
|
||||
return `${baseUrlObj.protocol}${relativeUrl}`
|
||||
} else if (relativeUrl.startsWith("/")) {
|
||||
// Root-relative URL
|
||||
return `${baseUrlObj.protocol}//${baseUrlObj.host}${relativeUrl}`
|
||||
} else {
|
||||
// Path-relative URL
|
||||
// Get the directory part of the URL
|
||||
let basePath = baseUrlObj.pathname
|
||||
if (!basePath.endsWith("/")) {
|
||||
// If the path doesn't end with a slash, remove the file part
|
||||
basePath = basePath.substring(0, basePath.lastIndexOf("/") + 1)
|
||||
}
|
||||
return `${baseUrlObj.protocol}//${baseUrlObj.host}${basePath}${relativeUrl}`
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error normalizing relative URL: ${error}`)
|
||||
return relativeUrl // Return original on error
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to ensure URL is in a format that can be opened
|
||||
export const formatUrlForOpening = (url: string): string => {
|
||||
// If it's a data URI, return as is
|
||||
if (url.startsWith("data:image/")) {
|
||||
return url
|
||||
}
|
||||
|
||||
// Use safeCreateUrl to validate and format the URL
|
||||
const urlObj = safeCreateUrl(url)
|
||||
if (urlObj) {
|
||||
return urlObj.href
|
||||
}
|
||||
|
||||
console.log(`Invalid URL format: ${url}`)
|
||||
// Return a safe fallback that won't crash
|
||||
return "about:blank"
|
||||
}
|
||||
|
||||
// Function to check if a URL is an image using HEAD request
|
||||
export const checkIfImageUrl = async (url: string): Promise<boolean> => {
|
||||
// For data URLs, we can check synchronously
|
||||
if (url.startsWith("data:image/")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Create a secure URL for the check but don't modify the original URL
|
||||
let secureUrl = url
|
||||
// Convert HTTP to HTTPS for security in the network request only
|
||||
if (secureUrl.startsWith("http://")) {
|
||||
secureUrl = secureUrl.replace("http://", "https://")
|
||||
console.log(`Using HTTPS version for image check: ${secureUrl}`)
|
||||
}
|
||||
|
||||
// Validate URL before proceeding
|
||||
if (!isUrl(url)) {
|
||||
console.log("Invalid URL format:", url)
|
||||
return false
|
||||
}
|
||||
|
||||
// For https URLs, we need to send a message to the extension
|
||||
if (url.startsWith("https")) {
|
||||
try {
|
||||
// Create a promise that will resolve when we get a response
|
||||
return new Promise((resolve) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined
|
||||
|
||||
// Set up a one-time listener for the response
|
||||
const messageListener = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "isImageUrlResult" && message.url === url) {
|
||||
window.removeEventListener("message", messageListener)
|
||||
resolve(message.isImage)
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", messageListener)
|
||||
|
||||
// Send the request to the extension
|
||||
vscode.postMessage({
|
||||
type: "checkIsImageUrl",
|
||||
text: url,
|
||||
})
|
||||
|
||||
// Set a timeout to avoid hanging indefinitely
|
||||
timeoutId = setTimeout(() => {
|
||||
window.removeEventListener("message", messageListener)
|
||||
console.log("Hit timeout waiting for checkIsImageUrl")
|
||||
resolve(false)
|
||||
}, 3000)
|
||||
})
|
||||
} catch (error) {
|
||||
console.log("Error checking if URL is an image:", url)
|
||||
// Don't fall back to extension check on error
|
||||
// Instead, return false to indicate it's not an image
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Don't fall back to extension check for other URLs
|
||||
// Only data URLs (handled above) are guaranteed to be images
|
||||
// For all other URLs, we need proper content type verification
|
||||
console.log(`URL protocol not supported for image check: ${url}`)
|
||||
return false
|
||||
}
|
||||
@@ -13,10 +13,10 @@ import { DEFAULT_MCP_TIMEOUT_SECONDS, McpServer } from "../../../../src/shared/m
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { getMcpServerDisplayName } from "../../utils/mcp"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import DangerButton from "../common/DangerButton"
|
||||
import McpMarketplaceView from "./marketplace/McpMarketplaceView"
|
||||
import McpResourceRow from "./McpResourceRow"
|
||||
import McpToolRow from "./McpToolRow"
|
||||
import DangerButton from "../common/DangerButton"
|
||||
|
||||
type McpViewProps = {
|
||||
onDone: () => void
|
||||
@@ -188,7 +188,15 @@ const StyledTabButton = styled.button<{ isActive: boolean }>`
|
||||
}
|
||||
`
|
||||
|
||||
const TabButton = ({ children, isActive, onClick }: { children: React.ReactNode; isActive: boolean; onClick: () => void }) => (
|
||||
export const TabButton = ({
|
||||
children,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
}) => (
|
||||
<StyledTabButton isActive={isActive} onClick={onClick}>
|
||||
{children}
|
||||
</StyledTabButton>
|
||||
@@ -379,6 +387,13 @@ const ServerRow = ({ server }: { server: McpServer }) => {
|
||||
}}>
|
||||
{server.status === "connecting" ? "Retrying..." : "Retry Connection"}
|
||||
</VSCodeButton>
|
||||
|
||||
<DangerButton
|
||||
style={{ width: "calc(100% - 20px)", margin: "0 10px 10px 10px" }}
|
||||
disabled={isDeleting}
|
||||
onClick={handleDelete}>
|
||||
{isDeleting ? "Deleting..." : "Delete Server"}
|
||||
</DangerButton>
|
||||
</div>
|
||||
) : (
|
||||
isExpanded && (
|
||||
@@ -470,12 +485,9 @@ const ServerRow = ({ server }: { server: McpServer }) => {
|
||||
</VSCodeButton>
|
||||
|
||||
<DangerButton
|
||||
onClick={handleDelete}
|
||||
style={{ width: "calc(100% - 14px)", margin: "5px 7px 3px 7px" }}
|
||||
disabled={isDeleting}
|
||||
style={{
|
||||
width: "calc(100% - 14px)",
|
||||
margin: "5px 7px 3px 7px",
|
||||
}}>
|
||||
onClick={handleDelete}>
|
||||
{isDeleting ? "Deleting..." : "Delete Server"}
|
||||
</DangerButton>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,683 @@
|
||||
# How To Test Rich MCP Responses
|
||||
|
||||
Use the `echo` MCP server to read back one of the test cases below into an MCP response.
|
||||
https://github.com/Garoth/echo-mcp
|
||||
|
||||
Manually check the embeds, images, and whatever other enhancements for proper rendering.
|
||||
Remember that toggling Rich MCP off should cancel pending fetches. If the toggle was
|
||||
set to Plain, then the image/link previews should never be fetched until it's enabled.
|
||||
Remember that rich display mode will only load the first n URLs, currently set to 50
|
||||
|
||||
## Main Test Case
|
||||
|
||||
Working Image URLs
|
||||
|
||||
jpg: https://yavuzceliker.github.io/sample-images/image-205.jpg
|
||||
webp: https://seenandheard.app/assets/img/face-2.webp
|
||||
svg: https://seenandheard.app/assets/img/logo-white.svg
|
||||
|
||||
Looks like Image URL but is website
|
||||
|
||||
site: https://github.com/google/pprof/blob/main/doc/images/webui/flame-multi.png
|
||||
raw png: https://raw.githubusercontent.com/google/pprof/refs/heads/main/doc/images/webui/flame-multi.png
|
||||
|
||||
Gif:
|
||||
|
||||
https://upload.wikimedia.org/wikipedia/commons/thumb/d/d0/01_Das_Sandberg-Modell.gif/750px-01_Das_Sandberg-Modell.gif
|
||||
|
||||
Normal Working URLs for OG Embeds
|
||||
|
||||
https://www.google.com
|
||||
https://www.blogger.com
|
||||
https://youtube.com
|
||||
https://linkedin.com
|
||||
https://support.google.com
|
||||
https://cloudflare.com
|
||||
https://microsoft.com
|
||||
https://apple.com
|
||||
https://en.wikipedia.org
|
||||
https://play.google.com
|
||||
https://wordpress.org
|
||||
|
||||
Attack URLs & Unsupported Formats
|
||||
|
||||
data:text/html,<h1>Hello World</h1>
|
||||
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
|
||||
javascript:alert('XSS')
|
||||
mailto:user@example.com
|
||||
tel:+1-234-567-8901
|
||||
sms:+1-234-567-8901?body=Hello
|
||||
https://www.example.com/path/to/file.html?param=<script>alert('XSS')</script>
|
||||
https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
|
||||
https://www.example.com/path/to/file.html?param=javascript:alert('XSS')
|
||||
https://www.example.com/path/to/file.html?param=data:text/html,<script>alert('XSS')</script>
|
||||
https://www.example.com/path/to/file.html?param=data:image/svg+xml,<svg onload="alert('XSS')">
|
||||
https://www.example.com/path/to/file.html?param=<iframe src="javascript:alert('XSS')">
|
||||
https://www.example.com/path/to/file.html?param=<a href="javascript:alert('XSS')">Click me</a>
|
||||
|
||||
Broken & Weird Edge Cases
|
||||
|
||||
https://tectum.io/blog/dex-tools/
|
||||
http://0.0.0.0:8025/img.png
|
||||
https://localhost:8080/img.jpg
|
||||
http://localhost:8080/
|
||||
https://localhost/
|
||||
http://httpbin.org/#/
|
||||
https://snthonstcrgrfonhenth.com/nthshtf
|
||||
http://domain/.well-known/acme-challenge/token
|
||||
https://<strong>dextools</strong>.apiable.io/(Only
|
||||
|
||||
## Generated Links Test Case
|
||||
|
||||
1. https://www.google.com
|
||||
2. http://example.com/path/to/resource?query=value#fragment
|
||||
3. https://images.unsplash.com/photo-1575936123452-b67c3203c357
|
||||
4. file:///home/user/document.txt
|
||||
5. https://user:password@example.com:8080/path
|
||||
6. http://192.168.1.1:8080
|
||||
7. https://www.example.com/path with spaces/file.html
|
||||
8. ftp://ftp.example.com/pub/file.zip
|
||||
9. https://www.example.com/index.php?id=1&name=test
|
||||
10. https://subdomain.example.co.uk/path
|
||||
11. https://www.example.com/path/to/image.jpg
|
||||
12. https://www.example.com:8443/secure
|
||||
13. http://localhost:3000
|
||||
14. https://www.example.com/path/to/file.pdf#page=10
|
||||
15. https://www.example.com/search?q=query+with+spaces
|
||||
16. https://www.example.com/path/to/file.html#section-2
|
||||
17. https://www.example.com/path/to/file.php?id=123&action=view
|
||||
18. https://www.example.com/path/to/file.html?param1=value1¶m2=value2#fragment
|
||||
19. https://www.example.com/path/to/file.html?param=value with spaces
|
||||
20. https://www.example.com/path/to/file.html?param=value%20with%20encoded%20spaces
|
||||
21. https://www.example.com/path/to/file.html?param=value+with+plus+signs
|
||||
22. https://www.example.com/path/to/file.html?param=special@characters!
|
||||
23. https://www.example.com/path/to/file.html?param=special%40characters%21
|
||||
24. https://www.example.com/path/to/file.html?param=value¶m=duplicate
|
||||
25. https://www.example.com/path/to/file.html?param=
|
||||
26. https://www.example.com/path/to/file.html?=value
|
||||
27. https://www.example.com/path/to/file.html?
|
||||
28. https://www.example.com/path/to/file.html#
|
||||
29. https://www.example.com/path/to/file.html#fragment1#fragment2
|
||||
30. https://www.example.com/path/to/file.html?param1=value1#fragment?param2=value2
|
||||
31. https://www.example.com/index.html#!hashbang
|
||||
32. https://www.example.com/path/to/file.html?param=value#fragment=value
|
||||
33. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment
|
||||
34. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment=value
|
||||
35. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment?param3=value3
|
||||
36. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment¶m3=value3
|
||||
37. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment#fragment2
|
||||
38. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment/path
|
||||
39. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment?param3=value3¶m4=value4
|
||||
40. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment¶m3=value3¶m4=value4
|
||||
41. data:text/html,<h1>Hello World</h1>
|
||||
42. data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
|
||||
43. javascript:alert('XSS')
|
||||
44. mailto:user@example.com
|
||||
45. tel:+1-234-567-8901
|
||||
46. sms:+1-234-567-8901?body=Hello
|
||||
47. https://www.example.com/path/to/file.html?param=<script>alert('XSS')</script>
|
||||
48. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
|
||||
49. https://www.example.com/path/to/file.html?param=javascript:alert('XSS')
|
||||
50. https://www.example.com/path/to/file.html?param=data:text/html,<script>alert('XSS')</script>
|
||||
51. https://www.example.com/path/to/file.html?param=data:image/svg+xml,<svg onload="alert('XSS')">
|
||||
52. https://www.example.com/path/to/file.html?param=<iframe src="javascript:alert('XSS')">
|
||||
53. https://www.example.com/path/to/file.html?param=<a href="javascript:alert('XSS')">Click me</a>
|
||||
54. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
|
||||
55. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script></svg>
|
||||
56. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
57. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
|
||||
58. https://www.example.com/path/to/file.html?param=<body onload="alert('XSS')">
|
||||
59. https://www.example.com/path/to/file.html?param=<input autofocus onfocus="alert('XSS')">
|
||||
60. https://www.example.com/path/to/file.html?param=<video src="x" onerror="alert('XSS')">
|
||||
61. https://www.example.com/path/to/file.html?param=<audio src="x" onerror="alert('XSS')">
|
||||
62. https://www.example.com/path/to/file.html?param=<iframe srcdoc="<script>alert('XSS')</script>">
|
||||
63. https://www.example.com/path/to/file.html?param=<math><maction actiontype="statusline#" xlink:href="javascript:alert('XSS')">Click
|
||||
64. https://www.example.com/path/to/file.html?param=<form action="javascript:alert('XSS')"><input type="submit">
|
||||
65. https://www.example.com/path/to/file.html?param=<isindex action="javascript:alert('XSS')" type="image">
|
||||
66. https://www.example.com/path/to/file.html?param=<object data="javascript:alert('XSS')">
|
||||
67. https://www.example.com/path/to/file.html?param=<embed src="javascript:alert('XSS')">
|
||||
68. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script>
|
||||
69. https://www.example.com/path/to/file.html?param=<marquee onstart="alert('XSS')">
|
||||
70. https://www.example.com/path/to/file.html?param=<div style="background-image: url(javascript:alert('XSS'))">
|
||||
71. https://www.example.com/path/to/file.html?param=<link rel="stylesheet" href="javascript:alert('XSS')">
|
||||
72. https://www.example.com/path/to/file.html?param=<table background="javascript:alert('XSS')">
|
||||
73. https://www.example.com/path/to/file.html?param=<div style="width: expression(alert('XSS'))">
|
||||
74. https://www.example.com/path/to/file.html?param=<style>@import "javascript:alert('XSS')";</style>
|
||||
75. https://www.example.com/path/to/file.html?param=<meta http-equiv="refresh" content="0;url=javascript:alert('XSS')">
|
||||
76. https://www.example.com/path/to/file.html?param=<iframe src="data:text/html,<script>alert('XSS')</script>">
|
||||
77. https://www.example.com/path/to/file.html?param=<svg><set attributeName="onload" to="alert('XSS')" />
|
||||
78. https://www.example.com/path/to/file.html?param=<script>alert('XSS')</script>
|
||||
79. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
|
||||
80. https://www.example.com/path/to/file.html?param=<svg><animate xlink:href="#xss" attributeName="href" values="javascript:alert('XSS')" />
|
||||
81. https://www.example.com/path/to/file.html?param=<svg><a><animate attributeName="href" values="javascript:alert('XSS')" />
|
||||
82. https://www.example.com/path/to/file.html?param=<svg><a xlink:href="javascript:alert('XSS')"><text x="20" y="20">XSS</text></a>
|
||||
83. https://www.example.com/path/to/file.html?param=<svg><a><animate attributeName="href" values="javascript:alert('XSS')" /><text x="20" y="20">XSS</text></a>
|
||||
84. https://www.example.com/path/to/file.html?param=<svg><discard onbegin="alert('XSS')" />
|
||||
85. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script></svg>
|
||||
86. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script>
|
||||
87. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
88. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
89. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
90. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
91. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
92. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
93. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
94. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
95. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
96. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
97. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
98. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
99. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
100. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
101. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
102. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
103. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
104. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
105. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
106. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
107. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
108. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
|
||||
|
||||
## Popular URLs by Popularity Test Case
|
||||
|
||||
1. https://www.google.com
|
||||
2. https://www.blogger.com
|
||||
3. https://youtube.com
|
||||
4. https://linkedin.com
|
||||
5. https://support.google.com
|
||||
6. https://cloudflare.com
|
||||
7. https://microsoft.com
|
||||
8. https://apple.com
|
||||
9. https://en.wikipedia.org
|
||||
10. https://play.google.com
|
||||
11. https://wordpress.org
|
||||
12. https://docs.google.com
|
||||
13. https://mozilla.org
|
||||
14. https://maps.google.com
|
||||
15. https://youtu.be
|
||||
16. https://drive.google.com
|
||||
17. https://bp.blogspot.com
|
||||
18. https://sites.google.com
|
||||
19. https://googleusercontent.com
|
||||
20. https://accounts.google.com
|
||||
21. https://t.me
|
||||
22. https://europa.eu
|
||||
23. https://plus.google.com
|
||||
24. https://whatsapp.com
|
||||
25. https://adobe.com
|
||||
26. https://facebook.com
|
||||
27. https://policies.google.com
|
||||
28. https://uol.com.br
|
||||
29. https://istockphoto.com
|
||||
30. https://vimeo.com
|
||||
31. https://vk.com
|
||||
32. https://github.com
|
||||
33. https://amazon.com
|
||||
34. https://search.google.com
|
||||
35. https://bbc.co.uk
|
||||
36. https://google.de
|
||||
37. https://live.com
|
||||
38. https://gravatar.com
|
||||
39. https://nih.gov
|
||||
40. https://dan.com
|
||||
41. https://files.wordpress.com
|
||||
42. https://www.yahoo.com
|
||||
43. https://cnn.com
|
||||
44. https://dropbox.com
|
||||
45. https://wikimedia.org
|
||||
46. https://creativecommons.org
|
||||
47. https://google.com.br
|
||||
48. https://line.me
|
||||
49. https://googleblog.com
|
||||
50. https://opera.com
|
||||
51. https://es.wikipedia.org
|
||||
52. https://globo.com
|
||||
53. https://brandbucket.com
|
||||
54. https://myspace.com
|
||||
55. https://slideshare.net
|
||||
56. https://paypal.com
|
||||
57. https://tiktok.com
|
||||
58. https://netvibes.com
|
||||
59. https://theguardian.com
|
||||
60. https://who.int
|
||||
61. https://goo.gl
|
||||
62. https://medium.com
|
||||
63. https://tools.google.com
|
||||
64. https://draft.blogger.com
|
||||
65. https://pt.wikipedia.org
|
||||
66. https://fr.wikipedia.org
|
||||
67. https://www.weebly.com
|
||||
68. https://news.google.com
|
||||
69. https://developers.google.com
|
||||
70. https://w3.org
|
||||
71. https://mail.google.com
|
||||
72. https://gstatic.com
|
||||
73. https://jimdofree.com
|
||||
74. https://cpanel.net
|
||||
75. https://imdb.com
|
||||
76. https://wa.me
|
||||
77. https://feedburner.com
|
||||
78. https://enable-javascript.com
|
||||
79. https://nytimes.com
|
||||
80. https://workspace.google.com
|
||||
81. https://ok.ru
|
||||
82. https://google.es
|
||||
83. https://dailymotion.com
|
||||
84. https://afternic.com
|
||||
85. https://bloomberg.com
|
||||
86. https://amazon.de
|
||||
87. https://photos.google.com
|
||||
88. https://wiley.com
|
||||
89. https://aliexpress.com
|
||||
90. https://indiatimes.com
|
||||
91. https://youronlinechoices.com
|
||||
92. https://elpais.com
|
||||
93. https://tinyurl.com
|
||||
94. https://yadi.sk
|
||||
95. https://spotify.com
|
||||
96. https://huffpost.com
|
||||
97. https://ru.wikipedia.org
|
||||
98. https://google.fr
|
||||
99. https://webmd.com
|
||||
100. https://samsung.com
|
||||
101. https://independent.co.uk
|
||||
102. https://amazon.co.jp
|
||||
103. https://get.google.com
|
||||
104. https://amazon.co.uk
|
||||
105. https://4shared.com
|
||||
106. https://telegram.me
|
||||
107. https://planalto.gov.br
|
||||
108. https://businessinsider.com
|
||||
109. https://ig.com.br
|
||||
110. https://issuu.com
|
||||
111. https://www.gov.br
|
||||
112. https://wsj.com
|
||||
113. https://hugedomains.com
|
||||
114. https://picasaweb.google.com
|
||||
115. https://usatoday.com
|
||||
116. https://scribd.com
|
||||
117. https://www.gov.uk
|
||||
118. https://storage.googleapis.com
|
||||
119. https://huffingtonpost.com
|
||||
120. https://bbc.com
|
||||
121. https://estadao.com.br
|
||||
122. https://nature.com
|
||||
123. https://mediafire.com
|
||||
124. https://washingtonpost.com
|
||||
125. https://forms.gle
|
||||
126. https://namecheap.com
|
||||
127. https://forbes.com
|
||||
128. https://mirror.co.uk
|
||||
129. https://soundcloud.com
|
||||
130. https://fb.com
|
||||
131. https://marketingplatform.google
|
||||
132. https://domainmarket.com
|
||||
133. https://ytimg.com
|
||||
134. https://terra.com.br
|
||||
135. https://google.co.uk
|
||||
136. https://shutterstock.com
|
||||
137. https://dailymail.co.uk
|
||||
138. https://reg.ru
|
||||
139. https://t.co
|
||||
140. https://cdc.gov
|
||||
141. https://thesun.co.uk
|
||||
142. https://wp.com
|
||||
143. https://cnet.com
|
||||
144. https://instagram.com
|
||||
145. https://researchgate.net
|
||||
146. https://google.it
|
||||
147. https://fandom.com
|
||||
148. https://office.com
|
||||
149. https://list-manage.com
|
||||
150. https://msn.com
|
||||
151. https://un.org
|
||||
152. https://de.wikipedia.org
|
||||
153. https://ovh.com
|
||||
154. https://mail.ru
|
||||
155. https://bing.com
|
||||
156. https://news.yahoo.com
|
||||
157. https://myaccount.google.com
|
||||
158. https://hatena.ne.jp
|
||||
159. https://shopify.com
|
||||
160. https://adssettings.google.com
|
||||
161. https://bit.ly
|
||||
162. https://reuters.com
|
||||
163. https://booking.com
|
||||
164. https://discord.com
|
||||
165. https://buydomains.com
|
||||
166. https://nasa.gov
|
||||
167. https://aboutads.info
|
||||
168. https://time.com
|
||||
169. https://abril.com.br
|
||||
170. https://change.org
|
||||
171. https://nginx.org
|
||||
172. https://twitter.com
|
||||
173. https://www.wikipedia.org
|
||||
174. https://archive.org
|
||||
175. https://cbsnews.com
|
||||
176. https://networkadvertising.org
|
||||
177. https://telegraph.co.uk
|
||||
178. https://pinterest.com
|
||||
179. https://google.co.jp
|
||||
180. https://pixabay.com
|
||||
181. https://zendesk.com
|
||||
182. https://cpanel.com
|
||||
183. https://vistaprint.com
|
||||
184. https://sky.com
|
||||
185. https://windows.net
|
||||
186. https://alicdn.com
|
||||
187. https://google.ca
|
||||
188. https://lemonde.fr
|
||||
189. https://newyorker.com
|
||||
190. https://webnode.page
|
||||
191. https://surveymonkey.com
|
||||
192. https://translate.google.com
|
||||
193. https://calendar.google.com
|
||||
194. https://amazonaws.com
|
||||
195. https://academia.edu
|
||||
196. https://apache.org
|
||||
197. https://imageshack.us
|
||||
198. https://akamaihd.net
|
||||
199. https://nginx.com
|
||||
200. https://discord.gg
|
||||
201. https://thetimes.co.uk
|
||||
202. https://search.yahoo.com
|
||||
203. https://amazon.fr
|
||||
204. https://yelp.com
|
||||
205. https://berkeley.edu
|
||||
206. https://google.ru
|
||||
207. https://sedoparking.com
|
||||
208. https://cbc.ca
|
||||
209. https://unesco.org
|
||||
210. https://ggpht.com
|
||||
211. https://privacyshield.gov
|
||||
212. https://www.over-blog.com
|
||||
213. https://clarin.com
|
||||
214. https://www.wix.com
|
||||
215. https://whitehouse.gov
|
||||
216. https://icann.org
|
||||
217. https://gnu.org
|
||||
218. https://yandex.ru
|
||||
219. https://francetvinfo.fr
|
||||
220. https://gmail.com
|
||||
221. https://mozilla.com
|
||||
222. https://ziddu.com
|
||||
223. https://guardian.co.uk
|
||||
224. https://twitch.tv
|
||||
225. https://sedo.com
|
||||
226. https://foxnews.com
|
||||
227. https://rambler.ru
|
||||
228. https://books.google.com
|
||||
229. https://stanford.edu
|
||||
230. https://wikihow.com
|
||||
231. https://it.wikipedia.org
|
||||
232. https://20minutos.es
|
||||
233. https://sfgate.com
|
||||
234. https://liveinternet.ru
|
||||
235. https://ja.wikipedia.org
|
||||
236. https://000webhost.com
|
||||
237. https://espn.com
|
||||
238. https://eventbrite.com
|
||||
239. https://disney.com
|
||||
240. https://statista.com
|
||||
241. https://addthis.com
|
||||
242. https://pinterest.fr
|
||||
243. https://lavanguardia.com
|
||||
244. https://vkontakte.ru
|
||||
245. https://doubleclick.net
|
||||
246. https://bp2.blogger.com
|
||||
247. https://skype.com
|
||||
248. https://sciencedaily.com
|
||||
249. https://bloglovin.com
|
||||
250. https://insider.com
|
||||
251. https://pl.wikipedia.org
|
||||
252. https://sputniknews.com
|
||||
253. https://id.wikipedia.org
|
||||
254. https://doi.org
|
||||
255. https://nypost.com
|
||||
256. https://elmundo.es
|
||||
257. https://abcnews.go.com
|
||||
258. https://ipv4.google.com
|
||||
259. https://deezer.com
|
||||
260. https://express.co.uk
|
||||
261. https://detik.com
|
||||
262. https://mystrikingly.com
|
||||
263. https://rakuten.co.jp
|
||||
264. https://amzn.to
|
||||
265. https://arxiv.org
|
||||
266. https://alibaba.com
|
||||
267. https://fb.me
|
||||
268. https://wikia.com
|
||||
269. https://t-online.de
|
||||
270. https://telegra.ph
|
||||
271. https://mega.nz
|
||||
272. https://usnews.com
|
||||
273. https://plos.org
|
||||
274. https://naver.com
|
||||
275. https://ibm.com
|
||||
276. https://smh.com.au
|
||||
277. https://dw.com
|
||||
278. https://google.nl
|
||||
279. https://lefigaro.fr
|
||||
280. https://bp1.blogger.com
|
||||
281. https://picasa.google.com
|
||||
282. https://theatlantic.com
|
||||
283. https://nydailynews.com
|
||||
284. https://themeforest.net
|
||||
285. https://rtve.es
|
||||
286. https://newsweek.com
|
||||
287. https://ovh.net
|
||||
288. https://ca.gov
|
||||
289. https://goodreads.com
|
||||
290. https://economist.com
|
||||
291. https://target.com
|
||||
292. https://marca.com
|
||||
293. https://kickstarter.com
|
||||
294. https://hindustantimes.com
|
||||
295. https://weibo.com
|
||||
296. https://finance.yahoo.com
|
||||
297. https://huawei.com
|
||||
298. https://e-monsite.com
|
||||
299. https://hubspot.com
|
||||
300. https://npr.org
|
||||
301. https://netflix.com
|
||||
302. https://gizmodo.com
|
||||
303. https://netlify.app
|
||||
304. https://yandex.com
|
||||
305. https://mashable.com
|
||||
306. https://cnil.fr
|
||||
307. https://latimes.com
|
||||
308. https://steampowered.com
|
||||
309. https://rt.com
|
||||
310. https://photobucket.com
|
||||
311. https://quora.com
|
||||
312. https://nbcnews.com
|
||||
313. https://android.com
|
||||
314. https://instructables.com
|
||||
315. https://www.canalblog.com
|
||||
316. https://www.livejournal.com
|
||||
317. https://ouest-france.fr
|
||||
318. https://tripadvisor.com
|
||||
319. https://ovhcloud.com
|
||||
320. https://pexels.com
|
||||
321. https://oracle.com
|
||||
322. https://yahoo.co.jp
|
||||
323. https://addtoany.com
|
||||
324. https://sakura.ne.jp
|
||||
325. https://cointernet.com.co
|
||||
326. https://twimg.com
|
||||
327. https://britannica.com
|
||||
328. https://php.net
|
||||
329. https://standard.co.uk
|
||||
330. https://groups.google.com
|
||||
331. https://cnbc.com
|
||||
332. https://loc.gov
|
||||
333. https://qq.com
|
||||
334. https://buzzfeed.com
|
||||
335. https://godaddy.com
|
||||
336. https://ikea.com
|
||||
337. https://disqus.com
|
||||
338. https://taringa.net
|
||||
339. https://ea.com
|
||||
340. https://dropcatch.com
|
||||
341. https://techcrunch.com
|
||||
342. https://canva.com
|
||||
343. https://offset.com
|
||||
344. https://ebay.com
|
||||
345. https://zoom.us
|
||||
346. https://cambridge.org
|
||||
347. https://unsplash.com
|
||||
348. https://playstation.com
|
||||
349. https://people.com
|
||||
350. https://springer.com
|
||||
351. https://psychologytoday.com
|
||||
352. https://sendspace.com
|
||||
353. https://home.pl
|
||||
354. https://rapidshare.com
|
||||
355. https://prezi.com
|
||||
356. https://photos1.blogger.com
|
||||
357. https://thenai.org
|
||||
358. https://ftc.gov
|
||||
359. https://google.pl
|
||||
360. https://ted.com
|
||||
361. https://secureserver.net
|
||||
362. https://code.google.com
|
||||
363. https://plesk.com
|
||||
364. https://aol.com
|
||||
365. https://biglobe.ne.jp
|
||||
366. https://hp.com
|
||||
367. https://canada.ca
|
||||
368. https://linktr.ee
|
||||
369. https://hollywoodreporter.com
|
||||
370. https://ietf.org
|
||||
371. https://clickbank.net
|
||||
372. https://harvard.edu
|
||||
373. https://amazon.es
|
||||
374. https://oup.com
|
||||
375. https://timeweb.ru
|
||||
376. https://engadget.com
|
||||
377. https://vice.com
|
||||
378. https://cornell.edu
|
||||
379. https://dreamstime.com
|
||||
380. https://tmz.com
|
||||
381. https://gofundme.com
|
||||
382. https://pbs.org
|
||||
383. https://stackoverflow.com
|
||||
384. https://abc.net.au
|
||||
385. https://sciencedirect.com
|
||||
386. https://ft.com
|
||||
387. https://variety.com
|
||||
388. https://alexa.com
|
||||
389. https://abc.es
|
||||
390. https://walmart.com
|
||||
391. https://gooyaabitemplates.com
|
||||
392. https://redbull.com
|
||||
393. https://ssl-images-amazon.com
|
||||
394. https://theverge.com
|
||||
395. https://spiegel.de
|
||||
396. https://about.com
|
||||
397. https://nationalgeographic.com
|
||||
398. https://bandcamp.com
|
||||
399. https://m.wikipedia.org
|
||||
400. https://zippyshare.com
|
||||
401. https://wired.com
|
||||
402. https://freepik.com
|
||||
403. https://outlook.com
|
||||
404. https://mit.edu
|
||||
405. https://sapo.pt
|
||||
406. https://goo.ne.jp
|
||||
407. https://java.com
|
||||
408. https://google.co.th
|
||||
409. https://scmp.com
|
||||
410. https://mayoclinic.org
|
||||
411. https://scholastic.com
|
||||
412. https://nba.com
|
||||
413. https://reverbnation.com
|
||||
414. https://depositfiles.com
|
||||
415. https://video.google.com
|
||||
416. https://howstuffworks.com
|
||||
417. https://cbslocal.com
|
||||
418. https://merriam-webster.com
|
||||
419. https://focus.de
|
||||
420. https://admin.ch
|
||||
421. https://gfycat.com
|
||||
422. https://com.com
|
||||
423. https://narod.ru
|
||||
424. https://boston.com
|
||||
425. https://sony.com
|
||||
426. https://justjared.com
|
||||
427. https://bitly.com
|
||||
428. https://jstor.org
|
||||
429. https://amebaownd.com
|
||||
430. https://g.co
|
||||
431. https://gsmarena.com
|
||||
432. https://lexpress.fr
|
||||
433. https://reddit.com
|
||||
434. https://usgs.gov
|
||||
435. https://bigcommerce.com
|
||||
436. https://gettyimages.com
|
||||
437. https://ign.com
|
||||
438. https://justgiving.com
|
||||
439. https://techradar.com
|
||||
440. https://weather.com
|
||||
441. https://amazon.ca
|
||||
442. https://justice.gov
|
||||
443. https://sciencemag.org
|
||||
444. https://pcmag.com
|
||||
445. https://theconversation.com
|
||||
446. https://foursquare.com
|
||||
447. https://flickr.com
|
||||
448. https://giphy.com
|
||||
449. https://tvtropes.org
|
||||
450. https://fifa.com
|
||||
451. https://upenn.edu
|
||||
452. https://digg.com
|
||||
453. https://bestfreecams.club
|
||||
454. https://histats.com
|
||||
455. https://salesforce.com
|
||||
456. https://blog.google
|
||||
457. https://apnews.com
|
||||
458. https://theglobeandmail.com
|
||||
459. https://m.me
|
||||
460. https://europapress.es
|
||||
461. https://washington.edu
|
||||
462. https://thefreedictionary.com
|
||||
463. https://jhu.edu
|
||||
464. https://euronews.com
|
||||
465. https://liberation.fr
|
||||
466. https://ads.google.com
|
||||
467. https://trustpilot.com
|
||||
468. https://google.com.tw
|
||||
469. https://softonic.com
|
||||
470. https://kakao.com
|
||||
471. https://storage.canalblog.com
|
||||
472. https://interia.pl
|
||||
473. https://metro.co.uk
|
||||
474. https://viglink.com
|
||||
475. https://last.fm
|
||||
476. https://blackberry.com
|
||||
477. https://public-api.wordpress.com
|
||||
478. https://sina.com.cn
|
||||
479. https://unicef.org
|
||||
480. https://archives.gov
|
||||
481. https://nps.gov
|
||||
482. https://utexas.edu
|
||||
483. https://biblegateway.com
|
||||
484. https://usda.gov
|
||||
485. https://indiegogo.com
|
||||
486. https://nikkei.com
|
||||
487. https://radiofrance.fr
|
||||
488. https://repubblica.it
|
||||
489. https://substack.com
|
||||
490. https://ap.org
|
||||
491. https://nicovideo.jp
|
||||
492. https://joomla.org
|
||||
493. https://news.com.au
|
||||
494. https://allaboutcookies.org
|
||||
495. https://mailchimp.com
|
||||
496. https://stores.jp
|
||||
497. https://intel.com
|
||||
498. https://bp0.blogger.com
|
||||
499. https://box.com
|
||||
499. https://nhk.or.jp
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user