mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c6587149d | ||
|
|
2721ad194b | ||
|
|
fb91128208 | ||
|
|
c809af9770 | ||
|
|
f8a284c6fe | ||
|
|
39718cc521 | ||
|
|
e945a45102 | ||
|
|
e2f9c38902 | ||
|
|
447a6ba4d5 | ||
|
|
3f914f5092 | ||
|
|
b26997abc0 | ||
|
|
560c79b885 | ||
|
|
77cc05c843 | ||
|
|
7db8c6ae4c |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Remove the clsx / tailwind merge dependencies and replace with template literals
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Migrate fetchUserCreditsData to protobus
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Migrate authStateChanged to protobus
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add taskId as metadata to use from LiteLLM
|
||||
@@ -0,0 +1,116 @@
|
||||
name: 💡 Feature Proposal & Contribution
|
||||
description: Propose a new feature or improvement, and optionally offer to implement feature as a contributor
|
||||
labels: ["proposal"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Feature Proposal & Contribution for Cline**
|
||||
|
||||
Thank you for proposing a feature or improvement for Cline! This template helps us understand the problem, evaluate the solution, and coordinate implementation.
|
||||
|
||||
**For detailed proposals:** Please provide comprehensive information to enable fast prioritization and discussion.
|
||||
**For contribution offers:** You can indicate your willingness to implement the feature yourself.
|
||||
|
||||
Before submitting:
|
||||
- Search existing [Issues](https://github.com/cline/cline/issues) and [Discussions](https://github.com/cline/cline/discussions) to avoid duplicates
|
||||
- Read the [Contributing Guide](https://github.com/cline/cline/blob/main/CONTRIBUTING.md) if you plan to contribute
|
||||
- Don't start implementation until the proposal is reviewed and approved
|
||||
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: What problem does this solve?
|
||||
description: |
|
||||
Describe the problem clearly from a user's point of view. Focus on why this matters, who it affects, and when it occurs.
|
||||
|
||||
✅ Good examples:
|
||||
- "LLM provider returns 400 error when nearing the context window instead of truncating"
|
||||
- "Submit button is invisible in dark mode"
|
||||
- "Users can't easily share their Cline configurations with team members"
|
||||
|
||||
❌ Avoid vague descriptions:
|
||||
- "Performance is bad"
|
||||
- "UI needs work"
|
||||
|
||||
Your description should include:
|
||||
- Who is affected?
|
||||
- When does it happen?
|
||||
- What's the current vs expected behavior?
|
||||
- What is the impact?
|
||||
placeholder: Be specific about the problem, who it affects, and the impact.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed-solution
|
||||
attributes:
|
||||
label: What's the proposed solution?
|
||||
description: |
|
||||
Describe how the problem should be solved. Be specific about UX, system behavior, and any flows that would change.
|
||||
|
||||
✅ Good examples:
|
||||
- "Add error handling immediately after attempting to create the llm stream and retry after manually truncating"
|
||||
- "Update button styling to ensure contrast in all themes"
|
||||
- "Add export/import functionality in settings with JSON format"
|
||||
|
||||
❌ Avoid vague solutions:
|
||||
- "Improve performance"
|
||||
- "Fix the bug"
|
||||
|
||||
Your solution should include:
|
||||
- What exactly will change?
|
||||
- How will users interact with it?
|
||||
- What's the expected outcome?
|
||||
placeholder: Describe the proposed changes and how they solve the problem.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: contribution-intent
|
||||
attributes:
|
||||
label: Are you interested in implementing this?
|
||||
description: Let us know if you'd like to contribute to this feature
|
||||
options:
|
||||
- "No, just proposing the idea"
|
||||
- "Yes, I'd like to implement this myself"
|
||||
- "Yes, I'd like to collaborate with others"
|
||||
- "Maybe, depending on complexity and guidance"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: implementation-approach
|
||||
attributes:
|
||||
label: Implementation approach (if contributing)
|
||||
description: |
|
||||
**Only fill this out if you selected "Yes" above.**
|
||||
|
||||
How do you plan to implement this? Include:
|
||||
- High-level technical approach
|
||||
- Files/components that would be affected
|
||||
- Any new dependencies required
|
||||
- Potential challenges or considerations you've identified
|
||||
|
||||
This helps us provide better guidance and ensures alignment before you start coding.
|
||||
placeholder: "My implementation approach would be..."
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Proposal checklist
|
||||
options:
|
||||
- label: I've checked for existing issues or related proposals
|
||||
required: true
|
||||
- label: I understand this needs review before implementation can start
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: contribution-checklist
|
||||
attributes:
|
||||
label: Contribution checklist (if contributing)
|
||||
description: Only check these if you plan to contribute
|
||||
options:
|
||||
- label: I've read the [Contributing Guide](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)
|
||||
- label: I'm willing to make changes based on feedback
|
||||
- label: I understand the code review process and requirements
|
||||
@@ -1,130 +0,0 @@
|
||||
name: 📝 Detailed Feature Proposal
|
||||
description: Propose a new feature or improvement
|
||||
labels: ["proposal"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Feature Proposal for Cline**
|
||||
|
||||
Thank you for creating a feature proposal for Cline! This template is for clear, actionable proposals that define a specific problem and a high-confidence solution. Please provide enough detail to enable fast prioritization, discussion, and execution.
|
||||
|
||||
Detailed proposals will be prioritized, while vague proposals may be closed or require extensive back and forth communication.
|
||||
|
||||
Before submitting:
|
||||
- Search existing [Issues](https://github.com/cline/cline/issues) and [Discussions](https://github.com/cline/cline/discussions) to avoid duplicates
|
||||
- Don’t start implementation until the proposal is reviewed and approved
|
||||
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: What problem does this solve?
|
||||
description: |
|
||||
Describe the problem clearly from a user's point of view. Focus on why this matters, who it affects, and when it occurs.
|
||||
|
||||
✅ Solid:
|
||||
- "LLM provider returns 400 error when nearing the context window instead of truncating"
|
||||
- "Submit button is invisible in dark mode"
|
||||
|
||||
❌ Avoid:
|
||||
- "Performance is bad"
|
||||
- "UI needs work"
|
||||
|
||||
Your description should include:
|
||||
- Who is affected?
|
||||
- When does it happen?
|
||||
- What's the current vs expected behavior?
|
||||
- What is the impact?
|
||||
placeholder: Be specific about the problem, who it affects, and the impact.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed-solution
|
||||
attributes:
|
||||
label: What’s the proposed solution?
|
||||
description: |
|
||||
Describe how the problem should be solved. Be specific about UX, system behavior, and any flows that would change.
|
||||
|
||||
✅ Solid:
|
||||
- "Add error handling immediately after attempting to create the llm stream and retry after manually truncating"
|
||||
- "Update button styling to ensure contrast in all themes"
|
||||
|
||||
❌ Avoid:
|
||||
- "Improve performance"
|
||||
- "Fix the bug"
|
||||
|
||||
Your solution should include:
|
||||
- What exactly will change?
|
||||
- How will users interact with it?
|
||||
- What’s the expected outcome?
|
||||
placeholder: Describe the proposed changes and how they solve the problem.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: acceptance-criteria
|
||||
attributes:
|
||||
label: How will we know it works? (Acceptance Criteria)
|
||||
description: Define clear, testable success criteria.
|
||||
placeholder: Provide specific and testable conditions for success.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: estimated-effort
|
||||
attributes:
|
||||
label: Estimated effort and complexity
|
||||
description: |
|
||||
Help us understand scope and risks. Include:
|
||||
|
||||
- Size estimate (XS/S/M/L/XL or hours/days)
|
||||
- Why this size? What’s technically involved?
|
||||
- Any tricky parts, refactors, or risks?
|
||||
- Performance or compatibility concerns?
|
||||
- Any dependencies on systems, teams, or libraries?
|
||||
placeholder: Size, reasoning, risks, and dependencies.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: technical-considerations
|
||||
attributes:
|
||||
label: Technical considerations, tradeoffs, and/or risks (optional)
|
||||
description: |
|
||||
Include any technical context that helps us evaluate or implement the proposal more effectively.
|
||||
|
||||
You may include:
|
||||
- Architectural changes or required refactors
|
||||
- Performance implications or system-level impacts
|
||||
- Known limitations, risks, or tricky edge cases
|
||||
- Compatibility concerns or migration steps
|
||||
- Alternative approaches you considered and why they were not chosen
|
||||
- Dependencies on other systems, teams, or libraries
|
||||
- Were other approaches considered? Why is this one preferred?
|
||||
placeholder: Technical considerations, tradeoffs, and/or risks.
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context (optional)
|
||||
description: Diagrams, mockups, logs, links, or anything else that helps explain or justify the proposal.
|
||||
placeholder: Diagrams, mockups, logs, links, or anything else that helps explain or justify the proposal.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Proposal checklist
|
||||
options:
|
||||
- label: I’ve checked for existing issues or related proposals
|
||||
required: true
|
||||
- label: I understand this needs review before implementation can start
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: willingness-to-contribute
|
||||
attributes:
|
||||
label: Interested in implementing this?
|
||||
description: Optional
|
||||
options:
|
||||
- label: Yes, I’d like to help implement this feature
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@28ca103
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
days-before-issue-stale: 60
|
||||
days-before-issue-close: 14
|
||||
|
||||
Vendored
+4
-3
@@ -50,10 +50,11 @@
|
||||
"outFiles": ["${workspaceFolder}/dist-standalone/**/*.js"],
|
||||
"preLaunchTask": "compile-standalone",
|
||||
"env": {
|
||||
"GRPC_TRACE": "all",
|
||||
"GRPC_VERBOSITY": "DEBUG",
|
||||
// Turns on grpc debug log.
|
||||
//"GRPC_TRACE": "all",
|
||||
//"GRPC_VERBOSITY": "DEBUG",
|
||||
"NODE_PATH": "${workspaceFolder}/dist-standalone/node_modules",
|
||||
"CLINE_DIR": "${userHome}/.cline-standalone",
|
||||
|
||||
"HOST_BRIDGE_ADDRESS": "localhost:50052"
|
||||
},
|
||||
"program": "standalone.js"
|
||||
|
||||
+59
-12
@@ -10,13 +10,6 @@ Bug reports help make Cline better for everyone! Before creating a new issue, pl
|
||||
🔐 <b>Important:</b> If you discover a security vulnerability, please use the <a href="https://github.com/cline/cline/security/advisories/new">Github security tool to report it privately</a>.
|
||||
</blockquote>
|
||||
|
||||
## Deciding What to Work On
|
||||
|
||||
Looking for a good first contribution? Check out issues labeled ["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue) or ["help wanted"](https://github.com/cline/cline/labels/help%20wanted). These are specifically curated for new contributors and areas where we'd love some help!
|
||||
|
||||
We also welcome contributions to our [documentation](https://github.com/cline/cline/tree/main/docs)! Whether it's fixing typos, improving existing guides, or creating new educational content - we'd love to build a community-driven repository of resources that helps everyone get the most out of Cline. You can start by diving into `/docs` and looking for areas that need improvement.
|
||||
|
||||
If you're planning to work on a bigger feature, please create a [feature request](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop) first so we can discuss whether it aligns with Cline's vision.
|
||||
|
||||
## Before Contributing
|
||||
|
||||
@@ -24,14 +17,70 @@ All contributions must begin with a GitHub Issue, unless the change is for small
|
||||
|
||||
- **Check existing issues**: Search [GitHub Issues](https://github.com/cline/cline/issues).
|
||||
- **Create an issue**: Use appropriate templates:
|
||||
- **Bugs:** "Bug Report" template.
|
||||
- **Features:** "Detailed Feature Proposal" template. Approval from a core Cline contributor required before starting.
|
||||
- **Claim issues**: Comment your interest.
|
||||
- **Contributions:** Use the "Contribution Request" template to propose what you'd like to work on.
|
||||
- **Bugs:** "Bug Report" template for reporting issues.
|
||||
- **Features:** "Detailed Feature Proposal" template for suggesting new features.
|
||||
- **Wait for approval**: A core Cline contributor must approve your contribution request before you start implementation.
|
||||
- **Claim issues**: Once approved, the issue will be assigned to you.
|
||||
|
||||
**PRs without approved issues may be closed.**
|
||||
|
||||
|
||||
## Deciding What to Work On
|
||||
|
||||
Looking for a good first contribution? Check out issues labeled ["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue) or ["help wanted"](https://github.com/cline/cline/labels/help%20wanted). These are specifically curated for new contributors and areas where we'd love some help!
|
||||
|
||||
We also welcome contributions to our [documentation](https://github.com/cline/cline/tree/main/docs)! Whether it's fixing typos, improving existing guides, or creating new educational content - we'd love to build a community-driven repository of resources that helps everyone get the most out of Cline. You can start by diving into `/docs` and looking for areas that need improvement.
|
||||
|
||||
## Development Setup
|
||||
|
||||
|
||||
### Local Development Instructions
|
||||
|
||||
1. Clone the repository _(Requires [git-lfs](https://git-lfs.com/))_:
|
||||
```bash
|
||||
git clone https://github.com/cline/cline.git
|
||||
```
|
||||
2. Open the project in VSCode:
|
||||
```bash
|
||||
code cline
|
||||
```
|
||||
3. Install the necessary dependencies for the extension and webview-gui:
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
4. Launch by pressing `F5` (or `Run`->`Start Debugging`) to open a new VSCode window with the extension loaded. (You may need to install the [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers) if you run into issues building the project.)
|
||||
|
||||
|
||||
|
||||
|
||||
### Creating a Pull Request
|
||||
|
||||
1. Before creating a PR, generate a changeset entry:
|
||||
```bash
|
||||
npm run changeset
|
||||
```
|
||||
This will prompt you for:
|
||||
- Type of change (major, minor, patch)
|
||||
- `major` → breaking changes (1.0.0 → 2.0.0)
|
||||
- `minor` → new features (1.0.0 → 1.1.0)
|
||||
- `patch` → bug fixes (1.0.0 → 1.0.1)
|
||||
- Description of your changes
|
||||
|
||||
2. Commit your changes and the generated `.changeset` file
|
||||
|
||||
3. Push your branch and create a PR on GitHub. Our CI will:
|
||||
- Run tests and checks
|
||||
- Changesetbot will create a comment showing the version impact
|
||||
- When merged to main, changesetbot will create a Version Packages PR
|
||||
- When the Version Packages PR is merged, a new release will be published
|
||||
4. Testing
|
||||
- Run `npm run test` to run tests locally.
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
- Run `npm run test:ci` to run tests locally
|
||||
|
||||
### Extension
|
||||
|
||||
1. **VS Code Extensions**
|
||||
|
||||
- When opening the project, VS Code will prompt you to install recommended extensions
|
||||
@@ -86,8 +135,6 @@ All contributions must begin with a GitHub Issue, unless the change is for small
|
||||
xvfb
|
||||
```
|
||||
|
||||
- Run `npm run test:ci` to run tests locally
|
||||
|
||||
## Writing and Submitting Code
|
||||
|
||||
Anyone can contribute code to Cline, but we ask that you follow these guidelines to ensure your contributions can be smoothly integrated:
|
||||
|
||||
@@ -141,50 +141,6 @@ For example, when working with a local web server, you can use 'Restore Workspac
|
||||
|
||||
To contribute to the project, start with our [Contributing Guide](CONTRIBUTING.md) to learn the basics. You can also join our [Discord](https://discord.gg/cline) to chat with other contributors in the `#contributors` channel. If you're looking for full-time work, check out our open positions on our [careers page](https://cline.bot/join-us)!
|
||||
|
||||
<details>
|
||||
<summary>Local Development Instructions</summary>
|
||||
|
||||
1. Clone the repository _(Requires [git-lfs](https://git-lfs.com/))_:
|
||||
```bash
|
||||
git clone https://github.com/cline/cline.git
|
||||
```
|
||||
2. Open the project in VSCode:
|
||||
```bash
|
||||
code cline
|
||||
```
|
||||
3. Install the necessary dependencies for the extension and webview-gui:
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
4. Launch by pressing `F5` (or `Run`->`Start Debugging`) to open a new VSCode window with the extension loaded. (You may need to install the [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers) if you run into issues building the project.)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Creating a Pull Request</summary>
|
||||
|
||||
1. Before creating a PR, generate a changeset entry:
|
||||
```bash
|
||||
npm run changeset
|
||||
```
|
||||
This will prompt you for:
|
||||
- Type of change (major, minor, patch)
|
||||
- `major` → breaking changes (1.0.0 → 2.0.0)
|
||||
- `minor` → new features (1.0.0 → 1.1.0)
|
||||
- `patch` → bug fixes (1.0.0 → 1.0.1)
|
||||
- Description of your changes
|
||||
|
||||
2. Commit your changes and the generated `.changeset` file
|
||||
|
||||
3. Push your branch and create a PR on GitHub. Our CI will:
|
||||
- Run tests and checks
|
||||
- Changesetbot will create a comment showing the version impact
|
||||
- When merged to main, changesetbot will create a Version Packages PR
|
||||
- When the Version Packages PR is merged, a new release will be published
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
+3
-1
@@ -19,4 +19,6 @@ diff_editing/test_outputs/
|
||||
.cache
|
||||
|
||||
# Python bytecode cache
|
||||
*__pycache__/
|
||||
*__pycache__/
|
||||
|
||||
diff-edits/cases.zip
|
||||
@@ -14,6 +14,8 @@ interface RunDiffEvalOptions {
|
||||
testPath: string
|
||||
outputPath: string
|
||||
replay: boolean
|
||||
replayRunId?: string
|
||||
diffApplyFile?: string
|
||||
maxCases?: number
|
||||
}
|
||||
|
||||
@@ -56,6 +58,14 @@ export async function runDiffEvalHandler(options: RunDiffEvalOptions) {
|
||||
args.push("--replay")
|
||||
}
|
||||
|
||||
if (options.replayRunId) {
|
||||
args.push("--replay-run-id", options.replayRunId)
|
||||
}
|
||||
|
||||
if (options.diffApplyFile) {
|
||||
args.push("--diff-apply-file", options.diffApplyFile)
|
||||
}
|
||||
|
||||
if (options.verbose) {
|
||||
args.push("--verbose")
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ program
|
||||
.option("--thinking-budget <tokens>", "Set the thinking tokens budget", "0")
|
||||
.option("--parallel", "Run tests in parallel", false)
|
||||
.option("--replay", "Run evaluation from a pre-recorded LLM output, skipping the API call", false)
|
||||
.option("--replay-run-id <run_id>", "The ID of the run to replay from the database")
|
||||
.option("--diff-apply-file <filename>", "The name of the diff apply file to use for the replay")
|
||||
.option("-v, --verbose", "Enable verbose logging", false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AssistantMessageContent,
|
||||
} from "./parsing/parse-assistant-message-06-06-25" // "../../src/core/assistant-message"
|
||||
import { constructNewFileContent as constructNewFileContentV1, constructNewFileContentV2 } from "./diff-apply/diff-06-06-25"
|
||||
import { constructNewFileContent as constructNewFileContentV2_1 } from "./diff-apply/diff-06-23-25"
|
||||
import { constructNewFileContent as constructNewFileContentV3 } from "../../src/core/assistant-message/diff" // this defaults to the new v1 when called
|
||||
|
||||
type ParseAssistantMessageFn = (message: string) => AssistantMessageContent[]
|
||||
@@ -21,6 +22,8 @@ const parsingFunctions: Record<string, ParseAssistantMessageFn> = {
|
||||
}
|
||||
|
||||
const diffEditingFunctions: Record<string, ConstructNewFileContentFn> = {
|
||||
"diff-06-06-25": constructNewFileContentV2,
|
||||
"diff-06-23-25": constructNewFileContentV2_1,
|
||||
constructNewFileContentV1: constructNewFileContentV1,
|
||||
constructNewFileContentV2: constructNewFileContentV2,
|
||||
constructNewFileContentV3: constructNewFileContentV3, // position invariant diff
|
||||
@@ -151,6 +154,7 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
|
||||
diffEditFunction,
|
||||
thinkingBudgetTokens,
|
||||
originalDiffEditToolCallMessage,
|
||||
diffApplyFile,
|
||||
} = input
|
||||
|
||||
const requiredParams = {
|
||||
@@ -176,7 +180,7 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
|
||||
}
|
||||
|
||||
const parseAssistantMessage = parsingFunctions[parsingFunction]
|
||||
const constructNewFileContent = diffEditingFunctions[diffEditFunction]
|
||||
const constructNewFileContent = diffEditingFunctions[diffApplyFile || diffEditFunction]
|
||||
|
||||
if (!parseAssistantMessage || !constructNewFileContent) {
|
||||
return {
|
||||
|
||||
@@ -63,9 +63,22 @@ For example, if we ask for 5 valid attempts per test case, the system will keep
|
||||
|
||||
This way, if we're comparing two models and one gets a 10% success rate on its valid diff edit attempts, and another gets 90%, we have a much clearer picture of their actual diff-generating capabilities. It avoids muddying the waters with attempts where the model didn't even try to perform the specific action we're evaluating. This approach helps us isolate and measure the diff-editing skill more directly, despite the non-deterministic nature of these models.
|
||||
|
||||
## Some known edge cases
|
||||
## Replays
|
||||
|
||||
I noticed that some of the current conversation jsons in the `./cases` folder are a little big bogus. Here's a running list of these areas:
|
||||
You can also use the replay argument to replay a previous benchmark run. This is super useful for iterating on our diffing algorithms without having to re-run expensive and time-consuming LLM calls.
|
||||
|
||||
- ~~What if the conversation json was using a model with a massive context window, like Google Gemini's 1M context window, and we're now re-rolling that case on a smaller context window model like claude 4 (200k) or grok-3 (128k)? It's def gonna fail, and we shouldn't just keep trying. We should have a smart system for selecting which cases we can use given the arguments passed in. For example, if we pass in claude and grok with `max cases = 2`, we shouldn't just pick the first two jsons in the folder. We should go through, use a tokenizer, and make sure it would fit with some padding like 20k tokens. Use tiktoken. 20k padding will be sufficient even though different models tokenize differently.~~
|
||||
- There are some weird jsons, where something weird happen, where essentially there's a fluke. Maybe the user was using an extremely dumb model that just hallucinated a fake filepath. Now when we try to reroll that case, we never get a valid case. This can easily be handled by making sure that the file is present before selecting that eval for testing. By "file is present" I mean, that file_contents is present in the eval. Additionally, we should in the streamlit dashboard show cases where getting a valid attempt is a challenge, so we can review those cases more easily and throw them out if they're bogus. Maybe a special tab/page in the dashboard for this purpose. Across all runs / cases, what the most consistently problematic cases are. Pop one open to see the case formatted json with all the user/assistant turns.
|
||||
When you run an evaluation, every detail is stored in the database—including the raw, unmodified output from the model. The replay feature takes advantage of this by pulling that raw output and feeding it into a *different* diffing algorithm. This lets you isolate the performance of the diffing logic itself. We can see if a new algorithm is better at applying the exact same set of diffs that a model generated in a previous run.
|
||||
|
||||
This process is blazingly fast and free, as it completely bypasses the need to make new API calls. It ensures a true apples-to-apples comparison between diffing strategies, since the model's output—the "ground truth" for the evaluation—remains identical.
|
||||
|
||||
Here’s an example of how you would replay a previous run with a new diffing algorithm:
|
||||
|
||||
```shell
|
||||
cd evals && npm run diff-eval -- --replay-run-id 9902189e-63a8-4210-a4fc-fe59e2eaf2c2 --diff-apply-file diff-06-23-25 --verbose
|
||||
```
|
||||
|
||||
In this command:
|
||||
- `--replay-run-id` specifies the original run we want to use as our ground truth.
|
||||
- `--diff-apply-file` tells the script to use the new diffing logic from the `diff-06-23-25.ts` file.
|
||||
|
||||
The script will then create a new run in the database that mirrors the original, but with the results of applying the new diffing algorithm. This allows for a direct comparison in the dashboard, helping us quickly see which of our diffing strategies is the most robust.
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { runSingleEvaluation, TestInput, TestResult } from "./ClineWrapper"
|
||||
import { parseAssistantMessageV2, AssistantMessageContent } from "./parsing/parse-assistant-message-06-06-25"
|
||||
import { constructNewFileContent as constructNewFileContentV2 } from "./diff-apply/diff-06-06-25"
|
||||
import { constructNewFileContent as constructNewFileContentV2_1 } from "./diff-apply/diff-06-23-25"
|
||||
import { constructNewFileContent as constructNewFileContentV3 } from "../../src/core/assistant-message/diff"
|
||||
import { basicSystemPrompt } from "./prompts/basicSystemPrompt-06-06-25"
|
||||
import { claude4SystemPrompt } from "./prompts/claude4SystemPrompt-06-06-25"
|
||||
import { formatResponse } from "./helpers"
|
||||
@@ -18,6 +22,10 @@ import {
|
||||
insertResult,
|
||||
DatabaseClient,
|
||||
CreateResultInput,
|
||||
getResultsByRun,
|
||||
getCaseById,
|
||||
getFileByHash,
|
||||
getBenchmarkRun,
|
||||
} from "./database"
|
||||
|
||||
// Load environment variables from .env file
|
||||
@@ -184,6 +192,78 @@ class NodeTestRunner {
|
||||
log(isVerbose, `✓ Created ${this.caseIdMap.size} database case records`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store replay result in database, copying original data but with new diffing results
|
||||
*/
|
||||
async storeReplayResultInDatabase(replayResult: TestResult, originalResult: any, testId: string, newCaseId: string): Promise<void> {
|
||||
if (!this.currentRunId || !this.processingFunctionsHash) {
|
||||
return; // Skip if database not initialized
|
||||
}
|
||||
|
||||
try {
|
||||
// Map error string to error enum (simple mapping)
|
||||
const errorEnum = this.mapErrorToEnum(replayResult.error);
|
||||
|
||||
// Store diff edit content if available
|
||||
let fileEditedHash: string | undefined;
|
||||
if (replayResult.diffEdit) {
|
||||
fileEditedHash = await upsertFile({
|
||||
filepath: `diff-edit-${testId}`,
|
||||
content: replayResult.diffEdit
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate basic metrics from diff edit if available
|
||||
let numEdits = 0;
|
||||
let numLinesAdded = 0;
|
||||
let numLinesDeleted = 0;
|
||||
|
||||
if (replayResult.diffEdit) {
|
||||
// Simple parsing to count edits - count SEARCH/REPLACE blocks
|
||||
const searchBlocks = (replayResult.diffEdit.match(/------- SEARCH/g) || []).length;
|
||||
numEdits = searchBlocks;
|
||||
|
||||
// Count added/deleted lines (rough approximation)
|
||||
const lines = replayResult.diffEdit.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
numLinesAdded++;
|
||||
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
numLinesDeleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy original result data but update replay-specific fields
|
||||
const resultInput: CreateResultInput = {
|
||||
run_id: this.currentRunId, // New run ID
|
||||
case_id: newCaseId, // New case ID
|
||||
model_id: originalResult.model_id, // Copy from original
|
||||
processing_functions_hash: this.processingFunctionsHash, // New processing functions
|
||||
succeeded: replayResult.success && (replayResult.diffEditSuccess ?? false), // New result
|
||||
error_enum: errorEnum, // New error if any
|
||||
num_edits: numEdits || originalResult.num_edits, // New or original
|
||||
num_lines_deleted: numLinesDeleted || originalResult.num_lines_deleted, // New or original
|
||||
num_lines_added: numLinesAdded || originalResult.num_lines_added, // New or original
|
||||
// Copy timing and cost data from original (since we didn't make API calls)
|
||||
time_to_first_token_ms: originalResult.time_to_first_token_ms,
|
||||
time_to_first_edit_ms: originalResult.time_to_first_edit_ms,
|
||||
time_round_trip_ms: originalResult.time_round_trip_ms,
|
||||
cost_usd: originalResult.cost_usd,
|
||||
completion_tokens: originalResult.completion_tokens,
|
||||
// Use original model output (since we're replaying)
|
||||
raw_model_output: originalResult.raw_model_output,
|
||||
file_edited_hash: fileEditedHash || originalResult.file_edited_hash,
|
||||
parsed_tool_call_json: replayResult.toolCalls ? JSON.stringify(replayResult.toolCalls) : originalResult.parsed_tool_call_json
|
||||
};
|
||||
|
||||
await insertResult(resultInput);
|
||||
} catch (error) {
|
||||
console.error(`Failed to store replay result in database for ${testId}:`, error);
|
||||
// Continue execution - don't fail the test run
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store test result in database
|
||||
*/
|
||||
@@ -394,6 +474,142 @@ class NodeTestRunner {
|
||||
}
|
||||
}
|
||||
|
||||
async runDatabaseReplay(replayRunId: string, diffApplyFile: string, isVerbose: boolean) {
|
||||
log(isVerbose, `Starting database replay for run_id: ${replayRunId}`)
|
||||
log(isVerbose, `Using diff apply file: ${diffApplyFile}`)
|
||||
|
||||
// 1. Get the correct diffing function
|
||||
const diffEditingFunctions: Record<string, any> = {
|
||||
"diff-06-06-25": constructNewFileContentV2,
|
||||
"diff-06-23-25": constructNewFileContentV2_1,
|
||||
constructNewFileContentV3: constructNewFileContentV3,
|
||||
}
|
||||
const constructNewFileContent = diffEditingFunctions[diffApplyFile]
|
||||
|
||||
if (!constructNewFileContent) {
|
||||
throw new Error(`Could not find diff apply function for: ${diffApplyFile}`)
|
||||
}
|
||||
log(isVerbose, `Successfully loaded diff apply function: ${diffApplyFile}`)
|
||||
|
||||
// 2. Fetch original run data
|
||||
const originalResults = await getResultsByRun(replayRunId)
|
||||
if (originalResults.length === 0) {
|
||||
throw new Error(`No results found for run_id: ${replayRunId}`)
|
||||
}
|
||||
log(isVerbose, `Found ${originalResults.length} results to replay.`)
|
||||
|
||||
const originalRun = await getBenchmarkRun(replayRunId)
|
||||
if (!originalRun) {
|
||||
throw new Error(`Could not find original run with id ${replayRunId}`)
|
||||
}
|
||||
|
||||
// 3. Create a new benchmark run for the replay
|
||||
const replayRunDescription = `Replay of run ${replayRunId} using ${diffApplyFile}`
|
||||
this.currentRunId = await createBenchmarkRun({
|
||||
description: replayRunDescription,
|
||||
system_prompt_hash: originalRun.system_prompt_hash,
|
||||
})
|
||||
log(isVerbose, `Created new run for replay: ${this.currentRunId}`)
|
||||
|
||||
// 4. Set up processing functions for the new run
|
||||
this.processingFunctionsHash = await upsertProcessingFunctions({
|
||||
name: `replay-${diffApplyFile}`,
|
||||
parsing_function: "parseAssistantMessageV2",
|
||||
diff_edit_function: diffApplyFile,
|
||||
})
|
||||
|
||||
// 5. Process each result from the original run
|
||||
let replayedCount = 0
|
||||
const caseIdMirror: Map<string, string> = new Map()
|
||||
|
||||
for (const originalResult of originalResults) {
|
||||
// 5a. Basic validation to ensure we can even process this
|
||||
if (!originalResult.case_id) {
|
||||
log(isVerbose, `Skipping result ${originalResult.result_id} due to missing case_id.`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 5b. Mirror the case for the new run, reusing if already created
|
||||
let newCaseId = caseIdMirror.get(originalResult.case_id)
|
||||
if (!newCaseId) {
|
||||
const originalCase = await getCaseById(originalResult.case_id)
|
||||
if (!originalCase) {
|
||||
log(isVerbose, `Skipping result ${originalResult.result_id} because original case could not be found.`)
|
||||
continue
|
||||
}
|
||||
newCaseId = await createCase({
|
||||
run_id: this.currentRunId,
|
||||
description: `Replay of case ${originalCase.case_id} from run ${replayRunId}`,
|
||||
system_prompt_hash: originalCase.system_prompt_hash,
|
||||
task_id: originalCase.task_id,
|
||||
tokens_in_context: originalCase.tokens_in_context,
|
||||
file_hash: originalCase.file_hash,
|
||||
})
|
||||
caseIdMirror.set(originalResult.case_id, newCaseId)
|
||||
}
|
||||
|
||||
// 5c. Determine if the original attempt was a "valid attempt"
|
||||
const isValidOriginalAttempt = originalResult.error_enum === null || originalResult.error_enum === 3 // 3 is diff_edit_error
|
||||
|
||||
const newResultInput: CreateResultInput = {
|
||||
...(originalResult as any),
|
||||
run_id: this.currentRunId,
|
||||
case_id: newCaseId,
|
||||
processing_functions_hash: this.processingFunctionsHash,
|
||||
}
|
||||
delete (newResultInput as any).result_id
|
||||
|
||||
if (isValidOriginalAttempt) {
|
||||
// This was a valid attempt. Re-run the diff algorithm.
|
||||
const originalCase = await getCaseById(originalResult.case_id)
|
||||
if (!originalCase) {
|
||||
log(isVerbose, ` [WARN] Replay for result ${originalResult.result_id}: Could not find original case. Copying original result.`)
|
||||
newResultInput.succeeded = originalResult.succeeded
|
||||
newResultInput.error_enum = originalResult.error_enum
|
||||
} else {
|
||||
const originalFile = originalCase.file_hash ? await getFileByHash(originalCase.file_hash) : null
|
||||
const parsedToolCall = originalResult.parsed_tool_call_json ? JSON.parse(originalResult.parsed_tool_call_json)[0] : null
|
||||
const diffContent = parsedToolCall?.input?.diff
|
||||
|
||||
if (originalFile && diffContent) {
|
||||
let diffSuccess = false
|
||||
try {
|
||||
await constructNewFileContent(diffContent, originalFile.content, true)
|
||||
diffSuccess = true
|
||||
log(isVerbose, ` [OK] Replay for task ${originalCase.task_id}: Diff applied successfully.`)
|
||||
} catch (e) {
|
||||
diffSuccess = false
|
||||
log(isVerbose, ` [FAIL] Replay for task ${originalCase.task_id}: New diff algorithm failed.`)
|
||||
}
|
||||
newResultInput.succeeded = diffSuccess
|
||||
newResultInput.error_enum = diffSuccess ? undefined : 3 // 3 = diff_edit_error
|
||||
} else {
|
||||
// Something is wrong with the ground truth data, just copy it.
|
||||
log(
|
||||
isVerbose,
|
||||
` [WARN] Replay for task ${originalCase.task_id}: Valid original attempt but missing file or diff content. Copying original result.`,
|
||||
)
|
||||
newResultInput.succeeded = originalResult.succeeded
|
||||
newResultInput.error_enum = originalResult.error_enum
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// This was not a valid attempt. Just copy the original result's outcome.
|
||||
log(isVerbose, ` [SKIP] Replay for task ${originalResult.case_id}: Invalid original attempt. Copying original result.`)
|
||||
newResultInput.succeeded = originalResult.succeeded
|
||||
newResultInput.error_enum = originalResult.error_enum
|
||||
}
|
||||
|
||||
await insertResult(newResultInput)
|
||||
replayedCount++
|
||||
}
|
||||
|
||||
log(isVerbose, `\n✓ Database replay completed successfully.`)
|
||||
log(isVerbose, ` Total original results: ${originalResults.length}`)
|
||||
log(isVerbose, ` Total replayed results: ${replayedCount}`)
|
||||
log(isVerbose, ` New run ID: ${this.currentRunId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single test example
|
||||
*/
|
||||
@@ -420,6 +636,7 @@ class NodeTestRunner {
|
||||
diffEditFunction: testConfig.diff_edit_function,
|
||||
thinkingBudgetTokens: testConfig.thinking_tokens_budget,
|
||||
originalDiffEditToolCallMessage: testConfig.replay ? testCase.original_diff_edit_tool_call_message : undefined,
|
||||
diffApplyFile: testConfig.diff_apply_file,
|
||||
}
|
||||
|
||||
if (isVerbose) {
|
||||
@@ -712,6 +929,8 @@ async function main() {
|
||||
.option("--thinking-budget <tokens>", "Set the thinking tokens budget", "0")
|
||||
.option("--parallel", "Run tests in parallel", false)
|
||||
.option("--replay", "Run evaluation from a pre-recorded LLM output, skipping the API call", false)
|
||||
.option("--replay-run-id <run_id>", "The ID of the run to replay from the database")
|
||||
.option("--diff-apply-file <filename>", "The name of the diff apply file to use for the replay")
|
||||
.option("-v, --verbose", "Enable verbose logging", false)
|
||||
.option("--max-concurrency <number>", "Maximum number of parallel requests", "80")
|
||||
|
||||
@@ -732,6 +951,17 @@ async function main() {
|
||||
}
|
||||
|
||||
const validAttemptsPerCase = parseInt(options.validAttemptsPerCase, 10);
|
||||
|
||||
const runner = new NodeTestRunner(options.replay || !!options.replayRunId)
|
||||
|
||||
if (options.replayRunId) {
|
||||
if (!options.diffApplyFile) {
|
||||
console.error("Error: --diff-apply-file is required when using --replay-run-id")
|
||||
process.exit(1)
|
||||
}
|
||||
await runner.runDatabaseReplay(options.replayRunId, options.diffApplyFile, isVerbose)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
|
||||
@@ -33,7 +33,7 @@ This is the most granular and important table in the database. A `result` repres
|
||||
|
||||
- **Purpose**: To store the detailed outcome of every single model attempt, providing the raw data for all quantitative and qualitative analysis.
|
||||
- **Key Columns**:
|
||||
- `result_id`: A unique identifier for the individual attempt.
|
||||
- `result_id`: The primary key for the result.
|
||||
- `run_id`, `case_id`, `model_id`, `processing_functions_hash`: A set of foreign keys that precisely situate this result within the context of a specific run, case, model, and set of helper functions.
|
||||
- `succeeded`: A boolean indicating if the generated diff was applied successfully.
|
||||
- `error_enum`: A numeric code representing the specific type of error if the attempt failed (e.g., `1` for `no_tool_calls`, `7` for `wrong_file_edited`).
|
||||
@@ -82,3 +82,15 @@ This relational schema provides a powerful foundation for sophisticated analysis
|
||||
- "Which of our internal diffing algorithms is the most robust against a wide range of model-generated edits?"
|
||||
|
||||
Ultimately, this data model enables us to move from simply *measuring* performance to truly *understanding* it, providing the insights needed to build more capable and reliable AI engineering systems.
|
||||
|
||||
---
|
||||
|
||||
## Viewing the Full Schema
|
||||
|
||||
To see the most up-to-date and detailed schema for the database, you can use the `sqlite3` command-line tool. From the `evals/diff-edits` directory, run the following command:
|
||||
|
||||
```bash
|
||||
sqlite3 evals.db .schema
|
||||
```
|
||||
|
||||
This will print the complete `CREATE TABLE` statements for all tables in the database, providing a definitive reference for the database structure.
|
||||
|
||||
@@ -0,0 +1,827 @@
|
||||
const SEARCH_BLOCK_START = "------- SEARCH"
|
||||
const SEARCH_BLOCK_END = "======="
|
||||
const REPLACE_BLOCK_END = "+++++++ REPLACE"
|
||||
|
||||
const SEARCH_BLOCK_CHAR = "-"
|
||||
const REPLACE_BLOCK_CHAR = "+"
|
||||
const LEGACY_SEARCH_BLOCK_CHAR = "<"
|
||||
const LEGACY_REPLACE_BLOCK_CHAR = ">"
|
||||
|
||||
// Replace the exact string constants with flexible regex patterns
|
||||
const SEARCH_BLOCK_START_REGEX = /^[-]{3,} SEARCH$/
|
||||
const SEARCH_BLOCK_END_REGEX = /^[=]{3,}$/
|
||||
const REPLACE_BLOCK_END_REGEX = /^[+]{3,} REPLACE$/
|
||||
const LEGACY_SEARCH_BLOCK_START_REGEX = /^[<]{3,} SEARCH$/
|
||||
const LEGACY_REPLACE_BLOCK_END_REGEX = /^[>]{3,} REPLACE$/
|
||||
|
||||
// Helper functions to check if a line matches the flexible patterns
|
||||
function isSearchBlockStart(line: string): boolean {
|
||||
return SEARCH_BLOCK_START_REGEX.test(line) || LEGACY_SEARCH_BLOCK_START_REGEX.test(line)
|
||||
}
|
||||
|
||||
function isSearchBlockEnd(line: string): boolean {
|
||||
return SEARCH_BLOCK_END_REGEX.test(line)
|
||||
}
|
||||
|
||||
function isReplaceBlockEnd(line: string): boolean {
|
||||
return REPLACE_BLOCK_END_REGEX.test(line) || LEGACY_REPLACE_BLOCK_END_REGEX.test(line)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts a line-trimmed fallback match for the given search content in the original content.
|
||||
* It tries to match `searchContent` lines against a block of lines in `originalContent` starting
|
||||
* from `lastProcessedIndex`. Lines are matched by trimming leading/trailing whitespace and ensuring
|
||||
* they are identical afterwards.
|
||||
*
|
||||
* Returns [matchIndexStart, matchIndexEnd] if found, or false if not found.
|
||||
*/
|
||||
function lineTrimmedFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
|
||||
// Split both contents into lines
|
||||
const originalLines = originalContent.split("\n")
|
||||
const searchLines = searchContent.split("\n")
|
||||
|
||||
// Trim trailing empty line if exists (from the trailing \n in searchContent)
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
// Find the line number where startIndex falls
|
||||
let startLineNum = 0
|
||||
let currentIndex = 0
|
||||
while (currentIndex < startIndex && startLineNum < originalLines.length) {
|
||||
currentIndex += originalLines[startLineNum].length + 1 // +1 for \n
|
||||
startLineNum++
|
||||
}
|
||||
|
||||
// For each possible starting position in original content
|
||||
for (let i = startLineNum; i <= originalLines.length - searchLines.length; i++) {
|
||||
let matches = true
|
||||
|
||||
// Try to match all search lines from this position
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
const originalTrimmed = originalLines[i + j].trim()
|
||||
const searchTrimmed = searchLines[j].trim()
|
||||
|
||||
if (originalTrimmed !== searchTrimmed) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a match, calculate the exact character positions
|
||||
if (matches) {
|
||||
// Find start character index
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1 // +1 for \n
|
||||
}
|
||||
|
||||
// Find end character index
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchLines.length; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1 // +1 for \n
|
||||
}
|
||||
|
||||
return [matchStartIndex, matchEndIndex]
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to match blocks of code by using the first and last lines as anchors.
|
||||
* This is a third-tier fallback strategy that helps match blocks where we can identify
|
||||
* the correct location by matching the beginning and end, even if the exact content
|
||||
* differs slightly.
|
||||
*
|
||||
* The matching strategy:
|
||||
* 1. Only attempts to match blocks of 3 or more lines to avoid false positives
|
||||
* 2. Extracts from the search content:
|
||||
* - First line as the "start anchor"
|
||||
* - Last line as the "end anchor"
|
||||
* 3. For each position in the original content:
|
||||
* - Checks if the next line matches the start anchor
|
||||
* - If it does, jumps ahead by the search block size
|
||||
* - Checks if that line matches the end anchor
|
||||
* - All comparisons are done after trimming whitespace
|
||||
*
|
||||
* This approach is particularly useful for matching blocks of code where:
|
||||
* - The exact content might have minor differences
|
||||
* - The beginning and end of the block are distinctive enough to serve as anchors
|
||||
* - The overall structure (number of lines) remains the same
|
||||
*
|
||||
* @param originalContent - The full content of the original file
|
||||
* @param searchContent - The content we're trying to find in the original file
|
||||
* @param startIndex - The character index in originalContent where to start searching
|
||||
* @returns A tuple of [startIndex, endIndex] if a match is found, false otherwise
|
||||
*/
|
||||
function blockAnchorFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
|
||||
const originalLines = originalContent.split("\n")
|
||||
const searchLines = searchContent.split("\n")
|
||||
|
||||
// Only use this approach for blocks of 3+ lines
|
||||
if (searchLines.length < 3) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Trim trailing empty line if exists
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
const firstLineSearch = searchLines[0].trim()
|
||||
const lastLineSearch = searchLines[searchLines.length - 1].trim()
|
||||
const searchBlockSize = searchLines.length
|
||||
|
||||
// Find the line number where startIndex falls
|
||||
let startLineNum = 0
|
||||
let currentIndex = 0
|
||||
while (currentIndex < startIndex && startLineNum < originalLines.length) {
|
||||
currentIndex += originalLines[startLineNum].length + 1
|
||||
startLineNum++
|
||||
}
|
||||
|
||||
// Look for matching start and end anchors
|
||||
for (let i = startLineNum; i <= originalLines.length - searchBlockSize; i++) {
|
||||
// Check if first line matches
|
||||
if (originalLines[i].trim() !== firstLineSearch) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if last line matches at the expected position
|
||||
if (originalLines[i + searchBlockSize - 1].trim() !== lastLineSearch) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate exact character positions
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
}
|
||||
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchBlockSize; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1
|
||||
}
|
||||
|
||||
return [matchStartIndex, matchEndIndex]
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* This function reconstructs the file content by applying a streamed diff (in a
|
||||
* specialized SEARCH/REPLACE block format) to the original file content. It is designed
|
||||
* to handle both incremental updates and the final resulting file after all chunks have
|
||||
* been processed.
|
||||
*
|
||||
* The diff format is a custom structure that uses three markers to define changes:
|
||||
*
|
||||
* ------- SEARCH
|
||||
* [Exact content to find in the original file]
|
||||
* =======
|
||||
* [Content to replace with]
|
||||
* +++++++ REPLACE
|
||||
*
|
||||
* Behavior and Assumptions:
|
||||
* 1. The file is processed chunk-by-chunk. Each chunk of `diffContent` may contain
|
||||
* partial or complete SEARCH/REPLACE blocks. By calling this function with each
|
||||
* incremental chunk (with `isFinal` indicating the last chunk), the final reconstructed
|
||||
* file content is produced.
|
||||
*
|
||||
* 2. Matching Strategy (in order of attempt):
|
||||
* a. Exact Match: First attempts to find the exact SEARCH block text in the original file
|
||||
* b. Line-Trimmed Match: Falls back to line-by-line comparison ignoring leading/trailing whitespace
|
||||
* c. Block Anchor Match: For blocks of 3+ lines, tries to match using first/last lines as anchors
|
||||
* If all matching strategies fail, an error is thrown.
|
||||
*
|
||||
* 3. Empty SEARCH Section:
|
||||
* - If SEARCH is empty and the original file is empty, this indicates creating a new file
|
||||
* (pure insertion).
|
||||
* - If SEARCH is empty and the original file is not empty, this indicates a complete
|
||||
* file replacement (the entire original content is considered matched and replaced).
|
||||
*
|
||||
* 4. Applying Changes:
|
||||
* - Before encountering the "=======" marker, lines are accumulated as search content.
|
||||
* - After "=======" and before ">>>>>>> REPLACE", lines are accumulated as replacement content.
|
||||
* - Once the block is complete (">>>>>>> REPLACE"), the matched section in the original
|
||||
* file is replaced with the accumulated replacement lines, and the position in the original
|
||||
* file is advanced.
|
||||
*
|
||||
* 5. Incremental Output:
|
||||
* - As soon as the match location is found and we are in the REPLACE section, each new
|
||||
* replacement line is appended to the result so that partial updates can be viewed
|
||||
* incrementally.
|
||||
*
|
||||
* 6. Partial Markers:
|
||||
* - If the final line of the chunk looks like it might be part of a marker but is not one
|
||||
* of the known markers, it is removed. This prevents incomplete or partial markers
|
||||
* from corrupting the output.
|
||||
*
|
||||
* 7. Finalization:
|
||||
* - Once all chunks have been processed (when `isFinal` is true), any remaining original
|
||||
* content after the last replaced section is appended to the result.
|
||||
* - Trailing newlines are not forcibly added. The code tries to output exactly what is specified.
|
||||
*
|
||||
* Errors:
|
||||
* - If the search block cannot be matched using any of the available matching strategies,
|
||||
* an error is thrown.
|
||||
*/
|
||||
export async function constructNewFileContent(
|
||||
diffContent: string,
|
||||
originalContent: string,
|
||||
isFinal: boolean,
|
||||
version: "v1" | "v2" = "v1",
|
||||
): Promise<string> {
|
||||
const constructor = constructNewFileContentVersionMapping[version]
|
||||
if (!constructor) {
|
||||
throw new Error(`Invalid version '${version}' for file content constructor`)
|
||||
}
|
||||
return constructor(diffContent, originalContent, isFinal)
|
||||
}
|
||||
|
||||
const constructNewFileContentVersionMapping: Record<
|
||||
string,
|
||||
(diffContent: string, originalContent: string, isFinal: boolean) => Promise<string>
|
||||
> = {
|
||||
v1: constructNewFileContentV1,
|
||||
v2: constructNewFileContentV2,
|
||||
} as const
|
||||
|
||||
async function constructNewFileContentV1(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
let result = ""
|
||||
let lastProcessedIndex = 0
|
||||
|
||||
let currentSearchContent = ""
|
||||
let currentReplaceContent = ""
|
||||
let inSearch = false
|
||||
let inReplace = false
|
||||
|
||||
let searchMatchIndex = -1
|
||||
let searchEndIndex = -1
|
||||
|
||||
// Track all replacements to handle out-of-order edits
|
||||
let replacements: Array<{ start: number; end: number; content: string }> = []
|
||||
let pendingOutOfOrderReplacement = false
|
||||
|
||||
let lines = diffContent.split("\n")
|
||||
|
||||
// If the last line looks like a partial marker but isn't recognized,
|
||||
// remove it because it might be incomplete.
|
||||
const lastLine = lines[lines.length - 1]
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith("=") ||
|
||||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
|
||||
!isSearchBlockStart(lastLine) &&
|
||||
!isSearchBlockEnd(lastLine) &&
|
||||
!isReplaceBlockEnd(lastLine)
|
||||
) {
|
||||
lines.pop()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (isSearchBlockStart(line)) {
|
||||
inSearch = true
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if (isSearchBlockEnd(line)) {
|
||||
inSearch = false
|
||||
inReplace = true
|
||||
|
||||
// Remove trailing linebreak for adding the === marker
|
||||
// if (currentSearchContent.endsWith("\r\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -2)
|
||||
// } else if (currentSearchContent.endsWith("\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -1)
|
||||
// }
|
||||
|
||||
if (!currentSearchContent) {
|
||||
// Empty search block
|
||||
if (originalContent.length === 0) {
|
||||
// New file scenario: nothing to match, just start inserting
|
||||
searchMatchIndex = 0
|
||||
searchEndIndex = 0
|
||||
} else {
|
||||
// ERROR: Empty search block with non-empty file indicates malformed SEARCH marker
|
||||
throw new Error(
|
||||
"Empty SEARCH block detected with non-empty file. This usually indicates a malformed SEARCH marker.\n" +
|
||||
"Please ensure your SEARCH marker follows the correct format:\n" +
|
||||
"- Use '------- SEARCH' (7+ dashes + space + SEARCH)\n",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Add check for inefficient full-file search
|
||||
// if (currentSearchContent.trim() === originalContent.trim()) {
|
||||
// throw new Error(
|
||||
// "The SEARCH block contains the entire file content. Please either:\n" +
|
||||
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
|
||||
// "2. Make focused changes to specific parts of the file that need modification.",
|
||||
// )
|
||||
// }
|
||||
|
||||
// Exact search match scenario
|
||||
const exactIndex = originalContent.indexOf(currentSearchContent, lastProcessedIndex)
|
||||
if (exactIndex !== -1) {
|
||||
searchMatchIndex = exactIndex
|
||||
searchEndIndex = exactIndex + currentSearchContent.length
|
||||
} else {
|
||||
// Attempt fallback line-trimmed matching
|
||||
const lineMatch = lineTrimmedFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
|
||||
if (lineMatch) {
|
||||
;[searchMatchIndex, searchEndIndex] = lineMatch
|
||||
} else {
|
||||
// Try block anchor fallback for larger blocks
|
||||
const blockMatch = blockAnchorFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
|
||||
if (blockMatch) {
|
||||
;[searchMatchIndex, searchEndIndex] = blockMatch
|
||||
} else {
|
||||
// Last resort: search the entire file from the beginning
|
||||
const fullFileIndex = originalContent.indexOf(currentSearchContent, 0)
|
||||
if (fullFileIndex !== -1) {
|
||||
// Found in the file - could be out of order
|
||||
searchMatchIndex = fullFileIndex
|
||||
searchEndIndex = fullFileIndex + currentSearchContent.length
|
||||
if (searchMatchIndex < lastProcessedIndex) {
|
||||
pendingOutOfOrderReplacement = true
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is an out-of-order replacement
|
||||
if (searchMatchIndex < lastProcessedIndex) {
|
||||
pendingOutOfOrderReplacement = true
|
||||
}
|
||||
|
||||
// For in-order replacements, output everything up to the match location
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
result += originalContent.slice(lastProcessedIndex, searchMatchIndex)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (isReplaceBlockEnd(line)) {
|
||||
// Finished one replace block
|
||||
|
||||
// Store this replacement
|
||||
replacements.push({
|
||||
start: searchMatchIndex,
|
||||
end: searchEndIndex,
|
||||
content: currentReplaceContent,
|
||||
})
|
||||
|
||||
// If this was an in-order replacement, advance lastProcessedIndex
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
lastProcessedIndex = searchEndIndex
|
||||
}
|
||||
|
||||
// Reset for next block
|
||||
inSearch = false
|
||||
inReplace = false
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
searchMatchIndex = -1
|
||||
searchEndIndex = -1
|
||||
pendingOutOfOrderReplacement = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Accumulate content for search or replace
|
||||
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
|
||||
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
|
||||
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
|
||||
if (inSearch) {
|
||||
currentSearchContent += line + "\n"
|
||||
} else if (inReplace) {
|
||||
currentReplaceContent += line + "\n"
|
||||
// Only output replacement lines immediately for in-order replacements
|
||||
if (searchMatchIndex !== -1 && !pendingOutOfOrderReplacement) {
|
||||
result += line + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this is the final chunk, we need to apply all replacements and build the final result
|
||||
if (isFinal) {
|
||||
// Handle the case where we're still in replace mode when processing ends
|
||||
// and this is the final chunk - treat it as if we encountered the REPLACE marker
|
||||
if (inReplace && searchMatchIndex !== -1) {
|
||||
// Store this replacement
|
||||
replacements.push({
|
||||
start: searchMatchIndex,
|
||||
end: searchEndIndex,
|
||||
content: currentReplaceContent,
|
||||
})
|
||||
|
||||
// If this was an in-order replacement, advance lastProcessedIndex
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
lastProcessedIndex = searchEndIndex
|
||||
}
|
||||
|
||||
// Reset state
|
||||
inSearch = false
|
||||
inReplace = false
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
searchMatchIndex = -1
|
||||
searchEndIndex = -1
|
||||
pendingOutOfOrderReplacement = false
|
||||
}
|
||||
// end of handling missing replace marker
|
||||
|
||||
// Sort replacements by start position
|
||||
replacements.sort((a, b) => a.start - b.start)
|
||||
|
||||
// Rebuild the entire result by applying all replacements
|
||||
result = ""
|
||||
let currentPos = 0
|
||||
|
||||
for (const replacement of replacements) {
|
||||
// Add original content up to this replacement
|
||||
result += originalContent.slice(currentPos, replacement.start)
|
||||
// Add the replacement content
|
||||
result += replacement.content
|
||||
// Move position to after the replaced section
|
||||
currentPos = replacement.end
|
||||
}
|
||||
|
||||
// Add any remaining original content
|
||||
result += originalContent.slice(currentPos)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
enum ProcessingState {
|
||||
Idle = 0,
|
||||
StateSearch = 1 << 0,
|
||||
StateReplace = 1 << 1,
|
||||
}
|
||||
|
||||
class NewFileContentConstructor {
|
||||
private originalContent: string
|
||||
private isFinal: boolean
|
||||
private state: number
|
||||
private pendingNonStandardLines: string[]
|
||||
private result: string
|
||||
private lastProcessedIndex: number
|
||||
private currentSearchContent: string
|
||||
private currentReplaceContent: string
|
||||
private searchMatchIndex: number
|
||||
private searchEndIndex: number
|
||||
|
||||
constructor(originalContent: string, isFinal: boolean) {
|
||||
this.originalContent = originalContent
|
||||
this.isFinal = isFinal
|
||||
this.pendingNonStandardLines = []
|
||||
this.result = ""
|
||||
this.lastProcessedIndex = 0
|
||||
this.state = ProcessingState.Idle
|
||||
this.currentSearchContent = ""
|
||||
this.currentReplaceContent = ""
|
||||
this.searchMatchIndex = -1
|
||||
this.searchEndIndex = -1
|
||||
}
|
||||
|
||||
private resetForNextBlock() {
|
||||
// Reset for next block
|
||||
this.state = ProcessingState.Idle
|
||||
this.currentSearchContent = ""
|
||||
this.currentReplaceContent = ""
|
||||
this.searchMatchIndex = -1
|
||||
this.searchEndIndex = -1
|
||||
}
|
||||
|
||||
private findLastMatchingLineIndex(regx: RegExp, lineLimit: number) {
|
||||
for (let i = lineLimit; i > 0; ) {
|
||||
i--
|
||||
if (this.pendingNonStandardLines[i].match(regx)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private updateProcessingState(newState: ProcessingState) {
|
||||
const isValidTransition =
|
||||
(this.state === ProcessingState.Idle && newState === ProcessingState.StateSearch) ||
|
||||
(this.state === ProcessingState.StateSearch && newState === ProcessingState.StateReplace)
|
||||
|
||||
if (!isValidTransition) {
|
||||
throw new Error(
|
||||
`Invalid state transition.\n` +
|
||||
"Valid transitions are:\n" +
|
||||
"- Idle → StateSearch\n" +
|
||||
"- StateSearch → StateReplace",
|
||||
)
|
||||
}
|
||||
|
||||
this.state |= newState
|
||||
}
|
||||
|
||||
private isStateActive(state: ProcessingState): boolean {
|
||||
return (this.state & state) === state
|
||||
}
|
||||
|
||||
private activateReplaceState() {
|
||||
this.updateProcessingState(ProcessingState.StateReplace)
|
||||
}
|
||||
|
||||
private activateSearchState() {
|
||||
this.updateProcessingState(ProcessingState.StateSearch)
|
||||
this.currentSearchContent = ""
|
||||
this.currentReplaceContent = ""
|
||||
}
|
||||
|
||||
private isSearchingActive(): boolean {
|
||||
return this.isStateActive(ProcessingState.StateSearch)
|
||||
}
|
||||
|
||||
private isReplacingActive(): boolean {
|
||||
return this.isStateActive(ProcessingState.StateReplace)
|
||||
}
|
||||
|
||||
private hasPendingNonStandardLines(pendingNonStandardLineLimit: number): boolean {
|
||||
return this.pendingNonStandardLines.length - pendingNonStandardLineLimit < this.pendingNonStandardLines.length
|
||||
}
|
||||
|
||||
public processLine(line: string) {
|
||||
this.internalProcessLine(line, true, this.pendingNonStandardLines.length)
|
||||
}
|
||||
|
||||
public getResult() {
|
||||
// If this is the final chunk, append any remaining original content
|
||||
if (this.isFinal && this.lastProcessedIndex < this.originalContent.length) {
|
||||
this.result += this.originalContent.slice(this.lastProcessedIndex)
|
||||
}
|
||||
if (this.isFinal && this.state !== ProcessingState.Idle) {
|
||||
throw new Error("File processing incomplete - SEARCH/REPLACE operations still active during finalization")
|
||||
}
|
||||
return this.result
|
||||
}
|
||||
|
||||
private internalProcessLine(
|
||||
line: string,
|
||||
canWritependingNonStandardLines: boolean,
|
||||
pendingNonStandardLineLimit: number,
|
||||
): number {
|
||||
let removeLineCount = 0
|
||||
if (isSearchBlockStart(line)) {
|
||||
removeLineCount = this.trimPendingNonStandardTrailingEmptyLines(pendingNonStandardLineLimit)
|
||||
if (removeLineCount > 0) {
|
||||
pendingNonStandardLineLimit = pendingNonStandardLineLimit - removeLineCount
|
||||
}
|
||||
if (this.hasPendingNonStandardLines(pendingNonStandardLineLimit)) {
|
||||
this.tryFixSearchReplaceBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.activateSearchState()
|
||||
} else if (isSearchBlockEnd(line)) {
|
||||
// 校验非标内容
|
||||
if (!this.isSearchingActive()) {
|
||||
this.tryFixSearchBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.activateReplaceState()
|
||||
this.beforeReplace()
|
||||
} else if (isReplaceBlockEnd(line)) {
|
||||
if (!this.isReplacingActive()) {
|
||||
this.tryFixReplaceBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.lastProcessedIndex = this.searchEndIndex
|
||||
this.resetForNextBlock()
|
||||
} else {
|
||||
// Accumulate content for search or replace
|
||||
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
|
||||
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
|
||||
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
|
||||
if (this.isReplacingActive()) {
|
||||
this.currentReplaceContent += line + "\n"
|
||||
// Output replacement lines immediately if we know the insertion point
|
||||
if (this.searchMatchIndex !== -1) {
|
||||
this.result += line + "\n"
|
||||
}
|
||||
} else if (this.isSearchingActive()) {
|
||||
this.currentSearchContent += line + "\n"
|
||||
} else {
|
||||
let appendToPendingNonStandardLines = canWritependingNonStandardLines
|
||||
if (appendToPendingNonStandardLines) {
|
||||
// 处理非标内容
|
||||
this.pendingNonStandardLines.push(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private beforeReplace() {
|
||||
// Remove trailing linebreak for adding the === marker
|
||||
// if (currentSearchContent.endsWith("\r\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -2)
|
||||
// } else if (currentSearchContent.endsWith("\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -1)
|
||||
// }
|
||||
|
||||
if (!this.currentSearchContent) {
|
||||
// Empty search block
|
||||
if (this.originalContent.length === 0) {
|
||||
// New file scenario: nothing to match, just start inserting
|
||||
this.searchMatchIndex = 0
|
||||
this.searchEndIndex = 0
|
||||
} else {
|
||||
// Complete file replacement scenario: treat the entire file as matched
|
||||
this.searchMatchIndex = 0
|
||||
this.searchEndIndex = this.originalContent.length
|
||||
}
|
||||
} else {
|
||||
// Add check for inefficient full-file search
|
||||
// if (currentSearchContent.trim() === originalContent.trim()) {
|
||||
// throw new Error(
|
||||
// "The SEARCH block contains the entire file content. Please either:\n" +
|
||||
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
|
||||
// "2. Make focused changes to specific parts of the file that need modification.",
|
||||
// )
|
||||
// }
|
||||
// Exact search match scenario
|
||||
const exactIndex = this.originalContent.indexOf(this.currentSearchContent, this.lastProcessedIndex)
|
||||
if (exactIndex !== -1) {
|
||||
this.searchMatchIndex = exactIndex
|
||||
this.searchEndIndex = exactIndex + this.currentSearchContent.length
|
||||
} else {
|
||||
// Attempt fallback line-trimmed matching
|
||||
const lineMatch = lineTrimmedFallbackMatch(
|
||||
this.originalContent,
|
||||
this.currentSearchContent,
|
||||
this.lastProcessedIndex,
|
||||
)
|
||||
if (lineMatch) {
|
||||
;[this.searchMatchIndex, this.searchEndIndex] = lineMatch
|
||||
} else {
|
||||
// Try block anchor fallback for larger blocks
|
||||
const blockMatch = blockAnchorFallbackMatch(
|
||||
this.originalContent,
|
||||
this.currentSearchContent,
|
||||
this.lastProcessedIndex,
|
||||
)
|
||||
if (blockMatch) {
|
||||
;[this.searchMatchIndex, this.searchEndIndex] = blockMatch
|
||||
} else {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.searchMatchIndex < this.lastProcessedIndex) {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...matched an incorrect content in the file.`,
|
||||
)
|
||||
}
|
||||
// Output everything up to the match location
|
||||
this.result += this.originalContent.slice(this.lastProcessedIndex, this.searchMatchIndex)
|
||||
}
|
||||
|
||||
private tryFixSearchBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error("Invalid SEARCH/REPLACE block structure - no lines available to process")
|
||||
}
|
||||
let searchTagRegexp = /^([-]{3,}|[<]{3,}) SEARCH$/
|
||||
const searchTagIndex = this.findLastMatchingLineIndex(searchTagRegexp, lineLimit)
|
||||
if (searchTagIndex !== -1) {
|
||||
let fixLines = this.pendingNonStandardLines.slice(searchTagIndex, lineLimit)
|
||||
fixLines[0] = SEARCH_BLOCK_START
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, searchTagIndex)
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid REPLACE marker detected - could not find matching SEARCH block starting from line ${searchTagIndex + 1}`,
|
||||
)
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private tryFixReplaceBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error()
|
||||
}
|
||||
let replaceBeginTagRegexp = /^[=]{3,}$/
|
||||
const replaceBeginTagIndex = this.findLastMatchingLineIndex(replaceBeginTagRegexp, lineLimit)
|
||||
if (replaceBeginTagIndex !== -1) {
|
||||
// // 校验非标内容
|
||||
// if (!this.isSearchingActive()) {
|
||||
// removeLineCount += this.tryFixSearchBlock(replaceBeginTagIndex)
|
||||
// }
|
||||
let fixLines = this.pendingNonStandardLines.slice(replaceBeginTagIndex - removeLineCount, lineLimit - removeLineCount)
|
||||
fixLines[0] = SEARCH_BLOCK_END
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, replaceBeginTagIndex - removeLineCount)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Malformed REPLACE block - missing valid separator after line ${replaceBeginTagIndex + 1}`)
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private tryFixSearchReplaceBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
let replaceEndTagRegexp = /^([+]{3,}|[>]{3,}) REPLACE$/
|
||||
const replaceEndTagIndex = this.findLastMatchingLineIndex(replaceEndTagRegexp, lineLimit)
|
||||
const likeReplaceEndTag = replaceEndTagIndex === lineLimit - 1
|
||||
if (likeReplaceEndTag) {
|
||||
// // 校验非标内容
|
||||
// if (!this.isReplacingActive()) {
|
||||
// removeLineCount += this.tryFixReplaceBlock(replaceEndTagIndex)
|
||||
// }
|
||||
let fixLines = this.pendingNonStandardLines.slice(replaceEndTagIndex - removeLineCount, lineLimit - removeLineCount)
|
||||
fixLines[fixLines.length - 1] = REPLACE_BLOCK_END
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, replaceEndTagIndex - removeLineCount)
|
||||
}
|
||||
} else {
|
||||
throw new Error("Malformed SEARCH/REPLACE block structure: Missing valid closing REPLACE marker")
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes trailing empty lines from the pendingNonStandardLines array
|
||||
* @param lineLimit - The index to start checking from (exclusive).
|
||||
* Removes empty lines from lineLimit-1 backwards.
|
||||
* @returns The number of empty lines removed
|
||||
*/
|
||||
private trimPendingNonStandardTrailingEmptyLines(lineLimit: number): number {
|
||||
let removedCount = 0
|
||||
let i = Math.min(lineLimit, this.pendingNonStandardLines.length) - 1
|
||||
|
||||
while (i >= 0 && this.pendingNonStandardLines[i].trim() === "") {
|
||||
this.pendingNonStandardLines.pop()
|
||||
removedCount++
|
||||
i--
|
||||
}
|
||||
|
||||
return removedCount
|
||||
}
|
||||
}
|
||||
|
||||
export async function constructNewFileContentV2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
let newFileContentConstructor = new NewFileContentConstructor(originalContent, isFinal)
|
||||
|
||||
let lines = diffContent.split("\n")
|
||||
|
||||
// If the last line looks like a partial marker but isn't recognized,
|
||||
// remove it because it might be incomplete.
|
||||
const lastLine = lines[lines.length - 1]
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith("=") ||
|
||||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
|
||||
lastLine !== SEARCH_BLOCK_START &&
|
||||
lastLine !== SEARCH_BLOCK_END &&
|
||||
lastLine !== REPLACE_BLOCK_END
|
||||
) {
|
||||
lines.pop()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
newFileContentConstructor.processLine(line)
|
||||
}
|
||||
|
||||
let result = newFileContentConstructor.getResult()
|
||||
return result
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export interface TestConfig {
|
||||
diff_edit_function: string
|
||||
thinking_tokens_budget: number
|
||||
replay: boolean
|
||||
diff_apply_file?: string
|
||||
}
|
||||
|
||||
export interface SystemPromptDetails {
|
||||
@@ -100,4 +101,5 @@ export interface TestInput {
|
||||
diffEditFunction: string
|
||||
thinkingBudgetTokens: number
|
||||
originalDiffEditToolCallMessage?: string
|
||||
diffApplyFile?: string
|
||||
}
|
||||
|
||||
@@ -19,4 +19,58 @@ service AccountService {
|
||||
|
||||
// Subscribe to auth callback events (when authentication tokens are received)
|
||||
rpc subscribeToAuthCallback(EmptyRequest) returns (stream String);
|
||||
|
||||
// Handles authentication state changes from the Firebase context.
|
||||
// Updates the user info in global state and returns the updated value.
|
||||
rpc authStateChanged(AuthStateChangedRequest) returns (AuthStateChanged);
|
||||
|
||||
// Fetches all user credits data (balance, usage transactions, payment transactions)
|
||||
rpc fetchUserCreditsData(EmptyRequest) returns (UserCreditsData);
|
||||
}
|
||||
|
||||
message AuthStateChangedRequest {
|
||||
Metadata metadata = 1;
|
||||
UserInfo user = 2;
|
||||
}
|
||||
|
||||
message AuthStateChanged {
|
||||
optional UserInfo user = 1;
|
||||
}
|
||||
|
||||
message UserInfo {
|
||||
optional string display_name = 1;
|
||||
optional string email = 2;
|
||||
optional string photo_url = 3;
|
||||
}
|
||||
|
||||
// Response containing all user credits data
|
||||
message UserCreditsData {
|
||||
UserCreditsBalance balance = 1;
|
||||
repeated UsageTransaction usage_transactions = 2;
|
||||
repeated PaymentTransaction payment_transactions = 3;
|
||||
}
|
||||
|
||||
// User's current credit balance
|
||||
message UserCreditsBalance {
|
||||
double current_balance = 1;
|
||||
}
|
||||
|
||||
// Usage transaction record
|
||||
message UsageTransaction {
|
||||
string spent_at = 1;
|
||||
string creator_id = 2;
|
||||
double credits = 3;
|
||||
string model_provider = 4;
|
||||
string model = 5;
|
||||
int32 prompt_tokens = 6;
|
||||
int32 completion_tokens = 7;
|
||||
int32 total_tokens = 8;
|
||||
}
|
||||
|
||||
// Payment transaction record
|
||||
message PaymentTransaction {
|
||||
string paid_at = 1;
|
||||
string creator_id = 2;
|
||||
int32 amount_cents = 3;
|
||||
double credits = 4;
|
||||
}
|
||||
|
||||
@@ -262,4 +262,7 @@ service UiService {
|
||||
|
||||
// Subscribe to webview visibility change events
|
||||
rpc subscribeToDidBecomeVisible(EmptyRequest) returns (stream Empty);
|
||||
|
||||
// Returns the HTML for the webview index page. This is only used by external clients, not by the vscode webview.
|
||||
rpc getWebviewHtml(EmptyRequest) returns (String);
|
||||
}
|
||||
|
||||
@@ -56,13 +56,24 @@ archive.glob("**/*", {
|
||||
|
||||
// Add the whole cline directory under "extension"
|
||||
archive.directory(process.cwd(), "extension", (entry) => {
|
||||
// Skip certain directories
|
||||
if (
|
||||
entry.name.startsWith(BUILD_DIR + "/") ||
|
||||
entry.name.startsWith("node_modules/") || // node_modules nearly 1GB.
|
||||
entry.name.startsWith("webview-ui/node_modules/") || // node_modules nearly 1GB.
|
||||
entry.name.match(/(^|\/)\./) // exclude dot directories
|
||||
) {
|
||||
// Skip certain directories.
|
||||
const exclude = [
|
||||
BUILD_DIR + "/",
|
||||
"node_modules/", // node_modules nearly 1GB.
|
||||
"webview-ui/node_modules/", // node_modules nearly 1GB.
|
||||
]
|
||||
// These node modules are used at runtime as assets, they need to be included.
|
||||
const include = ["node_modules/@vscode/", "webview-ui/node_modules/katex"]
|
||||
const name = entry.name
|
||||
|
||||
if (include.some((prefix) => name.startsWith(prefix))) {
|
||||
return entry
|
||||
}
|
||||
if (exclude.some((prefix) => name.startsWith(prefix))) {
|
||||
return false
|
||||
}
|
||||
if (name.match(/(^|\/)\./)) {
|
||||
// exclude dot directories
|
||||
return false
|
||||
}
|
||||
return entry
|
||||
|
||||
@@ -101,14 +101,21 @@ export class LiteLlmHandler implements ApiHandler {
|
||||
return message
|
||||
})
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const requestPayload: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & {
|
||||
metadata?: { cline_task_id: string }
|
||||
} = {
|
||||
model: this.options.liteLlmModelId || liteLlmDefaultModelId,
|
||||
messages: [enhancedSystemMessage, ...enhancedMessages],
|
||||
temperature,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...(thinkingConfig && { thinking: thinkingConfig }), // Add thinking configuration when applicable
|
||||
})
|
||||
...(this.options.taskId && {
|
||||
metadata: { cline_task_id: this.options.taskId },
|
||||
}),
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create(requestPayload)
|
||||
|
||||
const inputCost = (await this.calculateCost(1e6, 0)) || 0
|
||||
const outputCost = (await this.calculateCost(0, 1e6)) || 0
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { AuthStateChangedRequest, AuthStateChanged } from "@shared/proto/account"
|
||||
import type { Controller } from "../index"
|
||||
import { updateGlobalState } from "../../storage/state"
|
||||
|
||||
/**
|
||||
* Handles authentication state changes from the Firebase context.
|
||||
* Updates the user info in global state and returns the updated value.
|
||||
* @param controller The controller instance
|
||||
* @param request The auth state change request
|
||||
* @returns The updated user info
|
||||
*/
|
||||
export async function authStateChanged(controller: Controller, request: AuthStateChangedRequest): Promise<AuthStateChanged> {
|
||||
try {
|
||||
// Store the user info directly in global state
|
||||
await updateGlobalState(controller.context, "userInfo", request.user)
|
||||
|
||||
// Return the same user info
|
||||
return AuthStateChanged.create({ user: request.user })
|
||||
} catch (error) {
|
||||
console.error(`Failed to update auth state: ${error}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Controller } from "../index"
|
||||
import type { EmptyRequest } from "@shared/proto/common"
|
||||
import { UserCreditsData } from "@shared/proto/account"
|
||||
|
||||
/**
|
||||
* Handles fetching all user credits data (balance, usage, payments)
|
||||
* @param controller The controller instance
|
||||
* @param request Empty request
|
||||
* @returns User credits data response
|
||||
*/
|
||||
export async function fetchUserCreditsData(controller: Controller, request: EmptyRequest): Promise<UserCreditsData> {
|
||||
try {
|
||||
if (!controller.accountService) {
|
||||
throw new Error("Account service not available")
|
||||
}
|
||||
|
||||
// Call the individual RPC variants in parallel
|
||||
const [balance, usageTransactions, paymentTransactions] = await Promise.all([
|
||||
controller.accountService.fetchBalanceRPC(),
|
||||
controller.accountService.fetchUsageTransactionsRPC(),
|
||||
controller.accountService.fetchPaymentTransactionsRPC(),
|
||||
])
|
||||
|
||||
// Since generated types match exactly, no conversion needed!
|
||||
return UserCreditsData.create({
|
||||
balance: balance ? { currentBalance: balance.currentBalance } : { currentBalance: 0 },
|
||||
usageTransactions: usageTransactions || [],
|
||||
paymentTransactions: paymentTransactions || [],
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch user credits data: ${error}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { ChatSettings } from "@shared/ChatSettings"
|
||||
import { ExtensionMessage, ExtensionState, Platform } from "@shared/ExtensionMessage"
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { McpMarketplaceCatalog } from "@shared/mcp"
|
||||
import { UserInfo } from "@shared/UserInfo"
|
||||
import { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import { WebviewMessage } from "@shared/WebviewMessage"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
@@ -124,7 +125,7 @@ export class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
async setUserInfo(info?: { displayName: string | null; email: string | null; photoURL: string | null }) {
|
||||
async setUserInfo(info?: UserInfo) {
|
||||
await updateGlobalState(this.context, "userInfo", info)
|
||||
}
|
||||
|
||||
@@ -204,15 +205,6 @@ export class Controller {
|
||||
*/
|
||||
async handleWebviewMessage(message: WebviewMessage) {
|
||||
switch (message.type) {
|
||||
case "authStateChanged":
|
||||
await this.setUserInfo(message.user || undefined)
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
|
||||
case "fetchUserCreditsData": {
|
||||
await this.fetchUserCreditsData()
|
||||
break
|
||||
}
|
||||
case "fetchMcpMarketplace": {
|
||||
await this.fetchMcpMarketplace(message.bool)
|
||||
break
|
||||
@@ -496,20 +488,6 @@ export class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
// 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> {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Controller } from "../index"
|
||||
import { EmptyRequest, Empty, String } from "@shared/proto/common"
|
||||
import * as hostProviders from "@hosts/host-providers"
|
||||
import { WebviewProviderType } from "@/shared/webview/types"
|
||||
|
||||
/**
|
||||
* Initialize webview when it launches
|
||||
* @param controller The controller instance
|
||||
* @param request The empty request
|
||||
* @returns Empty response
|
||||
*/
|
||||
export async function getWebviewHtml(_controller: Controller, _: EmptyRequest): Promise<String> {
|
||||
const webviewProvider = hostProviders.createWebviewProvider(WebviewProviderType.SIDEBAR)
|
||||
|
||||
return Promise.resolve(String.create({ value: webviewProvider.getHtmlContent() }))
|
||||
}
|
||||
@@ -34,6 +34,9 @@ export class TaskState {
|
||||
isAwaitingPlanResponse = false
|
||||
didRespondToPlanAskBySwitchingMode = false
|
||||
|
||||
// Context and history
|
||||
conversationHistoryDeletedRange?: [number, number]
|
||||
|
||||
// Tool execution flags
|
||||
didRejectTool = false
|
||||
didAlreadyUseTool = false
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+52
-2373
File diff suppressed because it is too large
Load Diff
@@ -11,13 +11,14 @@ import * as path from "path"
|
||||
import CheckpointTracker from "@integrations/checkpoints/CheckpointTracker"
|
||||
import { HistoryItem } from "@/shared/HistoryItem"
|
||||
import Anthropic from "@anthropic-ai/sdk"
|
||||
import { TaskState } from "./TaskState"
|
||||
|
||||
interface MessageStateHandlerParams {
|
||||
context: vscode.ExtensionContext
|
||||
taskId: string
|
||||
conversationHistoryDeletedRange?: [number, number]
|
||||
taskIsFavorited?: boolean
|
||||
updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
|
||||
taskState: TaskState
|
||||
}
|
||||
|
||||
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
|
||||
@@ -25,17 +26,17 @@ const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath
|
||||
export class MessageStateHandler {
|
||||
private apiConversationHistory: Anthropic.MessageParam[] = []
|
||||
private clineMessages: ClineMessage[] = []
|
||||
private conversationHistoryDeletedRange: [number, number] | undefined
|
||||
private taskIsFavorited: boolean
|
||||
private checkpointTracker: CheckpointTracker | undefined
|
||||
private updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
|
||||
private context: vscode.ExtensionContext
|
||||
private taskId: string
|
||||
private taskState: TaskState
|
||||
|
||||
constructor(params: MessageStateHandlerParams) {
|
||||
this.context = params.context
|
||||
this.taskId = params.taskId
|
||||
this.conversationHistoryDeletedRange = params.conversationHistoryDeletedRange
|
||||
this.taskState = params.taskState
|
||||
this.taskIsFavorited = params.taskIsFavorited ?? false
|
||||
this.updateTaskHistory = params.updateTaskHistory
|
||||
}
|
||||
@@ -95,7 +96,7 @@ export class MessageStateHandler {
|
||||
size: taskDirSize,
|
||||
shadowGitConfigWorkTree: await this.checkpointTracker?.getShadowGitConfigWorkTree(),
|
||||
cwdOnTaskInitialization: cwd,
|
||||
conversationHistoryDeletedRange: this.conversationHistoryDeletedRange,
|
||||
conversationHistoryDeletedRange: this.taskState.conversationHistoryDeletedRange,
|
||||
isFavorited: this.taskIsFavorited,
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -117,7 +118,7 @@ export class MessageStateHandler {
|
||||
// these values allow us to reconstruct the conversation history at the time this cline message was created
|
||||
// it's important that apiConversationHistory is initialized before we add cline messages
|
||||
message.conversationHistoryIndex = this.apiConversationHistory.length - 1 // NOTE: this is the index of the last added message which is the user message, and once the clinemessages have been presented we update the apiconversationhistory with the completed assistant message. This means when resetting to a message, we need to +1 this index to get the correct assistant message that this tool use corresponds to
|
||||
message.conversationHistoryDeletedRange = this.conversationHistoryDeletedRange
|
||||
message.conversationHistoryDeletedRange = this.taskState.conversationHistoryDeletedRange
|
||||
this.clineMessages.push(message)
|
||||
await this.saveClineMessagesAndUpdateHistory()
|
||||
}
|
||||
|
||||
@@ -15,14 +15,6 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
|
||||
export class VscodeWebviewProvider extends WebviewProvider implements vscode.WebviewViewProvider {
|
||||
public webview?: vscode.WebviewView | vscode.WebviewPanel
|
||||
|
||||
public static create(
|
||||
context: vscode.ExtensionContext,
|
||||
outputChannel: vscode.OutputChannel,
|
||||
providerType: WebviewProviderType,
|
||||
) {
|
||||
return new VscodeWebviewProvider(context, outputChannel, providerType)
|
||||
}
|
||||
|
||||
constructor(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, providerType: WebviewProviderType) {
|
||||
super(context, outputChannel, providerType)
|
||||
}
|
||||
|
||||
+9
-5
@@ -29,6 +29,7 @@ import { FileContextTracker } from "./core/context/context-tracking/FileContextT
|
||||
import * as hostProviders from "@hosts/host-providers"
|
||||
import { vscodeHostBridgeClient } from "@/hosts/vscode/client/host-grpc-client"
|
||||
import { VscodeWebviewProvider } from "./core/webview/VscodeWebviewProvider"
|
||||
import { ExtensionContext } from "vscode"
|
||||
|
||||
/*
|
||||
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
|
||||
@@ -51,7 +52,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
Logger.initialize(outputChannel)
|
||||
Logger.log("Cline extension activated")
|
||||
|
||||
maybeSetupHostProviders()
|
||||
maybeSetupHostProviders(context)
|
||||
|
||||
// Migrate global storage values to workspace storage (one-time cleanup)
|
||||
await migratePlanActGlobalToWorkspaceStorage(context)
|
||||
@@ -65,7 +66,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
// Version checking for autoupdate notification
|
||||
const currentVersion = context.extension.packageJSON.version
|
||||
const previousVersion = context.globalState.get<string>("clineVersion")
|
||||
const sidebarWebview = hostProviders.createWebviewProvider(context, outputChannel, WebviewProviderType.SIDEBAR)
|
||||
const sidebarWebview = hostProviders.createWebviewProvider(WebviewProviderType.SIDEBAR)
|
||||
|
||||
// Initialize test mode and add disposables to context
|
||||
context.subscriptions.push(...initializeTestMode(context, sidebarWebview))
|
||||
@@ -168,7 +169,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
Logger.log("Opening Cline in new tab")
|
||||
// (this example uses webviewProvider activation event which is necessary to deserialize cached webview, but since we use retainContextWhenHidden, we don't need to use that event)
|
||||
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts
|
||||
const tabWebview = hostProviders.createWebviewProvider(context, outputChannel, WebviewProviderType.TAB)
|
||||
const tabWebview = hostProviders.createWebviewProvider(WebviewProviderType.TAB)
|
||||
//const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined
|
||||
const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0))
|
||||
|
||||
@@ -653,10 +654,13 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
return createClineAPI(outputChannel, sidebarWebview.controller)
|
||||
}
|
||||
|
||||
function maybeSetupHostProviders() {
|
||||
function maybeSetupHostProviders(context: ExtensionContext) {
|
||||
if (!hostProviders.isSetup) {
|
||||
console.log("Setting up vscode host providers...")
|
||||
hostProviders.initializeHostProviders(VscodeWebviewProvider.create, vscodeHostBridgeClient)
|
||||
const createWebview = function (type: WebviewProviderType) {
|
||||
return new VscodeWebviewProvider(context, outputChannel, type)
|
||||
}
|
||||
hostProviders.initializeHostProviders(createWebview, vscodeHostBridgeClient)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,7 @@ import * as vscode from "vscode"
|
||||
/**
|
||||
* A function that creates WebviewProvider instances
|
||||
*/
|
||||
export type WebviewProviderCreator = (
|
||||
context: vscode.ExtensionContext,
|
||||
outputChannel: vscode.OutputChannel,
|
||||
providerType: WebviewProviderType,
|
||||
) => WebviewProvider
|
||||
export type WebviewProviderCreator = (providerType: WebviewProviderType) => WebviewProvider
|
||||
|
||||
let _webviewProviderCreator: WebviewProviderCreator | undefined
|
||||
let _hostBridgeProvider: HostBridgeClientProvider | undefined
|
||||
@@ -26,15 +22,11 @@ export function initializeHostProviders(
|
||||
isSetup = true
|
||||
}
|
||||
|
||||
export function createWebviewProvider(
|
||||
context: vscode.ExtensionContext,
|
||||
outputChannel: vscode.OutputChannel,
|
||||
providerType: WebviewProviderType,
|
||||
): WebviewProvider {
|
||||
export function createWebviewProvider(providerType: WebviewProviderType): WebviewProvider {
|
||||
if (!_webviewProviderCreator) {
|
||||
throw Error("Host providers not initialized")
|
||||
}
|
||||
return _webviewProviderCreator(context, outputChannel, providerType)
|
||||
return _webviewProviderCreator(providerType)
|
||||
}
|
||||
|
||||
export function getHostBridgeProvider(): HostBridgeClientProvider {
|
||||
|
||||
@@ -49,61 +49,43 @@ export class ClineAccountService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's current credit balance
|
||||
* RPC variant that fetches the user's current credit balance without posting to webview
|
||||
* @returns Balance data or undefined if failed
|
||||
*/
|
||||
async fetchBalance(): Promise<BalanceResponse | undefined> {
|
||||
async fetchBalanceRPC(): Promise<BalanceResponse | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<BalanceResponse>("/user/credits/balance")
|
||||
|
||||
// Post to webview
|
||||
await this.postMessageToWebview({
|
||||
type: "userCreditsBalance",
|
||||
userCreditsBalance: data,
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch balance:", error)
|
||||
console.error("Failed to fetch balance (RPC):", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's usage transactions
|
||||
* RPC variant that fetches the user's usage transactions without posting to webview
|
||||
* @returns Usage transactions or undefined if failed
|
||||
*/
|
||||
async fetchUsageTransactions(): Promise<UsageTransaction[] | undefined> {
|
||||
async fetchUsageTransactionsRPC(): Promise<UsageTransaction[] | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<UsageTransaction[]>("/user/credits/usage")
|
||||
|
||||
// Post to webview
|
||||
await this.postMessageToWebview({
|
||||
type: "userCreditsUsage",
|
||||
userCreditsUsage: data,
|
||||
})
|
||||
|
||||
return data
|
||||
const data = await this.authenticatedRequest<{ usageTransactions: UsageTransaction[] }>("/user/credits/usage")
|
||||
return data.usageTransactions
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch usage transactions:", error)
|
||||
console.error("Failed to fetch usage transactions (RPC):", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's payment transactions
|
||||
* RPC variant that fetches the user's payment transactions without posting to webview
|
||||
* @returns Payment transactions or undefined if failed
|
||||
*/
|
||||
async fetchPaymentTransactions(): Promise<PaymentTransaction[] | undefined> {
|
||||
async fetchPaymentTransactionsRPC(): Promise<PaymentTransaction[] | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<PaymentTransaction[]>("/user/credits/payments")
|
||||
|
||||
// Post to webview
|
||||
await this.postMessageToWebview({
|
||||
type: "userCreditsPayments",
|
||||
userCreditsPayments: data,
|
||||
})
|
||||
|
||||
return data
|
||||
const data = await this.authenticatedRequest<{ paymentTransactions: PaymentTransaction[] }>("/user/credits/payments")
|
||||
return data.paymentTransactions
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch payment transactions:", error)
|
||||
console.error("Failed to fetch payment transactions (RPC):", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,18 @@ export interface BalanceResponse {
|
||||
|
||||
export interface UsageTransaction {
|
||||
spentAt: string
|
||||
credits: string
|
||||
creatorId: string
|
||||
credits: number
|
||||
modelProvider: string
|
||||
model: string
|
||||
promptTokens: string
|
||||
completionTokens: string
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
totalTokens: number
|
||||
}
|
||||
|
||||
export interface PaymentTransaction {
|
||||
paidAt: string
|
||||
amountCents: string
|
||||
credits: string
|
||||
creatorId: string
|
||||
amountCents: number
|
||||
credits: number
|
||||
}
|
||||
|
||||
@@ -10,18 +10,11 @@ import { McpServer, McpMarketplaceCatalog, McpDownloadResponse, McpViewTab } fro
|
||||
import { TelemetrySetting } from "./TelemetrySetting"
|
||||
import type { BalanceResponse, UsageTransaction, PaymentTransaction } from "../shared/ClineAccount"
|
||||
import { ClineRulesToggles } from "./cline-rules"
|
||||
import { UserInfo } from "./UserInfo"
|
||||
|
||||
// webview will hold state
|
||||
export interface ExtensionMessage {
|
||||
type:
|
||||
| "action"
|
||||
| "state"
|
||||
| "selectedImages"
|
||||
| "mcpDownloadDetails"
|
||||
| "userCreditsBalance"
|
||||
| "userCreditsUsage"
|
||||
| "userCreditsPayments"
|
||||
| "grpc_response" // New type for gRPC responses
|
||||
type: "action" | "state" | "selectedImages" | "mcpDownloadDetails" | "grpc_response" // New type for gRPC responses
|
||||
text?: string
|
||||
action?: "accountLogoutClicked"
|
||||
state?: ExtensionState
|
||||
@@ -39,9 +32,6 @@ export interface ExtensionMessage {
|
||||
commits?: GitCommit[]
|
||||
url?: string
|
||||
isImage?: boolean
|
||||
userCreditsBalance?: BalanceResponse
|
||||
userCreditsUsage?: UsageTransaction[]
|
||||
userCreditsPayments?: PaymentTransaction[]
|
||||
success?: boolean
|
||||
endpoint?: string
|
||||
isBundled?: boolean
|
||||
@@ -91,11 +81,7 @@ export interface ExtensionState {
|
||||
terminalOutputLineLimit: number
|
||||
defaultTerminalProfile?: string
|
||||
uriScheme?: string
|
||||
userInfo?: {
|
||||
displayName: string | null
|
||||
email: string | null
|
||||
photoURL: string | null
|
||||
}
|
||||
userInfo?: UserInfo
|
||||
version: string
|
||||
distinctId: string
|
||||
globalClineRulesToggles: ClineRulesToggles
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface UserInfo {
|
||||
displayName: string | null
|
||||
email: string | null
|
||||
photoURL: string | null
|
||||
displayName?: string
|
||||
email?: string
|
||||
photoURL?: string
|
||||
}
|
||||
|
||||
@@ -9,12 +9,10 @@ import { McpViewTab } from "./mcp"
|
||||
export interface WebviewMessage {
|
||||
type:
|
||||
| "requestVsCodeLmModels"
|
||||
| "authStateChanged"
|
||||
| "fetchMcpMarketplace"
|
||||
| "searchCommits"
|
||||
| "telemetrySetting"
|
||||
| "clearAllTaskHistory"
|
||||
| "fetchUserCreditsData"
|
||||
| "grpc_request"
|
||||
| "grpc_request_cancel"
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { ExtensionMessage } from "@/shared/ExtensionMessage"
|
||||
import { WebviewProviderType } from "@/shared/webview/types"
|
||||
import { sendThemeEvent } from "@core/controller/ui/subscribeToTheme"
|
||||
import { getTheme } from "@integrations/theme/getTheme"
|
||||
import * as vscode from "vscode"
|
||||
import { Uri } from "vscode"
|
||||
import { URI } from "vscode-uri"
|
||||
import { WebviewProvider } from "@core/webview"
|
||||
|
||||
/*
|
||||
@@ -12,25 +10,16 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
|
||||
*/
|
||||
|
||||
export class ExternalWebviewProvider extends WebviewProvider {
|
||||
public webview?: vscode.WebviewView | vscode.WebviewPanel
|
||||
|
||||
public static create(
|
||||
context: vscode.ExtensionContext,
|
||||
outputChannel: vscode.OutputChannel,
|
||||
providerType: WebviewProviderType,
|
||||
) {
|
||||
return new ExternalWebviewProvider(context, outputChannel, providerType)
|
||||
}
|
||||
|
||||
private RESOURCE_AUTHORITY: string = "file.resources"
|
||||
constructor(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, providerType: WebviewProviderType) {
|
||||
super(context, outputChannel, providerType)
|
||||
}
|
||||
|
||||
override getWebviewUri(uri: Uri) {
|
||||
return uri.with({
|
||||
scheme: "http",
|
||||
authority: "resources",
|
||||
})
|
||||
override getWebviewUri(uri: URI) {
|
||||
if (uri.scheme !== "file") {
|
||||
return uri
|
||||
}
|
||||
return URI.from({ scheme: "https", authority: this.RESOURCE_AUTHORITY, path: uri.fsPath })
|
||||
}
|
||||
override getCspSource() {
|
||||
return "csp-source"
|
||||
@@ -46,7 +35,7 @@ export class ExternalWebviewProvider extends WebviewProvider {
|
||||
return {}
|
||||
}
|
||||
|
||||
override resolveWebviewView(_: vscode.WebviewView | vscode.WebviewPanel): Promise<void> {
|
||||
override resolveWebviewView(_: any): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,13 @@ import { addProtobusServices } from "@generated/standalone/server-setup"
|
||||
import { StreamingResponseHandler } from "@/core/controller/grpc-handler"
|
||||
import { ExternalHostBridgeClientManager } from "./host-bridge-client-manager"
|
||||
import { ExternalWebviewProvider } from "./ExternalWebviewProvider"
|
||||
import { WebviewProviderType } from "@/shared/webview/types"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
|
||||
async function main() {
|
||||
log("Starting standalone service...")
|
||||
|
||||
hostProviders.initializeHostProviders(ExternalWebviewProvider.create, new ExternalHostBridgeClientManager())
|
||||
hostProviders.initializeHostProviders(createWebview, new ExternalHostBridgeClientManager())
|
||||
activate(extensionContext)
|
||||
const controller = new Controller(extensionContext, outputChannel, postMessage, uuidv4())
|
||||
const server = new grpc.Server()
|
||||
@@ -44,6 +45,10 @@ async function main() {
|
||||
})
|
||||
}
|
||||
|
||||
const createWebview = () => {
|
||||
return new ExternalWebviewProvider(extensionContext, outputChannel, WebviewProviderType.SIDEBAR)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a Promise-based handler function to make it compatible with gRPC's callback-based API.
|
||||
* This function converts an async handler that returns a Promise into a function that uses
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { URI } from "vscode-uri"
|
||||
|
||||
import os from "os"
|
||||
import { mkdirSync, readFileSync } from "fs"
|
||||
import path, { join } from "path"
|
||||
import type { Extension, ExtensionContext } from "vscode"
|
||||
@@ -8,19 +8,15 @@ import { log } from "./utils"
|
||||
import { outputChannel, postMessage } from "./vscode-context-stubs"
|
||||
import { EnvironmentVariableCollection, MementoStore, readJson, SecretStore } from "./vscode-context-utils"
|
||||
|
||||
if (!process.env.CLINE_DIR) {
|
||||
console.warn("Environment variable CLINE_DIR was not set.")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const VERSION = getPackageVersion()
|
||||
log("Running standalone cline ", VERSION)
|
||||
|
||||
const DATA_DIR = path.join(process.env.CLINE_DIR, "data")
|
||||
const CLINE_DIR = process.env.CLINE_DIR || `${os.homedir()}/.cline`
|
||||
const DATA_DIR = path.join(CLINE_DIR, "data")
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
log("Using settings dir:", DATA_DIR)
|
||||
|
||||
const EXTENSION_DIR = path.join(process.env.CLINE_DIR, "core", VERSION, "extension")
|
||||
const EXTENSION_DIR = path.join(CLINE_DIR, "core", VERSION, "extension")
|
||||
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
|
||||
|
||||
const extension: Extension<void> = {
|
||||
|
||||
@@ -63,13 +63,8 @@ vscode.window = {
|
||||
registerWebviewViewProvider: () => ({ dispose: () => {} }),
|
||||
onDidChangeActiveTextEditor: () => ({ dispose: () => {} }),
|
||||
createTextEditorDecorationType: () => ({ dispose: () => {} }),
|
||||
createWebviewPanel: (...args) => {
|
||||
console.log("Stubbed createWebviewPanel:", ...args)
|
||||
return {
|
||||
webview: {},
|
||||
reveal: () => {},
|
||||
dispose: () => {},
|
||||
}
|
||||
createWebviewPanel: (..._args) => {
|
||||
throw new Error("WebviewPanel is not supported in standalone app.")
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Generated
-11
@@ -11,7 +11,6 @@
|
||||
"@floating-ui/react": "^0.27.4",
|
||||
"@heroui/react": "^2.8.0-beta.2",
|
||||
"@vscode/webview-ui-toolkit": "^1.4.0",
|
||||
"clsx": "^2.1.1",
|
||||
"debounce": "^2.1.1",
|
||||
"dompurify": "^3.2.4",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -38,7 +37,6 @@
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"styled-components": "^6.1.15",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"unified": "^11.0.5",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
@@ -15551,15 +15549,6 @@
|
||||
"integrity": "sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.0.tgz",
|
||||
"integrity": "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-variants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-1.0.0.tgz",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"@floating-ui/react": "^0.27.4",
|
||||
"@heroui/react": "^2.8.0-beta.2",
|
||||
"@vscode/webview-ui-toolkit": "^1.4.0",
|
||||
"clsx": "^2.1.1",
|
||||
"debounce": "^2.1.1",
|
||||
"dompurify": "^3.2.4",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -45,7 +44,6 @@
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"styled-components": "^6.1.15",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"unified": "^11.0.5",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
|
||||
@@ -42,30 +42,21 @@ export const ClineAccountView = () => {
|
||||
const [usageData, setUsageData] = useState<UsageTransaction[]>([])
|
||||
const [paymentsData, setPaymentsData] = useState<PaymentTransaction[]>([])
|
||||
|
||||
// Listen for balance and transaction data updates from the extension
|
||||
// Fetch all account data when component mounts using gRPC
|
||||
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)
|
||||
AccountServiceClient.fetchUserCreditsData(EmptyRequest.create())
|
||||
.then((response) => {
|
||||
setBalance(response.balance?.currentBalance || 0)
|
||||
setUsageData(response.usageTransactions)
|
||||
setPaymentsData(response.paymentTransactions)
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to fetch user credits data:", error)
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
}, [user])
|
||||
|
||||
@@ -135,7 +126,20 @@ export const ClineAccountView = () => {
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
className="mt-1"
|
||||
onClick={() => vscode.postMessage({ type: "fetchUserCreditsData" })}>
|
||||
onClick={() => {
|
||||
setIsLoading(true)
|
||||
AccountServiceClient.fetchUserCreditsData(EmptyRequest.create())
|
||||
.then((response) => {
|
||||
setBalance(response.balance?.currentBalance || 0)
|
||||
setUsageData(response.usageTransactions)
|
||||
setPaymentsData(response.paymentTransactions)
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to refresh user credits data:", error)
|
||||
setIsLoading(false)
|
||||
})
|
||||
}}>
|
||||
<span className="codicon codicon-refresh"></span>
|
||||
</VSCodeButton>
|
||||
</>
|
||||
|
||||
@@ -92,7 +92,7 @@ const CreditsHistoryTable = ({ isLoading, usageData, paymentsData }: CreditsHist
|
||||
<VSCodeDataGridCell grid-column="1">
|
||||
{formatTimestamp(row.paidAt)}
|
||||
</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell grid-column="2">{`$${formatDollars(parseInt(row.amountCents))}`}</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell grid-column="2">{`$${formatDollars(row.amountCents)}`}</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell grid-column="3">{`${row.credits}`}</VSCodeDataGridCell>
|
||||
</VSCodeDataGridRow>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { ReactNode } from "react"
|
||||
import { cn } from "@/utils/cn"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
import { OPENROUTER_MODEL_PICKER_Z_INDEX } from "../settings/OpenRouterModelPicker"
|
||||
@@ -22,7 +21,7 @@ export function AlertDialog({ open, onOpenChange, children }: AlertDialogProps)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(`fixed inset-0 bg-black/50 flex items-center justify-center`)}
|
||||
className={`fixed inset-0 bg-black/50 flex items-center justify-center`}
|
||||
onClick={handleBackdropClick}
|
||||
style={{ zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 50 }}>
|
||||
{children}
|
||||
@@ -33,10 +32,7 @@ export function AlertDialog({ open, onOpenChange, children }: AlertDialogProps)
|
||||
export function AlertDialogContent({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
`fixed top-[50%] left-[50%] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]`,
|
||||
className,
|
||||
)}
|
||||
className={`fixed top-[50%] left-[50%] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] ${className}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}>
|
||||
<div className="bg-[var(--vscode-editor-background)] rounded-sm gap-3 border border-[var(--vscode-panel-border)] p-4 shadow-lg sm:max-w-md">
|
||||
@@ -47,27 +43,24 @@ export function AlertDialogContent({ className, children, ...props }: React.HTML
|
||||
}
|
||||
|
||||
export function AlertDialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col gap-1 text-left", className)} {...props} />
|
||||
return <div className={`flex flex-col gap-1 text-left ${className}`} {...props} />
|
||||
}
|
||||
|
||||
export function AlertDialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-row justify-end gap-2 mt-4", className)} {...props} />
|
||||
return <div className={`flex flex-row justify-end gap-2 mt-4 ${className}`} {...props} />
|
||||
}
|
||||
|
||||
export function AlertDialogTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h2
|
||||
className={cn(
|
||||
"text-base font-medium text-[var(--vscode-editor-foreground)] flex items-center gap-2 text-left",
|
||||
className,
|
||||
)}
|
||||
className={`text-base font-medium text-[var(--vscode-editor-foreground)] flex items-center gap-2 text-left ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function AlertDialogDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("text-[var(--vscode-descriptionForeground)] text-sm text-left", className)} {...props} />
|
||||
return <p className={`text-[var(--vscode-descriptionForeground)] text-sm text-left ${className}`} {...props} />
|
||||
}
|
||||
|
||||
export function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof VSCodeButton>) {
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import React, { HTMLAttributes, useCallback, forwardRef } from "react"
|
||||
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { cn } from "@/utils/cn"
|
||||
|
||||
type TabProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const Tab = ({ className, children, ...props }: TabProps) => (
|
||||
<div className={cn("fixed inset-0 flex flex-col", className)} {...props}>
|
||||
<div className={`fixed inset-0 flex flex-col ${className}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const TabHeader = ({ className, children, ...props }: TabProps) => (
|
||||
<div className={cn("px-5 py-2.5 border-b border-[var(--vscode-panel-border)]", className)} {...props}>
|
||||
<div className={`px-5 py-2.5 border-b border-[var(--vscode-panel-border)] ${className}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -31,7 +30,7 @@ export const TabContent = ({ className, children, ...props }: TabProps) => {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={cn("flex-1 overflow-auto", className)} onWheel={onWheel} {...props}>
|
||||
<div className={`flex-1 overflow-auto ${className}`} onWheel={onWheel} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -53,7 +52,7 @@ export const TabList = forwardRef<
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={ref} role="tablist" className={cn("flex", className)} {...props}>
|
||||
<div ref={ref} role="tablist" className={`flex ${className}`} {...props}>
|
||||
{React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child)) {
|
||||
// Make sure we're passing the correct props to the TabTrigger
|
||||
@@ -83,7 +82,7 @@ export const TabTrigger = forwardRef<
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
className={cn("focus:outline-none", className)}
|
||||
className={`focus:outline-none ${className}`}
|
||||
onClick={onSelect}
|
||||
data-value={value} // Add data-value attribute for debugging
|
||||
{...props}>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ApiConfiguration,
|
||||
askSageDefaultURL,
|
||||
askSageModels,
|
||||
azureOpenAiDefaultApiVersion,
|
||||
bedrockDefaultModelId,
|
||||
bedrockModels,
|
||||
cerebrasModels,
|
||||
@@ -18,9 +17,7 @@ import {
|
||||
mainlandQwenModels,
|
||||
ModelInfo,
|
||||
nebiusModels,
|
||||
openAiModelInfoSaneDefaults,
|
||||
openAiNativeModels,
|
||||
sambanovaModels,
|
||||
vertexGlobalModels,
|
||||
vertexModels,
|
||||
xaiModels,
|
||||
@@ -55,6 +52,8 @@ import { OpenRouterProvider } from "./providers/OpenRouterProvider"
|
||||
import { MistralProvider } from "./providers/MistralProvider"
|
||||
import { DeepSeekProvider } from "./providers/DeepSeekProvider"
|
||||
import { TogetherProvider } from "./providers/TogetherProvider"
|
||||
import { OpenAICompatibleProvider } from "./providers/OpenAICompatible"
|
||||
import { SambanovaProvider } from "./providers/SambanovaProvider"
|
||||
|
||||
interface ApiOptionsProps {
|
||||
showModelOptions: boolean
|
||||
@@ -128,7 +127,6 @@ const ApiOptions = ({
|
||||
const [vsCodeLmModels, setVsCodeLmModels] = useState<vscodemodels.LanguageModelChatSelector[]>([])
|
||||
const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
|
||||
const [geminiBaseUrlSelected, setGeminiBaseUrlSelected] = useState(!!apiConfiguration?.geminiBaseUrl)
|
||||
const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion)
|
||||
const [awsEndpointSelected, setAwsEndpointSelected] = useState(!!apiConfiguration?.awsBedrockEndpoint)
|
||||
const [modelConfigurationSelected, setModelConfigurationSelected] = useState(false)
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
||||
@@ -253,36 +251,6 @@ const ApiOptions = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Debounced function to refresh OpenAI models (prevents excessive API calls while typing)
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const debouncedRefreshOpenAiModels = useCallback((baseUrl?: string, apiKey?: string) => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
if (baseUrl && apiKey) {
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
ModelsServiceClient.refreshOpenAiModels(
|
||||
OpenAiModelsRequest.create({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.error("Failed to refresh OpenAI models:", error)
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5, marginBottom: isPopup ? -10 : 0 }}>
|
||||
<DropdownContainer className="dropdown-container">
|
||||
@@ -594,6 +562,24 @@ const ApiOptions = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{apiConfiguration && selectedProvider === "openai" && (
|
||||
<OpenAICompatibleProvider
|
||||
apiConfiguration={apiConfiguration}
|
||||
handleInputChange={handleInputChange}
|
||||
showModelOptions={showModelOptions}
|
||||
isPopup={isPopup}
|
||||
/>
|
||||
)}
|
||||
|
||||
{apiConfiguration && selectedProvider === "sambanova" && (
|
||||
<SambanovaProvider
|
||||
apiConfiguration={apiConfiguration}
|
||||
handleInputChange={handleInputChange}
|
||||
showModelOptions={showModelOptions}
|
||||
isPopup={isPopup}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedProvider === "bedrock" && (
|
||||
<div
|
||||
style={{
|
||||
@@ -681,6 +667,8 @@ const ApiOptions = ({
|
||||
<VSCodeOption value="eu-west-2">eu-west-2</VSCodeOption>
|
||||
<VSCodeOption value="eu-west-3">eu-west-3</VSCodeOption>
|
||||
<VSCodeOption value="eu-north-1">eu-north-1</VSCodeOption>
|
||||
<VSCodeOption value="eu-south-1">eu-south-1</VSCodeOption>
|
||||
<VSCodeOption value="eu-south-2">eu-south-2</VSCodeOption>
|
||||
{/* <VSCodeOption value="me-south-1">me-south-1</VSCodeOption> */}
|
||||
<VSCodeOption value="sa-east-1">sa-east-1</VSCodeOption>
|
||||
<VSCodeOption value="us-gov-east-1">us-gov-east-1</VSCodeOption>
|
||||
@@ -973,341 +961,6 @@ const ApiOptions = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedProvider === "openai" && (
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.openAiBaseUrl || ""}
|
||||
style={{ width: "100%", marginBottom: 10 }}
|
||||
type="url"
|
||||
onInput={(e: any) => {
|
||||
const baseUrl = e.target.value
|
||||
handleInputChange("openAiBaseUrl")({ target: { value: baseUrl } })
|
||||
|
||||
debouncedRefreshOpenAiModels(baseUrl, apiConfiguration?.openAiApiKey)
|
||||
}}
|
||||
placeholder={"Enter base URL..."}>
|
||||
<span style={{ fontWeight: 500 }}>Base URL</span>
|
||||
</VSCodeTextField>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.openAiApiKey || ""}
|
||||
style={{ width: "100%", marginBottom: 10 }}
|
||||
type="password"
|
||||
onInput={(e: any) => {
|
||||
const apiKey = e.target.value
|
||||
handleInputChange("openAiApiKey")({ target: { value: apiKey } })
|
||||
|
||||
debouncedRefreshOpenAiModels(apiConfiguration?.openAiBaseUrl, apiKey)
|
||||
}}
|
||||
placeholder="Enter API Key...">
|
||||
<span style={{ fontWeight: 500 }}>API Key</span>
|
||||
</VSCodeTextField>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.openAiModelId || ""}
|
||||
style={{ width: "100%", marginBottom: 10 }}
|
||||
onInput={handleInputChange("openAiModelId")}
|
||||
placeholder={"Enter Model ID..."}>
|
||||
<span style={{ fontWeight: 500 }}>Model ID</span>
|
||||
</VSCodeTextField>
|
||||
|
||||
{/* OpenAI Compatible Custom Headers */}
|
||||
{(() => {
|
||||
const headerEntries = Object.entries(apiConfiguration?.openAiHeaders ?? {})
|
||||
return (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontWeight: 500 }}>Custom Headers</span>
|
||||
<VSCodeButton
|
||||
onClick={() => {
|
||||
const currentHeaders = { ...(apiConfiguration?.openAiHeaders || {}) }
|
||||
const headerCount = Object.keys(currentHeaders).length
|
||||
const newKey = `header${headerCount + 1}`
|
||||
currentHeaders[newKey] = ""
|
||||
handleInputChange("openAiHeaders")({
|
||||
target: {
|
||||
value: currentHeaders,
|
||||
},
|
||||
})
|
||||
}}>
|
||||
Add Header
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
<div>
|
||||
{headerEntries.map(([key, value], index) => (
|
||||
<div key={index} style={{ display: "flex", gap: 5, marginTop: 5 }}>
|
||||
<VSCodeTextField
|
||||
value={key}
|
||||
style={{ width: "40%" }}
|
||||
placeholder="Header name"
|
||||
onInput={(e: any) => {
|
||||
const currentHeaders = apiConfiguration?.openAiHeaders ?? {}
|
||||
const newValue = e.target.value
|
||||
if (newValue && newValue !== key) {
|
||||
const { [key]: _, ...rest } = currentHeaders
|
||||
handleInputChange("openAiHeaders")({
|
||||
target: {
|
||||
value: {
|
||||
...rest,
|
||||
[newValue]: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<VSCodeTextField
|
||||
value={value}
|
||||
style={{ width: "40%" }}
|
||||
placeholder="Header value"
|
||||
onInput={(e: any) => {
|
||||
handleInputChange("openAiHeaders")({
|
||||
target: {
|
||||
value: {
|
||||
...(apiConfiguration?.openAiHeaders ?? {}),
|
||||
[key]: e.target.value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
onClick={() => {
|
||||
const { [key]: _, ...rest } = apiConfiguration?.openAiHeaders ?? {}
|
||||
handleInputChange("openAiHeaders")({
|
||||
target: {
|
||||
value: rest,
|
||||
},
|
||||
})
|
||||
}}>
|
||||
Remove
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
<VSCodeCheckbox
|
||||
checked={azureApiVersionSelected}
|
||||
onChange={(e: any) => {
|
||||
const isChecked = e.target.checked === true
|
||||
setAzureApiVersionSelected(isChecked)
|
||||
if (!isChecked) {
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
azureApiVersion: "",
|
||||
})
|
||||
}
|
||||
}}>
|
||||
Set Azure API version
|
||||
</VSCodeCheckbox>
|
||||
{azureApiVersionSelected && (
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.azureApiVersion || ""}
|
||||
style={{ width: "100%", marginTop: 3 }}
|
||||
onInput={handleInputChange("azureApiVersion")}
|
||||
placeholder={`Default: ${azureOpenAiDefaultApiVersion}`}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
color: getAsVar(VSC_DESCRIPTION_FOREGROUND),
|
||||
display: "flex",
|
||||
margin: "10px 0",
|
||||
cursor: "pointer",
|
||||
alignItems: "center",
|
||||
}}
|
||||
onClick={() => setModelConfigurationSelected((val) => !val)}>
|
||||
<span
|
||||
className={`codicon ${modelConfigurationSelected ? "codicon-chevron-down" : "codicon-chevron-right"}`}
|
||||
style={{
|
||||
marginRight: "4px",
|
||||
}}></span>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
Model Configuration
|
||||
</span>
|
||||
</div>
|
||||
{modelConfigurationSelected && (
|
||||
<>
|
||||
<VSCodeCheckbox
|
||||
checked={!!apiConfiguration?.openAiModelInfo?.supportsImages}
|
||||
onChange={(e: any) => {
|
||||
const isChecked = e.target.checked === true
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo.supportsImages = isChecked
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
openAiModelInfo: modelInfo,
|
||||
})
|
||||
}}>
|
||||
Supports Images
|
||||
</VSCodeCheckbox>
|
||||
<VSCodeCheckbox
|
||||
checked={!!apiConfiguration?.openAiModelInfo?.supportsImages}
|
||||
onChange={(e: any) => {
|
||||
const isChecked = e.target.checked === true
|
||||
let modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo.supportsImages = isChecked
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
openAiModelInfo: modelInfo,
|
||||
})
|
||||
}}>
|
||||
Supports browser use
|
||||
</VSCodeCheckbox>
|
||||
<VSCodeCheckbox
|
||||
checked={!!apiConfiguration?.openAiModelInfo?.isR1FormatRequired}
|
||||
onChange={(e: any) => {
|
||||
const isChecked = e.target.checked === true
|
||||
let modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo = { ...modelInfo, isR1FormatRequired: isChecked }
|
||||
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
openAiModelInfo: modelInfo,
|
||||
})
|
||||
}}>
|
||||
Enable R1 messages format
|
||||
</VSCodeCheckbox>
|
||||
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
|
||||
<VSCodeTextField
|
||||
value={
|
||||
apiConfiguration?.openAiModelInfo?.contextWindow
|
||||
? apiConfiguration.openAiModelInfo.contextWindow.toString()
|
||||
: openAiModelInfoSaneDefaults.contextWindow?.toString()
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
onInput={(input: any) => {
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo.contextWindow = Number(input.target.value)
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
openAiModelInfo: modelInfo,
|
||||
})
|
||||
}}>
|
||||
<span style={{ fontWeight: 500 }}>Context Window Size</span>
|
||||
</VSCodeTextField>
|
||||
<VSCodeTextField
|
||||
value={
|
||||
apiConfiguration?.openAiModelInfo?.maxTokens
|
||||
? apiConfiguration.openAiModelInfo.maxTokens.toString()
|
||||
: openAiModelInfoSaneDefaults.maxTokens?.toString()
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
onInput={(input: any) => {
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo.maxTokens = input.target.value
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
openAiModelInfo: modelInfo,
|
||||
})
|
||||
}}>
|
||||
<span style={{ fontWeight: 500 }}>Max Output Tokens</span>
|
||||
</VSCodeTextField>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
|
||||
<VSCodeTextField
|
||||
value={
|
||||
apiConfiguration?.openAiModelInfo?.inputPrice
|
||||
? apiConfiguration.openAiModelInfo.inputPrice.toString()
|
||||
: openAiModelInfoSaneDefaults.inputPrice?.toString()
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
onInput={(input: any) => {
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo.inputPrice = input.target.value
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
openAiModelInfo: modelInfo,
|
||||
})
|
||||
}}>
|
||||
<span style={{ fontWeight: 500 }}>Input Price / 1M tokens</span>
|
||||
</VSCodeTextField>
|
||||
<VSCodeTextField
|
||||
value={
|
||||
apiConfiguration?.openAiModelInfo?.outputPrice
|
||||
? apiConfiguration.openAiModelInfo.outputPrice.toString()
|
||||
: openAiModelInfoSaneDefaults.outputPrice?.toString()
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
onInput={(input: any) => {
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo.outputPrice = input.target.value
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
openAiModelInfo: modelInfo,
|
||||
})
|
||||
}}>
|
||||
<span style={{ fontWeight: 500 }}>Output Price / 1M tokens</span>
|
||||
</VSCodeTextField>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
|
||||
<VSCodeTextField
|
||||
value={
|
||||
apiConfiguration?.openAiModelInfo?.temperature
|
||||
? apiConfiguration.openAiModelInfo.temperature.toString()
|
||||
: openAiModelInfoSaneDefaults.temperature?.toString()
|
||||
}
|
||||
onInput={(input: any) => {
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
|
||||
// Check if the input ends with a decimal point or has trailing zeros after decimal
|
||||
const value = input.target.value
|
||||
const shouldPreserveFormat =
|
||||
value.endsWith(".") || (value.includes(".") && value.endsWith("0"))
|
||||
|
||||
modelInfo.temperature =
|
||||
value === ""
|
||||
? openAiModelInfoSaneDefaults.temperature
|
||||
: shouldPreserveFormat
|
||||
? value // Keep as string to preserve decimal format
|
||||
: parseFloat(value)
|
||||
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
openAiModelInfo: modelInfo,
|
||||
})
|
||||
}}>
|
||||
<span style={{ fontWeight: 500 }}>Temperature</span>
|
||||
</VSCodeTextField>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: 3,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
<span style={{ color: "var(--vscode-errorForeground)" }}>
|
||||
(<span style={{ fontWeight: 500 }}>Note:</span> Cline uses complex prompts and works best with Claude
|
||||
models. Less capable models may not work as expected.)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedProvider === "requesty" && (
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
@@ -1893,37 +1546,6 @@ const ApiOptions = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedProvider === "sambanova" && (
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.sambanovaApiKey || ""}
|
||||
style={{ width: "100%" }}
|
||||
type="password"
|
||||
onInput={handleInputChange("sambanovaApiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<span style={{ fontWeight: 500 }}>SambaNova API Key</span>
|
||||
</VSCodeTextField>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: 3,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
This key is stored locally and only used to make API requests from this extension.
|
||||
{!apiConfiguration?.sambanovaApiKey && (
|
||||
<VSCodeLink
|
||||
href="https://docs.sambanova.ai/cloud/docs/get-started/overview"
|
||||
style={{
|
||||
display: "inline",
|
||||
fontSize: "inherit",
|
||||
}}>
|
||||
You can get a SambaNova API key by signing up here.
|
||||
</VSCodeLink>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedProvider === "cerebras" && (
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
@@ -2118,6 +1740,7 @@ const ApiOptions = ({
|
||||
selectedProvider !== "bedrock" &&
|
||||
selectedProvider !== "mistral" &&
|
||||
selectedProvider !== "deepseek" &&
|
||||
selectedProvider !== "sambanova" &&
|
||||
showModelOptions && (
|
||||
<>
|
||||
<DropdownContainer zIndex={DROPDOWN_Z_INDEX - 2} className="dropdown-container">
|
||||
@@ -2137,7 +1760,6 @@ const ApiOptions = ({
|
||||
{selectedProvider === "doubao" && createDropdown(doubaoModels)}
|
||||
{selectedProvider === "asksage" && createDropdown(askSageModels)}
|
||||
{selectedProvider === "xai" && createDropdown(xaiModels)}
|
||||
{selectedProvider === "sambanova" && createDropdown(sambanovaModels)}
|
||||
{selectedProvider === "cerebras" && createDropdown(cerebrasModels)}
|
||||
{selectedProvider === "nebius" && createDropdown(nebiusModels)}
|
||||
{selectedProvider === "sapaicore" && createDropdown(sapAiCoreModels)}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { HTMLAttributes } from "react"
|
||||
import { cn } from "@/utils/cn"
|
||||
|
||||
type SectionProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const Section = ({ className, ...props }: SectionProps) => (
|
||||
<div className={cn("flex flex-col gap-3 p-5 py-2", className)} {...props} />
|
||||
<div className={`flex flex-col gap-3 p-5 py-2 ${className || ""}`} {...props} />
|
||||
)
|
||||
|
||||
export default Section
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { HTMLAttributes } from "react"
|
||||
import { cn } from "@/utils/cn"
|
||||
|
||||
import { OPENROUTER_MODEL_PICKER_Z_INDEX } from "./OpenRouterModelPicker"
|
||||
|
||||
@@ -11,10 +10,7 @@ type SectionHeaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
export const SectionHeader = ({ description, children, className, ...props }: SectionHeaderProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
`sticky top-0 text-[var(--vscode-foreground)] bg-[var(--vscode-panel-background)] px-5 py-3`,
|
||||
className,
|
||||
)}
|
||||
className={`sticky top-0 text-[var(--vscode-foreground)] bg-[var(--vscode-panel-background)] px-5 py-3 ${className || ""}`}
|
||||
{...props}
|
||||
style={{ zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 20 }}>
|
||||
<h4 className="m-0">{children}</h4>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { UnsavedChangesDialog } from "@/components/common/AlertDialog"
|
||||
import HeroTooltip from "@/components/common/HeroTooltip"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { StateServiceClient } from "@/services/grpc-client"
|
||||
import { cn } from "@/utils/cn"
|
||||
import { validateApiConfiguration, validateModelId } from "@/utils/validate"
|
||||
import { vscode } from "@/utils/vscode"
|
||||
import { ExtensionMessage } from "@shared/ExtensionMessage"
|
||||
@@ -504,23 +503,22 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
</TabHeader>
|
||||
|
||||
{/* Vertical tabs layout */}
|
||||
<div ref={containerRef} className={cn(settingsTabsContainer, isCompactMode && "narrow")}>
|
||||
<div ref={containerRef} className={`${settingsTabsContainer} ${isCompactMode ? "narrow" : ""}`}>
|
||||
{/* Tab sidebar */}
|
||||
<TabList
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className={cn(settingsTabList)}
|
||||
className={settingsTabList}
|
||||
data-compact={isCompactMode}>
|
||||
{SETTINGS_TABS.map((tab) =>
|
||||
isCompactMode ? (
|
||||
<HeroTooltip key={tab.id} content={tab.tooltipText} placement="right">
|
||||
<div
|
||||
className={cn(
|
||||
className={`${
|
||||
activeTab === tab.id
|
||||
? `${settingsTabTrigger} ${settingsTabTriggerActive}`
|
||||
: settingsTabTrigger,
|
||||
"focus:ring-0",
|
||||
)}
|
||||
: settingsTabTrigger
|
||||
} focus:ring-0`}
|
||||
data-compact={isCompactMode}
|
||||
data-testid={`tab-${tab.id}`}
|
||||
data-value={tab.id}
|
||||
@@ -528,7 +526,7 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
console.log("Compact tab clicked:", tab.id)
|
||||
handleTabChange(tab.id)
|
||||
}}>
|
||||
<div className={cn("flex items-center gap-2", isCompactMode && "justify-center")}>
|
||||
<div className={`flex items-center gap-2 ${isCompactMode ? "justify-center" : ""}`}>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
<span className="tab-label">{tab.name}</span>
|
||||
</div>
|
||||
@@ -538,15 +536,14 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
<TabTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className={cn(
|
||||
className={`${
|
||||
activeTab === tab.id
|
||||
? `${settingsTabTrigger} ${settingsTabTriggerActive}`
|
||||
: settingsTabTrigger,
|
||||
"focus:ring-0",
|
||||
)}
|
||||
: settingsTabTrigger
|
||||
} focus:ring-0`}
|
||||
data-compact={isCompactMode}
|
||||
data-testid={`tab-${tab.id}`}>
|
||||
<div className={cn("flex items-center gap-2", isCompactMode && "justify-center")}>
|
||||
<div className={`flex items-center gap-2 ${isCompactMode ? "justify-center" : ""}`}>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
<span className="tab-label">{tab.name}</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import { ApiConfiguration, azureOpenAiDefaultApiVersion, openAiModelInfoSaneDefaults } from "@shared/api"
|
||||
import { OpenAiModelsRequest } from "@shared/proto/models"
|
||||
import { ModelsServiceClient } from "@/services/grpc-client"
|
||||
import { getAsVar, VSC_DESCRIPTION_FOREGROUND } from "@/utils/vscStyles"
|
||||
import { VSCodeTextField, VSCodeButton, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { ModelInfoView } from "../common/ModelInfoView"
|
||||
import { ApiKeyField } from "../common/ApiKeyField"
|
||||
import { BaseUrlField } from "../common/BaseUrlField"
|
||||
import { normalizeApiConfiguration } from "../utils/providerUtils"
|
||||
|
||||
/**
|
||||
* Props for the OpenAICompatibleProvider component
|
||||
*/
|
||||
interface OpenAICompatibleProviderProps {
|
||||
apiConfiguration: ApiConfiguration
|
||||
handleInputChange: (field: keyof ApiConfiguration) => (event: any) => void
|
||||
showModelOptions: boolean
|
||||
isPopup?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The OpenAI Compatible provider configuration component
|
||||
*/
|
||||
export const OpenAICompatibleProvider = ({
|
||||
apiConfiguration,
|
||||
handleInputChange,
|
||||
showModelOptions,
|
||||
isPopup,
|
||||
}: OpenAICompatibleProviderProps) => {
|
||||
const [modelConfigurationSelected, setModelConfigurationSelected] = useState(false)
|
||||
|
||||
// Get the normalized configuration
|
||||
const { selectedModelId, selectedModelInfo } = normalizeApiConfiguration(apiConfiguration)
|
||||
|
||||
// Debounced function to refresh OpenAI models (prevents excessive API calls while typing)
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const debouncedRefreshOpenAiModels = useCallback((baseUrl?: string, apiKey?: string) => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
if (baseUrl && apiKey) {
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
ModelsServiceClient.refreshOpenAiModels(
|
||||
OpenAiModelsRequest.create({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.error("Failed to refresh OpenAI models:", error)
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.openAiBaseUrl || ""}
|
||||
style={{ width: "100%", marginBottom: 10 }}
|
||||
type="url"
|
||||
onInput={(e: any) => {
|
||||
const baseUrl = e.target.value
|
||||
handleInputChange("openAiBaseUrl")({ target: { value: baseUrl } })
|
||||
|
||||
debouncedRefreshOpenAiModels(baseUrl, apiConfiguration?.openAiApiKey)
|
||||
}}
|
||||
placeholder={"Enter base URL..."}>
|
||||
<span style={{ fontWeight: 500 }}>Base URL</span>
|
||||
</VSCodeTextField>
|
||||
|
||||
<ApiKeyField
|
||||
value={apiConfiguration?.openAiApiKey || ""}
|
||||
onChange={(e: any) => {
|
||||
const apiKey = e.target.value
|
||||
handleInputChange("openAiApiKey")({ target: { value: apiKey } })
|
||||
|
||||
debouncedRefreshOpenAiModels(apiConfiguration?.openAiBaseUrl, apiKey)
|
||||
}}
|
||||
providerName="OpenAI Compatible"
|
||||
/>
|
||||
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.openAiModelId || ""}
|
||||
style={{ width: "100%", marginBottom: 10 }}
|
||||
onInput={handleInputChange("openAiModelId")}
|
||||
placeholder={"Enter Model ID..."}>
|
||||
<span style={{ fontWeight: 500 }}>Model ID</span>
|
||||
</VSCodeTextField>
|
||||
|
||||
{/* OpenAI Compatible Custom Headers */}
|
||||
{(() => {
|
||||
const headerEntries = Object.entries(apiConfiguration?.openAiHeaders ?? {})
|
||||
return (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontWeight: 500 }}>Custom Headers</span>
|
||||
<VSCodeButton
|
||||
onClick={() => {
|
||||
const currentHeaders = { ...(apiConfiguration?.openAiHeaders || {}) }
|
||||
const headerCount = Object.keys(currentHeaders).length
|
||||
const newKey = `header${headerCount + 1}`
|
||||
currentHeaders[newKey] = ""
|
||||
handleInputChange("openAiHeaders")({
|
||||
target: {
|
||||
value: currentHeaders,
|
||||
},
|
||||
})
|
||||
}}>
|
||||
Add Header
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
<div>
|
||||
{headerEntries.map(([key, value], index) => (
|
||||
<div key={index} style={{ display: "flex", gap: 5, marginTop: 5 }}>
|
||||
<VSCodeTextField
|
||||
value={key}
|
||||
style={{ width: "40%" }}
|
||||
placeholder="Header name"
|
||||
onInput={(e: any) => {
|
||||
const currentHeaders = apiConfiguration?.openAiHeaders ?? {}
|
||||
const newValue = e.target.value
|
||||
if (newValue && newValue !== key) {
|
||||
const { [key]: _, ...rest } = currentHeaders
|
||||
handleInputChange("openAiHeaders")({
|
||||
target: {
|
||||
value: {
|
||||
...rest,
|
||||
[newValue]: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<VSCodeTextField
|
||||
value={value}
|
||||
style={{ width: "40%" }}
|
||||
placeholder="Header value"
|
||||
onInput={(e: any) => {
|
||||
handleInputChange("openAiHeaders")({
|
||||
target: {
|
||||
value: {
|
||||
...(apiConfiguration?.openAiHeaders ?? {}),
|
||||
[key]: e.target.value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
onClick={() => {
|
||||
const { [key]: _, ...rest } = apiConfiguration?.openAiHeaders ?? {}
|
||||
handleInputChange("openAiHeaders")({
|
||||
target: {
|
||||
value: rest,
|
||||
},
|
||||
})
|
||||
}}>
|
||||
Remove
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
<BaseUrlField
|
||||
value={apiConfiguration?.azureApiVersion}
|
||||
onChange={(value) => handleInputChange("azureApiVersion")({ target: { value } })}
|
||||
label="Set Azure API version"
|
||||
placeholder={`Default: ${azureOpenAiDefaultApiVersion}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
color: getAsVar(VSC_DESCRIPTION_FOREGROUND),
|
||||
display: "flex",
|
||||
margin: "10px 0",
|
||||
cursor: "pointer",
|
||||
alignItems: "center",
|
||||
}}
|
||||
onClick={() => setModelConfigurationSelected((val) => !val)}>
|
||||
<span
|
||||
className={`codicon ${modelConfigurationSelected ? "codicon-chevron-down" : "codicon-chevron-right"}`}
|
||||
style={{
|
||||
marginRight: "4px",
|
||||
}}></span>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
Model Configuration
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{modelConfigurationSelected && (
|
||||
<>
|
||||
<VSCodeCheckbox
|
||||
checked={!!apiConfiguration?.openAiModelInfo?.supportsImages}
|
||||
onChange={(e: any) => {
|
||||
const isChecked = e.target.checked === true
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo.supportsImages = isChecked
|
||||
handleInputChange("openAiModelInfo")({
|
||||
target: { value: modelInfo },
|
||||
})
|
||||
}}>
|
||||
Supports Images
|
||||
</VSCodeCheckbox>
|
||||
|
||||
<VSCodeCheckbox
|
||||
checked={!!apiConfiguration?.openAiModelInfo?.supportsImages}
|
||||
onChange={(e: any) => {
|
||||
const isChecked = e.target.checked === true
|
||||
let modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo.supportsImages = isChecked
|
||||
handleInputChange("openAiModelInfo")({
|
||||
target: { value: modelInfo },
|
||||
})
|
||||
}}>
|
||||
Supports browser use
|
||||
</VSCodeCheckbox>
|
||||
|
||||
<VSCodeCheckbox
|
||||
checked={!!apiConfiguration?.openAiModelInfo?.isR1FormatRequired}
|
||||
onChange={(e: any) => {
|
||||
const isChecked = e.target.checked === true
|
||||
let modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo = { ...modelInfo, isR1FormatRequired: isChecked }
|
||||
|
||||
handleInputChange("openAiModelInfo")({
|
||||
target: { value: modelInfo },
|
||||
})
|
||||
}}>
|
||||
Enable R1 messages format
|
||||
</VSCodeCheckbox>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
|
||||
<VSCodeTextField
|
||||
value={
|
||||
apiConfiguration?.openAiModelInfo?.contextWindow
|
||||
? apiConfiguration.openAiModelInfo.contextWindow.toString()
|
||||
: openAiModelInfoSaneDefaults.contextWindow?.toString()
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
onInput={(input: any) => {
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo.contextWindow = Number(input.target.value)
|
||||
handleInputChange("openAiModelInfo")({
|
||||
target: { value: modelInfo },
|
||||
})
|
||||
}}>
|
||||
<span style={{ fontWeight: 500 }}>Context Window Size</span>
|
||||
</VSCodeTextField>
|
||||
|
||||
<VSCodeTextField
|
||||
value={
|
||||
apiConfiguration?.openAiModelInfo?.maxTokens
|
||||
? apiConfiguration.openAiModelInfo.maxTokens.toString()
|
||||
: openAiModelInfoSaneDefaults.maxTokens?.toString()
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
onInput={(input: any) => {
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo.maxTokens = input.target.value
|
||||
handleInputChange("openAiModelInfo")({
|
||||
target: { value: modelInfo },
|
||||
})
|
||||
}}>
|
||||
<span style={{ fontWeight: 500 }}>Max Output Tokens</span>
|
||||
</VSCodeTextField>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
|
||||
<VSCodeTextField
|
||||
value={
|
||||
apiConfiguration?.openAiModelInfo?.inputPrice
|
||||
? apiConfiguration.openAiModelInfo.inputPrice.toString()
|
||||
: openAiModelInfoSaneDefaults.inputPrice?.toString()
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
onInput={(input: any) => {
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo.inputPrice = input.target.value
|
||||
handleInputChange("openAiModelInfo")({
|
||||
target: { value: modelInfo },
|
||||
})
|
||||
}}>
|
||||
<span style={{ fontWeight: 500 }}>Input Price / 1M tokens</span>
|
||||
</VSCodeTextField>
|
||||
|
||||
<VSCodeTextField
|
||||
value={
|
||||
apiConfiguration?.openAiModelInfo?.outputPrice
|
||||
? apiConfiguration.openAiModelInfo.outputPrice.toString()
|
||||
: openAiModelInfoSaneDefaults.outputPrice?.toString()
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
onInput={(input: any) => {
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo.outputPrice = input.target.value
|
||||
handleInputChange("openAiModelInfo")({
|
||||
target: { value: modelInfo },
|
||||
})
|
||||
}}>
|
||||
<span style={{ fontWeight: 500 }}>Output Price / 1M tokens</span>
|
||||
</VSCodeTextField>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
|
||||
<VSCodeTextField
|
||||
value={
|
||||
apiConfiguration?.openAiModelInfo?.temperature
|
||||
? apiConfiguration.openAiModelInfo.temperature.toString()
|
||||
: openAiModelInfoSaneDefaults.temperature?.toString()
|
||||
}
|
||||
onInput={(input: any) => {
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
|
||||
// Check if the input ends with a decimal point or has trailing zeros after decimal
|
||||
const value = input.target.value
|
||||
const shouldPreserveFormat = value.endsWith(".") || (value.includes(".") && value.endsWith("0"))
|
||||
|
||||
modelInfo.temperature =
|
||||
value === ""
|
||||
? openAiModelInfoSaneDefaults.temperature
|
||||
: shouldPreserveFormat
|
||||
? value // Keep as string to preserve decimal format
|
||||
: parseFloat(value)
|
||||
|
||||
handleInputChange("openAiModelInfo")({
|
||||
target: { value: modelInfo },
|
||||
})
|
||||
}}>
|
||||
<span style={{ fontWeight: 500 }}>Temperature</span>
|
||||
</VSCodeTextField>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: 3,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
<span style={{ color: "var(--vscode-errorForeground)" }}>
|
||||
(<span style={{ fontWeight: 500 }}>Note:</span> Cline uses complex prompts and works best with Claude models.
|
||||
Less capable models may not work as expected.)
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{showModelOptions && (
|
||||
<ModelInfoView selectedModelId={selectedModelId} modelInfo={selectedModelInfo} isPopup={isPopup} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ApiConfiguration, sambanovaModels } from "@shared/api"
|
||||
import { ApiKeyField } from "../common/ApiKeyField"
|
||||
import { ModelSelector } from "../common/ModelSelector"
|
||||
import { ModelInfoView } from "../common/ModelInfoView"
|
||||
import { normalizeApiConfiguration } from "../utils/providerUtils"
|
||||
|
||||
/**
|
||||
* Props for the SambanovaProvider component
|
||||
*/
|
||||
interface SambanovaProviderProps {
|
||||
apiConfiguration: ApiConfiguration
|
||||
handleInputChange: (field: keyof ApiConfiguration) => (event: any) => void
|
||||
showModelOptions: boolean
|
||||
isPopup?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The SambaNova provider configuration component
|
||||
*/
|
||||
export const SambanovaProvider = ({ apiConfiguration, handleInputChange, showModelOptions, isPopup }: SambanovaProviderProps) => {
|
||||
// Get the normalized configuration
|
||||
const { selectedModelId, selectedModelInfo } = normalizeApiConfiguration(apiConfiguration)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ApiKeyField
|
||||
value={apiConfiguration?.sambanovaApiKey || ""}
|
||||
onChange={handleInputChange("sambanovaApiKey")}
|
||||
providerName="SambaNova"
|
||||
signupUrl="https://docs.sambanova.ai/cloud/docs/get-started/overview"
|
||||
/>
|
||||
|
||||
{showModelOptions && (
|
||||
<>
|
||||
<ModelSelector
|
||||
models={sambanovaModels}
|
||||
selectedModelId={selectedModelId}
|
||||
onChange={handleInputChange("apiModelId")}
|
||||
label="Model"
|
||||
/>
|
||||
|
||||
<ModelInfoView selectedModelId={selectedModelId} modelInfo={selectedModelInfo} isPopup={isPopup} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { McpMarketplaceCatalog, McpServer, McpViewTab } from "../../../src/shared/mcp"
|
||||
import { convertTextMateToHljs } from "../utils/textMateToHljs"
|
||||
import { OpenRouterCompatibleModelInfo } from "@shared/proto/models"
|
||||
import { UserInfo } from "@shared/proto/account"
|
||||
|
||||
interface ExtensionStateContextType extends ExtensionState {
|
||||
didHydrateState: boolean
|
||||
@@ -81,6 +82,7 @@ interface ExtensionStateContextType extends ExtensionState {
|
||||
|
||||
// Refresh functions
|
||||
refreshOpenRouterModels: () => void
|
||||
setUserInfo: (userInfo?: UserInfo) => void
|
||||
|
||||
// Navigation state setters
|
||||
setShowMcp: (value: boolean) => void
|
||||
@@ -835,6 +837,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
setTotalTasksSize,
|
||||
refreshOpenRouterModels,
|
||||
onRelinquishControl,
|
||||
setUserInfo: (userInfo?: UserInfo) => setState((prevState) => ({ ...prevState, userInfo })),
|
||||
}
|
||||
|
||||
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { EmptyRequest } from "@shared/proto/common"
|
||||
import { initializeApp } from "firebase/app"
|
||||
import { User, getAuth, signInWithCustomToken, signOut } from "firebase/auth"
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from "react"
|
||||
import { useExtensionState } from "./ExtensionStateContext"
|
||||
import { AuthStateChanged, AuthStateChangedRequest } from "@shared/proto/account"
|
||||
|
||||
// Firebase configuration from extension
|
||||
const firebaseConfig = {
|
||||
@@ -28,6 +30,7 @@ const FirebaseAuthContext = createContext<FirebaseAuthContextType | undefined>(u
|
||||
export const FirebaseAuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const { setUserInfo } = useExtensionState()
|
||||
|
||||
// Initialize Firebase
|
||||
const app = initializeApp(firebaseConfig)
|
||||
@@ -45,16 +48,23 @@ export const FirebaseAuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
return
|
||||
}
|
||||
// Sync auth state with extension
|
||||
vscode.postMessage({
|
||||
type: "authStateChanged",
|
||||
user: user
|
||||
? {
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
photoURL: user.photoURL,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
AccountServiceClient.authStateChanged(
|
||||
AuthStateChangedRequest.create({
|
||||
user: user
|
||||
? {
|
||||
displayName: user.displayName ?? undefined,
|
||||
email: user.email ?? undefined,
|
||||
photoURL: user.photoURL ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
)
|
||||
.then((response: AuthStateChanged) => {
|
||||
setUserInfo(response.user)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error updating auth state via gRPC:", error)
|
||||
})
|
||||
})
|
||||
|
||||
return () => unsubscribe()
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
/**
|
||||
* A utility function that combines clsx and tailwind-merge to handle class name merging
|
||||
* with proper Tailwind CSS conflict resolution.
|
||||
*
|
||||
* @param inputs - Class values to be merged
|
||||
* @returns A string of merged class names
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user