mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
323 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8986c43b7 | |||
| 995944c4f3 | |||
| 09a11a5db9 | |||
| 5dd9f8e151 | |||
| 616d56468f | |||
| efd28b3eeb | |||
| 997798fd0a | |||
| 7247877049 | |||
| 3859d11beb | |||
| 19c4439877 | |||
| ec01e1f19d | |||
| 4a0a40ead1 | |||
| 124569c391 | |||
| 5a08f977c3 | |||
| 4ec85af1fb | |||
| 00f88050f7 | |||
| c4f73e7752 | |||
| e84b8669a6 | |||
| 5b6eae2905 | |||
| c153077bde | |||
| 1796558733 | |||
| 2f7f8bff20 | |||
| dc9fb4ddb8 | |||
| f9c46f898f | |||
| 2d2eb5dd36 | |||
| 9562671bcc | |||
| 6a2aef27ea | |||
| c7d6173e20 | |||
| 62da7fe720 | |||
| f97f3e47cc | |||
| f47be1a7bb | |||
| 0cd2e69551 | |||
| 4b19010d56 | |||
| 49803bf958 | |||
| 33704dcdc1 | |||
| 78552287ae | |||
| a29689dfd3 | |||
| 2ca4738b59 | |||
| b4a2d5a06d | |||
| 7f2de3956c | |||
| 059b14e494 | |||
| 4d95817eec | |||
| d8c13db683 | |||
| a35e1ff8df | |||
| a0c5276fb1 | |||
| 696a2c4e48 | |||
| e983358dde | |||
| 9ae90c99ad | |||
| 4cd914970e | |||
| 4919424faf | |||
| e65fa89566 | |||
| 935a68fedc | |||
| 5c78f64978 | |||
| 328e49bd3c | |||
| 0f7c0205b6 | |||
| af0cd9bdff | |||
| 647a964853 | |||
| 58305dd6b3 | |||
| 88c33ee96d | |||
| 23a0d41646 | |||
| 974f698e54 | |||
| 12f16a47e3 | |||
| bc7d6f0c66 | |||
| c8b446ab6c | |||
| 2cc64973fb | |||
| 599c3f6717 | |||
| 1c491bf719 | |||
| 10ac28b9d6 | |||
| 95b2e327a8 | |||
| 53d11c88bb | |||
| 7ef0f6d1b6 | |||
| 9012d6a1fc | |||
| e3d363c56f | |||
| 62a04ad38a | |||
| 1774cf8b79 | |||
| 6133eb4864 | |||
| 087f1d3be3 | |||
| 8cc7759584 | |||
| f9d70ceb6c | |||
| e8bf106965 | |||
| b528640bd5 | |||
| 470778aa2f | |||
| 14f726c5e8 | |||
| 89e1086981 | |||
| c762583d38 | |||
| 73a340c75f | |||
| d3bb6ff456 | |||
| 93cd64f031 | |||
| 36836ad4d6 | |||
| 4ade5daeff | |||
| 24fe5c9c98 | |||
| 18d4a268e2 | |||
| 686f51c248 | |||
| 9aa04a376f | |||
| ca17a06618 | |||
| 48ea04f2ea | |||
| e11ad166e6 | |||
| 6b9c2a1a45 | |||
| bf0bbb4a16 | |||
| a8a24fcb62 | |||
| e570662d89 | |||
| 78f5faf6d2 | |||
| cc28851ba8 | |||
| 0afa4c4fd8 | |||
| 0f0512363f | |||
| ddc91a46c0 | |||
| 11a32d9a3a | |||
| 45ddaf99e1 | |||
| 6f21cb98cc | |||
| 544d4503ac | |||
| a5663e7cf8 | |||
| 09b0873e94 | |||
| e54623bc7e | |||
| 8909ee77fc | |||
| 3a3943623b | |||
| 2446b16828 | |||
| 30f1d72eb5 | |||
| c67edf4b91 | |||
| 07cf5cddad | |||
| eda2a60a69 | |||
| c1b9fcd05c | |||
| d470ba9150 | |||
| ea302ff4f1 | |||
| f11bddc4be | |||
| aceb7e8ddd | |||
| 8ca0568005 | |||
| 42b17cfbaa | |||
| 359dac9f20 | |||
| 3948b952cc | |||
| 867f36dabe | |||
| 822647c3e9 | |||
| fa0c4e5c36 | |||
| 59dc92c65c | |||
| 6972b2a975 | |||
| 973d18f4f3 | |||
| 5ab50a41b1 | |||
| d92292e405 | |||
| d0728c58af | |||
| 30f6a3ca29 | |||
| 6aad12dc01 | |||
| 9cd0a5ed6a | |||
| 311a4d6ee8 | |||
| 4e2f3d9b0e | |||
| bdccc40a0f | |||
| 5d10a0f89a | |||
| a41bb3a70a | |||
| 42e0c74c93 | |||
| ef76c8ba5a | |||
| 80542434c5 | |||
| 69bda35df8 | |||
| e3b87ae56f | |||
| daf88ba19e | |||
| 4759586a17 | |||
| 39359307dc | |||
| 9fa8a7a7a8 | |||
| 7eaf1a7cbc | |||
| 66911b89de | |||
| 0de047116f | |||
| 60cd10eec6 | |||
| 209d0830c6 | |||
| 8f938b34ab | |||
| ca4fdcf3c4 | |||
| a2572ea233 | |||
| b0b9a662e0 | |||
| 6033d2286c | |||
| 6c8accd585 | |||
| 14ed1c088c | |||
| 55e0586d95 | |||
| 5e5a6a6c21 | |||
| 7f5d03c258 | |||
| d584a8b0fb | |||
| 112eeddcd8 | |||
| 43f9541385 | |||
| ead395022d | |||
| ab47580073 | |||
| ae77bcec4a | |||
| 33f94728de | |||
| 49f0c92456 | |||
| 9c5a1fa917 | |||
| ffae0b9985 | |||
| b4b307e449 | |||
| 8900df7285 | |||
| faa5e4dd61 | |||
| 3a44bcf784 | |||
| ccba2ed8f8 | |||
| 0fc2f474a4 | |||
| 0ce66efc19 | |||
| e231cfd3e5 | |||
| 10f8637fb8 | |||
| bf985064b9 | |||
| 922183793f | |||
| 2c02547088 | |||
| df65f5f5dd | |||
| 542ce36a80 | |||
| 6baac79138 | |||
| 6a2172e156 | |||
| 3ca529dc62 | |||
| 7728c0304c | |||
| f837ecefeb | |||
| 2d5dcf6a14 | |||
| fcf6734949 | |||
| d44fd44a81 | |||
| d103f8ee62 | |||
| 3d34de4908 | |||
| dcd275480f | |||
| aea06ce659 | |||
| 65f582bb0d | |||
| f8796ce69a | |||
| 4d29e1f4bd | |||
| 499c7dfb9f | |||
| 57b36e16b8 | |||
| c891d24ef6 | |||
| 3f5b6f6e33 | |||
| dda8a6c829 | |||
| 0959438db8 | |||
| a838cf9a61 | |||
| 4e1dab8d4b | |||
| 93b6c937a5 | |||
| f555b83c7c | |||
| 21d4ec13e1 | |||
| 38e51bcf73 | |||
| f40ff25d1e | |||
| 29c01eb7d3 | |||
| 682fce458e | |||
| 34e87af5f1 | |||
| eaa76512fc | |||
| f608b914b8 | |||
| 9f5df9836c | |||
| 0cdbdb057e | |||
| 4846476f6c | |||
| a6f189c5c5 | |||
| ad8e0b19be | |||
| 22b2e50210 | |||
| af0d2f31a7 | |||
| 8313fa099c | |||
| 9f418b10f6 | |||
| 1b56c3fc11 | |||
| df7b458229 | |||
| 560cefecca | |||
| 12dbf1556b | |||
| b4149b5a18 | |||
| cee959eeec | |||
| 46121094d7 | |||
| 6fe1015310 | |||
| bb902f9b6b | |||
| dfeb57d3af | |||
| 9232d752b2 | |||
| d1a097cbd4 | |||
| a02eb40c31 | |||
| c87fb06f33 | |||
| eedae086a3 | |||
| c1d589f6be | |||
| d7e9ead730 | |||
| 9df45fb216 | |||
| d6184e9dac | |||
| d01b99463e | |||
| 3525592c61 | |||
| 1913b69ca5 | |||
| aa714f28e1 | |||
| eef04542d2 | |||
| 602d25075b | |||
| ea2f4080f8 | |||
| 10cde3671e | |||
| 43e939666d | |||
| ff0eb8fa77 | |||
| db4f6a72c3 | |||
| 39980e9be7 | |||
| 9b5cefd69d | |||
| 30a4a0e1f1 | |||
| e5d616b0bf | |||
| 5d8172a25f | |||
| 916660a390 | |||
| 4506eb39a6 | |||
| 202e3a6d05 | |||
| a229b8ce9d | |||
| d58c947c41 | |||
| 9449f5dcd1 | |||
| 7bc02926b4 | |||
| 5e65ea04c9 | |||
| e14fd78767 | |||
| 8c5f8b253b | |||
| db463dc1ef | |||
| a13aba4bd4 | |||
| 568932492b | |||
| 35a64a3666 | |||
| 3d19123e0b | |||
| 4f6605ccdb | |||
| 408c0887ac | |||
| b4e67afcba | |||
| 1c9da770a8 | |||
| 5f57c1f2f3 | |||
| f14542d61f | |||
| 131b245023 | |||
| c2809491b4 | |||
| 87670a37b8 | |||
| 0f1240063c | |||
| 45b13dd775 | |||
| a2749782ff | |||
| 1127e2a33c | |||
| 25ea46aa8d | |||
| b0446f7bac | |||
| f8e4bfdbeb | |||
| 9b30efefbe | |||
| 82f1f79b3e | |||
| 00506c0fcf | |||
| 45e1f58bbc | |||
| cd3f3e2150 | |||
| ab8376fee4 | |||
| ff7e5c8866 | |||
| 26be9469c4 | |||
| a925e672ed | |||
| 2491b469e7 | |||
| 3fd1f16acb | |||
| 0a14f0bad5 | |||
| d72127550d | |||
| 97fa8cffb9 | |||
| d6f51b4525 | |||
| c846bc4455 | |||
| c134291ba0 | |||
| 9b66eac9fe | |||
| c756470c51 | |||
| b059b3ac1e | |||
| 7ae437fb94 |
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
Add Bedrock prompt caching support (optional).
|
||||
|
||||
This feature protected under checkbox because it is not yet rolled out to everyone, and if you will try to send cache headers, and its not enabled for you, you will get error.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
feat(bedrock): adding Amazon Nova
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Improve file handling for NextJS folder naming conventions and increase file listing limits. Fix glob pattern interpretation issues with parentheses in folder names
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Handle input too large Anthropic
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix "See more" not showing up for tasks after task un-fold
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix gpt-4.5-preview's supportsPromptCache value to true
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Can test on WebIDE
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Added a script to create test tasks in dev mode
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
updated move context management out of cline
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Added support for SambaNova QwQ-32B model
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add OpenAI "dynamic" model chatgpt-4o-latest
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Unit tests
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
DangerButton.tsx to Tailwind
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
feat(bedrock): adding two regions
|
||||
+517
@@ -0,0 +1,517 @@
|
||||
# Cline Extension Architecture & Development Guide
|
||||
|
||||
## Project Overview
|
||||
|
||||
Cline is a VSCode extension that provides AI assistance through a combination of a core extension backend and a React-based webview frontend. The extension is built with TypeScript and follows a modular architecture pattern.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph VSCode Extension Host
|
||||
subgraph Core Extension
|
||||
ExtensionEntry[Extension Entry<br/>src/extension.ts]
|
||||
ClineProvider[ClineProvider<br/>src/core/webview/ClineProvider.ts]
|
||||
ClineClass[Cline Class<br/>src/core/Cline.ts]
|
||||
GlobalState[VSCode Global State]
|
||||
SecretsStorage[VSCode Secrets Storage]
|
||||
end
|
||||
|
||||
subgraph Webview UI
|
||||
WebviewApp[React App<br/>webview-ui/src/App.tsx]
|
||||
ExtStateContext[ExtensionStateContext<br/>webview-ui/src/context/ExtensionStateContext.tsx]
|
||||
ReactComponents[React Components]
|
||||
end
|
||||
|
||||
subgraph Storage
|
||||
TaskStorage[Task Storage<br/>Per-Task Files & History]
|
||||
CheckpointSystem[Git-based Checkpoints]
|
||||
end
|
||||
end
|
||||
|
||||
%% Core Extension Data Flow
|
||||
ExtensionEntry --> ClineProvider
|
||||
ClineProvider --> ClineClass
|
||||
ClineClass --> GlobalState
|
||||
ClineClass --> SecretsStorage
|
||||
ClineClass --> TaskStorage
|
||||
ClineClass --> CheckpointSystem
|
||||
|
||||
%% Webview Data Flow
|
||||
WebviewApp --> ExtStateContext
|
||||
ExtStateContext --> ReactComponents
|
||||
|
||||
%% Bidirectional Communication
|
||||
ClineProvider <-->|postMessage| ExtStateContext
|
||||
|
||||
style GlobalState fill:#f9f,stroke:#333,stroke-width:2px
|
||||
style SecretsStorage fill:#f9f,stroke:#333,stroke-width:2px
|
||||
style ExtStateContext fill:#bbf,stroke:#333,stroke-width:2px
|
||||
style ClineProvider fill:#bfb,stroke:#333,stroke-width:2px
|
||||
```
|
||||
|
||||
## Definitions
|
||||
|
||||
- core extension: Anything inside the src folder starting with the Cline.ts file
|
||||
- core extension state: Managed by the ClineProvider class in src/core/webview/ClineProvider.ts, which serves as the single source of truth for the extension's state. It manages multiple types of persistent storage (global state, workspace state, and secrets), handles state distribution to both the core extension and webview components, and coordinates state across multiple extension instances. This includes managing API configurations, task history, settings, and MCP configurations.
|
||||
- webview: Anything inside the webview-ui. All the react or view's seen by the user and user interaction compone
|
||||
- webview state: Managed by ExtensionStateContext in webview-ui/src/context/ExtensionStateContext.tsx, which provides React components with access to the extension's state through a context provider pattern. It maintains local state for UI components, handles real-time updates through message events, manages partial message updates, and provides methods for state modifications. The context includes extension version, messages, task history, theme, API configurations, MCP servers, marketplace catalog, and workspace file paths. It synchronizes with the core extension through VSCode's message passing system and provides type-safe access to state through a custom hook (useExtensionState).
|
||||
|
||||
### Core Extension State
|
||||
|
||||
The `ClineProvider` class manages multiple types of persistent storage:
|
||||
|
||||
- **Global State:** Stored across all VSCode instances. Used for settings and data that should persist globally.
|
||||
- **Workspace State:** Specific to the current workspace. Used for task-specific data and settings.
|
||||
- **Secrets:** Secure storage for sensitive information like API keys.
|
||||
|
||||
The `ClineProvider` handles the distribution of state to both the core extension and webview components. It also coordinates state across multiple extension instances, ensuring consistency.
|
||||
|
||||
### Webview State
|
||||
|
||||
The `ExtensionStateContext` in `webview-ui/src/context/ExtensionStateContext.tsx` provides React components with access to the extension's state. It uses a context provider pattern and maintains local state for UI components. The context includes:
|
||||
|
||||
- Extension version
|
||||
- Messages
|
||||
- Task history
|
||||
- Theme
|
||||
- API configurations
|
||||
- MCP servers
|
||||
- Marketplace catalog
|
||||
- Workspace file paths
|
||||
|
||||
It synchronizes with the core extension through VSCode's message passing system and provides type-safe access to the state via a custom hook (`useExtensionState`).
|
||||
|
||||
## Core Extension (Cline.ts)
|
||||
|
||||
The Cline class is the heart of the extension, managing task execution, state persistence, and tool coordination. Each task runs in its own instance of the Cline class, ensuring isolation and proper state management.
|
||||
|
||||
### Task Execution Loop
|
||||
|
||||
The core task execution loop follows this pattern:
|
||||
|
||||
```typescript
|
||||
class Cline {
|
||||
async initiateTaskLoop(userContent: UserContent, isNewTask: boolean) {
|
||||
while (!this.abort) {
|
||||
// 1. Make API request and stream response
|
||||
const stream = this.attemptApiRequest()
|
||||
|
||||
// 2. Parse and present content blocks
|
||||
for await (const chunk of stream) {
|
||||
switch (chunk.type) {
|
||||
case "text":
|
||||
// Parse into content blocks
|
||||
this.assistantMessageContent = parseAssistantMessage(chunk.text)
|
||||
// Present blocks to user
|
||||
await this.presentAssistantMessage()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Wait for tool execution to complete
|
||||
await pWaitFor(() => this.userMessageContentReady)
|
||||
|
||||
// 4. Continue loop with tool result
|
||||
const recDidEndLoop = await this.recursivelyMakeClineRequests(
|
||||
this.userMessageContent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Message Streaming System
|
||||
|
||||
The streaming system handles real-time updates and partial content:
|
||||
|
||||
```typescript
|
||||
class Cline {
|
||||
async presentAssistantMessage() {
|
||||
// Handle streaming locks to prevent race conditions
|
||||
if (this.presentAssistantMessageLocked) {
|
||||
this.presentAssistantMessageHasPendingUpdates = true
|
||||
return
|
||||
}
|
||||
this.presentAssistantMessageLocked = true
|
||||
|
||||
// Present current content block
|
||||
const block = this.assistantMessageContent[this.currentStreamingContentIndex]
|
||||
|
||||
// Handle different types of content
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
await this.say("text", content, undefined, block.partial)
|
||||
break
|
||||
case "tool_use":
|
||||
// Handle tool execution
|
||||
break
|
||||
}
|
||||
|
||||
// Move to next block if complete
|
||||
if (!block.partial) {
|
||||
this.currentStreamingContentIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tool Execution Flow
|
||||
|
||||
Tools follow a strict execution pattern:
|
||||
|
||||
```typescript
|
||||
class Cline {
|
||||
async executeToolWithApproval(block: ToolBlock) {
|
||||
// 1. Check auto-approval settings
|
||||
if (this.shouldAutoApproveTool(block.name)) {
|
||||
await this.say("tool", message)
|
||||
this.consecutiveAutoApprovedRequestsCount++
|
||||
} else {
|
||||
// 2. Request user approval
|
||||
const didApprove = await askApproval("tool", message)
|
||||
if (!didApprove) {
|
||||
this.didRejectTool = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Execute tool
|
||||
const result = await this.executeTool(block)
|
||||
|
||||
// 4. Save checkpoint
|
||||
await this.saveCheckpoint()
|
||||
|
||||
// 5. Return result to API
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling & Recovery
|
||||
|
||||
The system includes robust error handling:
|
||||
|
||||
```typescript
|
||||
class Cline {
|
||||
async handleError(action: string, error: Error) {
|
||||
// 1. Check if task was abandoned
|
||||
if (this.abandoned) return
|
||||
|
||||
// 2. Format error message
|
||||
const errorString = `Error ${action}: ${error.message}`
|
||||
|
||||
// 3. Present error to user
|
||||
await this.say("error", errorString)
|
||||
|
||||
// 4. Add error to tool results
|
||||
pushToolResult(formatResponse.toolError(errorString))
|
||||
|
||||
// 5. Cleanup resources
|
||||
await this.diffViewProvider.revertChanges()
|
||||
await this.browserSession.closeBrowser()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Request & Token Management
|
||||
|
||||
The Cline class handles API requests with built-in retry, streaming, and token management:
|
||||
|
||||
```typescript
|
||||
class Cline {
|
||||
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
|
||||
// 1. Wait for MCP servers to connect
|
||||
await pWaitFor(() => this.providerRef.deref()?.mcpHub?.isConnecting !== true)
|
||||
|
||||
// 2. Manage context window
|
||||
const previousRequest = this.clineMessages[previousApiReqIndex]
|
||||
if (previousRequest?.text) {
|
||||
const { tokensIn, tokensOut } = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0)
|
||||
|
||||
// Truncate conversation if approaching context limit
|
||||
if (totalTokens >= maxAllowedSize) {
|
||||
this.conversationHistoryDeletedRange = getNextTruncationRange(
|
||||
this.apiConversationHistory,
|
||||
this.conversationHistoryDeletedRange,
|
||||
totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Handle streaming with automatic retry
|
||||
try {
|
||||
this.isWaitingForFirstChunk = true
|
||||
const firstChunk = await iterator.next()
|
||||
yield firstChunk.value
|
||||
this.isWaitingForFirstChunk = false
|
||||
|
||||
// Stream remaining chunks
|
||||
yield* iterator
|
||||
} catch (error) {
|
||||
// 4. Error handling with retry
|
||||
if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
|
||||
await setTimeoutPromise(1000)
|
||||
this.didAutomaticallyRetryFailedApiRequest = true
|
||||
yield* this.attemptApiRequest(previousApiReqIndex)
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Ask user to retry if automatic retry failed
|
||||
const { response } = await this.ask(
|
||||
"api_req_failed",
|
||||
this.formatErrorWithStatusCode(error)
|
||||
)
|
||||
if (response === "yesButtonClicked") {
|
||||
await this.say("api_req_retried")
|
||||
yield* this.attemptApiRequest(previousApiReqIndex)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key features:
|
||||
|
||||
1. **Context Window Management**
|
||||
- Tracks token usage across requests
|
||||
- Automatically truncates conversation when needed
|
||||
- Preserves important context while freeing space
|
||||
- Handles different model context sizes
|
||||
|
||||
2. **Streaming Architecture**
|
||||
- Real-time chunk processing
|
||||
- Partial content handling
|
||||
- Race condition prevention
|
||||
- Error recovery during streaming
|
||||
|
||||
3. **Error Handling**
|
||||
- Automatic retry for transient failures
|
||||
- User-prompted retry for persistent issues
|
||||
- Detailed error reporting
|
||||
- State cleanup on failure
|
||||
|
||||
4. **Token Tracking**
|
||||
- Per-request token counting
|
||||
- Cumulative usage tracking
|
||||
- Cost calculation
|
||||
- Cache hit monitoring
|
||||
|
||||
### Task State & Resumption
|
||||
|
||||
The Cline class provides robust task state management and resumption capabilities:
|
||||
|
||||
```typescript
|
||||
class Cline {
|
||||
async resumeTaskFromHistory() {
|
||||
// 1. Load saved state
|
||||
this.clineMessages = await this.getSavedClineMessages()
|
||||
this.apiConversationHistory = await this.getSavedApiConversationHistory()
|
||||
|
||||
// 2. Handle interrupted tool executions
|
||||
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
|
||||
if (lastMessage.role === "assistant") {
|
||||
const toolUseBlocks = content.filter(block => block.type === "tool_use")
|
||||
if (toolUseBlocks.length > 0) {
|
||||
// Add interrupted tool responses
|
||||
const toolResponses = toolUseBlocks.map(block => ({
|
||||
type: "tool_result",
|
||||
tool_use_id: block.id,
|
||||
content: "Task was interrupted before this tool call could be completed."
|
||||
}))
|
||||
modifiedOldUserContent = [...toolResponses]
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Notify about interruption
|
||||
const agoText = this.getTimeAgoText(lastMessage?.ts)
|
||||
newUserContent.push({
|
||||
type: "text",
|
||||
text: `[TASK RESUMPTION] This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context.`
|
||||
})
|
||||
|
||||
// 4. Resume task execution
|
||||
await this.initiateTaskLoop(newUserContent, false)
|
||||
}
|
||||
|
||||
private async saveTaskState() {
|
||||
// Save conversation history
|
||||
await this.saveApiConversationHistory()
|
||||
await this.saveClineMessages()
|
||||
|
||||
// Create checkpoint
|
||||
const commitHash = await this.checkpointTracker?.commit()
|
||||
|
||||
// Update task history
|
||||
await this.providerRef.deref()?.updateTaskHistory({
|
||||
id: this.taskId,
|
||||
ts: lastMessage.ts,
|
||||
task: taskMessage.text,
|
||||
// ... other metadata
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key aspects of task state management:
|
||||
|
||||
1. **Task Persistence**
|
||||
- Each task has a unique ID and dedicated storage directory
|
||||
- Conversation history is saved after each message
|
||||
- File changes are tracked through Git-based checkpoints
|
||||
- Terminal output and browser state are preserved
|
||||
|
||||
2. **State Recovery**
|
||||
- Tasks can be resumed from any point
|
||||
- Interrupted tool executions are handled gracefully
|
||||
- File changes can be restored from checkpoints
|
||||
- Context is preserved across VSCode sessions
|
||||
|
||||
3. **Workspace Synchronization**
|
||||
- File changes are tracked through Git
|
||||
- Checkpoints are created after tool executions
|
||||
- State can be restored to any checkpoint
|
||||
- Changes can be compared between checkpoints
|
||||
|
||||
4. **Error Recovery**
|
||||
- Failed API requests can be retried
|
||||
- Interrupted tool executions are marked
|
||||
- Resources are cleaned up properly
|
||||
- User is notified of state changes
|
||||
|
||||
## Data Flow & State Management
|
||||
|
||||
### Core Extension Role
|
||||
|
||||
The core extension (ClineProvider) acts as the single source of truth for all persistent state. It:
|
||||
- Manages VSCode global state and secrets storage
|
||||
- Coordinates state updates between components
|
||||
- Ensures state consistency across webview reloads
|
||||
- Handles task-specific state persistence
|
||||
- Manages checkpoint creation and restoration
|
||||
|
||||
### Terminal Management
|
||||
|
||||
The Cline class manages terminal instances and command execution:
|
||||
|
||||
```typescript
|
||||
class Cline {
|
||||
async executeCommandTool(command: string): Promise<[boolean, ToolResponse]> {
|
||||
// 1. Get or create terminal
|
||||
const terminalInfo = await this.terminalManager.getOrCreateTerminal(cwd)
|
||||
terminalInfo.terminal.show()
|
||||
|
||||
// 2. Execute command with output streaming
|
||||
const process = this.terminalManager.runCommand(terminalInfo, command)
|
||||
|
||||
// 3. Handle real-time output
|
||||
let result = ""
|
||||
process.on("line", (line) => {
|
||||
result += line + "\n"
|
||||
if (!didContinue) {
|
||||
sendCommandOutput(line)
|
||||
} else {
|
||||
this.say("command_output", line)
|
||||
}
|
||||
})
|
||||
|
||||
// 4. Wait for completion or user feedback
|
||||
let completed = false
|
||||
process.once("completed", () => {
|
||||
completed = true
|
||||
})
|
||||
|
||||
await process
|
||||
|
||||
// 5. Return result
|
||||
if (completed) {
|
||||
return [false, `Command executed.\n${result}`]
|
||||
} else {
|
||||
return [
|
||||
false,
|
||||
`Command is still running in the user's terminal.\n${result}\n\nYou will be updated on the terminal status and new output in the future.`
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key features:
|
||||
1. **Terminal Instance Management**
|
||||
- Multiple terminal support
|
||||
- Terminal state tracking (busy/inactive)
|
||||
- Process cooldown monitoring
|
||||
- Output history per terminal
|
||||
|
||||
2. **Command Execution**
|
||||
- Real-time output streaming
|
||||
- User feedback handling
|
||||
- Process state monitoring
|
||||
- Error recovery
|
||||
|
||||
### Browser Session Management
|
||||
|
||||
The Cline class handles browser automation through Puppeteer:
|
||||
|
||||
```typescript
|
||||
class Cline {
|
||||
async executeBrowserAction(action: BrowserAction): Promise<BrowserActionResult> {
|
||||
switch (action) {
|
||||
case "launch":
|
||||
// 1. Launch browser with fixed resolution
|
||||
await this.browserSession.launchBrowser()
|
||||
return await this.browserSession.navigateToUrl(url)
|
||||
|
||||
case "click":
|
||||
// 2. Handle click actions with coordinates
|
||||
return await this.browserSession.click(coordinate)
|
||||
|
||||
case "type":
|
||||
// 3. Handle keyboard input
|
||||
return await this.browserSession.type(text)
|
||||
|
||||
case "close":
|
||||
// 4. Clean up resources
|
||||
return await this.browserSession.closeBrowser()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key aspects:
|
||||
1. **Browser Control**
|
||||
- Fixed 900x600 resolution window
|
||||
- Single instance per task lifecycle
|
||||
- Automatic cleanup on task completion
|
||||
- Console log capture
|
||||
|
||||
2. **Interaction Handling**
|
||||
- Coordinate-based clicking
|
||||
- Keyboard input simulation
|
||||
- Screenshot capture
|
||||
- Error recovery
|
||||
|
||||
## Conclusion
|
||||
|
||||
This guide provides a comprehensive overview of the Cline extension architecture, with special focus on state management, data persistence, and code organization. Following these patterns ensures robust feature implementation with proper state handling across the extension's components.
|
||||
|
||||
Remember:
|
||||
- Always persist important state in the extension
|
||||
- The core extension exists in the src/ folder
|
||||
- Use proper typing for all state and messages
|
||||
- Handle errors and edge cases
|
||||
- Test state persistence across webview reloads
|
||||
- Follow the established patterns for consistency
|
||||
- Place new code in appropriate directories
|
||||
- Maintain clear separation of concerns
|
||||
- Install dependencies in correct package.json
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions to the Cline extension are welcome! Please follow these guidelines:
|
||||
|
||||
When adding new tools or API providers, follow the existing patterns in the `src/integrations/` and `src/api/providers/` directories, respectively. Ensure that your code is well-documented and includes appropriate error handling.
|
||||
|
||||
The `.clineignore` file allows users to specify files and directories that Cline should not access. When implementing new features, respect the `.clineignore` rules and ensure that your code does not attempt to read or modify ignored files.
|
||||
+1
-1
@@ -1 +1 @@
|
||||
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash
|
||||
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett @saito-sv
|
||||
|
||||
@@ -28,10 +28,7 @@ updates:
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
# Ignore CRA and related packages that often have false positives
|
||||
- dependency-name: "react-scripts"
|
||||
- dependency-name: "@testing-library/*"
|
||||
- dependency-name: "web-vitals"
|
||||
- dependency-name: "*"
|
||||
update-types:
|
||||
- "version-update:semver-major"
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
name: Changeset Release
|
||||
run-name: Changeset Release ${{ github.actor != 'cline-bot' && '- Create PR' || '- Update Changelog' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [closed, opened, labeled]
|
||||
|
||||
env:
|
||||
REPO_PATH: ${{ github.repository }}
|
||||
GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
|
||||
|
||||
jobs:
|
||||
# Job 1: Create version bump PR when changesets are merged to main
|
||||
changeset-pr-version-bump:
|
||||
if: >
|
||||
( github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.base.ref == 'main' &&
|
||||
github.actor != 'cline-bot' ) ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ env.GIT_REF }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm run install:all
|
||||
|
||||
# Check if there are any new changesets to process
|
||||
- name: Check for changesets
|
||||
id: check-changesets
|
||||
run: |
|
||||
NEW_CHANGESETS=$(find .changeset -name "*.md" ! -name "README.md" | wc -l | tr -d ' ')
|
||||
echo "Changesets diff with previous version: $NEW_CHANGESETS"
|
||||
echo "new_changesets=$NEW_CHANGESETS" >> $GITHUB_OUTPUT
|
||||
|
||||
# Create version bump PR using changesets/action if there are new changesets
|
||||
- name: Changeset Pull Request
|
||||
if: steps.check-changesets.outputs.new_changesets != '0'
|
||||
id: changesets
|
||||
uses: changesets/action@e9cc34b540dd3ad1b030c57fd97269e8f6ad905a # v1
|
||||
with:
|
||||
commit: "changeset version bump"
|
||||
title: "Changeset version bump"
|
||||
version: npm run version-packages # This performs the changeset version bump
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Job 2: Process version bump PR created by cline-bot
|
||||
changeset-pr-edit-approve:
|
||||
name: Auto approve and merge Bump version PRs
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
if: >
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.base.ref == 'main' &&
|
||||
github.actor == 'cline-bot' &&
|
||||
contains(github.event.pull_request.title, 'Changeset version bump')
|
||||
steps:
|
||||
- name: Determine checkout ref
|
||||
id: checkout-ref
|
||||
run: |
|
||||
echo "Event action: ${{ github.event.action }}"
|
||||
echo "Actor: ${{ github.actor }}"
|
||||
echo "Head ref: ${{ github.head_ref }}"
|
||||
echo "PR SHA: ${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
if [[ "${{ github.event.action }}" == "opened" && "${{ github.actor }}" == "cline-bot" ]]; then
|
||||
echo "Using branch ref: ${{ github.head_ref }}"
|
||||
echo "git_ref=${{ github.head_ref }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Using SHA ref: ${{ github.event.pull_request.head.sha }}"
|
||||
echo "git_ref=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
ref: ${{ steps.checkout-ref.outputs.git_ref }}
|
||||
|
||||
# Get current and previous versions to edit changelog entry
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(git show HEAD:package.json | jq -r '.version')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
PREV_VERSION=$(git show origin/main:package.json | jq -r '.version')
|
||||
echo "prev_version=$PREV_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION"
|
||||
echo "prev_version=$PREV_VERSION"
|
||||
|
||||
# Update CHANGELOG.md with proper format
|
||||
- name: Update Changelog Format
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-ready') }}
|
||||
env:
|
||||
VERSION: ${{ steps.get_version.outputs.version }}
|
||||
PREV_VERSION: ${{ steps.get_version.outputs.prev_version }}
|
||||
run: python .github/scripts/overwrite_changeset_changelog.py
|
||||
|
||||
# Commit and push changelog updates
|
||||
- name: Push Changelog updates
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-ready') }}
|
||||
run: |
|
||||
git config user.name "cline-bot"
|
||||
git config user.email github-actions@github.com
|
||||
echo "Running git add and commit..."
|
||||
git add CHANGELOG.md
|
||||
git commit -m "Updating CHANGELOG.md format"
|
||||
git status
|
||||
echo "--------------------------------------------------------------------------------"
|
||||
echo "Pushing to remote..."
|
||||
echo "--------------------------------------------------------------------------------"
|
||||
git push
|
||||
|
||||
# Add label to indicate changelog has been formatted
|
||||
- name: Add changelog-ready label
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-ready') }}
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['changelog-ready']
|
||||
});
|
||||
|
||||
# Auto-approve PR only after it has been labeled
|
||||
- name: Auto approve PR
|
||||
if: contains(github.event.pull_request.labels.*.name, 'changelog-ready')
|
||||
uses: hmarr/auto-approve-action@de8bf34d0402c38aa2c8346973342b2cb02c4435 # v4
|
||||
with:
|
||||
review-message: "I'm approving since it's a bump version PR"
|
||||
|
||||
# Auto-merge PR
|
||||
- name: Automerge on PR
|
||||
if: false # Needs enablePullRequestAutoMerge in repo settings to work contains(github.event.pull_request.labels.*.name, 'changelog-ready')
|
||||
run: gh pr merge --auto --merge ${{ github.event.pull_request.number }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
+3
-1
@@ -7,4 +7,6 @@ tmp
|
||||
|
||||
.DS_Store
|
||||
|
||||
pnpm-lock.yaml
|
||||
pnpm-lock.yaml
|
||||
|
||||
.clineignore
|
||||
Vendored
+5
-1
@@ -11,7 +11,11 @@
|
||||
"request": "launch",
|
||||
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
|
||||
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
|
||||
"preLaunchTask": "${defaultBuildTask}"
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+59
-4
@@ -5,7 +5,7 @@
|
||||
"tasks": [
|
||||
{
|
||||
"label": "watch",
|
||||
"dependsOn": ["npm: build:webview", "npm: watch:tsc", "npm: watch:esbuild"],
|
||||
"dependsOn": ["npm: build:webview", "npm: dev:webview", "npm: watch:tsc", "npm: watch:esbuild"],
|
||||
"presentation": {
|
||||
"reveal": "never"
|
||||
},
|
||||
@@ -23,7 +23,47 @@
|
||||
"label": "npm: build:webview",
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "never"
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "dev:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [
|
||||
{
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": ".",
|
||||
"file": 1,
|
||||
"location": 2,
|
||||
"message": 3
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": ".",
|
||||
"endsPattern": "."
|
||||
}
|
||||
}
|
||||
],
|
||||
"isBackground": true,
|
||||
"label": "npm: dev:webview",
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -35,7 +75,8 @@
|
||||
"label": "npm: watch:esbuild",
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "never"
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -47,7 +88,8 @@
|
||||
"label": "npm: watch:tsc",
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "never"
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -65,6 +107,19 @@
|
||||
"label": "tasks: watch-tests",
|
||||
"dependsOn": ["npm: watch", "npm: watch-tests"],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "stop",
|
||||
"command": "echo ${input:terminate}",
|
||||
"type": "shell"
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"id": "terminate",
|
||||
"type": "command",
|
||||
"command": "workbench.action.tasks.terminate",
|
||||
"args": "terminateAll"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ demo.gif
|
||||
# Ignore all webview-ui files except the build directory (https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/frameworks/hello-world-react-cra/.vscodeignore)
|
||||
webview-ui/src/**
|
||||
webview-ui/public/**
|
||||
webview-ui/scripts/**
|
||||
webview-ui/index.html
|
||||
webview-ui/README.md
|
||||
webview-ui/package.json
|
||||
|
||||
@@ -1,5 +1,87 @@
|
||||
# Changelog
|
||||
|
||||
## [3.8.2]
|
||||
|
||||
- Fix bug where switching to plan/act would result in VS Code LM/OpenRouter model being reset
|
||||
|
||||
## [3.8.0]
|
||||
|
||||
- Add 'Add to Cline' as an option when you right-click in a file or the terminal, making it easier to add context to your current task
|
||||
- Add 'Fix with Cline' code action - when you see a lightbulb icon in your editor, you can now select 'Fix with Cline' to send the code and associated errors for Cline to fix. (Cursor users can also use the 'Quick Fix (CMD + .)' menu to see this option)
|
||||
- Add Account view to display billing and usage history for Cline account users. You can now keep track of credits used and transaction history right in the extension!
|
||||
- Add 'Sort underling provider routing' setting to Cline/OpenRouter allowing you to sort provider used by throughput, price, latency, or the default (combination of price and uptime)
|
||||
- Improve rich MCP display with dynamic image loading and support for GIFs
|
||||
- Add 'Documentation' menu item to easily access Cline's docs
|
||||
- Add OpenRouter's new usage_details feature for more reliable cost reporting
|
||||
- Display total space Cline takes on disk next to 'Delete all Tasks' button in History view
|
||||
- Fix 'Context Window Exceeded' error for OpenRouter/Cline Accounts (additional support coming soon)
|
||||
- Fix bug where OpenRouter model ID would be set to invalid value
|
||||
- Add button to delete MCP servers in a failure state
|
||||
|
||||
## [3.7.1]
|
||||
|
||||
- Fix issue with 'See more' button in task header not showing when starting new tasks
|
||||
- Fix issue with checkpoints using local git commit hooks
|
||||
|
||||
## [3.7.0]
|
||||
|
||||
- Cline now displays selectable options when asking questions or presenting a plan, saving you from having to type out responses!
|
||||
- Add support for a `.clinerules/` directory to load multiple files at once (thanks @ryo-ma!)
|
||||
- Prevent Cline from reading extremely large files into context that would overload context window
|
||||
- Improve checkpoints loading performance and display warning for large projects not suited for checkpoints
|
||||
- Add SambaNova API provider (thanks @saad-noodleseed!)
|
||||
- Add VPC endpoint option for AWS Bedrock profiles (thanks @minorunara!)
|
||||
- Add DeepSeek-R1 to AWS Bedrock (thanks @watany-dev!)
|
||||
|
||||
## [3.6.5]
|
||||
|
||||
- Add 'Delete all Task History' button to History view
|
||||
- Add toggle to disable model switching between Plan/Act modes in Settings (new users default to disabled)
|
||||
- Add temperature option to OpenAI Compatible
|
||||
- Add Kotlin support to tree-sitter parser (thanks @fumiya-kume!)
|
||||
|
||||
## [3.6.3]
|
||||
|
||||
- Improve QwQ support for Alibaba (thanks @meglinge!) and OpenRouter
|
||||
- Improve diff edit prompting to prevent immediately reverting to write_to_file when a model uses search patterns that don't match anything in the file
|
||||
- Fix bug where new checkpoints system would revert file changes when switching between tasks
|
||||
- Fix issue with incorrect token count for some OpenAI compatible providers
|
||||
|
||||
## [3.6.0]
|
||||
|
||||
- Add Cline API as a provider option, allowing new users to sign up and get started with Cline for free
|
||||
- Optimize checkpoints with branch-per-task strategy, reducing storage required and first task load times
|
||||
- Fix problem with Plan/Act toggle keyboard shortcut not working in Windows (thanks @yt3trees!)
|
||||
- Add new Gemini models to GCP Vertex (thanks @shohei-ihaya!) and Claude models AskSage (thanks @swhite24!)
|
||||
- Improve OpenRouter/Cline error reporting
|
||||
|
||||
## [3.5.1]
|
||||
|
||||
- Add timeout option to MCP servers
|
||||
- Add Gemini Flash models to Vertex provider (thanks @jpaodev!)
|
||||
- Add prompt caching support for AWS Bedrock provider (thanks @buger!)
|
||||
- Add AskSage provider (thanks @swhite24!)
|
||||
|
||||
## [3.5.0]
|
||||
|
||||
- Add 'Enable extended thinking' option for Claude 3.7 Sonnet, with ability to set different budgets for Plan and Act modes
|
||||
- Add support for rich MCP responses with automatic image previews, website thumbnails, and WolframAlpha visualizations
|
||||
- Add language preference option in Advanced Settings
|
||||
- Add xAI Provider Integration with support for all Grok models (thanks @andrewmonostate!)
|
||||
- Fix issue with Linux XDG pointing to incorrect path for Document folder (thanks @jonatkinson!)
|
||||
|
||||
## [3.4.10]
|
||||
|
||||
- Add support for GPT-4.5 preview model
|
||||
|
||||
## [3.4.9]
|
||||
|
||||
- Add toggle to let users opt-in to anonymous telemetry and error reporting
|
||||
|
||||
## [3.4.6]
|
||||
|
||||
- Add support for Claude 3.7 Sonnet
|
||||
|
||||
## [3.4.0]
|
||||
|
||||
- Introducing MCP Marketplace! You can now discover and install the best MCP servers right from within the extension, with new servers added regularly
|
||||
|
||||
@@ -31,6 +31,36 @@ If you're planning to work on a bigger feature, please create a [feature request
|
||||
- Run `npm run test` to run tests locally
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
|
||||
3. **Linux-specific Setup**
|
||||
VS Code extension tests on Linux require the following system libraries:
|
||||
|
||||
- `libatk1.0-0`
|
||||
- `libatk-bridge2.0-0`
|
||||
- `libxkbfile1`
|
||||
- `libx11-xcb1`
|
||||
- `libxcomposite1`
|
||||
- `libxdamage1`
|
||||
- `libxfixes3`
|
||||
- `libxrandr2`
|
||||
- `libgbm1`
|
||||
- `libdrm2`
|
||||
- `libgtk-3-0`
|
||||
- `dbus`
|
||||
- `xvfb`
|
||||
|
||||
These libraries provide necessary GUI components and system services for the test environment.
|
||||
|
||||
For example, on Debian-based distributions (e.g., Ubuntu), you can install these libraries using apt:
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y \
|
||||
libatk1.0-0 libatk-bridge2.0-0 libxkbfile1 libx11-xcb1 \
|
||||
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 \
|
||||
libdrm2 libgtk-3-0 dbus 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:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center"><sub>
|
||||
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a>
|
||||
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
|
||||
</sub></div>
|
||||
|
||||
# Cline – \#1 on OpenRouter
|
||||
@@ -32,7 +32,7 @@ English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md
|
||||
|
||||
Meet Cline, an AI assistant that can use your **CLI** a**N**d **E**ditor.
|
||||
|
||||
Thanks to [Claude 3.5 Sonnet's agentic coding capabilities](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf), Cline can handle complex software development tasks step-by-step. With tools that let him create & edit files, explore large projects, use the browser, and execute terminal commands (after you grant permission), he can assist you in ways that go beyond code completion or tech support. Cline can even use the Model Context Protocol (MCP) to create new tools and extend his own capabilities. While autonomous AI scripts traditionally run in sandboxed environments, this extension provides a human-in-the-loop GUI to approve every file change and terminal command, providing a safe and accessible way to explore the potential of agentic AI.
|
||||
Thanks to [Claude 3.7 Sonnet's agentic coding capabilities](https://www.anthropic.com/claude/sonnet), Cline can handle complex software development tasks step-by-step. With tools that let him create & edit files, explore large projects, use the browser, and execute terminal commands (after you grant permission), he can assist you in ways that go beyond code completion or tech support. Cline can even use the Model Context Protocol (MCP) to create new tools and extend his own capabilities. While autonomous AI scripts traditionally run in sandboxed environments, this extension provides a human-in-the-loop GUI to approve every file change and terminal command, providing a safe and accessible way to explore the potential of agentic AI.
|
||||
|
||||
1. Enter your task and add images to convert mockups into functional apps or fix bugs with screenshots.
|
||||
2. Cline starts by analyzing your file structure & source code ASTs, running regex searches, and reading relevant files to get up to speed in existing projects. By carefully managing what information is added to context, Cline can provide valuable assistance even for large, complex projects without overwhelming the context window.
|
||||
|
||||
+1
-99
@@ -1,99 +1 @@
|
||||
# Cline Privacy Policy
|
||||
|
||||
Cline Bot Inc. ("Cline," "we," "our," and/or "us") values the privacy of individuals who use our VS Code extension and related services (collectively, our "Services"). This privacy policy explains how we collect, use, and disclose information from users of our Services.
|
||||
|
||||
## Key Points
|
||||
|
||||
- Cline operates entirely client-side as a VS Code extension
|
||||
- No code or data is collected, stored, or transmitted to Cline's servers
|
||||
- Your data is only sent to your chosen AI provider (e.g., Anthropic, OpenAI) when you explicitly request assistance
|
||||
- All processing happens locally on your machine
|
||||
- API keys are stored securely in VS Code's built-in settings storage
|
||||
|
||||
## Information We Process
|
||||
|
||||
### A. Information You Provide
|
||||
|
||||
- **API Keys**: When you choose to use certain AI model providers (OpenRouter, Anthropic, OpenAI, etc.), you provide API keys. These are stored securely and locally in your VS Code settings.
|
||||
- **Communications**: If you contact us directly (e.g., via Discord or email), we may receive information like your name, email address, and message contents.
|
||||
|
||||
### B. Information Processing
|
||||
|
||||
Cline functions solely as a client-side VS Code extension that facilitates communication between your editor and your chosen AI model provider:
|
||||
|
||||
1. **File Contents**:
|
||||
|
||||
- Only sent to your chosen AI provider when you explicitly request assistance
|
||||
- Never stored or transmitted to Cline's servers
|
||||
- Only the specific files/content you select are included
|
||||
|
||||
2. **Terminal Commands**:
|
||||
|
||||
- Processed entirely locally on your machine
|
||||
- Require explicit user confirmation before execution
|
||||
- No command history is transmitted to Cline
|
||||
|
||||
3. **Browser Integration**:
|
||||
- Screenshots and console logs are processed locally
|
||||
- Temporary data is cleared after task completion
|
||||
|
||||
## Data Security
|
||||
|
||||
1. **Local-Only Processing**:
|
||||
|
||||
- All operations happen on your local machine
|
||||
- No central servers or data collection
|
||||
- No telemetry or usage statistics gathered
|
||||
- No account creation required
|
||||
|
||||
2. **API Key Security**:
|
||||
|
||||
- Stored using VS Code's secure settings storage system
|
||||
- Never transmitted to Cline's servers
|
||||
- You can remove/modify keys at any time
|
||||
|
||||
3. **User Control**:
|
||||
- Explicit approval required for file changes
|
||||
- Terminal commands require confirmation
|
||||
- Browser actions need explicit permission
|
||||
- You control which AI provider to use
|
||||
|
||||
## Communication with AI Providers
|
||||
|
||||
When you request assistance:
|
||||
|
||||
1. Selected content is sent directly to your chosen AI provider
|
||||
2. No data passes through Cline's servers
|
||||
3. Provider's own privacy policy applies to this communication:
|
||||
- [Anthropic Privacy Policy](https://www.anthropic.com/privacy)
|
||||
- [OpenAI Privacy Policy](https://openai.com/privacy)
|
||||
- [OpenRouter Privacy Policy](https://openrouter.ai/privacy)
|
||||
|
||||
## Error Handling & Debugging
|
||||
|
||||
- Error logs are processed locally
|
||||
- No automatic error reporting to Cline
|
||||
- You control what information to include when reporting issues
|
||||
|
||||
## Children's Privacy
|
||||
|
||||
We do not knowingly collect, maintain, or use personal information from children under 18 years of age, and no part of our Service(s) is directed to children. If you learn that a child has provided us with personal information in violation of this Privacy Policy, then you may alert us at support@cline.bot.
|
||||
|
||||
## Changes to Privacy Policy
|
||||
|
||||
We will post any changes to this policy on our GitHub repository. Significant changes will be announced in our Discord community.
|
||||
|
||||
## Security Concerns & Auditing
|
||||
|
||||
- Cline is open source and available for security audit
|
||||
- Our client-side architecture ensures no central point of data collection
|
||||
- You can inspect exactly what data is being sent to AI providers
|
||||
- Enterprise users can implement additional access controls through VS Code
|
||||
|
||||
## Contact Us
|
||||
|
||||
For privacy-related questions or concerns:
|
||||
|
||||
- Open an issue on our [GitHub repository](https://github.com/cline/cline)
|
||||
- Join our [Discord community](https://discord.gg/cline)
|
||||
- Email: support@cline.bot
|
||||
See [https://cline.bot/privacy](https://cline.bot/privacy) for our privacy policy.
|
||||
+1
-1
@@ -28,7 +28,7 @@ Welcome to the Cline documentation - your comprehensive guide to using and exten
|
||||
|
||||
- **Interested in contributing?** We welcome your input:
|
||||
- Feel free to submit a pull request
|
||||
- [Contribution Guidelines](CONTRIBUTING.md)
|
||||
- [Contribution Guidelines](../CONTRIBUTING.md)
|
||||
|
||||
## Additional Resources
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Cline Extension Architecture
|
||||
|
||||
This directory contains architectural documentation for the Cline VSCode extension.
|
||||
|
||||
## Extension Architecture Diagram
|
||||
|
||||
The [extension-architecture.mmd](./extension-architecture.mmd) file contains a Mermaid diagram showing the high-level architecture of the Cline extension. The diagram illustrates:
|
||||
|
||||
1. **Core Extension**
|
||||
- Extension entry point and main classes
|
||||
- State management through VSCode's global state and secrets storage
|
||||
- Core business logic in the Cline class
|
||||
|
||||
2. **Webview UI**
|
||||
- React-based user interface
|
||||
- State management through ExtensionStateContext
|
||||
- Component hierarchy
|
||||
|
||||
3. **Storage**
|
||||
- Task-specific storage for history and state
|
||||
- Git-based checkpoint system for file changes
|
||||
|
||||
4. **Data Flow**
|
||||
- Core extension data flow between components
|
||||
- Webview UI data flow
|
||||
- Bidirectional communication between core and webview
|
||||
|
||||
## Viewing the Diagram
|
||||
|
||||
To view the diagram:
|
||||
1. Install a Mermaid diagram viewer extension in VSCode
|
||||
2. Open extension-architecture.mmd
|
||||
3. Use the extension's preview feature to render the diagram
|
||||
|
||||
You can also view the diagram on GitHub, which has built-in Mermaid rendering support.
|
||||
|
||||
## Color Scheme
|
||||
|
||||
The diagram uses a high-contrast color scheme for better visibility:
|
||||
- Pink (#ff0066): Global state and secrets storage components
|
||||
- Blue (#0066ff): Extension state context
|
||||
- Green (#00cc66): Cline provider
|
||||
- All components use white text for maximum readability
|
||||
@@ -0,0 +1,41 @@
|
||||
graph TB
|
||||
subgraph VSCode Extension Host
|
||||
subgraph Core Extension
|
||||
ExtensionEntry[Extension Entry<br/>src/extension.ts]
|
||||
ClineProvider[ClineProvider<br/>src/core/webview/ClineProvider.ts]
|
||||
ClineClass[Cline Class<br/>src/core/Cline.ts]
|
||||
GlobalState[VSCode Global State]
|
||||
SecretsStorage[VSCode Secrets Storage]
|
||||
end
|
||||
|
||||
subgraph Webview UI
|
||||
WebviewApp[React App<br/>webview-ui/src/App.tsx]
|
||||
ExtStateContext[ExtensionStateContext<br/>webview-ui/src/context/ExtensionStateContext.tsx]
|
||||
ReactComponents[React Components]
|
||||
end
|
||||
|
||||
subgraph Storage
|
||||
TaskStorage[Task Storage<br/>Per-Task Files & History]
|
||||
CheckpointSystem[Git-based Checkpoints]
|
||||
end
|
||||
end
|
||||
|
||||
%% Core Extension Data Flow
|
||||
ExtensionEntry --> ClineProvider
|
||||
ClineProvider --> ClineClass
|
||||
ClineClass --> GlobalState
|
||||
ClineClass --> SecretsStorage
|
||||
ClineClass --> TaskStorage
|
||||
ClineClass --> CheckpointSystem
|
||||
|
||||
%% Webview Data Flow
|
||||
WebviewApp --> ExtStateContext
|
||||
ExtStateContext --> ReactComponents
|
||||
|
||||
%% Bidirectional Communication
|
||||
ClineProvider <-->|postMessage| ExtStateContext
|
||||
|
||||
style GlobalState fill:#ff0066,stroke:#333,stroke-width:2px,color:#ffffff
|
||||
style SecretsStorage fill:#ff0066,stroke:#333,stroke-width:2px,color:#ffffff
|
||||
style ExtStateContext fill:#0066ff,stroke:#333,stroke-width:2px,color:#ffffff
|
||||
style ClineProvider fill:#00cc66,stroke:#333,stroke-width:2px,color:#ffffff
|
||||
@@ -128,6 +128,27 @@ Cline's system prompt, on the other hand, is not user-editable ([here's where yo
|
||||
- Focus on Desired Outcomes: Describe the results you want, not the specific steps.
|
||||
- Test and Iterate: Experiment to find what works best for your workflow.
|
||||
|
||||
|
||||
### Support for Loading Files from the `.clinerules/` Directory
|
||||
All files under the `.clinerules/` directory are recursively loaded, and their contents are merged into clineRulesFileInstructions.
|
||||
|
||||
#### Example 1:
|
||||
```
|
||||
.clinerules/
|
||||
├── .local-clinerules
|
||||
└── .project-clinerules
|
||||
```
|
||||
|
||||
#### Example 2:
|
||||
```
|
||||
.clinerules/
|
||||
├── .clinerules-nextjs
|
||||
├── .clinerules-serverside
|
||||
└── tests/
|
||||
├── .pytest-clinerules
|
||||
└── .jest-clinerules
|
||||
```
|
||||
|
||||
## Prompting Cline 💬
|
||||
|
||||
**Prompting is how you communicate your needs for a given task in the back-and-forth chat with Cline.** Cline understands natural language, so write conversationally.
|
||||
|
||||
@@ -52,6 +52,7 @@ const copyWasmFiles = {
|
||||
"java",
|
||||
"php",
|
||||
"swift",
|
||||
"kotlin",
|
||||
]
|
||||
|
||||
languages.forEach((lang) => {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# ميثاق المساهمين
|
||||
|
||||
## تعهدنا
|
||||
|
||||
نحن المساهمون والقائمون على هذا المشروع، نتعهد بتوفير بيئة مفتوحة ومرحبة، ونجعل المشاركة في مشروعنا ومجتمعنا تجربة خالية من التحرش للجميع، بغض النظر عن العمر، أو حجم الجسم، أو الإعاقة، أو العرق، أو الخصائص الجنسية، أو الهوية الجنسية والتعبير عنها، أو مستوى الخبرة، أو التعليم، أو الوضع الاجتماعي والاقتصادي، أو الجنسية، أو المظهر الشخصي، أو الدين، أو الهوية الجنسية والتوجه الجنسي.
|
||||
|
||||
## معاييرنا
|
||||
|
||||
أمثلة على السلوك الذي يساهم في خلق بيئة إيجابية تشمل:
|
||||
|
||||
- استخدام لغة ترحيبية وشاملة
|
||||
- احترام وجهات النظر والخبرات المختلفة
|
||||
- تقبل النقد البناء برحابة صدر
|
||||
- التركيز على ما هو الأفضل للمجتمع
|
||||
- إظهار التعاطف تجاه أعضاء المجتمع الآخرين
|
||||
|
||||
أمثلة على السلوك غير المقبول من قبل المشاركين تشمل:
|
||||
|
||||
- استخدام لغة أو صور جنسية والاهتمام الجنسي غير المرغوب فيه أو التحرش الجنسي
|
||||
- التصيد، والتعليقات المهينة/المسيئة، والهجمات الشخصية أو السياسية
|
||||
- التحرش العلني أو الخاص
|
||||
- نشر معلومات الآخرين الخاصة، مثل العنوان الفعلي أو الإلكتروني، دون إذن صريح
|
||||
- أي سلوك آخر يمكن اعتباره غير لائق في بيئة مهنية
|
||||
|
||||
## مسؤولياتنا
|
||||
|
||||
يتحمل القائمون على المشروع مسؤولية توضيح معايير السلوك المقبول، ومن المتوقع أن يتخذوا إجراءات تصحيحية مناسبة وعادلة استجابة لأي حالات سلوك غير مقبول.
|
||||
|
||||
يحق للقائمين على المشروع إزالة أو تعديل أو رفض التعليقات والالتزامات والتعليمات البرمجية وتعديلات wiki والمشكلات والمساهمات الأخرى التي لا تتماشى مع مدونة قواعد السلوك هذه، أو حظر أي مساهم بشكل مؤقت أو دائم بسبب سلوكيات أخرى يعتبرونها غير لائقة أو مهددة أو مسيئة أو ضارة، كما أنهم يتحملون مسؤولية ذلك.
|
||||
|
||||
## النطاق
|
||||
|
||||
تنطبق مدونة قواعد السلوك هذه داخل مساحات المشروع وفي الأماكن العامة عندما يمثل الفرد المشروع أو مجتمعه. تتضمن أمثلة تمثيل مشروع أو مجتمع استخدام عنوان بريد إلكتروني رسمي للمشروع، أو النشر عبر حساب رسمي على وسائل التواصل الاجتماعي، أو العمل كممثل معين في حدث عبر الإنترنت أو خارجه. يمكن للقائمين على المشروع تحديد وتوضيح تمثيل المشروع بشكل أكبر.
|
||||
|
||||
## التنفيذ
|
||||
|
||||
يمكن الإبلاغ عن حالات السلوك المسيء أو التحرش أو السلوك غير المقبول عن طريق الاتصال بفريق المشروع على hi@cline.bot. ستتم مراجعة جميع الشكاوى والتحقيق فيها وستؤدي إلى استجابة تعتبر ضرورية ومناسبة للظروف. يلتزم فريق المشروع بالحفاظ على السرية فيما يتعلق بالمبلغ عن الحادث. يمكن نشر مزيد من التفاصيل حول سياسات التنفيذ المحددة بشكل منفصل.
|
||||
|
||||
قد يواجه القائمون على المشروع الذين لا يتبعون أو يفرضون مدونة قواعد السلوك بحسن نية تداعيات مؤقتة أو دائمة على النحو الذي يحدده الأعضاء الآخرون في قيادة المشروع.
|
||||
|
||||
## الإسناد
|
||||
|
||||
تم اقتباس مدونة قواعد السلوك هذه من [تعهد المساهم][homepage]، الإصدار 1.4، متاح على https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
للحصول على إجابات للأسئلة الشائعة حول مدونة قواعد السلوك هذه، راجع https://www.contributor-covenant.org/faq
|
||||
@@ -0,0 +1,93 @@
|
||||
# المساهمة في Cline
|
||||
|
||||
نحن سعداء لاهتمامك بالمساهمة في Cline. سواء كنت تصلح خطأً أو تضيف ميزة أو تحسن الوثائق لدينا، فإن كل مساهمة تجعل Cline أذكى! للحفاظ على مجتمعنا نابضًا بالحياة وترحيبيًا، يجب على جميع الأعضاء الالتزام بـ [مدونة قواعد السلوك](CODE_OF_CONDUCT.md) لدينا.
|
||||
|
||||
## الإبلاغ عن الأخطاء أو المشكلات
|
||||
|
||||
تساعد تقارير الأخطاء على جعل Cline أفضل للجميع! قبل إنشاء مشكلة جديدة، يرجى [البحث عن المشكلات الموجودة](https://github.com/cline/cline/issues) لتجنب الازدواجية. عندما تكون جاهزًا للإبلاغ عن خطأ، انتقل إلى [صفحة المشكلات](https://github.com/cline/cline/issues/new/choose) حيث ستجد قالبًا لمساعدتك في ملء المعلومات ذات الصلة.
|
||||
|
||||
<blockquote class='warning-note'>
|
||||
🔐 <b>مهم:</b> إذا اكتشفت ثغرة أمنية، فيرجى استخدام <a href="https://github.com/cline/cline/security/advisories/new">أداة الأمان على Github للإبلاغ عنها بشكل خاص</a>.
|
||||
</blockquote>
|
||||
|
||||
## تحديد ما يجب العمل عليه
|
||||
|
||||
تبحث عن مساهمة أولى جيدة؟ تحقق من المشكلات المميزة بـ ["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue) أو ["help wanted"](https://github.com/cline/cline/labels/help%20wanted). تم تحديد هذه المشكلات خصيصًا للمساهمين الجدد والمجالات التي نرحب فيها بالمساعدة!
|
||||
|
||||
نرحب أيضًا بالمساهمات في [الوثائق](https://github.com/cline/cline/tree/main/docs) لدينا! سواء كان تصحيح أخطاء إملائية، أو تحسين الأدلة الحالية، أو إنشاء محتوى تعليمي جديد - نود بناء مستودع موارد مدفوع من المجتمع يساعد الجميع على الاستفادة القصوى من Cline. يمكنك البدء بالغوص في `/docs` والبحث عن مجالات تحتاج إلى تحسين.
|
||||
|
||||
إذا كنت تخطط للعمل على ميزة أكبر، فيرجى إنشاء [طلب ميزة](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop) أولاً حتى نتمكن من مناقشة ما إذا كان ذلك يتماشى مع رؤية Cline.
|
||||
|
||||
## إعداد التطوير
|
||||
|
||||
1. **إضافات VS Code**
|
||||
|
||||
- عند فتح المشروع، سيطالبك VS Code بتثبيت الإضافات الموصى بها
|
||||
- هذه الإضافات مطلوبة للتطوير - يرجى قبول جميع مطالبات التثبيت
|
||||
- إذا تجاهلت المطالبات، يمكنك تثبيتها يدويًا من لوحة الإضافات
|
||||
|
||||
2. **التطوير المحلي**
|
||||
- قم بتشغيل `npm run install:all` لتثبيت التبعيات
|
||||
- قم بتشغيل `npm run test` لتشغيل الاختبارات محليًا
|
||||
- قبل تقديم طلب السحب، قم بتشغيل `npm run format:fix` لتنسيق التعليمات البرمجية الخاصة بك
|
||||
|
||||
## كتابة وتقديم التعليمات البرمجية
|
||||
|
||||
يمكن لأي شخص المساهمة بالتعليمات البرمجية في Cline، لكننا نطلب منك اتباع هذه الإرشادات لضمان دمج مساهماتك بسلاسة:
|
||||
|
||||
1. **احتفظ بطلبات السحب مركزة**
|
||||
|
||||
- قيد طلبات السحب بميزة واحدة أو إصلاح خطأ
|
||||
- قسم التغييرات الأكبر إلى طلبات سحب أصغر ومتصلة
|
||||
- قسم التغييرات إلى التزامات منطقية يمكن مراجعتها بشكل مستقل
|
||||
|
||||
2. **جودة التعليمات البرمجية**
|
||||
|
||||
- قم بتشغيل `npm run lint` للتحقق من نمط التعليمات البرمجية
|
||||
- قم بتشغيل `npm run format` لتنسيق التعليمات البرمجية تلقائيًا
|
||||
- يجب أن تجتاز جميع طلبات السحب عمليات التحقق المستمر التي تشمل كلاً من التنضيد والتنسيق
|
||||
- تعامل مع أي تحذيرات أو أخطاء ESLint قبل التقديم
|
||||
- اتبع أفضل ممارسات TypeScript والحفاظ على سلامة النوع
|
||||
|
||||
3. **الاختبار**
|
||||
|
||||
- أضف اختبارات للميزات الجديدة
|
||||
- قم بتشغيل `npm test` للتأكد من اجتياز جميع الاختبارات
|
||||
- قم بتحديث الاختبارات الحالية إذا كانت تغييراتك تؤثر عليها
|
||||
- تضمين كل من اختبارات الوحدة واختبارات التكامل حيثما كان ذلك مناسبًا
|
||||
|
||||
4. **إدارة الإصدار مع Changesets**
|
||||
|
||||
- أنشئ changeset لأي تغييرات واجهة المستخدم باستخدام `npm run changeset`
|
||||
- اختر زيادة الإصدار المناسبة:
|
||||
- `major` للتغييرات الكبيرة (1.0.0 → 2.0.0)
|
||||
- `minor` للميزات الجديدة (1.0.0 → 1.1.0)
|
||||
- `patch` لإصلاحات الأخطاء (1.0.0 → 1.0.1)
|
||||
- اكتب رسائل changeset واضحة ووصفية تشرح التأثير
|
||||
- لا تتطلب التغييرات في الوثائق فقط changesets
|
||||
|
||||
5. **إرشادات الالتزام (Commit Guidelines)**
|
||||
|
||||
- اكتب رسائل التزام واضحة وواصفة
|
||||
- استخدم تنسيق الالتزام التقليدي (مثل: "feat:", "fix:", "docs:")
|
||||
- أشر إلى القضايا ذات الصلة في الالتزامات باستخدام #رقم-القضية
|
||||
|
||||
6. **قبل الإرسال**
|
||||
|
||||
- قم بإعادة دمج فرعك مع أحدث إصدار من الفرع الرئيسي
|
||||
- تأكد من أن الفرع الخاص بك يُبنى بنجاح
|
||||
- تحقق من اجتياز جميع الاختبارات
|
||||
- راجع التغييرات الخاصة بك للتأكد من عدم وجود تعليمات تصحيح الأخطاء أو سجلات وحدة التحكم
|
||||
|
||||
7. **وصف طلب السحب (Pull Request Description)**
|
||||
|
||||
- صف بوضوح ما تقوم به التغييرات
|
||||
- قم بتضمين خطوات لاختبار التغييرات
|
||||
- أدرج أي تغييرات غير متوافقة
|
||||
- أضف لقطات شاشة للتغييرات في واجهة المستخدم
|
||||
|
||||
## اتفاقية المساهمة
|
||||
|
||||
من خلال إرسال طلب سحب، فإنك توافق على أن مساهماتك سيتم ترخيصها بنفس ترخيص المشروع ([Apache 2.0](LICENSE)).
|
||||
|
||||
تذكر: المساهمة في Cline لا تقتصر فقط على كتابة الكود - إنها تتعلق بأن تكون جزءًا من مجتمع يُشكل مستقبل التطوير بمساعدة الذكاء الاصطناعي. لنبنِ شيئًا رائعًا معًا! 🚀
|
||||
@@ -0,0 +1,189 @@
|
||||
<div align="center"><sub>
|
||||
العربية | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">الإسبانية</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">الألمانية</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">اليابانية</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">الصينية المبسطة</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">الصينية التقليدية</a> | <a href="https://github.com/cline/cline/blob/main/locales/pt-BR/README.md" target="_blank">البرتغالية</a>
|
||||
</sub></div>
|
||||
|
||||
# Cline – \#1 على OpenRouter
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tbody>
|
||||
<td align="center">
|
||||
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong>تنزيل من متجر VS</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://discord.gg/cline" target="_blank"><strong>Discord</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://www.reddit.com/r/cline/" target="_blank"><strong>r/cline</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>طلبات الميزات</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://docs.cline.bot/getting-started/getting-started-new-coders" target="_blank"><strong>البدء</strong></a>
|
||||
</td>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
التقى Cline، مساعد الذكاء الاصطناعي الذي يمكنه استخدام **سطر الأوامر** و **محرر النصوص** الخاص بك.
|
||||
|
||||
بفضل [قدرات Claude 3.7 Sonnet على التعليمات البرمجية الوكيلة](https://www.anthropic.com/claude/sonnet)، يمكن لـ Cline التعامل مع مهام تطوير البرامج المعقدة خطوة بخطوة. مع الأدوات التي تسمح له بإنشاء وتعديل الملفات، واستكشاف المشاريع الكبيرة، واستخدام المتصفح، وتنفيذ أوامر الطرفية (بعد منحك الإذن)، يمكنه مساعدتك بطرق تتجاوز إكمال الكود أو الدعم الفني. يمكن لـ Cline أيضًا استخدام بروتوكول سياق النموذج (MCP) لإنشاء أدوات جديدة وتوسيع قدراته الخاصة. في حين تعمل النصوص البرمجية الآلية المستقلة تقليديًا في بيئات محاصرة، توفر هذه الإضافة واجهة رسومية لموافقة المستخدم على كل تغيير في الملف وأمر طرفية، مما يوفر طريقة آمنة وسهلة الاستخدام لاستكشاف إمكانات الذكاء الاصطناعي الوكيل.
|
||||
|
||||
1. أدخل مهمتك وأضف الصور لتحويل المحاكاة إلى تطبيقات وظيفية أو إصلاح الأخطاء مع لقطات الشاشة.
|
||||
2. يبدأ Cline بتحليل هيكل الملفات الخاصة بك وشجرة التعريف المصدرية، وإجراء عمليات بحث regex، وقراءة الملفات ذات الصلة للاطلاع على المشاريع الحالية. من خلال إدارة المعلومات التي يتم إضافتها إلى السياق بعناية، يمكن لـ Cline تقديم مساعدة قيمة حتى للمشاريع الكبيرة والمعقدة دون إرهاق نافذة السياق.
|
||||
3. بمجرد حصول Cline على المعلومات التي يحتاجها، يمكنه:
|
||||
- إنشاء وتعديل الملفات + مراقبة أخطاء Linter/Compiler أثناء السير، مما يسمح له بإصلاح المشكلات مثل الواردات المفقودة وأخطاء البناء النحوي بمفرده.
|
||||
- تنفيذ الأوامر مباشرة في الطرفية الخاصة بك ومراقبة إخراجها أثناء العمل، مما يسمح له على سبيل المثال بالاستجابة لمشكلات خادم التطوير بعد تعديل ملف.
|
||||
- بالنسبة لمهام تطوير الويب، يمكن لـ Cline إطلاق الموقع في متصفح بلا رأس، والنقر، وكتابة النص، والتمرير، والتقاط لقطات الشاشة + سجلات وحدة التحكم، مما يسمح له بإصلاح أخطاء وقت التشغيل والأخطاء البصرية.
|
||||
4. عند اكتمال المهمة، سيقدم Cline النتيجة لك مع أمر طرفية مثل `open -a "Google Chrome" index.html`، والذي تقوم بتشغيله بنقرة زر.
|
||||
|
||||
> [!TIP]
|
||||
> استخدم اختصار `CMD/CTRL + Shift + P` لفتح لوحة الأوامر واكتب "Cline: Open In New Tab" لفتح الإضافة كعلامة تبويب في محرر النصوص الخاص بك. يتيح لك هذا استخدام Cline جنبًا إلى جنب مع مستكشف الملفات الخاص بك، ورؤية كيف يغير مساحة العمل الخاصة بك بوضوح أكبر.
|
||||
|
||||
---
|
||||
|
||||
<img align="right" width="340" src="https://github.com/user-attachments/assets/3cf21e04-7ce9-4d22-a7b9-ba2c595e88a4">
|
||||
|
||||
### استخدم أي واجهة برمجة تطبيقات ونموذج
|
||||
|
||||
يدعم Cline مقدمي واجهات برمجة التطبيقات مثل OpenRouter و Anthropic و OpenAI و Google Gemini و AWS Bedrock و Azure و GCP Vertex. يمكنك أيضًا تكوين أي واجهة برمجة تطبيقات متوافقة مع OpenAI، أو استخدام نموذج محلي من خلال LM Studio/Ollama. إذا كنت تستخدم OpenRouter، فستقوم الإضافة بجلب قائمة النماذج الأحدث الخاصة بهم، مما يسمح لك باستخدام أحدث النماذج بمجرد توفرها.
|
||||
|
||||
تتتبع الإضافة أيضًا إجمالي الرموز والاستخدام الخاص بواجهة برمجة التطبيقات لدورة المهمة بأكملها وطلبات فردية، مما يبقيك على اطلاع بالإنفاق في كل خطوة.
|
||||
|
||||
<!-- Transparent pixel to create line break after floating image -->
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="370" src="https://github.com/user-attachments/assets/81be79a8-1fdb-4028-9129-5fe055e01e76">
|
||||
|
||||
### تشغيل الأوامر في الطرفية
|
||||
|
||||
بفضل [تحديثات تكامل الشل الجديدة في VSCode v1.93](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api)، يمكن لـ Cline تنفيذ الأوامر مباشرة في الطرفية الخاصة بك وتلقي الإخراج. يسمح له هذا بأداء مجموعة واسعة من المهام، من تثبيت الحزم وتشغيل سكربتات البناء إلى نشر التطبيقات، وإدارة قواعد البيانات، وتنفيذ الاختبارات، وذلك بالتكيف مع بيئة التطوير الخاصة بك وسلسلة الأدوات للقيام بالعمل على النحو الصحيح.
|
||||
|
||||
بالنسبة للعمليات الطويلة المدى مثل خوادم التطوير، استخدم زر "المتابعة أثناء التشغيل" للسماح لـ Cline بالاستمرار في المهمة بينما يعمل الأمر في الخلفية. أثناء عمل Cline، سيتم إخباره بأي إخراج طرفية جديد على الطريق، مما يسمح له بالاستجابة للمشكلات التي قد تنشأ، مثل أخطاء وقت الإنشاء عند تعديل الملفات.
|
||||
|
||||
<!-- Transparent pixel to create line break after floating image -->
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="400" src="https://github.com/user-attachments/assets/c5977833-d9b8-491e-90f9-05f9cd38c588">
|
||||
|
||||
### إنشاء وتعديل الملفات
|
||||
|
||||
يمكن لـ Cline إنشاء وتعديل الملفات مباشرة في محرر النصوص الخاص بك، وعرض الاختلافات. يمكنك تعديل أو إلغاء تغييرات Cline مباشرة في محرر الاختلافات، أو تقديم ملاحظات في الدردشة حتى تكون راضيًا عن النتيجة. يراقب Cline أيضًا أخطاء Linter/Compiler (الواردات المفقودة، أخطاء البناء النحوي، إلخ) حتى يتمكن من إصلاح المشكلات التي تنشأ أثناء السير بمفرده.
|
||||
|
||||
يتم تسجيل جميع التغييرات التي أجراها Cline في جدول زمني للملف، مما يوفر طريقة سهلة لتتبع وإلغاء التعديلات إذا لزم الأمر.
|
||||
|
||||
<!-- Transparent pixel to create line break after floating image -->
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="370" src="https://github.com/user-attachments/assets/bc2e85ba-dfeb-4fe6-9942-7cfc4703cbe5">
|
||||
|
||||
### استخدم المتصفح
|
||||
|
||||
مع قدرة [استخدام الكمبيوتر](https://www.anthropic.com/news/3-5-models-and-computer-use) الجديدة لـ Claude 3.5 Sonnet، يمكن لـ Cline إطلاق متصفح، والنقر على العناصر، وكتابة النص، والتمرير، والتقاط لقطات الشاشة وسجلات وحدة التحكم في كل خطوة. يسمح له هذا بالتصحيح التفاعلي، واختبار نهاية إلى نهاية، وحتى الاستخدام العام للويب! يمنحه هذا الاستقلالية لإصلاح الأخطاء البصرية وأخطاء وقت التشغيل دون الحاجة إلى نسخ ولصق سجلات الأخطاء بنفسك.
|
||||
|
||||
حاول طلب من Cline "اختبار التطبيق"، وشاهده يشغل أمرًا مثل `npm run dev`، ويطلق خادم التطوير المحلي في متصفح، ويجري سلسلة من الاختبارات للتأكد من أن كل شيء يعمل. [شاهد عرضًا توضيحيًا هنا.](https://x.com/sdrzn/status/1850880547825823989)
|
||||
|
||||
<!-- Transparent pixel to create line break after floating image -->
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="350" src="https://github.com/user-attachments/assets/ac0efa14-5c1f-4c26-a42d-9d7c56f5fadd">
|
||||
|
||||
### "إضافة أداة التي..."
|
||||
|
||||
شكراً لـ [بروتوكول سياق النموذج](https://github.com/modelcontextprotocol)، يمكن لـ Cline توسيع قدراته من خلال الأدوات المخصصة. بينما يمكنك استخدام [الخوادم التي أنشأها المجتمع](https://github.com/modelcontextprotocol/servers)، يمكن لـ Cline بدلاً من ذلك إنشاء أدوات وتثبيتها مصممة خصيصًا لتناسب سير عملك. ما عليك سوى أن تطلب من Cline "إضافة أداة"، وسيتولى كل شيء، من إنشاء خادم MCP جديد إلى تثبيته في الامتداد. تصبح هذه الأدوات المخصصة بعد ذلك جزءًا من مجموعة أدوات Cline، جاهزة للاستخدام في المهام المستقبلية.
|
||||
|
||||
- **"أضف أداة تجلب تذاكر Jira"**: استرجع تذاكر AC وقم بتشغيل Cline
|
||||
- **"أضف أداة تدير AWS EC2s"**: تحقق من مقاييس الخادم وقم بتوسيع أو تقليص عدد الحالات
|
||||
- **"أضف أداة تجلب أحدث حوادث PagerDuty"**: استرجع التفاصيل واطلب من Cline إصلاح الأخطاء
|
||||
|
||||
<!-- بكسل شفاف لإنشاء فاصل سطر بعد الصورة العائمة -->
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="360" src="https://github.com/user-attachments/assets/7fdf41e6-281a-4b4b-ac19-020b838b6970">
|
||||
|
||||
### إضافة السياق
|
||||
|
||||
**`@url`**: الصق رابط URL ليقوم الامتداد بجلبه وتحويله إلى Markdown، مفيد عندما تريد تزويد Cline بأحدث الوثائق
|
||||
|
||||
**`@problems`**: أضف أخطاء وتحذيرات بيئة العمل ('لوحة المشكلات') ليتمكن Cline من إصلاحها
|
||||
|
||||
**`@file`**: يضيف محتويات ملف حتى لا تضطر إلى إهدار طلبات API بالموافقة على قراءة الملف (+ البحث في الملفات)
|
||||
|
||||
**`@folder`**: يضيف جميع ملفات المجلد دفعة واحدة لتسريع سير العمل بشكل أكبر
|
||||
|
||||
<!-- بكسل شفاف لإنشاء فاصل سطر بعد الصورة العائمة -->
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="350" src="https://github.com/user-attachments/assets/140c8606-d3bf-41b9-9a1f-4dbf0d4c90cb">
|
||||
|
||||
### نقاط التحقق: المقارنة والاستعادة
|
||||
|
||||
أثناء عمل Cline على مهمة، يأخذ الامتداد لقطة من بيئة العمل في كل خطوة. يمكنك استخدام زر "Compare" لرؤية الفرق بين اللقطة وبيئة العمل الحالية، وزر "Restore" للعودة إلى تلك النقطة.
|
||||
|
||||
على سبيل المثال، عند العمل مع خادم ويب محلي، يمكنك استخدام "استعادة بيئة العمل فقط" لاختبار إصدارات مختلفة من تطبيقك بسرعة، ثم استخدام "استعادة المهمة وبيئة العمل" عندما تجد الإصدار الذي تريد المتابعة منه. يتيح لك ذلك استكشاف أساليب مختلفة بأمان دون فقدان التقدم.
|
||||
|
||||
<!-- بكسل شفاف لإنشاء فاصل سطر بعد الصورة العائمة -->
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
## المساهمة
|
||||
|
||||
للمساهمة في المشروع، ابدأ بـ [دليل المساهمة](CONTRIBUTING.md) لتعلم الأساسيات. يمكنك أيضًا الانضمام إلى [خادم Discord](https://discord.gg/cline) للدردشة مع المساهمين الآخرين في قناة `#contributors`. إذا كنت تبحث عن عمل بدوام كامل، تحقق من الوظائف المتاحة على [صفحة التوظيف](https://cline.bot/join-us)!
|
||||
|
||||
<details>
|
||||
<summary>تعليمات التطوير المحلي</summary>
|
||||
|
||||
1. استنساخ المستودع _(يتطلب [git-lfs](https://git-lfs.com/))_:
|
||||
```bash
|
||||
git clone https://github.com/cline/cline.git
|
||||
```
|
||||
2. افتح المشروع في VSCode:
|
||||
```bash
|
||||
code cline
|
||||
```
|
||||
3. قم بتثبيت التبعيات اللازمة للامتداد وواجهة الويب:
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
4. قم بالتشغيل بالضغط على `F5` (أو من `Run` -> `Start Debugging`) لفتح نافذة VSCode جديدة مع تحميل الامتداد. (قد تحتاج إلى تثبيت [إضافة esbuild problem matchers](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers) إذا واجهت مشكلات في بناء المشروع.)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>إنشاء طلب سحب (Pull Request)</summary>
|
||||
|
||||
1. قبل إنشاء PR، قم بإنشاء إدخال للتغييرات:
|
||||
```bash
|
||||
npm run changeset
|
||||
```
|
||||
سيطلب منك تحديد:
|
||||
- نوع التغيير (رئيسي، ثانوي، إصلاح)
|
||||
- `رئيسي` → تغييرات غير متوافقة (1.0.0 → 2.0.0)
|
||||
- `ثانوي` → ميزات جديدة (1.0.0 → 1.1.0)
|
||||
- `إصلاح` → إصلاحات للأخطاء (1.0.0 → 1.0.1)
|
||||
- وصف التغييرات التي قمت بها
|
||||
|
||||
2. قم بحفظ التغييرات وملف `.changeset` الذي تم إنشاؤه
|
||||
|
||||
3. ادفع فرعك وأنشئ PR على GitHub. سيقوم CI بـ:
|
||||
- تشغيل الاختبارات والفحوصات
|
||||
- سيقوم Changesetbot بإنشاء تعليق يوضح تأثير الإصدار
|
||||
- عند الدمج مع الفرع الرئيسي، سيقوم Changesetbot بإنشاء PR لحزم الإصدار
|
||||
- عند دمج PR لحزم الإصدار، سيتم نشر إصدار جديد
|
||||
|
||||
</details>
|
||||
|
||||
## الرخصة
|
||||
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
Lernen Sie Cline kennen, einen KI-Assistenten, der Ihre **CLI** u**N**d **E**ditor nutzen kann.
|
||||
|
||||
Dank der [agentischen Codierungsfähigkeiten von Claude 3.5 Sonnet](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf) kann Cline komplexe Softwareentwicklungsaufgaben Schritt für Schritt bewältigen. Mit Werkzeugen, die ihm das Erstellen und Bearbeiten von Dateien, das Erkunden großer Projekte, die Nutzung des Browsers und das Ausführen von Terminalbefehlen (nach Ihrer Genehmigung) ermöglichen, kann er Ihnen auf eine Weise helfen, die über die Codevervollständigung oder technischen Support hinausgeht. Cline kann sogar das Model Context Protocol (MCP) verwenden, um neue Werkzeuge zu erstellen und seine eigenen Fähigkeiten zu erweitern. Während autonome KI-Skripte traditionell in sandboxed Umgebungen laufen, bietet diese Erweiterung eine Mensch-in-der-Schleife-GUI, um jede Dateiänderung und jeden Terminalbefehl zu genehmigen, was eine sichere und zugängliche Möglichkeit bietet, das Potenzial agentischer KI zu erkunden.
|
||||
Dank der [agentischen Codierungsfähigkeiten von Claude 3.7 Sonnet](https://www.anthropic.com/claude/sonnet) kann Cline komplexe Softwareentwicklungsaufgaben Schritt für Schritt bewältigen. Mit Werkzeugen, die ihm das Erstellen und Bearbeiten von Dateien, das Erkunden großer Projekte, die Nutzung des Browsers und das Ausführen von Terminalbefehlen (nach Ihrer Genehmigung) ermöglichen, kann er Ihnen auf eine Weise helfen, die über die Codevervollständigung oder technischen Support hinausgeht. Cline kann sogar das Model Context Protocol (MCP) verwenden, um neue Werkzeuge zu erstellen und seine eigenen Fähigkeiten zu erweitern. Während autonome KI-Skripte traditionell in sandboxed Umgebungen laufen, bietet diese Erweiterung eine Mensch-in-der-Schleife-GUI, um jede Dateiänderung und jeden Terminalbefehl zu genehmigen, was eine sichere und zugängliche Möglichkeit bietet, das Potenzial agentischer KI zu erkunden.
|
||||
|
||||
1. Geben Sie Ihre Aufgabe ein und fügen Sie Bilder hinzu, um Mockups in funktionale Apps zu konvertieren oder Fehler mit Screenshots zu beheben.
|
||||
2. Cline beginnt mit der Analyse Ihrer Dateistruktur und Quellcode-ASTs, führt Regex-Suchen durch und liest relevante Dateien, um sich in bestehenden Projekten zurechtzufinden. Durch sorgfältiges Management der hinzugefügten Informationen kann Cline wertvolle Unterstützung auch bei großen, komplexen Projekten bieten, ohne das Kontextfenster zu überladen.
|
||||
@@ -158,5 +158,5 @@ Um zum Projekt beizutragen, beginnen Sie mit unserem [Beitragsleitfaden](CONTRIB
|
||||
|
||||
## Lizenz
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
Conozca a Cline, un asistente de IA que puede usar su **CLI** y **E**ditor.
|
||||
|
||||
Gracias a las [habilidades de codificación agencial de Claude 3.5 Sonnet](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf), Cline puede abordar tareas complejas de desarrollo de software paso a paso. Con herramientas que le permiten crear y editar archivos, explorar grandes proyectos, usar el navegador y ejecutar comandos de terminal (con su aprobación), puede ayudarle de una manera que va más allá de la autocompletación de código o el soporte técnico. Cline incluso puede usar el Model Context Protocol (MCP) para crear nuevas herramientas y expandir sus propias capacidades. Mientras que los scripts de IA autónomos tradicionalmente se ejecutan en entornos aislados, esta extensión ofrece una GUI con un humano en el bucle para aprobar cada cambio de archivo y comando de terminal, proporcionando una forma segura y accesible de explorar el potencial de la IA agencial.
|
||||
Gracias a las [habilidades de codificación agencial de Claude 3.7 Sonnet](https://www.anthropic.com/claude/sonnet), Cline puede abordar tareas complejas de desarrollo de software paso a paso. Con herramientas que le permiten crear y editar archivos, explorar grandes proyectos, usar el navegador y ejecutar comandos de terminal (con su aprobación), puede ayudarle de una manera que va más allá de la autocompletación de código o el soporte técnico. Cline incluso puede usar el Model Context Protocol (MCP) para crear nuevas herramientas y expandir sus propias capacidades. Mientras que los scripts de IA autónomos tradicionalmente se ejecutan en entornos aislados, esta extensión ofrece una GUI con un humano en el bucle para aprobar cada cambio de archivo y comando de terminal, proporcionando una forma segura y accesible de explorar el potencial de la IA agencial.
|
||||
|
||||
1. Ingrese su tarea y agregue imágenes para convertir maquetas en aplicaciones funcionales o solucionar errores con capturas de pantalla.
|
||||
2. Cline comenzará analizando su estructura de archivos y ASTs de código fuente, realizando búsquedas Regex y leyendo archivos relevantes para orientarse en proyectos existentes. Al gestionar cuidadosamente la información agregada, Cline puede proporcionar asistencia valiosa incluso en proyectos grandes y complejos sin sobrecargar la ventana de contexto.
|
||||
@@ -158,4 +158,4 @@ Para contribuir al proyecto, comience con nuestra [guía de contribución](CONTR
|
||||
|
||||
## Licencia
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
Clineは、**CLI**と**エディター**を使用できるAIアシスタントです。
|
||||
|
||||
[Claude 3.5 Sonnetのエージェント的コーディング機能](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf)のおかげで、Clineは複雑なソフトウェア開発タスクをステップバイステップで処理できます。ファイルの作成と編集、大規模プロジェクトの探索、ブラウザの使用、ターミナルコマンドの実行(許可後)などのツールを使用して、コード補完や技術サポートを超えた支援を提供します。Clineは、Model Context Protocol (MCP)を使用して新しいツールを作成し、自身の機能を拡張することもできます。自律的なAIスクリプトは通常サンドボックス環境で実行されますが、この拡張機能はファイル変更やターミナルコマンドを承認するための人間インターフェースを提供し、エージェント的AIの可能性を安全かつアクセスしやすい方法で探求できます。
|
||||
[Claude 3.7 Sonnetのエージェント的コーディング機能](https://www.anthropic.com/claude/sonnet)のおかげで、Clineは複雑なソフトウェア開発タスクをステップバイステップで処理できます。ファイルの作成と編集、大規模プロジェクトの探索、ブラウザの使用、ターミナルコマンドの実行(許可後)などのツールを使用して、コード補完や技術サポートを超えた支援を提供します。Clineは、Model Context Protocol (MCP)を使用して新しいツールを作成し、自身の機能を拡張することもできます。自律的なAIスクリプトは通常サンドボックス環境で実行されますが、この拡張機能はファイル変更やターミナルコマンドを承認するための人間インターフェースを提供し、エージェント的AIの可能性を安全かつアクセスしやすい方法で探求できます。
|
||||
|
||||
1. タスクを入力し、モックアップを機能するアプリに変換したり、スクリーンショットでバグを修正したりします。
|
||||
2. Clineは、ファイル構造とソースコードASTの分析、正規表現検索の実行、関連ファイルの読み取りから始め、既存プロジェクトに精通します。コンテキストに追加される情報を慎重に管理することで、大規模で複雑なプロジェクトでもコンテキストウィンドウを圧倒することなく貴重な支援を提供できます。
|
||||
@@ -158,4 +158,4 @@ Clineがタスクを進める中で、拡張機能は各ステップでワーク
|
||||
|
||||
## ライセンス
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 기여자 행동 강령
|
||||
|
||||
## 서약
|
||||
|
||||
우리는 개방적이고 환영하는 환경을 조성하기 위해 노력하며, 기여자 및 유지 관리자로서 모든 사람이 차별과 괴롭힘 없이 프로젝트와 커뮤니티에 참여할 수 있도록 최선을 다할 것을 서약합니다. 이는 연령, 체형, 장애, 민족성, 성적 특성, 성 정체성 및 표현, 경험 수준, 교육 수준, 사회·경제적 지위, 국적, 외모, 인종, 종교, 성 정체성과 성적 지향에 관계없이 모든 사람에게 적용됩니다.
|
||||
|
||||
## 행동 기준
|
||||
|
||||
긍정적인 환경을 조성하기 위한 바람직한 행동의 예시:
|
||||
|
||||
- 환영하고 포용적인 언어 사용하기
|
||||
- 서로 다른 관점과 경험을 존중하기
|
||||
- 건설적인 비판을 우아하게 수용하기
|
||||
- 커뮤니티에 최선이 되는 것에 집중하기
|
||||
- 다른 커뮤니티 구성원들에 대한 공감 보여주기
|
||||
|
||||
참여자가 해서는 안 되는 행동의 예시:
|
||||
|
||||
- 성적인 언어와 이미지 사용, 원치 않는 성적 관심이나 접근
|
||||
- 트롤링, 모욕적/경멸적인 댓글, 개인적 또는 정치적 공격
|
||||
- 공개적 또는 사적인 괴롭힘
|
||||
- 상대방의 동의 없이 개인정보(실제 주소나 전자 주소 등) 공개하기
|
||||
- 전문적 환경에서 부적절하다고 여겨질 수 있는 기타 행위
|
||||
|
||||
## 책임
|
||||
|
||||
프로젝트 유지 관리자는 허용 가능한 행동 기준을 명확히 설명할 책임이 있으며, 부적절한 행동이 발생할 경우 적절하고 공정한 시정 조치를 취해야 합니다.
|
||||
|
||||
프로젝트 유지 관리자는 본 행동 강령에 부합하지 않는 댓글, 커밋, 코드, 위키 수정, 이슈 및 기타 기여를 삭제, 수정 또는 거부할 권리와 책임이 있으며, 부적절하다고 판단되는 행동(위협적이거나, 공격적이거나, 해로운 행위 등)을 한 기여자를 일시적 또는 영구적으로 차단할 권리를 가집니다.
|
||||
|
||||
## 범위
|
||||
|
||||
이 행동 강령은 프로젝트 공간과 개인이 프로젝트나 커뮤니티를 대표하는 공개 공간에서 모두 적용됩니다. 프로젝트 또는 커뮤니티를 대표하는 예로는 공식 프로젝트 이메일 주소 사용, 공식 소셜 미디어 계정을 통한 게시, 온라인 또는 오프라인 행사에서 지정된 대표자로 활동하는 경우 등이 포함됩니다. 프로젝트의 대표성은 프로젝트 유지 관리자가 추가로 정의하고 명확히 할 수 있습니다.
|
||||
|
||||
## 집행
|
||||
|
||||
학대, 괴롭힘 또는 기타 용납할 수 없는 행동은 프로젝트 팀에 hi@cline.bot을 통해 신고 할 수 있습니다. 모든 신고는 검토 및 조사되며, 상황에 따라 필요하고 적절한 조치가 취해질 것입니다. 프로젝트 팀은 사건 신고자의 신원을 보호할 의무가 있습니다. 특정 시행 정책에 대한 추가 세부 사항은 별도로 게시될 수 있습니다.
|
||||
|
||||
행동 강령을 성실히 준수하거나 집행하지 않는 프로젝트 유지관리자는 프로젝트 리더십의 구성원에 의해 일시적 또는 영구적인 제재를 받을 수 있습니다.
|
||||
|
||||
## 출처
|
||||
|
||||
이 행동 강령은 [Contributor Covenant][homepage] 버전 1.4에서 수정되었으며, https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 에서 확인할 수 있습니다.
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
이 행동 강령에 대한 일반적인 질문에 대한 답변은 https://www.contributor-covenant.org/faq 를 참조하시기 바랍니다.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Cline에 기여하기
|
||||
|
||||
Cline에 기여하는 것에 관심을 가져주셔서 감사합니다! 버그 수정, 기능 추가, 문서 개선 등 모든 기여는 Cline을 더욱 스마트하게 만드는 데 기여합니다. 활기차고 환영하는 커뮤니티를 유지하기 위해 모든 구성원은 [행동 강령](CODE_OF_CONDUCT.md)을 준수해야 합니다.
|
||||
|
||||
## 버그와 문제 보고
|
||||
|
||||
버그 보고는 Cline을 모두에게 더 나은 것으로 만드는 데 도움이 됩니다! 새로운 이슈를 생성하기 전에, 중복을 피하기 위해 [기존 이슈를 검색](https://github.com/cline/cline/issues)해 주세요. 버그를 보고할 준비가 되었다면, [이슈 페이지](https://github.com/cline/cline/issues/new/choose)로 이동하여 관련 정보를 작성하기 위한 템플릿을 사용해 주세요.
|
||||
|
||||
<blockquote class='warning-note'>
|
||||
🔐 <b>중요:</b> 보안 취약점을 발견한 경우, <a href="https://github.com/cline/cline/security/advisories/new">GitHub 보안 도구를 사용하여 비공개로 보고</a>해 주세요.
|
||||
</blockquote>
|
||||
|
||||
## 작업 내용 결정하기
|
||||
|
||||
첫 기여를 찾고 계신가요? ["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue)나 ["help wanted"](https://github.com/cline/cline/labels/help%20wanted) 라벨이 붙은 이슈를 확인해 보세요. 이러한 이슈들은 새로운 기여자를 위해 특별히 선정된 작업으로, 도움이 필요한 영역이 표시되어 있습니다!
|
||||
|
||||
또한, [문서](https://github.com/cline/cline/tree/main/docs)에 대한 기여도 환영합니다! 오타 수정, 기존 가이드 개선, 새로운 교육 콘텐츠 작성 등, 커뮤니티 주도의 리소스 저장소를 구축하는 데 여러분의 도움이 필요합니다. `/docs`를 살펴보고 개선이 필요한 부분을 찾아보세요.
|
||||
|
||||
큰 기능에 대해 작업할 계획이 있다면, 먼저 [기능 요청](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop)을 생성하여 이것이 Cline의 비전과 부합하는지 논의하는 것이 좋습니다.
|
||||
|
||||
## 개발 환경 설정
|
||||
|
||||
1. **VS Code 확장 프로그램**
|
||||
|
||||
- 프로젝트를 열면 VS Code가 권장 확장 프로그램 설치를 안내합니다
|
||||
- 개발을 위해 이 확장 프로그램들이 필요하므로, 설치 안내를 수락해 주세요.
|
||||
- 프롬프트를 닫은 경우 확장 프로그램 패널에서 수동으로 설치할 수 있습니다
|
||||
|
||||
2. **로컬 개발**
|
||||
- `npm run install:all`을 실행하여 의존성을 설치합니다
|
||||
- `npm run test`를 실행하여 로컬에서 테스트를 실행합니다
|
||||
- PR을 제출하기 전에 `npm run format:fix`를 실행하여 코드를 포맷팅합니다
|
||||
|
||||
## 코드 작성과 제출
|
||||
|
||||
누구나 Cline에 코드를 기여할 수 있지만, 기여가 원활하게 통합되도록 다음 가이드라인을 따라주세요:
|
||||
|
||||
1. **Pull Request 집중하기**
|
||||
|
||||
- PR은 단일 기능 또는 버그 수정으로 제한해 주세요
|
||||
- 큰 변경사항은 작은 관련 PR로 분할해 주세요
|
||||
- 논리적으로 독립적인 커밋 단위로 나누어 리뷰가 용이하도록 구성하세요.
|
||||
|
||||
2. **코드 품질**
|
||||
|
||||
- `npm run lint`를 실행하여 코드 스타일을 체크합니다
|
||||
- `npm run format`을 실행하여 코드를 자동으로 포맷팅합니다
|
||||
- 모든 PR은 린팅과 포맷팅을 포함한 CI 체크를 통과해야 합니다
|
||||
- 제출 전에 ESLint 경고나 에러를 모두 해결해 주세요
|
||||
- TypeScript 모범 사례를 따르고, 타입 안전성을 유지해 주세요
|
||||
|
||||
3. **테스트**
|
||||
|
||||
- 새로운 기능에는 테스트를 추가해 주세요
|
||||
- `npm test`를 실행하여 모든 테스트가 통과하는지 확인해 주세요
|
||||
- 변경사항이 기존 테스트에 영향을 미치는 경우 해당 테스트를 업데이트해 주세요
|
||||
- 적절한 경우 단위 테스트와 통합 테스트를 모두 포함해 주세요
|
||||
|
||||
4. **Changesets를 활용한 버전 관리**
|
||||
|
||||
- 사용자에게 영향을 미치는 변경 사항이 있는 경우, `npm run changeset`을 실행하여 changeset을 생성해 주세요
|
||||
- 적절한 버전 증가 옵션을 선택하세요:
|
||||
- `major` 호환되지 않는 변경 (1.0.0 → 2.0.0)
|
||||
- `minor` 새로운 기능 추가 (1.0.0 → 1.1.0)
|
||||
- `patch` 버그 수정 (1.0.0 → 1.0.1)
|
||||
- 영향을 설명하는 명확한 변경사항 메시지를 작성해 주세요
|
||||
- 문서 변경만 있는 경우 changeset이 필요하지 않습니다
|
||||
|
||||
5. **커밋 가이드라인**
|
||||
|
||||
- 명확하고 설명적인 커밋 메시지를 작성해 주세요
|
||||
- 컨벤셔널 커밋 형식(예: "feat:", "fix:", "docs:")을 사용해 주세요
|
||||
- 커밋에서 관련 이슈를 #issue-number를 사용하여 참조해 주세요
|
||||
|
||||
6. **제출 전 확인사항**
|
||||
|
||||
- 최신 main에 브랜치를 리베이스해 주세요
|
||||
- 브랜치가 정상적으로 빌드되는지 확인해 주세요
|
||||
- 모든 테스트가 통과하는지 다시 확인해 주세요
|
||||
- 디버그 코드나 콘솔 로그가 없는지 변경사항을 확인해 주세요
|
||||
|
||||
7. **Pull Request 설명**
|
||||
- 변경 내용을 명확하게 설명해 주세요
|
||||
- 변경사항을 테스트하는 방법을 포함해 주세요
|
||||
- 호환되지 않는 변경 사항이 있다면 목록으로 작성해주세요
|
||||
- UI 변경이 있는 경우, 스크린샷을 추가해 주세요
|
||||
|
||||
## 기여 동의서
|
||||
|
||||
Pull Request를 제출함으로써, 귀하의 기여가 프로젝트와 동일한 라이선스([Apache 2.0](/LICENSE)) 에 따라 제공됨에 동의하는 것입니다.
|
||||
|
||||
기억하세요: Cline에 기여하는 것은 코드를 작성하는 것뿐만 아니라, AI 지원 개발의 미래를 형성하는 커뮤니티의 일원이 되는 것입니다. 함께 멋진 것을 만들어봅시다! 🚀
|
||||
@@ -0,0 +1,172 @@
|
||||
# Cline - 최고의 OpenRouter
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tbody>
|
||||
<td align="center">
|
||||
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong>VS Marketplace에서 다운로드</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://discord.gg/cline" target="_blank"><strong>Discord</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://www.reddit.com/r/cline/" target="_blank"><strong>r/cline</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>기능 요청</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://cline.bot/join-us" target="_blank"><strong>채용 정보</strong></a>
|
||||
</td>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Cline을 만나보세요, **CLI** 및 **에디터**를 활용할 수 있는 AI 어시스턴트입니다.
|
||||
|
||||
[Claude 3.7 Sonnet의 에이전트형 코딩 기능](https://www.anthropic.com/claude/sonnet) 덕분에, Cline은 복잡한 소프트웨어 개발 작업을 단계별로 처리할 수 있습니다. 파일 생성과 편집, 대규모 프로젝트 탐색, 브라우저 사용, 터미널 명령 실행(권한 허가 필요) 등의 도구를 사용하여 단순 코드 완성이나 기술 지원을 넘어서는 도움을 제공합니다. Cline은 Model Context Protocol(MCP)를 사용하여 새로운 도구를 만들고 자신의 기능을 확장할 수도 있습니다. 자율적인 AI 스크립트는 일반적으로 샌드박스 환경에서 실행되지만, 이 확장 프로그램은 모든 파일 변경 및 터미널 명령을 승인할 수 있는 사람이 개입가능한 GUI를 제공하여, 에이전트형 AI의 잠재력을 보다 안전하고 쉽게 탐색할 수 있도록 합니다.
|
||||
|
||||
1. 작업을 입력하고, 목업을 기능하는 앱으로 변환하거나 스크린샷으로 버그를 수정합니다.
|
||||
2. Cline은 파일 구조와 소스코드 AST의 분석, 정규식 검색 실행, 관련 파일 읽기부터 시작하여 기존 프로젝트를 파악합니다. 또한, 어떤 정보를 컨텍스트에 추가할지를 신중하게 관리하여, 대규모 복잡한 프로젝트에서도 컨텍스트 윈도우를 과부하시키지 않으면서도 효과적인 지원을 제공합니다.
|
||||
3. Cline이 필요한 정보를 얻은 후 다음과 같은 작업을 할 수 있습니다:
|
||||
- 파일 생성과 편집 + 린터/컴파일러 오류 모니터링을 수행하여 누락된 임포트나 구문 오류 등의 문제를 자동으로 수정합니다.
|
||||
- 터미널에서 명령을 직접 실행하고 작업 중에 출력을 모니터링합니다. 이를 통해 파일 편집 후 개발 서버의 문제에 대응할 수 있습니다.
|
||||
- 웹 개발 작업에서는 헤드리스 브라우저로 사이트를 실행하고, 클릭, 입력, 스크롤, 스크린샷과 콘솔 로그 캡처를 수행하여 런타임 오류나 시각적 버그를 수정합니다.
|
||||
4. 작업이 완료되면 Cline은 `open -a "Google Chrome" index.html`과 같은 터미널 명령을 제공하여 버튼 클릭 한 번으로 결과를 확인할 수 있도록 합니다.
|
||||
|
||||
> [!TIP]
|
||||
> `CMD/CTRL + Shift + P` 단축키를 사용하여 명령 팔레트를 열고 "Cline: Open In New Tab"을 입력하여 에디터의 탭으로 확장 프로그램을 엽니다. 이를 통해 파일 탐색기와 병행하여 Cline을 사용하고 워크스페이스의 변경을 더 명확하게 확인할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
<img align="right" width="340" src="https://github.com/user-attachments/assets/3cf21e04-7ce9-4d22-a7b9-ba2c595e88a4">
|
||||
|
||||
### 어떤 API나 모델이든 사용 가능
|
||||
|
||||
Cline은 OpenRouter, Anthropic, OpenAI, Google Gemini, AWS Bedrock, Azure, GCP Vertex 등의 API 제공자를 지원합니다. 또한 OpenAI 호환 API를 설정하거나 LM Studio/Ollama를 통해 로컬 모델을 사용할 수도 있습니다. OpenRouter를 사용하는 경우, 확장 프로그램에서 최신 모델 목록을 가져와 바로 최신 모델을 사용할 수 있게 합니다.
|
||||
|
||||
또한, Cline은 전체 작업 루프와 개별 요청별로 토큰 사용량과 API 비용을 추적하여, 진행 중인 작업의 비용을 실시간으로 확인할 수 있도록 도와줍니다.
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="370" src="https://github.com/user-attachments/assets/81be79a8-1fdb-4028-9129-5fe055e01e76">
|
||||
|
||||
### 터미널에서 명령 실행
|
||||
|
||||
VSCode v1.93의 새로운 [셸 통합 업데이트](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api) 덕분에, Cline은 터미널에서 명령을 직접 실행하고 출력을 받을 수 있습니다. 이를 통해 패키지 설치나 빌드 스크립트 실행부터 애플리케이션 배포, 데이터베이스 관리, 테스트 실행까지 광범위한 작업을 수행할 수 있습니다. Cline은 개발 환경과 도구 체인에 맞추어 정확하게 작업을 실행합니다.
|
||||
|
||||
개발 서버와 같은 오래 실행되는 프로세스의 경우, "실행 중 계속"(Proceed While Running) 버튼을 사용하여 명령이 백그라운드에서 실행되는 동안 Cline이 작업을 계속할 수 있게 합니다. 작업이 진행되는 동안 Cline은 새로운 터미널 출력을 실시간으로 확인하여, 파일 편집 시 발생하는 컴파일 오류와 같은 문제에 즉시 대응할 수 있습니다.
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="400" src="https://github.com/user-attachments/assets/c5977833-d9b8-491e-90f9-05f9cd38c588">
|
||||
|
||||
### 파일 생성과 편집
|
||||
|
||||
Cline은 에디터 내에서 파일을 생성 및 편집하고 변경의 Diff 뷰로 표시합니다. Diff 뷰 에디터에서 Cline의 변경을 직접 편집하거나 되돌릴 수 있으며, 채팅에서 피드백을 제공하여 만족할 때까지 개선 요청할 수 있습니다. Cline은 린터/컴파일러 오류(누락된 임포트, 구문 오류 등)도 모니터링하고 발생한 문제를 자동으로 수정합니다.
|
||||
|
||||
Cline에 의한 모든 변경은 파일의 타임라인에 기록되어 필요할 때 변경을 추적하고 되돌릴 수 있는 간단한 방법을 제공합니다.
|
||||
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="370" src="https://github.com/user-attachments/assets/bc2e85ba-dfeb-4fe6-9942-7cfc4703cbe5">
|
||||
|
||||
### 브라우저 사용
|
||||
|
||||
Claude 3.5 Sonnet의 새로운 [컴퓨터 사용](https://www.anthropic.com/news/3-5-models-and-computer-use) 기능으로 인해, Cline은 브라우저를 실행하고 요소를 클릭하고 텍스트를 입력하고 스크롤하며 각 단계에서 스크린샷과 콘솔 로그를 캡처할 수 있습니다. 이를 통해 인터랙티브한 디버깅, 엔드투엔드 테스트, 심지어 일반적인 웹 탐색까지 가능해집니다. 이로 인해 오류 로그를 수동으로 복사 & 붙여넣기 할 필요 없이 시각적 버그나 런타임 문제를 자율적으로 수정할 수 있습니다.
|
||||
|
||||
Cline에게 "앱을 테스트해줘"라고 요청하면, `npm run dev`와 같은 명령을 실행하고 로컬에서 실행 중인 개발 서버를 브라우저에서 실행하여 일련의 테스트를 수행하고 모든 것이 정상적으로 작동하는지 확인합니다. [데모는 여기를 참조하세요.](https://x.com/sdrzn/status/1850880547825823989)
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="350" src="https://github.com/user-attachments/assets/ac0efa14-5c1f-4c26-a42d-9d7c56f5fadd">
|
||||
|
||||
### "도구를 추가 해주세요."
|
||||
|
||||
Cline은 [Model Context Protocol](https://github.com/modelcontextprotocol)을 활용하여 커스텀 도구를 생성하고 기능을 확장할 수 있습니다. 기존의 [커뮤니티 서버](https://github.com/modelcontextprotocol/servers)를 사용할 수도 있지만, Cline은 사용자의 워크플로우에 최적화된 도구를 직접 제작하고 설치할 수도 있습니다. "~ 도구를 추가해주세요."라고 요청만 하면, Cline은 새로운 MCP 서버 생성부터 확장 프로그램 내 설치까지 모두 자동으로 처리합니다. 이러한 커스텀 도구는 Cline의 툴키트의 일부가 되어 향후 작업에서 사용할 수 있게 됩니다.
|
||||
|
||||
- "Jira 티켓을 가져오는 도구를 추가해주세요": 티켓 AC를 가져와 Cline에게 작업을 요청
|
||||
- "AWS EC2를 관리하는 도구를 추가해주세요": 서버 메트릭을 확인하고 인스턴스를 확장 또는 축소
|
||||
- "최신 PagerDuty 인시던트를 가져오는 도구를 추가해주세요": 최신 장애 정보를 가져와 Cline에게 버그 수정 요청
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="360" src="https://github.com/user-attachments/assets/7fdf41e6-281a-4b4b-ac19-020b838b6970">
|
||||
|
||||
### 컨텍스트 추가
|
||||
|
||||
**`@url`:** URL을 붙여넣으면 확장이 해당 페이지를 가져와 Markdown으로 변환합니다. 최신 문서를 Cline에게 제공할 때 유용합니다.
|
||||
|
||||
**`@problems`:** Cline이 수정할 워크스페이스 오류와 경고(Problems' panel)를 추가합니다.
|
||||
|
||||
**`@file`:** 파일의 내용을 추가하여, 파일을 읽는 데 API 요청을 허비하지 않고도 Cline이 접근할 수 있도록 합니다. (+ 파일 검색 가능)
|
||||
|
||||
**`@folder`:** 폴더 내 모든 파일을 한 번에 추가하여 워크플로우를 더욱 빠르게 진행할 수 있습니다.
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="350" src="https://github.com/user-attachments/assets/140c8606-d3bf-41b9-9a1f-4dbf0d4c90cb">
|
||||
|
||||
### 체크포인트: 비교 및 복원
|
||||
|
||||
Cline이 작업을 진행하는 동안 확장 프로그램은 각 단계에서 워크스페이스의 스냅샷을 저장합니다. “Compare” 버튼을 사용하여 스냅샷과 현재 워크스페이스의 차이를 확인하고, “Restore” 버튼을 사용하여 해당 시점으로 롤백할 수 있습니다.
|
||||
|
||||
예를 들어, 로컬 웹 서버에서 작업 중일 때 “Restore Workspace Only”을 사용하여 서로 다른 버전의 앱을 신속하게 테스트하고, “Restore Task and Workspace”을 사용하여 계속 진행할 버전을 찾을 수 있습니다. 이를 통해 진행 상황을 잃지 않고 안전하게 다양한 접근 방식을 실험할 수 있습니다.
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
## 기여
|
||||
|
||||
프로젝트에 기여하려면, [기여 가이드](CONTRIBUTING.md)에서 기본 사항을 익히세요. 또한, [Discord](https://discord.gg/cline)에 참여하여 `#contributors` 채널에서 다른 기여자들과 이야기할 수 있습니다. 풀타임 직업을 찾고 있다면, [채용 페이지](https://cline.bot/join-us)에서 열려있는 포지션을 확인하세요.
|
||||
|
||||
<details>
|
||||
<summary>로컬 개발 방법</summary>
|
||||
|
||||
1. 리포지토리를 클론합니다 _(Requires [git-lfs](https://git-lfs.com/))_:
|
||||
```bash
|
||||
git clone https://github.com/cline/cline.git
|
||||
```
|
||||
2. 프로젝트를 VSCode에서 엽니다:
|
||||
```bash
|
||||
code cline
|
||||
```
|
||||
3. 확장 프로그램과 webview-gui의 필요한 의존성을 설치합니다:
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
4. `F5`를 눌러(또는 `Run`->`Start Debugging`), 확장 프로그램이 로드된 새로운 VSCode 창을 엽니다. (프로젝트 빌드에 문제가 있는 경우, [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers)을 설치해야 할 수도 있습니다.)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Pull Request 생성 방법</summary>
|
||||
|
||||
1. PR을 만들기 전, 변경 사항을 기록하는 changeset 항목을 생성:
|
||||
```bash
|
||||
npm run changeset
|
||||
```
|
||||
이후 프롬프트에서 다음 정보를 입력하세요:
|
||||
- 변경 유형 (major, minor, patch)
|
||||
- `major` → 호환되지 않는 변경 (1.0.0 → 2.0.0)
|
||||
- `minor` → 새로운 기능 추가 (1.0.0 → 1.1.0)
|
||||
- `patch` → 버그 수정 (1.0.0 → 1.0.1)
|
||||
- 변경 사항 설명 입력
|
||||
|
||||
2. 변경 사항과 생성된 `.changeset` 파일을 커밋 후 브랜치를 푸시하고 GitHub에서 PR을 생성하세요.
|
||||
|
||||
3. 브랜치를 푸시하고 GitHub에서 PR을 생성하세요. CI가 다음과 같은 작업을 수행합니다:
|
||||
- 테스트 및 코드 검증 실행
|
||||
- Changesetbot이 버전 변경 영향을 보여주는 코멘트를 생성
|
||||
- 브랜치가 메인에 머지되면, Changesetbot이 버전 패키지 PR을 생성
|
||||
- 버전 패키지 PR이 머지되면, 새로운 릴리즈가 게시됨
|
||||
|
||||
</details>
|
||||
|
||||
## 라이센스
|
||||
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](/LICENSE)
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
Conheça o Cline: um assistente de IA que pode usar seu **CLI** e **Editor**.
|
||||
|
||||
Graças às [habilidades avançadas do Claude 3.5 Sonnet](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf), o Cline pode lidar com tarefas complexas de desenvolvimento de software passo a passo. Com ferramentas que permitem criar e editar arquivos, explorar grandes projetos, usar o navegador e executar comandos no terminal (com sua aprovação), ele pode ajudar você de maneiras que vão além da inclusão de código ou suporte técnico. O Cline pode é capaz inclusive de usar o Model Context Protocol (MCP) para criar novas ferramentas e expandir seus próprios recursos. Embora os scripts de IA autônomas tradicionalmente sejam executados em ambientes isolados, esta extensão oferece uma GUI com um humano no circuito para aprovar cada alteração de arquivo e comando de terminal, fornecendo uma maneira segura e acessível de explorar todo o potencial da IA.
|
||||
Graças às [habilidades avançadas do Claude 3.7 Sonnet](https://www.anthropic.com/claude/sonnet), o Cline pode lidar com tarefas complexas de desenvolvimento de software passo a passo. Com ferramentas que permitem criar e editar arquivos, explorar grandes projetos, usar o navegador e executar comandos no terminal (com sua aprovação), ele pode ajudar você de maneiras que vão além da inclusão de código ou suporte técnico. O Cline pode é capaz inclusive de usar o Model Context Protocol (MCP) para criar novas ferramentas e expandir seus próprios recursos. Embora os scripts de IA autônomas tradicionalmente sejam executados em ambientes isolados, esta extensão oferece uma GUI com um humano no circuito para aprovar cada alteração de arquivo e comando de terminal, fornecendo uma maneira segura e acessível de explorar todo o potencial da IA.
|
||||
|
||||
1. Insira sua tarefa e adicione imagens para transformar mockups em aplicativos funcionais ou corrigir erros através de capturas de tela.
|
||||
|
||||
@@ -158,4 +158,4 @@ Para contribuir com o projeto, comece com nosso [Guia de Contribuição](CONTRIB
|
||||
|
||||
## Licença
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
认识 Cline,一个可以使用你的 **CLI** 和 **编辑器** 的 AI 助手。
|
||||
|
||||
感谢 [Claude 3.5 Sonnet 的代理编码能力](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf),Cline 可以一步步处理复杂的软件开发任务。通过允许他创建和编辑文件、探索大型项目、使用浏览器和执行终端命令(在你授予权限后),他可以提供超越代码完成或技术支持的帮助。Cline 甚至可以使用 Model Context Protocol (MCP) 创建新工具并扩展自己的能力。虽然自主 AI 脚本传统上在沙盒环境中运行,但此扩展提供了一个人机交互的 GUI 来批准每个文件更改和终端命令,提供了一种安全且可访问的方式来探索代理 AI 的潜力。
|
||||
感谢 [Claude 3.7 Sonnet 的代理编码能力](https://www.anthropic.com/claude/sonnet),Cline 可以一步步处理复杂的软件开发任务。通过允许他创建和编辑文件、探索大型项目、使用浏览器和执行终端命令(在你授予权限后),他可以提供超越代码完成或技术支持的帮助。Cline 甚至可以使用 Model Context Protocol (MCP) 创建新工具并扩展自己的能力。虽然自主 AI 脚本传统上在沙盒环境中运行,但此扩展提供了一个人机交互的 GUI 来批准每个文件更改和终端命令,提供了一种安全且可访问的方式来探索代理 AI 的潜力。
|
||||
|
||||
1. 输入你的任务并添加图像,将模型转换为功能应用程序或通过截图修复错误。
|
||||
2. Cline 首先分析你的文件结构和源代码 AST,运行正则表达式搜索,并阅读相关文件以了解现有项目。通过仔细管理添加到上下文中的信息,Cline 即使在大型复杂项目中也能提供有价值的帮助,而不会使上下文窗口过载。
|
||||
@@ -158,5 +158,5 @@ Cline 所做的所有更改都会记录在你的文件时间轴中,提供了
|
||||
|
||||
## 许可证
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
|
||||
@@ -2,46 +2,48 @@
|
||||
|
||||
## 我們的承諾
|
||||
|
||||
為了促進一個開放和歡迎的環境,我們作為貢獻者和維護者承諾,使我們的項目和社區的參與對每個人來說都是一個無騷擾的體驗,不論年齡、體型、殘疾、種族、性別特徵、性別認同和表達、經驗水平、教育程度、社會經濟地位、國籍、個人外貌、種族、宗教或性取向。
|
||||
為了營造開放且友善的環境,我們身為貢獻者與維護者,承諾讓參與本專案及社群的體驗,對每個人都不帶有騷擾,不論其年齡、體型、身心障礙、族裔、性徵、性別認同與表現、經驗程度、教育程度、社經地位、國籍、個人外表、種族、宗教信仰、或性傾向。
|
||||
|
||||
## 我們的標準
|
||||
## 我們的準則
|
||||
|
||||
有助於創造積極環境的行為示例包括:
|
||||
有助於創造正面環境的行為包括:
|
||||
|
||||
- 使用歡迎和包容的語言
|
||||
- 尊重不同的觀點和經驗
|
||||
- 優雅地接受建設性的批評
|
||||
- 專注於對社區最有利的事情
|
||||
- 對其他社區成員表示同情
|
||||
- 使用友善和包容的語言
|
||||
- 尊重不同的觀點與經驗
|
||||
- 優雅地接受建設性批評
|
||||
- 著重於對社群最有利的事情
|
||||
- 對其他社群成員展現同理心
|
||||
|
||||
參與者不可接受的行為示例包括:
|
||||
參與者不可接受的行為包括:
|
||||
|
||||
- 使用性化語言或圖像以及不受歡迎的性注意或挑逗
|
||||
- 騷擾、侮辱/貶低性評論和個人或政治攻擊
|
||||
- 公開或私下騷擾
|
||||
- 未經明確許可發布他人的私人信息,例如物理或電子地址
|
||||
- 其他在專業環境中合理認為不適當的行為
|
||||
- 使用帶有性暗示的言語或影像,以及不受歡迎的性關注或騷擾
|
||||
- 挑釁、羞辱/貶低他人的評論,以及人身或政治攻擊
|
||||
- 公開或私下的騷擾行為
|
||||
- 未經他人明確許可,公開他人的私人資料,如實體或電子郵件地址
|
||||
- 其他在專業環境中可被合理認定為不恰當的行為
|
||||
|
||||
## 我們的責任
|
||||
|
||||
項目維護者有責任澄清可接受行為的標準,並預期對任何不可接受行為的實例採取適當和公平的糾正行動。
|
||||
專案維護者有責任釐清可接受行為的標準,並應對任何不可接受的行為採取適當且公平的糾正措施。
|
||||
|
||||
項目維護者有權利和責任刪除、編輯或拒絕與本行為準則不符的評論、提交、代碼、維基編輯、問題和其他貢獻,或暫時或永久禁止任何他們認為不適當、威脅、冒犯或有害的貢獻者。
|
||||
專案維護者有權利和責任移除、編輯或拒絕不符合本行為準則的評論、提交、程式碼、維基編輯、議題和其他貢獻,或暫時或永久封鎖任何他們認為有不當、威脅、冒犯或有害行為的貢獻者。
|
||||
|
||||
## 範圍
|
||||
## 範疇
|
||||
|
||||
此行為準則適用於項目空間內以及當個人代表項目或其社區時的公共空間。代表項目或社區的示例包括使用官方項目電子郵件地址、通過官方社交媒體帳戶發布或作為在線或離線活動的指定代表。項目的代表可能由項目維護者進一步定義和澄清。
|
||||
本行為準則適用於專案空間及公開場合,當個人代表本專案或其社群時都必須遵守。代表本專案或社群的情況包括:使用官方專案電子郵件地址、透過官方社群媒體帳號發文,或在線上或實體活動中擔任指定代表。專案維護者可進一步定義並釐清專案代表的其他情況。
|
||||
|
||||
## 執行
|
||||
|
||||
濫用、騷擾或其他不可接受行為的實例可以通過聯繫項目團隊 hi@cline.bot 來報告。所有投訴將被審查和調查,並將根據情況作出必要和適當的回應。項目團隊有義務對事件的報告者保密。具體執行政策的詳細信息可能會單獨發布。
|
||||
如發生辱罵、騷擾或其他不可接受的行為,請透過 hi@cline.bot 聯絡專案團隊回報。所有申訴都將被審查和調查,並做出必要且合適的回應。專案團隊有義務為事件回報者保密。具體執行政策的更多細節可能另行公佈。
|
||||
|
||||
未能善意遵循或執行行為準則的項目維護者可能會面臨由項目領導層其他成員決定的暫時或永久後果。
|
||||
未遵守或未切實執行本行為準則的專案維護者,可能會面臨由專案領導團隊其他成員所決定的暫時或永久的處置。
|
||||
|
||||
## 歸屬
|
||||
## 來源說明
|
||||
|
||||
此行為準則改編自 [Contributor Covenant][homepage],版本 1.4,可在 https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 獲得。
|
||||
本行為準則改編自[貢獻者公約][homepage]第 1.4 版,可在此查閱:
|
||||
https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
有關此行為準則的常見問題的答案,請參見 https://www.contributor-covenant.org/faq
|
||||
關於本行為準則的常見問題解答,請參考:
|
||||
https://www.contributor-covenant.org/faq
|
||||
|
||||
@@ -1,82 +1,85 @@
|
||||
# 貢獻於 Cline
|
||||
# 貢獻至 Cline
|
||||
|
||||
我們很高興您有興趣為 Cline 做出貢獻。無論您是修復錯誤、添加功能還是改進我們的文檔,每一個貢獻都讓 Cline 更加智能!為了保持我們的社區充滿活力和歡迎,所有成員必須遵守我們的[行為準則](CODE_OF_CONDUCT.md)。
|
||||
我們非常感謝您有意願貢獻至 Cline。無論是修正程式錯誤、新增功能或改善文件,每一份貢獻都能讓 Cline 更加出色!為了維持社群的活力與友善,所有成員都必須遵守我們的[行為準則](CODE_OF_CONDUCT.md)。
|
||||
|
||||
## 報告錯誤或問題
|
||||
## 回報程式錯誤或問題
|
||||
|
||||
錯誤報告有助於讓 Cline 對每個人都更好!在創建新問題之前,請[搜索現有問題](https://github.com/cline/cline/issues)以避免重複。當您準備報告錯誤時,請前往我們的[問題頁面](https://github.com/cline/cline/issues/new/choose),您會找到一個模板來幫助您填寫相關信息。
|
||||
程式錯誤回報能幫助 Cline 變得更好!在建立新的議題之前,請先[搜尋現有議題](https://github.com/cline/cline/issues),避免重複。當您準備好回報程式錯誤時,請前往我們的[議題頁面](https://github.com/cline/cline/issues/new/choose),您會找到協助填寫相關資訊的範本。
|
||||
|
||||
<blockquote class='warning-note'>
|
||||
🔐 <b>重要:</b> 如果您發現安全漏洞,請使用<a href="https://github.com/cline/cline/security/advisories/new">Github 安全工具私下報告</a>。
|
||||
🔐 <b>重要:</b> 若您發現安全性漏洞,請使用 <a href="https://github.com/cline/cline/security/advisories/new">GitHub 安全性工具進行私密回報</a>。
|
||||
</blockquote>
|
||||
|
||||
## 決定要做什麼
|
||||
## 決定要處理的工作
|
||||
|
||||
尋找一個好的首次貢獻?查看標有["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue)或["help wanted"](https://github.com/cline/cline/labels/help%20wanted)的問題。這些是專門為新貢獻者和我們希望得到幫助的領域策劃的!
|
||||
想找適合第一次貢獻的工作嗎?請檢視標示為[「good first issue」](https://github.com/cline/cline/labels/good%20first%20issue)或[「help wanted」](https://github.com/cline/cline/labels/help%20wanted)的議題。這些議題特別適合新手貢獻者,我們也非常歡迎您的協助!
|
||||
|
||||
我們也歡迎對我們[文檔](https://github.com/cline/cline/tree/main/docs)的貢獻!無論是修正錯別字、改進現有指南還是創建新的教育內容 - 我們希望建立一個由社區驅動的資源庫,幫助每個人充分利用 Cline。您可以從深入研究 `/docs` 並尋找需要改進的領域開始。
|
||||
我們也歡迎對[文件](https://github.com/cline/cline/tree/main/docs)的貢獻!無論是修正錯字、改善現有指南或建立新的教學內容,我們都期待能建立一個由社群共同維護的知識庫,協助每個人充分運用 Cline。您可以從 `/docs` 開始,尋找需要改善的地方。
|
||||
|
||||
如果您計劃開發一個更大的功能,請先創建一個[功能請求](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop),以便我們討論它是否符合 Cline 的願景。
|
||||
若您計畫處理較大的功能,請先建立一個[功能請求](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop),以便我們討論該功能是否符合 Cline 的願景。
|
||||
|
||||
## 開發設置
|
||||
## 開發環境設定
|
||||
|
||||
1. **VS Code 擴展**
|
||||
1. **VS Code 擴充套件**
|
||||
- 開啟專案時,VS Code 會提示您安裝建議的擴充套件
|
||||
- 這些擴充套件是開發所需,請接受所有安裝提示
|
||||
- 若您已關閉提示,可從擴充套件面板手動安裝
|
||||
|
||||
- 打開項目時,VS Code 會提示您安裝推薦的擴展
|
||||
- 這些擴展是開發所需的 - 請接受所有安裝提示
|
||||
- 如果您忽略了提示,可以從擴展面板手動安裝它們
|
||||
2. **本機開發**
|
||||
- 執行 `npm run install:all` 安裝相依套件
|
||||
- 執行 `npm run test` 在本機執行測試
|
||||
- 提交 PR 前,執行 `npm run format:fix` 格式化您的程式碼
|
||||
|
||||
2. **本地開發**
|
||||
- 運行 `npm run install:all` 安裝依賴項
|
||||
- 運行 `npm run test` 本地運行測試
|
||||
- 提交 PR 之前,運行 `npm run format:fix` 格式化您的代碼
|
||||
## 撰寫與提交程式碼
|
||||
|
||||
## 編寫和提交代碼
|
||||
任何人都可以貢獻程式碼至 Cline,但我們要求您遵守以下指引,以確保您的貢獻能順利整合:
|
||||
|
||||
任何人都可以為 Cline 貢獻代碼,但我們要求您遵循以下指南,以確保您的貢獻能夠順利集成:
|
||||
1. **保持 Pull Request 聚焦**
|
||||
- 每個 PR 限制在單一功能或錯誤修正
|
||||
- 將較大的變更拆分成較小且相關的 PR
|
||||
- 將變更拆分成邏輯性的提交,以便獨立審查
|
||||
|
||||
1. **保持 Pull Requests 集中**
|
||||
|
||||
- 將 PR 限制在單個功能或錯誤修復
|
||||
- 將較大的更改拆分為較小的相關 PR
|
||||
- 將更改分為邏輯提交,可以獨立審查
|
||||
|
||||
2. **代碼質量**
|
||||
|
||||
- 運行 `npm run lint` 檢查代碼風格
|
||||
- 運行 `npm run format` 自動格式化代碼
|
||||
- 所有 PR 必須通過包括 lint 和格式化在內的 CI 檢查
|
||||
2. **程式碼品質**
|
||||
- 執行 `npm run lint` 檢查程式碼風格
|
||||
- 執行 `npm run format` 自動格式化程式碼
|
||||
- 所有 PR 必須通過包含程式碼風格檢查與格式化的 CI 檢查
|
||||
- 提交前解決所有 ESLint 警告或錯誤
|
||||
- 遵循 TypeScript 最佳實踐並保持類型安全
|
||||
- 遵循 TypeScript 最佳實務並維持型別安全
|
||||
|
||||
3. **測試**
|
||||
- 為新功能新增測試
|
||||
- 執行 `npm test` 確保所有測試通過
|
||||
- 若您的變更影響現有測試,請更新測試
|
||||
- 適當時包含單元測試與整合測試
|
||||
|
||||
- 為新功能添加測試
|
||||
- 運行 `npm test` 確保所有測試通過
|
||||
- 如果您的更改影響現有測試,請更新它們
|
||||
- 在適當的地方包括單元測試和集成測試
|
||||
4. **使用 Changesets 管理版本**
|
||||
- 使用 `npm run changeset` 為任何面向使用者的變更建立 changeset
|
||||
- 選擇適當的版本升級:
|
||||
- `major` 重大變更 (1.0.0 → 2.0.0)
|
||||
- `minor` 新功能 (1.0.0 → 1.1.0)
|
||||
- `patch` 錯誤修正 (1.0.0 → 1.0.1)
|
||||
- 撰寫清晰且描述性的 changeset 訊息,說明影響
|
||||
- 僅文件變更不需建立 changeset
|
||||
|
||||
4. **提交指南**
|
||||
5. **提交指引**
|
||||
- 撰寫清晰且描述性的提交訊息
|
||||
- 使用慣用提交格式(例如:「feat:」、「fix:」、「docs:」)
|
||||
- 在提交中引用相關議題,使用 #issue-number
|
||||
|
||||
- 撰寫清晰、描述性的提交消息
|
||||
- 使用常規提交格式(例如 "feat:"、"fix:"、"docs:")
|
||||
- 在提交中引用相關問題,使用 #issue-number
|
||||
6. **提交前檢查**
|
||||
- 將您的分支 rebase 到最新的 main
|
||||
- 確保您的分支可以成功建置
|
||||
- 再次確認所有測試通過
|
||||
- 檢查您的變更是否包含除錯程式碼或 console 紀錄
|
||||
|
||||
5. **提交前**
|
||||
|
||||
- 將您的分支重新基於最新的 main
|
||||
- 確保您的分支成功構建
|
||||
- 仔細檢查所有測試是否通過
|
||||
- 檢查您的更改是否有任何調試代碼或控制台日誌
|
||||
|
||||
6. **Pull Request 描述**
|
||||
- 清楚地描述您的更改內容
|
||||
- 包括測試更改的步驟
|
||||
- 列出任何重大更改
|
||||
- 為 UI 更改添加截圖
|
||||
7. **Pull Request 說明**
|
||||
- 清楚描述您的變更內容
|
||||
- 包含測試變更的步驟
|
||||
- 列出任何重大變更
|
||||
- 若有使用者介面變更,請附上截圖
|
||||
|
||||
## 貢獻協議
|
||||
|
||||
通過提交 pull request,您同意您的貢獻將根據與項目相同的許可證([Apache 2.0](LICENSE))進行許可。
|
||||
提交 Pull Request 即表示您同意您的貢獻將依照專案相同的授權條款([Apache 2.0](LICENSE))進行授權。
|
||||
|
||||
記住:貢獻於 Cline 不僅僅是編寫代碼 - 這是關於成為一個塑造 AI 輔助開發未來的社區的一部分。讓我們一起創造一些驚人的東西!🚀
|
||||
請記住:貢獻至 Cline 不只是撰寫程式碼,更是成為塑造 AI 輔助開發未來的社群一份子。讓我們一起打造令人驚艷的成果吧!🚀
|
||||
|
||||
+98
-72
@@ -1,4 +1,8 @@
|
||||
# Cline – OpenRouter 上的 \#1
|
||||
<div align="center"><sub>
|
||||
<a href="https://github.com/cline/cline/blob/main/README.md" target="_blank">English</a> | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | 繁體中文 | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
|
||||
</sub></div>
|
||||
|
||||
# Cline – OpenRouter 第一名的 AI 工具
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
@@ -8,7 +12,7 @@
|
||||
<table>
|
||||
<tbody>
|
||||
<td align="center">
|
||||
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong>在 VS Marketplace 下載</strong></a>
|
||||
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong>從 VS Marketplace 下載</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://discord.gg/cline" target="_blank"><strong>Discord</strong></a>
|
||||
@@ -17,29 +21,29 @@
|
||||
<a href="https://www.reddit.com/r/cline/" target="_blank"><strong>r/cline</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>功能請求</strong></a>
|
||||
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>功能建議</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://cline.bot/join-us" target="_blank"><strong>我們正在招聘!</strong></a>
|
||||
<a href="https://docs.cline.bot/getting-started/getting-started-new-coders" target="_blank"><strong>新手上路</strong></a>
|
||||
</td>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
認識 Cline,一個可以使用你的 **CLI** 和 **編輯器** 的 AI 助手。
|
||||
認識 Cline,一個可以使用您的**命令列介面** (CLI) 和**程式編輯器** (Editor) 的 AI 助理。
|
||||
|
||||
感謝 [Claude 3.5 Sonnet 的代理編碼能力](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf),Cline 可以一步步處理複雜的軟件開發任務。通過允許他創建和編輯文件、探索大型項目、使用瀏覽器和執行終端命令(在你授予權限後),他可以提供超越代碼完成或技術支持的幫助。Cline 甚至可以使用 Model Context Protocol (MCP) 創建新工具並擴展自己的能力。雖然自主 AI 腳本傳統上在沙盒環境中運行,但此擴展提供了一個人機交互的 GUI 來批准每個文件更改和終端命令,提供了一種安全且可訪問的方式來探索代理 AI 的潛力。
|
||||
感謝 [Claude 3.7 Sonnet 的代理式程式設計能力](https://www.anthropic.com/claude/sonnet),Cline 能夠逐步處理複雜的軟體開發任務。透過能讓他建立和編輯檔案、探索大型專案、使用瀏覽器,以及執行終端機指令(在您授權後)的工具,他能以超越程式碼自動完成或技術支援的方式協助您。Cline 甚至能使用模型上下文協定(Model Context Protocol,MCP)來建立新工具並擴展自己的功能。雖然自主 AI 腳本傳統上會在沙箱環境中執行,但這個擴充套件提供了人機互動的圖形介面,讓您可以核准每個檔案變更和終端機指令,提供一個安全且容易使用的方式來探索代理式 AI 的潛力。
|
||||
|
||||
1. 輸入你的任務並添加圖像,將模型轉換為功能應用程序或通過截圖修復錯誤。
|
||||
2. Cline 首先分析你的文件結構和源代碼 AST,運行正則表達式搜索,並閱讀相關文件以了解現有項目。通過仔細管理添加到上下文中的信息,Cline 即使在大型複雜項目中也能提供有價值的幫助,而不會使上下文窗口過載。
|
||||
3. 一旦 Cline 獲得所需信息,他可以:
|
||||
- 創建和編輯文件 + 監控 linter/編譯器錯誤,從而主動修復諸如缺少導入和語法錯誤等問題。
|
||||
- 直接在你的終端中執行命令並監控其輸出,從而在編輯文件後對開發服務器問題做出反應。
|
||||
- 對於 Web 開發任務,Cline 可以在無頭瀏覽器中啟動網站,點擊、輸入、滾動並捕獲截圖和控制台日誌,從而修復運行時錯誤和視覺錯誤。
|
||||
4. 當任務完成時,Cline 將通過終端命令如 `open -a "Google Chrome" index.html` 向你展示結果,你可以通過點擊按鈕運行該命令。
|
||||
1. 輸入您的任務,並可以加入圖片來將設計稿轉換成功能性應用程式,或使用截圖來修正錯誤。
|
||||
2. Cline 會先分析您的檔案結構和程式碼 AST、執行正規表達式搜尋,並讀取相關檔案,以便在現有專案中快速掌握狀況。透過仔細管理加入上下文的資訊,Cline 可以在不超過上下文視窗的情況下,為大型且複雜的專案提供有價值的協助。
|
||||
3. 一旦 Cline 取得所需資訊後,他可以:
|
||||
- 建立和編輯檔案,並在過程中監控程式碼檢查工具/編譯器的錯誤,讓他能主動修正缺少的匯入語句和語法錯誤等問題。
|
||||
- 直接在您的終端機中執行指令並監控其輸出,讓他能夠在編輯檔案後回應開發伺服器的問題。
|
||||
- 對於網頁開發任務,Cline 可以在無頭瀏覽器中啟動網站、點選、輸入、捲動並擷取螢幕截圖和主控台記錄,讓他能修正執行時錯誤和視覺問題。
|
||||
4. 當任務完成時,Cline 會以終端機指令(如 `open -a "Google Chrome" index.html`)向您呈現結果,您只需點選按鈕即可執行。
|
||||
|
||||
> [!提示]
|
||||
> 使用 `CMD/CTRL + Shift + P` 快捷鍵打開命令面板並輸入 "Cline: Open In New Tab" 將擴展作為標籤在編輯器中打開。這讓你可以與文件資源管理器並排使用 Cline,更清楚地看到他如何改變你的工作空間。
|
||||
> [!TIP]
|
||||
> 使用 `CMD/CTRL + Shift + P` 快速鍵開啟命令選擇區,輸入「Cline: Open In New Tab」即可在編輯器中以分頁方式開啟擴充套件。這讓您可以同時檢視檔案總管,並更清楚地看到 Cline 如何變更您的工作區。
|
||||
|
||||
---
|
||||
|
||||
@@ -47,115 +51,137 @@
|
||||
|
||||
### 使用任何 API 和模型
|
||||
|
||||
Cline 支持 OpenRouter、Anthropic、OpenAI、Google Gemini、AWS Bedrock、Azure 和 GCP Vertex 等 API 提供商。你還可以配置任何兼容 OpenAI 的 API,或通過 LM Studio/Ollama 使用本地模型。如果你使用 OpenRouter,擴展會獲取他們的最新模型列表,讓你在新模型可用時立即使用。
|
||||
Cline 支援 OpenRouter、Anthropic、OpenAI、Google Gemini、AWS Bedrock、Azure 和 GCP Vertex 等 API 提供者。您也可以設定任何與 OpenAI 相容的 API,或透過 LM Studio/Ollama 使用本機模型。若您使用 OpenRouter,此擴充套件會擷取他們最新的模型列表,讓您能在新模型推出時立即使用。
|
||||
|
||||
擴展還會跟蹤整個任務循環和單個請求的總令牌和 API 使用成本,讓你在每一步都了解支出情況。
|
||||
|
||||
<!-- 透明像素以在浮動圖像後創建換行 -->
|
||||
此擴充套件也會追蹤整個任務迴圈和個別請求的 token 總數和 API 使用成本,讓您隨時掌握費用支出。
|
||||
|
||||
<!-- 透明像素用於浮動圖片後的換行 -->
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="370" src="https://github.com/user-attachments/assets/81be79a8-1fdb-4028-9129-5fe055e01e76">
|
||||
|
||||
### 在終端中運行命令
|
||||
### 在終端機中執行指令
|
||||
|
||||
感謝 VSCode v1.93 中的新 [終端 shell 集成更新](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api),Cline 可以直接在你的終端中執行命令並接收輸出。這使他能夠執行廣泛的任務,從安裝包和運行構建腳本到部署應用程序、管理數據庫和執行測試,同時適應你的開發環境和工具鏈以正確完成工作。
|
||||
感謝 [VSCode v1.93 的終端機整合更新](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api),Cline 可以直接在您的終端機中執行指令並接收輸出。這讓他能執行各種任務,從安裝套件和執行建置腳本到部署應用程式、管理資料庫和執行測試,同時適應您的開發環境和工具鏈,以正確完成工作。
|
||||
|
||||
對於長時間運行的進程如開發服務器,使用“在運行時繼續”按鈕讓 Cline 在命令後台運行時繼續任務。當 Cline 工作時,他會在過程中收到任何新的終端輸出通知,讓他對可能出現的問題做出反應,例如編輯文件時的編譯時錯誤。
|
||||
|
||||
<!-- 透明像素以在浮動圖像後創建換行 -->
|
||||
對於開發伺服器等長時間執行的程序,使用「繼續執行中的程序」按鈕讓 Cline 在指令於背景執行時繼續任務。當 Cline 工作時,他會收到任何新的終端機輸出通知,讓他能回應可能出現的問題,例如編輯檔案時的編譯錯誤。
|
||||
|
||||
<!-- 透明像素用於浮動圖片後的換行 -->
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="400" src="https://github.com/user-attachments/assets/c5977833-d9b8-491e-90f9-05f9cd38c588">
|
||||
|
||||
### 創建和編輯文件
|
||||
### 建立和編輯檔案
|
||||
|
||||
Cline 可以直接在你的編輯器中創建和編輯文件,向你展示更改的差異視圖。你可以直接在差異視圖編輯器中編輯或恢復 Cline 的更改,或在聊天中提供反饋,直到你對結果滿意。Cline 還會監控 linter/編譯器錯誤(缺少導入、語法錯誤等),以便他在過程中自行修復出現的問題。
|
||||
Cline 可以直接在您的編輯器中建立和編輯檔案,並顯示變更的差異檢視。您可以直接在差異檢視編輯器中編輯或還原 Cline 的變更,或在聊天中提供意見回饋,直到您滿意結果為止。Cline 也會監控程式碼檢查工具/編譯器的錯誤(缺少的匯入語句、語法錯誤等),讓他能自行修正過程中出現的問題。
|
||||
|
||||
Cline 所做的所有更改都會記錄在你的文件時間軸中,提供了一種簡單的方法來跟蹤和恢復修改(如果需要)。
|
||||
|
||||
<!-- 透明像素以在浮動圖像後創建換行 -->
|
||||
所有 Cline 做的變更都會記錄在您檔案的時間軸中,提供簡單的方式來追蹤和還原修改。
|
||||
|
||||
<!-- 透明像素用於浮動圖片後的換行 -->
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="370" src="https://github.com/user-attachments/assets/bc2e85ba-dfeb-4fe6-9942-7cfc4703cbe5">
|
||||
|
||||
### 使用瀏覽器
|
||||
|
||||
借助 Claude 3.5 Sonnet 的新 [計算機使用](https://www.anthropic.com/news/3-5-models-and-computer-use) 功能,Cline 可以啟動瀏覽器,點擊元素,輸入文本和滾動,在每一步捕獲截圖和控制台日誌。這允許進行交互式調試、端到端測試,甚至是一般的網頁使用!這使他能夠自主修復視覺錯誤和運行時問題,而無需你親自操作和複製粘貼錯誤日誌。
|
||||
透過 Claude 3.5 Sonnet 的新[電腦使用](https://www.anthropic.com/news/3-5-models-and-computer-use)功能,Cline 可以啟動瀏覽器、點選元素、輸入文字和捲動,在每個步驟擷取螢幕截圖和主控台記錄。這讓互動式除錯、端對端測試,甚至一般網頁使用成為可能!這讓他能獨立修正視覺問題和執行時錯誤,而不需要您手動複製錯誤記錄。
|
||||
|
||||
試試讓 Cline “測試應用程序”,看看他如何運行 `npm run dev` 命令,在瀏覽器中啟動你本地運行的開發服務器,並執行一系列測試以確認一切正常。[在這裡查看演示。](https://x.com/sdrzn/status/1850880547825823989)
|
||||
|
||||
<!-- 透明像素以在浮動圖像後創建換行 -->
|
||||
試著請 Cline 「測試應用程式」,觀察他如何執行 `npm run dev`、在瀏覽器中啟動您的本機開發伺服器,並執行一系列測試來確認一切正常運作。[點此觀看示範](https://x.com/sdrzn/status/1850880547825823989)。
|
||||
|
||||
<!-- 透明像素用於浮動圖片後的換行 -->
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="350" src="https://github.com/user-attachments/assets/ac0efa14-5c1f-4c26-a42d-9d7c56f5fadd">
|
||||
|
||||
### “添加一個工具……”
|
||||
### 「新增一個工具來...」
|
||||
|
||||
感謝 [Model Context Protocol](https://github.com/modelcontextprotocol),Cline 可以通過自定義工具擴展他的能力。雖然你可以使用 [社區製作的服務器](https://github.com/modelcontextprotocol/servers),但 Cline 可以創建和安裝適合你特定工作流程的工具。只需讓 Cline “添加一個工具”,他將處理所有事情,從創建新的 MCP 服務器到將其安裝到擴展中。這些自定義工具將成為 Cline 工具包的一部分,準備在未來的任務中使用。
|
||||
感謝[模型上下文協定](https://github.com/modelcontextprotocol),Cline 可以透過自訂工具擴展他的功能。雖然您可以使用[社群製作的伺服器](https://github.com/modelcontextprotocol/servers),但 Cline 可以改為建立專門為您的工作流程量身打造的工具。只要請 Cline 「新增工具」,他就會處理所有事情,從建立新的 MCP 伺服器到將其安裝到擴充套件中。這些自訂工具就會成為 Cline 工具箱的一部分,隨時可用於未來的任務。
|
||||
|
||||
- “添加一個獲取 Jira 工單的工具”:檢索工單 AC 並讓 Cline 開始工作
|
||||
- “添加一個管理 AWS EC2 的工具”:檢查服務器指標並上下擴展實例
|
||||
- “添加一個獲取最新 PagerDuty 事件的工具”:獲取詳細信息並讓 Cline 修復錯誤
|
||||
|
||||
<!-- 透明像素以在浮動圖像後創建換行 -->
|
||||
- 「新增一個擷取 Jira 工單的工具」:取得工單驗收條件並讓 Cline 開始工作
|
||||
- 「新增一個管理 AWS EC2 的工具」:檢查伺服器指標並調整執行個體規模
|
||||
- 「新增一個擷取最新 PagerDuty 事件的工具」:取得詳細資訊並請 Cline 修復錯誤
|
||||
|
||||
<!-- 透明像素用於浮動圖片後的換行 -->
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="360" src="https://github.com/user-attachments/assets/7fdf41e6-281a-4b4b-ac19-020b838b6970">
|
||||
|
||||
### 添加上下文
|
||||
### 新增上下文
|
||||
|
||||
**`@url`:** 粘貼一個 URL 以供擴展獲取並轉換為 markdown,當你想給 Cline 提供最新文檔時非常有用
|
||||
|
||||
**`@problems`:** 添加工作區錯誤和警告(“問題”面板)以供 Cline 修復
|
||||
|
||||
**`@file`:** 添加文件內容,這樣你就不必浪費 API 請求批准讀取文件(+ 輸入以搜索文件)
|
||||
|
||||
**`@folder`:** 一次添加文件夾的文件,以進一步加快你的工作流程
|
||||
|
||||
<!-- 透明像素以在浮動圖像後創建換行 -->
|
||||
**`@url`:**貼上網址讓擴充套件擷取並轉換為 Markdown,當您想給 Cline 最新文件時很有用
|
||||
**`@problems`:**新增工作區的錯誤和警告(「問題」面板)給 Cline 修正
|
||||
**`@file`:**新增檔案內容,讓您不必浪費 API 請求來核准讀取檔案(+ 輸入以搜尋檔案)
|
||||
**`@folder`:**一次新增整個資料夾的檔案,讓您的工作流程更快速
|
||||
|
||||
<!-- 透明像素用於浮動圖片後的換行 -->
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="350" src="https://github.com/user-attachments/assets/140c8606-d3bf-41b9-9a1f-4dbf0d4c90cb">
|
||||
|
||||
### 檢查點:比較和恢復
|
||||
### 檢查點:比較和還原
|
||||
|
||||
當 Cline 完成任務時,擴展會在每一步拍攝你的工作區快照。你可以使用“比較”按鈕查看快照和當前工作區之間的差異,並使用“恢復”按鈕回滾到該點。
|
||||
當 Cline 處理任務時,擴充套件會在每個步驟擷取您工作區的快照。您可以使用「比較」按鈕檢視快照與目前工作區的差異,並使用「還原」按鈕回到該時間點。
|
||||
|
||||
例如,當使用本地 Web 服務器時,你可以使用“僅恢復工作區”快速測試應用程序的不同版本,然後在找到要繼續構建的版本時使用“恢復任務和工作區”。這讓你可以安全地探索不同的方法而不會丟失進度。
|
||||
|
||||
<!-- 透明像素以在浮動圖像後創建換行 -->
|
||||
例如,在使用本機網頁伺服器時,您可以使用「僅還原工作區」來快速測試應用程式的不同版本,然後在找到想要繼續開發的版本時使用「還原任務和工作區」。這讓您能安全地探索不同方法而不會失去進度。
|
||||
|
||||
<!-- 透明像素用於浮動圖片後的換行 -->
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
## 貢獻
|
||||
|
||||
要為項目做出貢獻,請從我們的 [貢獻指南](CONTRIBUTING.md) 開始,了解基礎知識。你還可以加入我們的 [Discord](https://discord.gg/cline) 在 `#contributors` 頻道與其他貢獻者聊天。如果你正在尋找全職工作,請查看我們在 [招聘頁面](https://cline.bot/join-us) 上的開放職位!
|
||||
要為專案貢獻,請先閱讀我們的[貢獻指南](CONTRIBUTING.md)來了解基礎知識。您也可以加入我們的 [Discord](https://discord.gg/cline),在 `#contributors` 頻道與其他貢獻者交流。如果您在尋找全職工作,請檢視我們[職涯頁面](https://cline.bot/join-us)上的職缺!
|
||||
|
||||
<details>
|
||||
<summary>本地開發說明</summary>
|
||||
<summary>本機開發說明</summary>
|
||||
|
||||
1. 克隆倉庫 _(需要 [git-lfs](https://git-lfs.com/))_:
|
||||
```bash
|
||||
git clone https://github.com/cline/cline.git
|
||||
```
|
||||
2. 在 VSCode 中打開項目:
|
||||
```bash
|
||||
code cline
|
||||
```
|
||||
3. 安裝擴展和 webview-gui 的必要依賴:
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
4. 按 `F5`(或 `運行`->`開始調試`)啟動以打開一個加載了擴展的新 VSCode 窗口。(如果你在構建項目時遇到問題,可能需要安裝 [esbuild problem matchers 擴展](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers))
|
||||
1. 複製程式碼庫(需要 [git-lfs](https://git-lfs.com/)):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/cline/cline.git
|
||||
```
|
||||
|
||||
2. 在 VSCode 中開啟專案:
|
||||
|
||||
```bash
|
||||
code cline
|
||||
```
|
||||
|
||||
3. 安裝擴充套件和網頁介面所需的相依套件:
|
||||
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
|
||||
4. 按下 `F5`(或選擇「執行」->「開始除錯」)來啟動並開啟一個已載入擴充套件的新 VSCode 視窗。(如果建置專案時遇到問題,您可能需要安裝 [esbuild problem matchers 擴充套件](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers))
|
||||
|
||||
</details>
|
||||
|
||||
## 許可證
|
||||
<details>
|
||||
<summary>建立 Pull Request</summary>
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
1. 在建立 PR 前,產生一個 changeset 項目:
|
||||
|
||||
```bash
|
||||
npm run changeset
|
||||
```
|
||||
|
||||
這會提示您填寫:
|
||||
- 變更類型(major、minor、patch)
|
||||
- `major` → 重大變更(1.0.0 → 2.0.0)
|
||||
- `minor` → 新功能(1.0.0 → 1.1.0)
|
||||
- `patch` → 錯誤修正(1.0.0 → 1.0.1)
|
||||
- 您的變更說明
|
||||
|
||||
2. 提交您的變更和產生的 `.changeset` 檔案
|
||||
|
||||
3. 推送您的分支並在 GitHub 上建立 PR。我們的 CI 會:
|
||||
- 執行測試和檢查
|
||||
- Changesetbot 會建立一個顯示版本影響的評論
|
||||
- 當合併到 main 時,changesetbot 會建立一個 Version Packages PR
|
||||
- 當 Version Packages PR 合併時,就會發布新版本
|
||||
|
||||
</details>
|
||||
|
||||
## 授權條款
|
||||
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
Generated
+3930
-659
File diff suppressed because it is too large
Load Diff
+109
-15
@@ -2,12 +2,8 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.4.0",
|
||||
"version": "3.8.2",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"galleryBanner": {
|
||||
"color": "#617A91",
|
||||
"theme": "dark"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
},
|
||||
@@ -85,6 +81,11 @@
|
||||
"title": "Open in Editor",
|
||||
"icon": "$(link-external)"
|
||||
},
|
||||
{
|
||||
"command": "cline.accountButtonClicked",
|
||||
"title": "Account",
|
||||
"icon": "$(account)"
|
||||
},
|
||||
{
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"title": "Settings",
|
||||
@@ -94,6 +95,32 @@
|
||||
"command": "cline.openInNewTab",
|
||||
"title": "Open In New Tab",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.dev.createTestTasks",
|
||||
"title": "Create Test Tasks",
|
||||
"category": "Cline",
|
||||
"when": "cline.isDevMode"
|
||||
},
|
||||
{
|
||||
"command": "cline.openDocumentation",
|
||||
"title": "Documentation",
|
||||
"icon": "$(book)"
|
||||
},
|
||||
{
|
||||
"command": "cline.addToChat",
|
||||
"title": "Add to Cline",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.addTerminalOutputToChat",
|
||||
"title": "Add to Cline",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.fixWithCline",
|
||||
"title": "Fix with Cline",
|
||||
"category": "Cline"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -119,9 +146,32 @@
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"command": "cline.openDocumentation",
|
||||
"group": "navigation@5",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.accountButtonClicked",
|
||||
"group": "navigation@6",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"group": "navigation@7",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
}
|
||||
],
|
||||
"editor/context": [
|
||||
{
|
||||
"command": "cline.addToChat",
|
||||
"group": "navigation",
|
||||
"when": "editorHasSelection"
|
||||
}
|
||||
],
|
||||
"terminal/context": [
|
||||
{
|
||||
"command": "cline.addTerminalOutputToChat",
|
||||
"group": "navigation"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -160,7 +210,7 @@
|
||||
"cline.enableCheckpoints": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enables extension to save checkpoints of workspace throughout the task."
|
||||
"description": "Enables extension to save checkpoints of workspace throughout the task. Uses git under the hood which may not work well with large workspaces."
|
||||
},
|
||||
"cline.disableBrowserTool": {
|
||||
"type": "boolean",
|
||||
@@ -181,6 +231,36 @@
|
||||
"type": "string",
|
||||
"default": null,
|
||||
"description": "Path to Chrome executable for browser use functionality. If not set, the extension will attempt to find or download it automatically."
|
||||
},
|
||||
"cline.preferredLanguage": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"English",
|
||||
"Arabic - العربية",
|
||||
"Portuguese - Português (Brasil)",
|
||||
"Czech - Čeština",
|
||||
"French - Français",
|
||||
"German - Deutsch",
|
||||
"Hindi - हिन्दी",
|
||||
"Hungarian - Magyar",
|
||||
"Italian - Italiano",
|
||||
"Japanese - 日本語",
|
||||
"Korean - 한국어",
|
||||
"Polish - Polski",
|
||||
"Portuguese - Português (Portugal)",
|
||||
"Russian - Русский",
|
||||
"Simplified Chinese - 简体中文",
|
||||
"Spanish - Español",
|
||||
"Traditional Chinese - 繁體中文",
|
||||
"Turkish - Türkçe"
|
||||
],
|
||||
"default": "English",
|
||||
"description": "The language that Cline should use for communication."
|
||||
},
|
||||
"cline.mcpMarketplace.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Controls whether the MCP Marketplace is enabled."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,12 +276,13 @@
|
||||
"watch-tests": "tsc -p . -w --outDir out",
|
||||
"pretest": "npm run compile-tests && npm run compile && npm run lint",
|
||||
"check-types": "tsc --noEmit",
|
||||
"lint": "eslint src --ext ts",
|
||||
"lint": "eslint src --ext ts && eslint webview-ui/src --ext ts",
|
||||
"format": "prettier . --check",
|
||||
"format:fix": "prettier . --write",
|
||||
"test": "vscode-test",
|
||||
"test:ci": "node scripts/test-ci.js",
|
||||
"install:all": "npm install && cd webview-ui && npm install",
|
||||
"start:webview": "cd webview-ui && npm run start",
|
||||
"dev:webview": "cd webview-ui && npm run dev",
|
||||
"build:webview": "cd webview-ui && npm run build",
|
||||
"test:webview": "cd webview-ui && npm run test",
|
||||
"publish:marketplace": "vsce publish && ovsx publish",
|
||||
@@ -217,6 +298,7 @@
|
||||
"@types/mocha": "^10.0.7",
|
||||
"@types/node": "20.x",
|
||||
"@types/should": "^11.2.0",
|
||||
"@types/sinon": "^17.0.4",
|
||||
"@types/vscode": "^1.84.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
||||
"@typescript-eslint/parser": "^7.11.0",
|
||||
@@ -229,26 +311,34 @@
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.3.3",
|
||||
"should": "^13.2.3",
|
||||
"sinon": "^19.0.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/bedrock-sdk": "^0.10.2",
|
||||
"@anthropic-ai/sdk": "^0.26.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.4.1",
|
||||
"@anthropic-ai/bedrock-sdk": "^0.12.4",
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.6.4",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.758.0",
|
||||
"@google-cloud/vertexai": "^1.9.3",
|
||||
"@google/generative-ai": "^0.18.0",
|
||||
"@mistralai/mistralai": "^1.5.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.7.0",
|
||||
"@opentelemetry/api": "^1.4.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.39.1",
|
||||
"@opentelemetry/resources": "^1.30.1",
|
||||
"@opentelemetry/sdk-node": "^0.39.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.30.0",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"axios": "^1.7.4",
|
||||
"axios": "^1.8.2",
|
||||
"cheerio": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"clone-deep": "^4.0.1",
|
||||
"default-shell": "^2.2.0",
|
||||
"delay": "^6.0.0",
|
||||
"diff": "^5.2.0",
|
||||
"execa": "^9.5.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -259,10 +349,14 @@
|
||||
"isbinaryfile": "^5.0.2",
|
||||
"mammoth": "^1.8.0",
|
||||
"monaco-vscode-textmate-theme-converter": "^0.1.7",
|
||||
"ollama": "^0.5.13",
|
||||
"open-graph-scraper": "^6.9.0",
|
||||
"openai": "^4.83.0",
|
||||
"os-name": "^6.0.0",
|
||||
"p-timeout": "^6.1.4",
|
||||
"p-wait-for": "^5.0.2",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"posthog-node": "^4.8.1",
|
||||
"puppeteer-chromium-resolver": "^23.0.0",
|
||||
"puppeteer-core": "^23.4.0",
|
||||
"serialize-error": "^11.0.3",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
const { execSync } = require("child_process")
|
||||
const process = require("process")
|
||||
|
||||
try {
|
||||
if (process.platform === "linux") {
|
||||
console.log("Detected Linux environment.")
|
||||
|
||||
execSync("which xvfb-run", { stdio: "ignore" })
|
||||
|
||||
console.log("xvfb-run is installed. Running tests with xvfb-run...")
|
||||
execSync("xvfb-run -a npm run test", { stdio: "inherit" })
|
||||
} else {
|
||||
console.log("Non-Linux environment detected. Running tests normally.")
|
||||
execSync("npm run test", { stdio: "inherit" })
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.platform === "linux") {
|
||||
console.error(
|
||||
`Error: xvfb-run is not installed.\n` +
|
||||
`Please install it using the following command:\n` +
|
||||
` Debian/Ubuntu: sudo apt install xvfb\n` +
|
||||
` RHEL/CentOS: sudo yum install xvfb\n` +
|
||||
` Arch Linux: sudo pacman -S xvfb`,
|
||||
)
|
||||
} else {
|
||||
console.error("Error running tests:", error.message)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
+14
-1
@@ -9,18 +9,23 @@ import { OllamaHandler } from "./providers/ollama"
|
||||
import { LmStudioHandler } from "./providers/lmstudio"
|
||||
import { GeminiHandler } from "./providers/gemini"
|
||||
import { OpenAiNativeHandler } from "./providers/openai-native"
|
||||
import { ApiStream } from "./transform/stream"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
|
||||
import { DeepSeekHandler } from "./providers/deepseek"
|
||||
import { RequestyHandler } from "./providers/requesty"
|
||||
import { TogetherHandler } from "./providers/together"
|
||||
import { QwenHandler } from "./providers/qwen"
|
||||
import { MistralHandler } from "./providers/mistral"
|
||||
import { VsCodeLmHandler } from "./providers/vscode-lm"
|
||||
import { ClineHandler } from "./providers/cline"
|
||||
import { LiteLlmHandler } from "./providers/litellm"
|
||||
import { AskSageHandler } from "./providers/asksage"
|
||||
import { XAIHandler } from "./providers/xai"
|
||||
import { SambanovaHandler } from "./providers/sambanova"
|
||||
|
||||
export interface ApiHandler {
|
||||
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
|
||||
getModel(): { id: string; info: ModelInfo }
|
||||
getApiStreamUsage?(): Promise<ApiStreamUsageChunk | undefined>
|
||||
}
|
||||
|
||||
export interface SingleCompletionHandler {
|
||||
@@ -60,8 +65,16 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
|
||||
return new MistralHandler(options)
|
||||
case "vscode-lm":
|
||||
return new VsCodeLmHandler(options)
|
||||
case "cline":
|
||||
return new ClineHandler(options)
|
||||
case "litellm":
|
||||
return new LiteLlmHandler(options)
|
||||
case "asksage":
|
||||
return new AskSageHandler(options)
|
||||
case "xai":
|
||||
return new XAIHandler(options)
|
||||
case "sambanova":
|
||||
return new SambanovaHandler(options)
|
||||
default:
|
||||
return new AnthropicHandler(options)
|
||||
}
|
||||
|
||||
@@ -20,10 +20,15 @@ export class AnthropicHandler implements ApiHandler {
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const model = this.getModel()
|
||||
let stream: AnthropicStream<Anthropic.Beta.PromptCaching.Messages.RawPromptCachingBetaMessageStreamEvent>
|
||||
let stream: AnthropicStream<Anthropic.RawMessageStreamEvent>
|
||||
const modelId = model.id
|
||||
|
||||
let budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
|
||||
|
||||
switch (modelId) {
|
||||
// 'latest' alias does not support cache_control
|
||||
case "claude-3-7-sonnet-20250219":
|
||||
case "claude-3-5-sonnet-20241022":
|
||||
case "claude-3-5-haiku-20241022":
|
||||
case "claude-3-opus-20240229":
|
||||
@@ -37,11 +42,14 @@ export class AnthropicHandler implements ApiHandler {
|
||||
)
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
stream = await this.client.beta.promptCaching.messages.create(
|
||||
stream = await this.client.messages.create(
|
||||
{
|
||||
model: modelId,
|
||||
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
temperature: 0,
|
||||
// "Thinking isn’t compatible with temperature, top_p, or top_k modifications as well as forced tool use."
|
||||
// (https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking)
|
||||
temperature: reasoningOn ? undefined : 0,
|
||||
system: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
@@ -88,6 +96,7 @@ export class AnthropicHandler implements ApiHandler {
|
||||
// https://github.com/anthropics/anthropic-sdk-typescript?tab=readme-ov-file#default-headers
|
||||
// https://github.com/anthropics/anthropic-sdk-typescript/commit/c920b77fc67bd839bfeb6716ceab9d7c9bbe7393
|
||||
switch (modelId) {
|
||||
case "claude-3-7-sonnet-20250219":
|
||||
case "claude-3-5-sonnet-20241022":
|
||||
case "claude-3-5-haiku-20241022":
|
||||
case "claude-3-opus-20240229":
|
||||
@@ -146,6 +155,20 @@ export class AnthropicHandler implements ApiHandler {
|
||||
break
|
||||
case "content_block_start":
|
||||
switch (chunk.content_block.type) {
|
||||
case "thinking":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.content_block.thinking || "",
|
||||
}
|
||||
break
|
||||
case "redacted_thinking":
|
||||
// Handle redacted thinking blocks - we still mark it as reasoning
|
||||
// but note that the content is encrypted
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "[Redacted thinking block]",
|
||||
}
|
||||
break
|
||||
case "text":
|
||||
// we may receive multiple text blocks, in which case just insert a line break between them
|
||||
if (chunk.index > 0) {
|
||||
@@ -163,12 +186,22 @@ export class AnthropicHandler implements ApiHandler {
|
||||
break
|
||||
case "content_block_delta":
|
||||
switch (chunk.delta.type) {
|
||||
case "thinking_delta":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.delta.thinking,
|
||||
}
|
||||
break
|
||||
case "text_delta":
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.delta.text,
|
||||
}
|
||||
break
|
||||
case "signature_delta":
|
||||
// We don't need to do anything with the signature in the client
|
||||
// It's used when sending the thinking block back to the API
|
||||
break
|
||||
}
|
||||
break
|
||||
case "content_block_stop":
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ApiHandler } from ".."
|
||||
import {
|
||||
ApiHandlerOptions,
|
||||
ModelInfo,
|
||||
AskSageModelId,
|
||||
askSageModels,
|
||||
askSageDefaultModelId,
|
||||
askSageDefaultURL,
|
||||
} from "../../shared/api"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
type AskSageRequest = {
|
||||
system_prompt: string
|
||||
message: {
|
||||
user: "gpt" | "me"
|
||||
message: string
|
||||
}[]
|
||||
model: string
|
||||
dataset: "none"
|
||||
}
|
||||
|
||||
type AskSageResponse = {
|
||||
uuid: string
|
||||
status: number
|
||||
// Response status
|
||||
response: string
|
||||
// Generated response message
|
||||
message: string
|
||||
}
|
||||
|
||||
export class AskSageHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private apiUrl: string
|
||||
private apiKey: string
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
console.log("init api url", options.asksageApiUrl, askSageDefaultURL)
|
||||
this.options = options
|
||||
this.apiKey = options.asksageApiKey || ""
|
||||
this.apiUrl = options.asksageApiUrl || askSageDefaultURL
|
||||
|
||||
if (!this.apiKey) {
|
||||
throw new Error("AskSage API key is required")
|
||||
}
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
try {
|
||||
const model = this.getModel()
|
||||
|
||||
// Transform messages into AskSageRequest format
|
||||
const formattedMessages = messages.map((msg) => {
|
||||
const content = Array.isArray(msg.content)
|
||||
? msg.content.map((block) => ("text" in block ? block.text : "")).join("")
|
||||
: msg.content
|
||||
|
||||
return {
|
||||
user: msg.role === "assistant" ? ("gpt" as const) : ("me" as const),
|
||||
message: content,
|
||||
}
|
||||
})
|
||||
|
||||
const request: AskSageRequest = {
|
||||
system_prompt: systemPrompt,
|
||||
message: formattedMessages,
|
||||
model: model.id,
|
||||
dataset: "none",
|
||||
}
|
||||
|
||||
// Make request to AskSage API
|
||||
const response = await fetch(`${this.apiUrl}/query`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-access-tokens": this.apiKey,
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
throw new Error(`AskSage API error: ${error}`)
|
||||
}
|
||||
|
||||
const result = (await response.json()) as AskSageResponse
|
||||
|
||||
if (!result.message) {
|
||||
throw new Error("No content in AskSage response")
|
||||
}
|
||||
|
||||
// Return entire response as a single chunk since streaming is not supported
|
||||
yield {
|
||||
type: "text",
|
||||
text: result.message,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`AskSage request failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in askSageModels) {
|
||||
const id = modelId as AskSageModelId
|
||||
return { id, info: askSageModels[id] }
|
||||
}
|
||||
return {
|
||||
id: askSageDefaultModelId,
|
||||
info: askSageModels[askSageDefaultModelId],
|
||||
}
|
||||
}
|
||||
}
|
||||
+584
-65
@@ -1,88 +1,103 @@
|
||||
import AnthropicBedrock from "@anthropic-ai/bedrock-sdk"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiHandler } from "../"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
import { ApiHandlerOptions, bedrockDefaultModelId, BedrockModelId, bedrockModels, ModelInfo } from "../../shared/api"
|
||||
import { calculateApiCostOpenAI } from "../../utils/cost"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { fromIni } from "@aws-sdk/credential-providers"
|
||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||
import {
|
||||
BedrockRuntimeClient,
|
||||
ConversationRole,
|
||||
ConverseStreamCommand,
|
||||
InvokeModelWithResponseStreamCommand,
|
||||
} from "@aws-sdk/client-bedrock-runtime"
|
||||
|
||||
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
|
||||
export class AwsBedrockHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: AnthropicBedrock | any
|
||||
private initializationPromise: Promise<void>
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.initializationPromise = this.initializeClient()
|
||||
}
|
||||
|
||||
private async initializeClient() {
|
||||
let clientConfig: any = {
|
||||
awsRegion: this.options.awsRegion || "us-east-1",
|
||||
}
|
||||
try {
|
||||
if (this.options.awsUseProfile) {
|
||||
// Use profile-based credentials if enabled
|
||||
// Use named profile, defaulting to 'default' if not specified
|
||||
var credentials: any
|
||||
if (this.options.awsProfile) {
|
||||
credentials = await fromIni({
|
||||
profile: this.options.awsProfile,
|
||||
ignoreCache: true,
|
||||
})()
|
||||
} else {
|
||||
credentials = await fromIni({
|
||||
ignoreCache: true,
|
||||
})()
|
||||
}
|
||||
clientConfig.awsAccessKey = credentials.accessKeyId
|
||||
clientConfig.awsSecretKey = credentials.secretAccessKey
|
||||
clientConfig.awsSessionToken = credentials.sessionToken
|
||||
} else if (this.options.awsAccessKey && this.options.awsSecretKey) {
|
||||
// Use direct credentials if provided
|
||||
clientConfig.awsAccessKey = this.options.awsAccessKey
|
||||
clientConfig.awsSecretKey = this.options.awsSecretKey
|
||||
if (this.options.awsSessionToken) {
|
||||
clientConfig.awsSessionToken = this.options.awsSessionToken
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize Bedrock client:", error)
|
||||
throw error
|
||||
} finally {
|
||||
this.client = new AnthropicBedrock(clientConfig)
|
||||
}
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
// cross region inference requires prefixing the model id with the region
|
||||
let modelId: string
|
||||
if (this.options.awsUseCrossRegionInference) {
|
||||
let regionPrefix = (this.options.awsRegion || "").slice(0, 3)
|
||||
switch (regionPrefix) {
|
||||
case "us-":
|
||||
modelId = `us.${this.getModel().id}`
|
||||
break
|
||||
case "eu-":
|
||||
modelId = `eu.${this.getModel().id}`
|
||||
break
|
||||
default:
|
||||
// cross region inference is not supported in this region, falling back to default model
|
||||
modelId = this.getModel().id
|
||||
break
|
||||
}
|
||||
} else {
|
||||
modelId = this.getModel().id
|
||||
let modelId = await this.getModelId()
|
||||
const model = this.getModel()
|
||||
|
||||
// Check if this is an Amazon Nova model
|
||||
if (modelId.includes("amazon.nova")) {
|
||||
yield* this.createNovaMessage(systemPrompt, messages, modelId, model)
|
||||
return
|
||||
}
|
||||
|
||||
const stream = await this.client.messages.create({
|
||||
// Check if this is a Deepseek model
|
||||
if (modelId.includes("deepseek")) {
|
||||
yield* this.createDeepseekMessage(systemPrompt, messages, modelId, model)
|
||||
return
|
||||
}
|
||||
|
||||
let budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
|
||||
|
||||
// Get model info and message indices for caching
|
||||
const userMsgIndices = messages.reduce((acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), [] as number[])
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
|
||||
// Create anthropic client, using sessions created or renewed after this handler's
|
||||
// initialization, and allowing for session renewal if necessary as well
|
||||
const client = await this.getAnthropicClient()
|
||||
|
||||
const stream = await client.messages.create({
|
||||
model: modelId,
|
||||
max_tokens: this.getModel().info.maxTokens || 8192,
|
||||
temperature: 0,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
|
||||
temperature: reasoningOn ? undefined : 0,
|
||||
system: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
type: "text",
|
||||
...(this.options.awsBedrockUsePromptCache === true && {
|
||||
cache_control: { type: "ephemeral" },
|
||||
}),
|
||||
},
|
||||
],
|
||||
messages: messages.map((message, index) => {
|
||||
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
|
||||
return {
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string"
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: message.content,
|
||||
...(this.options.awsBedrockUsePromptCache === true && {
|
||||
cache_control: { type: "ephemeral" },
|
||||
}),
|
||||
},
|
||||
]
|
||||
: message.content.map((content, contentIndex) =>
|
||||
contentIndex === message.content.length - 1
|
||||
? {
|
||||
...content,
|
||||
...(this.options.awsBedrockUsePromptCache === true && {
|
||||
cache_control: { type: "ephemeral" },
|
||||
}),
|
||||
}
|
||||
: content,
|
||||
),
|
||||
}
|
||||
}
|
||||
return message
|
||||
}),
|
||||
stream: true,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
switch (chunk.type) {
|
||||
case "message_start":
|
||||
@@ -91,6 +106,8 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
type: "usage",
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
|
||||
cacheReadTokens: usage.cache_read_input_tokens || undefined,
|
||||
}
|
||||
break
|
||||
case "message_delta":
|
||||
@@ -100,9 +117,22 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
outputTokens: chunk.usage.output_tokens || 0,
|
||||
}
|
||||
break
|
||||
|
||||
case "content_block_start":
|
||||
switch (chunk.content_block.type) {
|
||||
case "thinking":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.content_block.thinking || "",
|
||||
}
|
||||
break
|
||||
case "redacted_thinking":
|
||||
// Handle redacted thinking blocks - we still mark it as reasoning
|
||||
// but note that the content is encrypted
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "[Redacted thinking block]",
|
||||
}
|
||||
break
|
||||
case "text":
|
||||
if (chunk.index > 0) {
|
||||
yield {
|
||||
@@ -119,6 +149,12 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
break
|
||||
case "content_block_delta":
|
||||
switch (chunk.delta.type) {
|
||||
case "thinking_delta":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.delta.thinking,
|
||||
}
|
||||
break
|
||||
case "text_delta":
|
||||
yield {
|
||||
type: "text",
|
||||
@@ -142,4 +178,487 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
info: bedrockModels[bedrockDefaultModelId],
|
||||
}
|
||||
}
|
||||
|
||||
// Default AWS region
|
||||
private static readonly DEFAULT_REGION = "us-east-1"
|
||||
|
||||
/**
|
||||
* Gets AWS credentials using the provider chain
|
||||
* Centralizes credential retrieval logic for all AWS services
|
||||
*/
|
||||
private async getAwsCredentials(): Promise<{
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
sessionToken?: string
|
||||
}> {
|
||||
// Create AWS credentials by executing an AWS provider chain
|
||||
const providerChain = fromNodeProviderChain()
|
||||
return await AwsBedrockHandler.withTempEnv(
|
||||
() => {
|
||||
AwsBedrockHandler.setEnv("AWS_REGION", this.options.awsRegion)
|
||||
AwsBedrockHandler.setEnv("AWS_ACCESS_KEY_ID", this.options.awsAccessKey)
|
||||
AwsBedrockHandler.setEnv("AWS_SECRET_ACCESS_KEY", this.options.awsSecretKey)
|
||||
AwsBedrockHandler.setEnv("AWS_SESSION_TOKEN", this.options.awsSessionToken)
|
||||
AwsBedrockHandler.setEnv("AWS_PROFILE", this.options.awsProfile)
|
||||
},
|
||||
() => providerChain(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the AWS region to use, with fallback to default
|
||||
*/
|
||||
private getRegion(): string {
|
||||
return this.options.awsRegion || AwsBedrockHandler.DEFAULT_REGION
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a BedrockRuntimeClient with the appropriate credentials
|
||||
*/
|
||||
private async getBedrockClient(): Promise<BedrockRuntimeClient> {
|
||||
const credentials = await this.getAwsCredentials()
|
||||
|
||||
return new BedrockRuntimeClient({
|
||||
region: this.getRegion(),
|
||||
credentials: {
|
||||
accessKeyId: credentials.accessKeyId,
|
||||
secretAccessKey: credentials.secretAccessKey,
|
||||
sessionToken: credentials.sessionToken,
|
||||
},
|
||||
...(this.options.awsBedrockEndpoint && { endpoint: this.options.awsBedrockEndpoint }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an AnthropicBedrock client with the appropriate credentials
|
||||
*/
|
||||
private async getAnthropicClient(): Promise<AnthropicBedrock> {
|
||||
const credentials = await this.getAwsCredentials()
|
||||
|
||||
// Return an AnthropicBedrock client with the resolved/assumed credentials.
|
||||
return new AnthropicBedrock({
|
||||
awsAccessKey: credentials.accessKeyId,
|
||||
awsSecretKey: credentials.secretAccessKey,
|
||||
awsSessionToken: credentials.sessionToken,
|
||||
awsRegion: this.getRegion(),
|
||||
...(this.options.awsBedrockEndpoint && { baseURL: this.options.awsBedrockEndpoint }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate model ID, accounting for cross-region inference if enabled
|
||||
*/
|
||||
async getModelId(): Promise<string> {
|
||||
if (this.options.awsUseCrossRegionInference) {
|
||||
let regionPrefix = this.getRegion().slice(0, 3)
|
||||
switch (regionPrefix) {
|
||||
case "us-":
|
||||
return `us.${this.getModel().id}`
|
||||
case "eu-":
|
||||
return `eu.${this.getModel().id}`
|
||||
case "ap-":
|
||||
return `apac.${this.getModel().id}`
|
||||
default:
|
||||
// cross region inference is not supported in this region, falling back to default model
|
||||
return this.getModel().id
|
||||
}
|
||||
}
|
||||
return this.getModel().id
|
||||
}
|
||||
|
||||
private static async withTempEnv<R>(updateEnv: () => void, fn: () => Promise<R>): Promise<R> {
|
||||
const previousEnv = { ...process.env }
|
||||
|
||||
try {
|
||||
updateEnv()
|
||||
return await fn()
|
||||
} finally {
|
||||
process.env = previousEnv
|
||||
}
|
||||
}
|
||||
|
||||
private static setEnv(key: string, value: string | undefined) {
|
||||
if (key !== "" && value !== undefined) {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a message using the Deepseek R1 model through AWS Bedrock
|
||||
*/
|
||||
private async *createDeepseekMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
modelId: string,
|
||||
model: { id: BedrockModelId; info: ModelInfo },
|
||||
): ApiStream {
|
||||
// Get Bedrock client with proper credentials
|
||||
const client = await this.getBedrockClient()
|
||||
|
||||
// Format prompt for DeepSeek R1 according to documentation
|
||||
const formattedPrompt = this.formatDeepseekR1Prompt(systemPrompt, messages)
|
||||
|
||||
// Prepare the request based on DeepSeek R1's expected format
|
||||
const command = new InvokeModelWithResponseStreamCommand({
|
||||
modelId: modelId,
|
||||
contentType: "application/json",
|
||||
accept: "application/json",
|
||||
body: JSON.stringify({
|
||||
prompt: formattedPrompt,
|
||||
max_tokens: model.info.maxTokens || 8000,
|
||||
temperature: 0,
|
||||
}),
|
||||
})
|
||||
|
||||
// Track token usage
|
||||
const inputTokenEstimate = this.estimateInputTokens(systemPrompt, messages)
|
||||
let outputTokens = 0
|
||||
let isFirstChunk = true
|
||||
let accumulatedTokens = 0
|
||||
const TOKEN_REPORT_THRESHOLD = 100 // Report usage after accumulating this many tokens
|
||||
|
||||
// Execute the streaming request
|
||||
const response = await client.send(command)
|
||||
|
||||
if (response.body) {
|
||||
for await (const chunk of response.body) {
|
||||
if (chunk.chunk?.bytes) {
|
||||
try {
|
||||
// Parse the response chunk
|
||||
const decodedChunk = new TextDecoder().decode(chunk.chunk.bytes)
|
||||
const parsedChunk = JSON.parse(decodedChunk)
|
||||
|
||||
// Report usage on first chunk
|
||||
if (isFirstChunk) {
|
||||
isFirstChunk = false
|
||||
const totalCost = calculateApiCostOpenAI(model.info, inputTokenEstimate, 0, 0, 0)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: inputTokenEstimate,
|
||||
outputTokens: 0,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle DeepSeek R1 response format
|
||||
if (parsedChunk.choices && parsedChunk.choices.length > 0) {
|
||||
// For non-streaming response (full response)
|
||||
const text = parsedChunk.choices[0].text
|
||||
if (text) {
|
||||
const chunkTokens = this.estimateTokenCount(text)
|
||||
outputTokens += chunkTokens
|
||||
accumulatedTokens += chunkTokens
|
||||
|
||||
yield {
|
||||
type: "text",
|
||||
text: text,
|
||||
}
|
||||
|
||||
if (accumulatedTokens >= TOKEN_REPORT_THRESHOLD) {
|
||||
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: accumulatedTokens,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
accumulatedTokens = 0
|
||||
}
|
||||
}
|
||||
} else if (parsedChunk.delta?.text) {
|
||||
// For streaming response (delta updates)
|
||||
const text = parsedChunk.delta.text
|
||||
const chunkTokens = this.estimateTokenCount(text)
|
||||
outputTokens += chunkTokens
|
||||
accumulatedTokens += chunkTokens
|
||||
|
||||
yield {
|
||||
type: "text",
|
||||
text: text,
|
||||
}
|
||||
// Report aggregated token usage only when threshold is reached
|
||||
if (accumulatedTokens >= TOKEN_REPORT_THRESHOLD) {
|
||||
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: accumulatedTokens,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
accumulatedTokens = 0
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing Deepseek response chunk:", error)
|
||||
// Propagate the error by yielding a text response with error information
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Failed to parse Deepseek response: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report any remaining accumulated tokens at the end of the stream
|
||||
if (accumulatedTokens > 0) {
|
||||
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: accumulatedTokens,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
}
|
||||
|
||||
// Add final total cost calculation that includes both input and output tokens
|
||||
const finalTotalCost = calculateApiCostOpenAI(model.info, inputTokenEstimate, outputTokens, 0, 0)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: inputTokenEstimate,
|
||||
outputTokens: outputTokens,
|
||||
totalCost: finalTotalCost,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats prompt for DeepSeek R1 model according to documentation
|
||||
* First uses convertToR1Format to merge consecutive messages with the same role,
|
||||
* then converts to the string format that DeepSeek R1 expects
|
||||
*/
|
||||
private formatDeepseekR1Prompt(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): string {
|
||||
// First use convertToR1Format to merge consecutive messages with the same role
|
||||
const r1Messages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
|
||||
// Then convert to the special string format expected by DeepSeek R1
|
||||
let combinedContent = ""
|
||||
|
||||
for (const message of r1Messages) {
|
||||
let content = ""
|
||||
|
||||
if (message.content) {
|
||||
if (typeof message.content === "string") {
|
||||
content = message.content
|
||||
} else {
|
||||
// Extract text content from message parts
|
||||
content = message.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
combinedContent += message.role === "user" ? "User: " + content + "\n" : "Assistant: " + content + "\n"
|
||||
}
|
||||
|
||||
// Format according to DeepSeek R1's expected prompt format
|
||||
return `<|begin▁of▁sentence|><|User|>${combinedContent}<|Assistant|><think>\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates token count based on text length (approximate)
|
||||
* Note: This is a rough estimation, as the actual token count depends on the tokenizer
|
||||
*/
|
||||
private estimateInputTokens(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): number {
|
||||
// For Deepseek R1, we estimate the token count of the formatted prompt
|
||||
// The formatted prompt includes special tokens and consistent formatting
|
||||
const formattedPrompt = this.formatDeepseekR1Prompt(systemPrompt, messages)
|
||||
return Math.ceil(formattedPrompt.length / 4)
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates token count for a text string
|
||||
*/
|
||||
private estimateTokenCount(text: string): number {
|
||||
// Approximate 4 characters per token
|
||||
return Math.ceil(text.length / 4)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a message using Amazon Nova models through AWS Bedrock
|
||||
* Implements support for Nova Micro, Nova Lite, and Nova Pro models
|
||||
*/
|
||||
private async *createNovaMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
modelId: string,
|
||||
model: { id: BedrockModelId; info: ModelInfo },
|
||||
): ApiStream {
|
||||
// Get Bedrock client with proper credentials
|
||||
const client = await this.getBedrockClient()
|
||||
|
||||
// Format messages for Nova model
|
||||
const formattedMessages = this.formatNovaMessages(messages)
|
||||
|
||||
// Prepare request for Nova model
|
||||
const command = new ConverseStreamCommand({
|
||||
modelId: modelId,
|
||||
messages: formattedMessages,
|
||||
system: systemPrompt ? [{ text: systemPrompt }] : undefined,
|
||||
inferenceConfig: {
|
||||
maxTokens: model.info.maxTokens || 5000,
|
||||
temperature: 0,
|
||||
// topP: 0.9, // Alternative: use topP instead of temperature
|
||||
},
|
||||
})
|
||||
|
||||
// Execute the streaming request and handle response
|
||||
try {
|
||||
const response = await client.send(command)
|
||||
|
||||
if (response.stream) {
|
||||
let hasReportedInputTokens = false
|
||||
|
||||
for await (const chunk of response.stream) {
|
||||
// Handle metadata events with token usage information
|
||||
if (chunk.metadata?.usage) {
|
||||
// Report complete token usage from the model itself
|
||||
const inputTokens = chunk.metadata.usage.inputTokens || 0
|
||||
const outputTokens = chunk.metadata.usage.outputTokens || 0
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalCost: calculateApiCostOpenAI(model.info, inputTokens, outputTokens, 0, 0),
|
||||
}
|
||||
hasReportedInputTokens = true
|
||||
}
|
||||
|
||||
// Handle content delta (text generation)
|
||||
if (chunk.contentBlockDelta?.delta?.text) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.contentBlockDelta.delta.text,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reasoning content if present
|
||||
if (chunk.contentBlockDelta?.delta?.reasoningContent?.text) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.contentBlockDelta.delta.reasoningContent.text,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (chunk.internalServerException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Internal server error: ${chunk.internalServerException.message}`,
|
||||
}
|
||||
} else if (chunk.modelStreamErrorException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Model stream error: ${chunk.modelStreamErrorException.message}`,
|
||||
}
|
||||
} else if (chunk.validationException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Validation error: ${chunk.validationException.message}`,
|
||||
}
|
||||
} else if (chunk.throttlingException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Throttling error: ${chunk.throttlingException.message}`,
|
||||
}
|
||||
} else if (chunk.serviceUnavailableException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Service unavailable: ${chunk.serviceUnavailableException.message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing Nova model response:", error)
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Failed to process Nova response: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats messages for Amazon Nova models according to the SDK specification
|
||||
*/
|
||||
private formatNovaMessages(messages: Anthropic.Messages.MessageParam[]): { role: ConversationRole; content: any[] }[] {
|
||||
return messages.map((message) => {
|
||||
// Determine role (user or assistant)
|
||||
const role = message.role === "user" ? ConversationRole.USER : ConversationRole.ASSISTANT
|
||||
|
||||
// Process content based on type
|
||||
let content: any[] = []
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
// Simple text content
|
||||
content = [{ text: message.content }]
|
||||
} else if (Array.isArray(message.content)) {
|
||||
// Convert Anthropic content format to Nova content format
|
||||
content = message.content
|
||||
.map((item) => {
|
||||
// Text content
|
||||
if (item.type === "text") {
|
||||
return { text: item.text }
|
||||
}
|
||||
|
||||
// Image content
|
||||
if (item.type === "image") {
|
||||
// Handle different image source formats
|
||||
let imageData: Uint8Array
|
||||
let format = "jpeg" // default format
|
||||
|
||||
// Extract format from media_type if available
|
||||
if (item.source.media_type) {
|
||||
// Extract format from media_type (e.g., "image/jpeg" -> "jpeg")
|
||||
const formatMatch = item.source.media_type.match(/image\/(\w+)/)
|
||||
if (formatMatch && formatMatch[1]) {
|
||||
format = formatMatch[1]
|
||||
// Ensure format is one of the allowed values
|
||||
if (!["png", "jpeg", "gif", "webp"].includes(format)) {
|
||||
format = "jpeg" // Default to jpeg if not supported
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get image data
|
||||
try {
|
||||
if (typeof item.source.data === "string") {
|
||||
// Handle base64 encoded data
|
||||
const base64Data = item.source.data.replace(/^data:image\/\w+;base64,/, "")
|
||||
imageData = new Uint8Array(Buffer.from(base64Data, "base64"))
|
||||
} else if (item.source.data && typeof item.source.data === "object") {
|
||||
// Try to convert to Uint8Array
|
||||
imageData = new Uint8Array(Buffer.from(item.source.data as any))
|
||||
} else {
|
||||
console.error("Unsupported image data format")
|
||||
return null // Skip this item if format is not supported
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Could not convert image data to Uint8Array:", error)
|
||||
return null // Skip this item if conversion fails
|
||||
}
|
||||
|
||||
return {
|
||||
image: {
|
||||
format,
|
||||
source: {
|
||||
bytes: imageData,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Return null for unsupported content types
|
||||
return null
|
||||
})
|
||||
.filter(Boolean) // Remove any null items
|
||||
}
|
||||
|
||||
// Return formatted message
|
||||
return {
|
||||
role,
|
||||
content,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
|
||||
import { createOpenRouterStream } from "../transform/openrouter-stream"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import axios from "axios"
|
||||
import { OpenRouterErrorResponse } from "./types"
|
||||
|
||||
export class ClineHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
lastGenerationId?: string
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.cline.bot/v1",
|
||||
apiKey: this.options.clineApiKey || "",
|
||||
})
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
this.lastGenerationId = undefined
|
||||
|
||||
const stream = await createOpenRouterStream(
|
||||
this.client,
|
||||
systemPrompt,
|
||||
messages,
|
||||
this.getModel(),
|
||||
this.options.o3MiniReasoningEffort,
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
)
|
||||
|
||||
let didOutputUsage: boolean = false
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as OpenRouterErrorResponse["error"]
|
||||
console.error(`Cline API Error: ${error?.code} - ${error?.message}`)
|
||||
// Include metadata in the error message if available
|
||||
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
|
||||
throw new Error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
|
||||
}
|
||||
|
||||
if (!this.lastGenerationId && chunk.id) {
|
||||
this.lastGenerationId = chunk.id
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
// Reasoning tokens are returned separately from the content
|
||||
if ("reasoning" in delta && delta.reasoning) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
// @ts-ignore-next-line
|
||||
reasoning: delta.reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: chunk.usage.cost || 0,
|
||||
}
|
||||
didOutputUsage = true
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to generation endpoint if usage chunk not returned
|
||||
if (!didOutputUsage) {
|
||||
const apiStreamUsage = await this.getApiStreamUsage()
|
||||
if (apiStreamUsage) {
|
||||
yield apiStreamUsage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
|
||||
if (this.lastGenerationId) {
|
||||
try {
|
||||
const response = await axios.get(`https://api.cline.bot/v1/generation?id=${this.lastGenerationId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.clineApiKey}`,
|
||||
},
|
||||
timeout: 15_000, // this request hangs sometimes
|
||||
})
|
||||
|
||||
const generation = response.data
|
||||
return {
|
||||
type: "usage",
|
||||
inputTokens: generation?.native_tokens_prompt || 0,
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore if fails
|
||||
console.error("Error fetching cline generation details:", error)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.openRouterModelId
|
||||
const modelInfo = this.options.openRouterModelInfo
|
||||
if (modelId && modelInfo) {
|
||||
return { id: modelId, info: modelInfo }
|
||||
}
|
||||
return { id: openRouterDefaultModelId, info: openRouterDefaultModelInfo }
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import OpenAI from "openai"
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, DeepSeekModelId, ModelInfo, deepSeekDefaultModelId, deepSeekModels } from "../../shared/api"
|
||||
import { calculateApiCostOpenAI } from "../../utils/cost"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
@@ -19,6 +20,37 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
})
|
||||
}
|
||||
|
||||
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
|
||||
// Deepseek reports total input AND cache reads/writes,
|
||||
// see context caching: https://api-docs.deepseek.com/guides/kv_cache)
|
||||
// where the input tokens is the sum of the cache hits/misses, just like OpenAI.
|
||||
// This affects:
|
||||
// 1) context management truncation algorithm, and
|
||||
// 2) cost calculation
|
||||
|
||||
// Deepseek usage includes extra fields.
|
||||
// Safely cast the prompt token details section to the appropriate structure.
|
||||
interface DeepSeekUsage extends OpenAI.CompletionUsage {
|
||||
prompt_cache_hit_tokens?: number
|
||||
prompt_cache_miss_tokens?: number
|
||||
}
|
||||
const deepUsage = usage as DeepSeekUsage
|
||||
|
||||
const inputTokens = deepUsage?.prompt_tokens || 0
|
||||
const outputTokens = deepUsage?.completion_tokens || 0
|
||||
const cacheReadTokens = deepUsage?.prompt_cache_hit_tokens || 0
|
||||
const cacheWriteTokens = deepUsage?.prompt_cache_miss_tokens || 0
|
||||
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: inputTokens,
|
||||
outputTokens: outputTokens,
|
||||
cacheWriteTokens: cacheWriteTokens,
|
||||
cacheReadTokens: cacheReadTokens,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const model = this.getModel()
|
||||
@@ -61,15 +93,7 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0, // (deepseek reports total input AND cache reads/writes, see context caching: https://api-docs.deepseek.com/guides/kv_cache) where the input tokens is the sum of the cache hits/misses, while anthropic reports them as separate tokens. This is important to know for 1) context management truncation algorithm, and 2) cost calculation (NOTE: we report both input and cache stats but for now set input price to 0 since all the cost calculation will be done using cache hits/misses)
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
cacheReadTokens: chunk.usage.prompt_cache_hit_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
|
||||
}
|
||||
yield* this.yieldUsage(model.info, chunk.usage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,18 @@ export class LiteLlmHandler implements ApiHandler {
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
}
|
||||
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
|
||||
const isOminiModel = modelId.includes("o1-mini") || modelId.includes("o3-mini")
|
||||
let temperature: number | undefined = 0
|
||||
|
||||
if (isOminiModel) {
|
||||
temperature = undefined // does not support temperature
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.options.liteLlmModelId || liteLlmDefaultModelId,
|
||||
messages: [systemMessage, ...formattedMessages],
|
||||
temperature: 0,
|
||||
temperature,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
|
||||
+12
-17
@@ -1,40 +1,35 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { Message, Ollama } from "ollama"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { convertToOllamaMessages } from "../transform/ollama-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
export class OllamaHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
private client: Ollama
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: (this.options.ollamaBaseUrl || "http://localhost:11434") + "/v1",
|
||||
apiKey: "ollama",
|
||||
})
|
||||
this.client = new Ollama({ host: this.options.ollamaBaseUrl || "http://localhost:11434" })
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
const ollamaMessages: Message[] = [{ role: "system", content: systemPrompt }, ...convertToOllamaMessages(messages)]
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const stream = await this.client.chat({
|
||||
model: this.getModel().id,
|
||||
messages: openAiMessages,
|
||||
temperature: 0,
|
||||
messages: ollamaMessages,
|
||||
stream: true,
|
||||
options: {
|
||||
num_ctx: Number(this.options.ollamaApiOptionsCtxNum) || 32768,
|
||||
},
|
||||
})
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
if (typeof chunk.message.content === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
text: chunk.message.content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
openAiNativeModels,
|
||||
} from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { calculateApiCostOpenAI } from "../../utils/cost"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { ChatCompletionReasoningEffort } from "openai/resources/chat/completions.mjs"
|
||||
|
||||
@@ -24,31 +25,47 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
})
|
||||
}
|
||||
|
||||
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
|
||||
const inputTokens = usage?.prompt_tokens || 0
|
||||
const outputTokens = usage?.completion_tokens || 0
|
||||
const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens || 0
|
||||
const cacheWriteTokens = 0
|
||||
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: inputTokens,
|
||||
outputTokens: outputTokens,
|
||||
cacheWriteTokens: cacheWriteTokens,
|
||||
cacheReadTokens: cacheReadTokens,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
switch (this.getModel().id) {
|
||||
const model = this.getModel()
|
||||
|
||||
switch (model.id) {
|
||||
case "o1":
|
||||
case "o1-preview":
|
||||
case "o1-mini": {
|
||||
// o1 doesnt support streaming, non-1 temp, or system prompt
|
||||
const response = await this.client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
model: model.id,
|
||||
messages: [{ role: "user", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
})
|
||||
yield {
|
||||
type: "text",
|
||||
text: response.choices[0]?.message.content || "",
|
||||
}
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: response.usage?.prompt_tokens || 0,
|
||||
outputTokens: response.usage?.completion_tokens || 0,
|
||||
}
|
||||
|
||||
yield* this.yieldUsage(model.info, response.usage)
|
||||
|
||||
break
|
||||
}
|
||||
case "o3-mini": {
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
model: model.id,
|
||||
messages: [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
@@ -63,18 +80,15 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
}
|
||||
// Only last chunk contains usage
|
||||
yield* this.yieldUsage(model.info, chunk.usage)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
model: model.id,
|
||||
// max_completion_tokens: this.getModel().info.maxTokens,
|
||||
temperature: 0,
|
||||
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
@@ -90,14 +104,9 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
// contains a null value except for the last chunk which contains the token usage statistics for the entire request
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
}
|
||||
// Only last chunk contains usage
|
||||
yield* this.yieldUsage(model.info, chunk.usage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ApiHandler } from "../index"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
import { ChatCompletionReasoningEffort } from "openai/resources/chat/completions.mjs"
|
||||
|
||||
export class OpenAiHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
@@ -14,7 +15,8 @@ export class OpenAiHandler implements ApiHandler {
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
// Azure API shape slightly differs from the core API shape: https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
|
||||
if (this.options.openAiBaseUrl?.toLowerCase().includes("azure.com")) {
|
||||
// Use azureApiVersion to determine if this is an Azure endpoint, since the URL may not always contain 'azure.com'
|
||||
if (this.options.azureApiVersion || this.options.openAiBaseUrl?.toLowerCase().includes("azure.com")) {
|
||||
this.client = new AzureOpenAI({
|
||||
baseURL: this.options.openAiBaseUrl,
|
||||
apiKey: this.options.openAiApiKey,
|
||||
@@ -32,20 +34,30 @@ export class OpenAiHandler implements ApiHandler {
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const modelId = this.options.openAiModelId ?? ""
|
||||
const isDeepseekReasoner = modelId.includes("deepseek-reasoner")
|
||||
const isO3Mini = modelId.includes("o3-mini")
|
||||
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
let temperature: number | undefined = this.options.openAiModelInfo?.temperature ?? openAiModelInfoSaneDefaults.temperature
|
||||
let reasoningEffort: ChatCompletionReasoningEffort | undefined = undefined
|
||||
|
||||
if (isDeepseekReasoner) {
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
|
||||
if (isO3Mini) {
|
||||
openAiMessages = [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)]
|
||||
temperature = undefined // does not support temperature
|
||||
reasoningEffort = (this.options.o3MiniReasoningEffort as ChatCompletionReasoningEffort) || "medium"
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: modelId,
|
||||
messages: openAiMessages,
|
||||
temperature: 0,
|
||||
temperature,
|
||||
reasoning_effort: reasoningEffort,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
|
||||
+50
-145
@@ -1,17 +1,18 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import axios from "axios"
|
||||
import delay from "delay"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import OpenAI from "openai"
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
import { withRetry } from "../retry"
|
||||
import { createOpenRouterStream } from "../transform/openrouter-stream"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { OpenRouterErrorResponse } from "./types"
|
||||
|
||||
export class OpenRouterHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
lastGenerationId?: string
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
@@ -27,121 +28,32 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const model = this.getModel()
|
||||
this.lastGenerationId = undefined
|
||||
|
||||
// Convert Anthropic messages to OpenAI format
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
const stream = await createOpenRouterStream(
|
||||
this.client,
|
||||
systemPrompt,
|
||||
messages,
|
||||
this.getModel(),
|
||||
this.options.o3MiniReasoningEffort,
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
)
|
||||
|
||||
// prompt caching: https://openrouter.ai/docs/prompt-caching
|
||||
// this is specifically for claude models (some models may 'support prompt caching' automatically without this)
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-3.5-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet-20240620":
|
||||
case "anthropic/claude-3.5-sonnet-20240620:beta":
|
||||
case "anthropic/claude-3-5-haiku":
|
||||
case "anthropic/claude-3-5-haiku:beta":
|
||||
case "anthropic/claude-3-5-haiku-20241022":
|
||||
case "anthropic/claude-3-5-haiku-20241022:beta":
|
||||
case "anthropic/claude-3-haiku":
|
||||
case "anthropic/claude-3-haiku:beta":
|
||||
case "anthropic/claude-3-opus":
|
||||
case "anthropic/claude-3-opus:beta":
|
||||
openAiMessages[0] = {
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: systemPrompt,
|
||||
// @ts-ignore-next-line
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
}
|
||||
// Add cache_control to the last two user messages
|
||||
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
|
||||
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
|
||||
lastTwoUserMessages.forEach((msg) => {
|
||||
if (typeof msg.content === "string") {
|
||||
msg.content = [{ type: "text", text: msg.content }]
|
||||
}
|
||||
if (Array.isArray(msg.content)) {
|
||||
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
|
||||
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
|
||||
|
||||
if (!lastTextPart) {
|
||||
lastTextPart = { type: "text", text: "..." }
|
||||
msg.content.push(lastTextPart)
|
||||
}
|
||||
// @ts-ignore-next-line
|
||||
lastTextPart["cache_control"] = { type: "ephemeral" }
|
||||
}
|
||||
})
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Not sure how openrouter defaults max tokens when no value is provided, but the anthropic api requires this value and since they offer both 4096 and 8192 variants, we should ensure 8192.
|
||||
// (models usually default to max tokens allowed)
|
||||
let maxTokens: number | undefined
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-3.5-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet-20240620":
|
||||
case "anthropic/claude-3.5-sonnet-20240620:beta":
|
||||
case "anthropic/claude-3-5-haiku":
|
||||
case "anthropic/claude-3-5-haiku:beta":
|
||||
case "anthropic/claude-3-5-haiku-20241022":
|
||||
case "anthropic/claude-3-5-haiku-20241022:beta":
|
||||
maxTokens = 8_192
|
||||
break
|
||||
}
|
||||
|
||||
let temperature = 0
|
||||
let topP: number | undefined = undefined
|
||||
if (this.getModel().id.startsWith("deepseek/deepseek-r1") || this.getModel().id === "perplexity/sonar-reasoning") {
|
||||
// Recommended values from DeepSeek
|
||||
temperature = 0.7
|
||||
topP = 0.95
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
|
||||
// Removes messages in the middle when close to context window limit. Should not be applied to models that support prompt caching since it would continuously break the cache.
|
||||
let shouldApplyMiddleOutTransform = !model.info.supportsPromptCache
|
||||
// except for deepseek (which we set supportsPromptCache to true for), where because the context window is so small our truncation algo might miss and we should use openrouter's middle-out transform as a fallback to ensure we don't exceed the context window (FIXME: once we have a more robust token estimator we should not rely on this)
|
||||
if (model.id === "deepseek/deepseek-chat") {
|
||||
shouldApplyMiddleOutTransform = true
|
||||
}
|
||||
|
||||
// @ts-ignore-next-line
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: model.id,
|
||||
max_tokens: maxTokens,
|
||||
temperature: temperature,
|
||||
top_p: topP,
|
||||
messages: openAiMessages,
|
||||
stream: true,
|
||||
transforms: shouldApplyMiddleOutTransform ? ["middle-out"] : undefined,
|
||||
include_reasoning: true,
|
||||
...(model.id === "openai/o3-mini" ? { reasoning_effort: this.options.o3MiniReasoningEffort || "medium" } : {}),
|
||||
})
|
||||
|
||||
let genId: string | undefined
|
||||
let didOutputUsage: boolean = false
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as { message?: string; code?: number }
|
||||
const error = chunk.error as OpenRouterErrorResponse["error"]
|
||||
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
|
||||
throw new Error(`OpenRouter API Error ${error?.code}: ${error?.message}`)
|
||||
// Include metadata in the error message if available
|
||||
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
|
||||
throw new Error(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`)
|
||||
}
|
||||
|
||||
if (!genId && chunk.id) {
|
||||
genId = chunk.id
|
||||
if (!this.lastGenerationId && chunk.id) {
|
||||
this.lastGenerationId = chunk.id
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta
|
||||
@@ -154,50 +66,42 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
|
||||
// Reasoning tokens are returned separately from the content
|
||||
if ("reasoning" in delta && delta.reasoning) {
|
||||
// console.log("reasoning", delta.reasoning)
|
||||
yield {
|
||||
type: "reasoning",
|
||||
// @ts-ignore-next-line
|
||||
reasoning: delta.reasoning,
|
||||
}
|
||||
|
||||
// if (didStreamThinkTagInReasoning) {
|
||||
// yield {
|
||||
// type: "text",
|
||||
// // @ts-ignore-next-line
|
||||
// text: delta.reasoning,
|
||||
// }
|
||||
// } else {
|
||||
// yield {
|
||||
// type: "reasoning",
|
||||
// // @ts-ignore-next-line
|
||||
// text: delta.reasoning,
|
||||
// }
|
||||
|
||||
// // @ts-ignore-next-line
|
||||
// reasoningResponse += delta.reasoning
|
||||
// if (reasoningResponse.includes("</think>")) {
|
||||
// didStreamThinkTagInReasoning = true
|
||||
// console.log("did hit think tag", reasoningResponse)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
// if (chunk.usage) {
|
||||
// yield {
|
||||
// type: "usage",
|
||||
// inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
// outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// }
|
||||
// }
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: chunk.usage.cost || 0,
|
||||
}
|
||||
didOutputUsage = true
|
||||
}
|
||||
}
|
||||
|
||||
if (genId) {
|
||||
await delay(500) // FIXME: necessary delay to ensure generation endpoint is ready
|
||||
// Fallback to generation endpoint if usage chunk not returned
|
||||
if (!didOutputUsage) {
|
||||
const apiStreamUsage = await this.getApiStreamUsage()
|
||||
if (apiStreamUsage) {
|
||||
yield apiStreamUsage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
|
||||
if (this.lastGenerationId) {
|
||||
await setTimeoutPromise(500) // FIXME: necessary delay to ensure generation endpoint is ready
|
||||
try {
|
||||
const generationIterator = this.fetchGenerationDetails(genId)
|
||||
const generationIterator = this.fetchGenerationDetails(this.lastGenerationId)
|
||||
const generation = (await generationIterator.next()).value
|
||||
// console.log("OpenRouter generation details:", generation)
|
||||
yield {
|
||||
return {
|
||||
type: "usage",
|
||||
// cacheWriteTokens: 0,
|
||||
// cacheReadTokens: 0,
|
||||
@@ -211,6 +115,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
console.error("Error fetching OpenRouter generation details:", error)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@withRetry({ maxRetries: 4, baseDelay: 250, maxDelay: 1000, retryAllErrors: true })
|
||||
@@ -221,7 +126,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.openRouterApiKey}`,
|
||||
},
|
||||
timeout: 5_000, // this request hangs sometimes
|
||||
timeout: 15_000, // this request hangs sometimes
|
||||
})
|
||||
yield response.data?.data
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, QwenModelId, ModelInfo, qwenDefaultModelId, qwenModels } from "../../shared/api"
|
||||
import {
|
||||
ApiHandlerOptions,
|
||||
ModelInfo,
|
||||
mainlandQwenModels,
|
||||
internationalQwenModels,
|
||||
mainlandQwenDefaultModelId,
|
||||
internationalQwenDefaultModelId,
|
||||
MainlandQwenModelId,
|
||||
InternationalQwenModelId,
|
||||
} from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
@@ -21,15 +30,21 @@ export class QwenHandler implements ApiHandler {
|
||||
})
|
||||
}
|
||||
|
||||
getModel(): { id: QwenModelId; info: ModelInfo } {
|
||||
getModel(): { id: MainlandQwenModelId | InternationalQwenModelId; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in qwenModels) {
|
||||
const id = modelId as QwenModelId
|
||||
return { id, info: qwenModels[id] }
|
||||
}
|
||||
return {
|
||||
id: qwenDefaultModelId,
|
||||
info: qwenModels[qwenDefaultModelId],
|
||||
// Branch based on API line to let poor typescript know what to do
|
||||
if (this.options.qwenApiLine === "china") {
|
||||
return {
|
||||
id: (modelId as MainlandQwenModelId) ?? mainlandQwenDefaultModelId,
|
||||
info: mainlandQwenModels[modelId as MainlandQwenModelId] ?? mainlandQwenModels[mainlandQwenDefaultModelId],
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
id: (modelId as InternationalQwenModelId) ?? internationalQwenDefaultModelId,
|
||||
info:
|
||||
internationalQwenModels[modelId as InternationalQwenModelId] ??
|
||||
internationalQwenModels[internationalQwenDefaultModelId],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
|
||||
import { ApiHandler } from "../index"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
@@ -69,13 +69,19 @@ export class RequestyHandler implements ApiHandler {
|
||||
|
||||
if (chunk.usage) {
|
||||
const usage = chunk.usage as RequestyUsage
|
||||
const inputTokens = usage.prompt_tokens || 0
|
||||
const outputTokens = usage.completion_tokens || 0
|
||||
const cacheWriteTokens = usage.prompt_tokens_details?.caching_tokens || undefined
|
||||
const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens || undefined
|
||||
const totalCost = 0 // TODO: Replace with calculateApiCostOpenAI(model.info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: usage.prompt_tokens || 0,
|
||||
outputTokens: usage.completion_tokens || 0,
|
||||
cacheWriteTokens: usage.prompt_tokens_details?.caching_tokens || undefined,
|
||||
cacheReadTokens: usage.prompt_tokens_details?.cached_tokens || undefined,
|
||||
totalCost: usage.total_cost || undefined,
|
||||
inputTokens: inputTokens,
|
||||
outputTokens: outputTokens,
|
||||
cacheWriteTokens: cacheWriteTokens,
|
||||
cacheReadTokens: cacheReadTokens,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiHandlerOptions, ModelInfo, SambanovaModelId, sambanovaDefaultModelId, sambanovaModels } from "../../shared/api"
|
||||
import { ApiHandler } from "../index"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
|
||||
export class SambanovaHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.sambanova.ai/v1",
|
||||
apiKey: this.options.sambanovaApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const model = this.getModel()
|
||||
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
const modelId = model.id.toLowerCase()
|
||||
|
||||
if (modelId.includes("deepseek") || modelId.includes("qwen") || modelId.includes("qwq")) {
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
messages: openAiMessages,
|
||||
temperature: 0,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in sambanovaModels) {
|
||||
const id = modelId as SambanovaModelId
|
||||
return { id, info: sambanovaModels[id] }
|
||||
}
|
||||
return {
|
||||
id: sambanovaDefaultModelId,
|
||||
info: sambanovaModels[sambanovaDefaultModelId],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// For the following openrouter error type sources, see the docs here:
|
||||
// https://openrouter.ai/docs/api-reference/errors
|
||||
|
||||
export type OpenRouterErrorResponse = {
|
||||
error: {
|
||||
message: string
|
||||
code: number
|
||||
metadata?: OpenRouterProviderErrorMetadata | OpenRouterModerationErrorMetadata | Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenRouterProviderErrorMetadata = {
|
||||
provider_name: string // The name of the provider that encountered the error
|
||||
raw: unknown // The raw error from the provider
|
||||
}
|
||||
|
||||
export type OpenRouterModerationErrorMetadata = {
|
||||
reasons: string[] // Why your input was flagged
|
||||
flagged_input: string // The text segment that was flagged, limited to 100 characters. If the flagged input is longer than 100 characters, it will be truncated in the middle and replaced with ...
|
||||
provider_name: string // The name of the provider that requested moderation
|
||||
model_slug: string
|
||||
}
|
||||
+237
-45
@@ -4,75 +4,267 @@ import { withRetry } from "../retry"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../../shared/api"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { VertexAI } from "@google-cloud/vertexai"
|
||||
|
||||
// https://docs.anthropic.com/en/api/claude-on-vertex-ai
|
||||
export class VertexHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: AnthropicVertex
|
||||
private clientAnthropic: AnthropicVertex
|
||||
private clientVertex: VertexAI
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new AnthropicVertex({
|
||||
this.clientAnthropic = new AnthropicVertex({
|
||||
projectId: this.options.vertexProjectId,
|
||||
// https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions
|
||||
region: this.options.vertexRegion,
|
||||
})
|
||||
this.clientVertex = new VertexAI({
|
||||
project: this.options.vertexProjectId,
|
||||
location: this.options.vertexRegion,
|
||||
})
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const stream = await this.client.messages.create({
|
||||
model: this.getModel().id,
|
||||
max_tokens: this.getModel().info.maxTokens || 8192,
|
||||
temperature: 0,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
stream: true,
|
||||
})
|
||||
for await (const chunk of stream) {
|
||||
switch (chunk.type) {
|
||||
case "message_start":
|
||||
const usage = chunk.message.usage
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
}
|
||||
break
|
||||
case "message_delta":
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: chunk.usage.output_tokens || 0,
|
||||
}
|
||||
break
|
||||
const model = this.getModel()
|
||||
const modelId = model.id
|
||||
|
||||
case "content_block_start":
|
||||
switch (chunk.content_block.type) {
|
||||
case "text":
|
||||
if (chunk.index > 0) {
|
||||
if (modelId.includes("claude")) {
|
||||
let budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
|
||||
|
||||
let stream
|
||||
switch (modelId) {
|
||||
case "claude-3-7-sonnet@20250219":
|
||||
case "claude-3-5-sonnet-v2@20241022":
|
||||
case "claude-3-5-sonnet@20240620":
|
||||
case "claude-3-5-haiku@20241022":
|
||||
case "claude-3-opus@20240229":
|
||||
case "claude-3-haiku@20240307": {
|
||||
// Find indices of user messages for cache control
|
||||
const userMsgIndices = messages.reduce(
|
||||
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
|
||||
[] as number[],
|
||||
)
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
|
||||
stream = await this.clientAnthropic.beta.messages.create(
|
||||
{
|
||||
model: modelId,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
|
||||
temperature: reasoningOn ? undefined : 0,
|
||||
system: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
type: "text",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
messages: messages.map((message, index) => {
|
||||
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
|
||||
return {
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string"
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: message.content,
|
||||
cache_control: {
|
||||
type: "ephemeral",
|
||||
},
|
||||
},
|
||||
]
|
||||
: message.content.map((content, contentIndex) =>
|
||||
contentIndex === message.content.length - 1
|
||||
? {
|
||||
...content,
|
||||
cache_control: {
|
||||
type: "ephemeral",
|
||||
},
|
||||
}
|
||||
: content,
|
||||
),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string"
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: message.content,
|
||||
},
|
||||
]
|
||||
: message.content,
|
||||
}
|
||||
}),
|
||||
stream: true,
|
||||
},
|
||||
{
|
||||
headers: {},
|
||||
},
|
||||
)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
stream = await this.clientAnthropic.beta.messages.create({
|
||||
model: modelId,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
temperature: 0,
|
||||
system: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
messages: messages.map((message) => ({
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string"
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: message.content,
|
||||
},
|
||||
]
|
||||
: message.content,
|
||||
})),
|
||||
stream: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
for await (const chunk of stream) {
|
||||
switch (chunk.type) {
|
||||
case "message_start":
|
||||
const usage = chunk.message.usage
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
|
||||
cacheReadTokens: usage.cache_read_input_tokens || undefined,
|
||||
}
|
||||
break
|
||||
case "message_delta":
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: chunk.usage.output_tokens || 0,
|
||||
}
|
||||
break
|
||||
case "message_stop":
|
||||
break
|
||||
case "content_block_start":
|
||||
switch (chunk.content_block.type) {
|
||||
case "thinking":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.content_block.thinking || "",
|
||||
}
|
||||
break
|
||||
case "redacted_thinking":
|
||||
// Handle redacted thinking blocks - we still mark it as reasoning
|
||||
// but note that the content is encrypted
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "[Redacted thinking block]",
|
||||
}
|
||||
break
|
||||
|
||||
case "text":
|
||||
if (chunk.index > 0) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: "\n",
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: "text",
|
||||
text: "\n",
|
||||
text: chunk.content_block.text,
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
case "content_block_delta":
|
||||
switch (chunk.delta.type) {
|
||||
case "thinking_delta":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.delta.thinking,
|
||||
}
|
||||
break
|
||||
case "text_delta":
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.delta.text,
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
case "content_block_stop":
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// gemini
|
||||
const generativeModel = this.clientVertex.getGenerativeModel({
|
||||
model: this.getModel().id,
|
||||
systemInstruction: {
|
||||
role: "system",
|
||||
parts: [{ text: systemPrompt }],
|
||||
},
|
||||
})
|
||||
const request = {
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: messages.map((m) => {
|
||||
if (typeof m.content === "string") {
|
||||
return { text: m.content }
|
||||
} else if (Array.isArray(m.content)) {
|
||||
return {
|
||||
text: m.content
|
||||
.map((block) => {
|
||||
if (typeof block === "string") {
|
||||
return block
|
||||
} else if (block.type === "text") {
|
||||
return block.text
|
||||
} else {
|
||||
console.log("Unsupported block type", block)
|
||||
return ""
|
||||
}
|
||||
})
|
||||
.join(" "),
|
||||
}
|
||||
} else {
|
||||
return { text: "" }
|
||||
}
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
const streamingResult = await generativeModel.generateContentStream(request)
|
||||
for await (const chunk of streamingResult.stream) {
|
||||
// If usage data is available, yield it similarly:
|
||||
// yield { type: "usage", inputTokens: 0, outputTokens: 0 }
|
||||
// Otherwise, just yield text:
|
||||
const candidates = chunk.candidates || []
|
||||
for (const candidate of candidates) {
|
||||
for (const part of candidate.content?.parts || []) {
|
||||
if (part.text) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.content_block.text,
|
||||
text: part.text,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
case "content_block_delta":
|
||||
switch (chunk.delta.type) {
|
||||
case "text_delta":
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.delta.text,
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import * as vscode from "vscode"
|
||||
import { ApiHandler, SingleCompletionHandler } from "../"
|
||||
import { calculateApiCost } from "../../utils/cost"
|
||||
import { calculateApiCostAnthropic } from "../../utils/cost"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToVsCodeLmMessages } from "../transform/vscode-lm-format"
|
||||
import { SELECTOR_SEPARATOR, stringifyVsCodeLmModelSelector } from "../../shared/vsCodeSelectorUtils"
|
||||
@@ -525,7 +525,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
|
||||
type: "usage",
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
totalCost: calculateApiCost(this.getModel().info, totalInputTokens, totalOutputTokens),
|
||||
totalCost: calculateApiCostAnthropic(this.getModel().info, totalInputTokens, totalOutputTokens),
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
this.ensureCleanState()
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, XAIModelId, ModelInfo, xaiDefaultModelId, xaiModels } from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
export class XAIHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.x.ai/v1",
|
||||
apiKey: this.options.xaiApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
max_completion_tokens: this.getModel().info.maxTokens,
|
||||
temperature: 0,
|
||||
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
cacheReadTokens: chunk.usage.prompt_cache_hit_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: XAIModelId; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in xaiModels) {
|
||||
const id = modelId as XAIModelId
|
||||
return { id, info: xaiModels[id] }
|
||||
}
|
||||
return {
|
||||
id: xaiDefaultModelId,
|
||||
info: xaiModels[xaiDefaultModelId],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,7 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import {
|
||||
Content,
|
||||
EnhancedGenerateContentResponse,
|
||||
FunctionCallPart,
|
||||
FunctionDeclaration,
|
||||
FunctionResponsePart,
|
||||
InlineDataPart,
|
||||
Part,
|
||||
SchemaType,
|
||||
TextPart,
|
||||
} from "@google/generative-ai"
|
||||
import { Content, EnhancedGenerateContentResponse, InlineDataPart, Part, TextPart } from "@google/generative-ai"
|
||||
|
||||
export function convertAnthropicContentToGemini(
|
||||
content:
|
||||
| string
|
||||
| Array<
|
||||
| Anthropic.Messages.TextBlockParam
|
||||
| Anthropic.Messages.ImageBlockParam
|
||||
| Anthropic.Messages.ToolUseBlockParam
|
||||
| Anthropic.Messages.ToolResultBlockParam
|
||||
>,
|
||||
): Part[] {
|
||||
export function convertAnthropicContentToGemini(content: string | Anthropic.ContentBlockParam[]): Part[] {
|
||||
if (typeof content === "string") {
|
||||
return [{ text: content } as TextPart]
|
||||
}
|
||||
@@ -38,55 +19,6 @@ export function convertAnthropicContentToGemini(
|
||||
mimeType: block.source.media_type,
|
||||
},
|
||||
} as InlineDataPart
|
||||
case "tool_use":
|
||||
return {
|
||||
functionCall: {
|
||||
name: block.name,
|
||||
args: block.input,
|
||||
},
|
||||
} as FunctionCallPart
|
||||
case "tool_result":
|
||||
const name = block.tool_use_id.split("-")[0]
|
||||
if (!block.content) {
|
||||
return []
|
||||
}
|
||||
if (typeof block.content === "string") {
|
||||
return {
|
||||
functionResponse: {
|
||||
name,
|
||||
response: {
|
||||
name,
|
||||
content: block.content,
|
||||
},
|
||||
},
|
||||
} as FunctionResponsePart
|
||||
} else {
|
||||
// The only case when tool_result could be array is when the tool failed and we're providing ie user feedback potentially with images
|
||||
const textParts = block.content.filter((part) => part.type === "text")
|
||||
const imageParts = block.content.filter((part) => part.type === "image")
|
||||
const text = textParts.length > 0 ? textParts.map((part) => part.text).join("\n\n") : ""
|
||||
const imageText = imageParts.length > 0 ? "\n\n(See next part for image)" : ""
|
||||
return [
|
||||
{
|
||||
functionResponse: {
|
||||
name,
|
||||
response: {
|
||||
name,
|
||||
content: text + imageText,
|
||||
},
|
||||
},
|
||||
} as FunctionResponsePart,
|
||||
...imageParts.map(
|
||||
(part) =>
|
||||
({
|
||||
inlineData: {
|
||||
data: part.source.data,
|
||||
mimeType: part.source.media_type,
|
||||
},
|
||||
}) as InlineDataPart,
|
||||
),
|
||||
]
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported content block type: ${(block as any).type}`)
|
||||
}
|
||||
@@ -100,26 +32,6 @@ export function convertAnthropicMessageToGemini(message: Anthropic.Messages.Mess
|
||||
}
|
||||
}
|
||||
|
||||
export function convertAnthropicToolToGemini(tool: Anthropic.Messages.Tool): FunctionDeclaration {
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description || "",
|
||||
parameters: {
|
||||
type: SchemaType.OBJECT,
|
||||
properties: Object.fromEntries(
|
||||
Object.entries(tool.input_schema.properties || {}).map(([key, value]) => [
|
||||
key,
|
||||
{
|
||||
type: (value as any).type.toUpperCase(),
|
||||
description: (value as any).description || "",
|
||||
},
|
||||
]),
|
||||
),
|
||||
required: (tool.input_schema.required as string[]) || [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
It looks like gemini likes to double escape certain characters when writing file contents: https://discuss.ai.google.dev/t/function-call-string-property-is-double-escaped/37867
|
||||
*/
|
||||
@@ -133,23 +45,7 @@ export function convertGeminiResponseToAnthropic(response: EnhancedGenerateConte
|
||||
// Add the main text response
|
||||
const text = response.text()
|
||||
if (text) {
|
||||
content.push({ type: "text", text })
|
||||
}
|
||||
|
||||
// Add function calls as tool_use blocks
|
||||
const functionCalls = response.functionCalls()
|
||||
if (functionCalls) {
|
||||
functionCalls.forEach((call, index) => {
|
||||
if ("content" in call.args && typeof call.args.content === "string") {
|
||||
call.args.content = unescapeGeminiContent(call.args.content)
|
||||
}
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
id: `${call.name}-${index}-${Date.now()}`,
|
||||
name: call.name,
|
||||
input: call.args,
|
||||
})
|
||||
})
|
||||
content.push({ type: "text", text, citations: null })
|
||||
}
|
||||
|
||||
// Determine stop reason
|
||||
@@ -183,6 +79,8 @@ export function convertGeminiResponseToAnthropic(response: EnhancedGenerateConte
|
||||
usage: {
|
||||
input_tokens: response.usageMetadata?.promptTokenCount ?? 0,
|
||||
output_tokens: response.usageMetadata?.candidatesTokenCount ?? 0,
|
||||
cache_creation_input_tokens: null,
|
||||
cache_read_input_tokens: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { Mistral } from "@mistralai/mistralai"
|
||||
import { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage"
|
||||
import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage"
|
||||
import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage"
|
||||
@@ -21,25 +20,15 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
|
||||
})
|
||||
} else {
|
||||
if (anthropicMessage.role === "user") {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
|
||||
toolMessages: Anthropic.ToolResultBlockParam[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_result") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
} // user cannot send tool_use messages
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
// Filter to only include text and image blocks
|
||||
const textAndImageBlocks = anthropicMessage.content.filter(
|
||||
(part) => part.type === "text" || part.type === "image",
|
||||
)
|
||||
|
||||
if (nonToolMessages.length > 0) {
|
||||
if (textAndImageBlocks.length > 0) {
|
||||
mistralMessages.push({
|
||||
role: "user",
|
||||
content: nonToolMessages.map((part) => {
|
||||
content: textAndImageBlocks.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return {
|
||||
type: "image_url",
|
||||
@@ -53,37 +42,17 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
|
||||
})
|
||||
}
|
||||
} else if (anthropicMessage.role === "assistant") {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
|
||||
toolMessages: Anthropic.ToolUseBlockParam[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_use") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
} // assistant cannot send tool_result messages
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
)
|
||||
// Only process text blocks - assistant cannot send images or other content types in Mistral's API format
|
||||
const textBlocks = anthropicMessage.content.filter((part) => part.type === "text")
|
||||
|
||||
let content: string | undefined
|
||||
if (nonToolMessages.length > 0) {
|
||||
content = nonToolMessages
|
||||
.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return "" // impossible as the assistant cannot send images
|
||||
}
|
||||
return part.text
|
||||
})
|
||||
.join("\n")
|
||||
if (textBlocks.length > 0) {
|
||||
const content = textBlocks.map((part) => part.text).join("\n")
|
||||
|
||||
mistralMessages.push({
|
||||
role: "assistant",
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
mistralMessages.push({
|
||||
role: "assistant",
|
||||
content,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,6 +376,7 @@ export function convertO1ResponseToAnthropicMessage(
|
||||
{
|
||||
type: "text",
|
||||
text: normalText,
|
||||
citations: null,
|
||||
},
|
||||
],
|
||||
model: completion.model,
|
||||
@@ -396,6 +397,8 @@ export function convertO1ResponseToAnthropicMessage(
|
||||
usage: {
|
||||
input_tokens: completion.usage?.prompt_tokens || 0,
|
||||
output_tokens: completion.usage?.completion_tokens || 0,
|
||||
cache_creation_input_tokens: null,
|
||||
cache_read_input_tokens: null,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { Message } from "ollama"
|
||||
|
||||
export function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] {
|
||||
const ollamaMessages: Message[] = []
|
||||
|
||||
for (const anthropicMessage of anthropicMessages) {
|
||||
if (typeof anthropicMessage.content === "string") {
|
||||
ollamaMessages.push({
|
||||
role: anthropicMessage.role,
|
||||
content: anthropicMessage.content,
|
||||
})
|
||||
} else {
|
||||
if (anthropicMessage.role === "user") {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
|
||||
toolMessages: Anthropic.ToolResultBlockParam[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_result") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
)
|
||||
|
||||
// Process tool result messages FIRST since they must follow the tool use messages
|
||||
let toolResultImages: string[] = []
|
||||
toolMessages.forEach((toolMessage) => {
|
||||
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the Ollama SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
|
||||
let content: string
|
||||
|
||||
if (typeof toolMessage.content === "string") {
|
||||
content = toolMessage.content
|
||||
} else {
|
||||
content =
|
||||
toolMessage.content
|
||||
?.map((part) => {
|
||||
if (part.type === "image") {
|
||||
toolResultImages.push(`data:${part.source.media_type};base64,${part.source.data}`)
|
||||
return "(see following user message for image)"
|
||||
}
|
||||
return part.text
|
||||
})
|
||||
.join("\n") ?? ""
|
||||
}
|
||||
ollamaMessages.push({
|
||||
role: "user",
|
||||
images: toolResultImages.length > 0 ? toolResultImages : undefined,
|
||||
content: content,
|
||||
})
|
||||
})
|
||||
|
||||
// Process non-tool messages
|
||||
if (nonToolMessages.length > 0) {
|
||||
ollamaMessages.push({
|
||||
role: "user",
|
||||
content: nonToolMessages
|
||||
.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return `data:${part.source.media_type};base64,${part.source.data}`
|
||||
}
|
||||
return part.text
|
||||
})
|
||||
.join("\n"),
|
||||
})
|
||||
}
|
||||
} else if (anthropicMessage.role === "assistant") {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
|
||||
toolMessages: Anthropic.ToolUseBlockParam[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_use") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
} // assistant cannot send tool_result messages
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
)
|
||||
|
||||
// Process non-tool messages
|
||||
let content: string = ""
|
||||
if (nonToolMessages.length > 0) {
|
||||
content = nonToolMessages
|
||||
.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return "" // impossible as the assistant cannot send images
|
||||
}
|
||||
return part.text
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
ollamaMessages.push({
|
||||
role: "assistant",
|
||||
content,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ollamaMessages
|
||||
}
|
||||
@@ -161,6 +161,7 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
|
||||
{
|
||||
type: "text",
|
||||
text: openAiMessage.content || "",
|
||||
citations: null,
|
||||
},
|
||||
],
|
||||
model: completion.model,
|
||||
@@ -181,6 +182,8 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
|
||||
usage: {
|
||||
input_tokens: completion.usage?.prompt_tokens || 0,
|
||||
output_tokens: completion.usage?.completion_tokens || 0,
|
||||
cache_creation_input_tokens: null,
|
||||
cache_read_input_tokens: null,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { ModelInfo } from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "./openai-format"
|
||||
import { convertToR1Format } from "./r1-format"
|
||||
import { ApiStream, ApiStreamChunk } from "./stream"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { OpenRouterErrorResponse } from "../providers/types"
|
||||
|
||||
export async function createOpenRouterStream(
|
||||
client: OpenAI,
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
model: { id: string; info: ModelInfo },
|
||||
o3MiniReasoningEffort?: string,
|
||||
thinkingBudgetTokens?: number,
|
||||
openRouterProviderSorting?: string,
|
||||
) {
|
||||
// Convert Anthropic messages to OpenAI format
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
// prompt caching: https://openrouter.ai/docs/prompt-caching
|
||||
// this is specifically for claude models (some models may 'support prompt caching' automatically without this)
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-3.7-sonnet":
|
||||
case "anthropic/claude-3.7-sonnet:beta":
|
||||
case "anthropic/claude-3.7-sonnet:thinking":
|
||||
case "anthropic/claude-3-7-sonnet":
|
||||
case "anthropic/claude-3-7-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet-20240620":
|
||||
case "anthropic/claude-3.5-sonnet-20240620:beta":
|
||||
case "anthropic/claude-3-5-haiku":
|
||||
case "anthropic/claude-3-5-haiku:beta":
|
||||
case "anthropic/claude-3-5-haiku-20241022":
|
||||
case "anthropic/claude-3-5-haiku-20241022:beta":
|
||||
case "anthropic/claude-3-haiku":
|
||||
case "anthropic/claude-3-haiku:beta":
|
||||
case "anthropic/claude-3-opus":
|
||||
case "anthropic/claude-3-opus:beta":
|
||||
openAiMessages[0] = {
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: systemPrompt,
|
||||
// @ts-ignore-next-line
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
}
|
||||
// Add cache_control to the last two user messages
|
||||
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
|
||||
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
|
||||
lastTwoUserMessages.forEach((msg) => {
|
||||
if (typeof msg.content === "string") {
|
||||
msg.content = [{ type: "text", text: msg.content }]
|
||||
}
|
||||
if (Array.isArray(msg.content)) {
|
||||
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
|
||||
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
|
||||
|
||||
if (!lastTextPart) {
|
||||
lastTextPart = { type: "text", text: "..." }
|
||||
msg.content.push(lastTextPart)
|
||||
}
|
||||
// @ts-ignore-next-line
|
||||
lastTextPart["cache_control"] = { type: "ephemeral" }
|
||||
}
|
||||
})
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Not sure how openrouter defaults max tokens when no value is provided, but the anthropic api requires this value and since they offer both 4096 and 8192 variants, we should ensure 8192.
|
||||
// (models usually default to max tokens allowed)
|
||||
let maxTokens: number | undefined
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-3.7-sonnet":
|
||||
case "anthropic/claude-3.7-sonnet:beta":
|
||||
case "anthropic/claude-3.7-sonnet:thinking":
|
||||
case "anthropic/claude-3-7-sonnet":
|
||||
case "anthropic/claude-3-7-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet-20240620":
|
||||
case "anthropic/claude-3.5-sonnet-20240620:beta":
|
||||
case "anthropic/claude-3-5-haiku":
|
||||
case "anthropic/claude-3-5-haiku:beta":
|
||||
case "anthropic/claude-3-5-haiku-20241022":
|
||||
case "anthropic/claude-3-5-haiku-20241022:beta":
|
||||
maxTokens = 8_192
|
||||
break
|
||||
}
|
||||
|
||||
let temperature: number | undefined = 0
|
||||
let topP: number | undefined = undefined
|
||||
if (
|
||||
model.id.startsWith("deepseek/deepseek-r1") ||
|
||||
model.id === "perplexity/sonar-reasoning" ||
|
||||
model.id === "qwen/qwq-32b:free" ||
|
||||
model.id === "qwen/qwq-32b"
|
||||
) {
|
||||
// Recommended values from DeepSeek
|
||||
temperature = 0.7
|
||||
topP = 0.95
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
|
||||
let reasoning: { max_tokens: number } | undefined = undefined
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-3.7-sonnet":
|
||||
case "anthropic/claude-3.7-sonnet:beta":
|
||||
case "anthropic/claude-3.7-sonnet:thinking":
|
||||
case "anthropic/claude-3-7-sonnet":
|
||||
case "anthropic/claude-3-7-sonnet:beta":
|
||||
let budget_tokens = thinkingBudgetTokens || 0
|
||||
const reasoningOn = budget_tokens !== 0 ? true : false
|
||||
if (reasoningOn) {
|
||||
temperature = undefined // extended thinking does not support non-1 temperature
|
||||
reasoning = { max_tokens: budget_tokens }
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Removes messages in the middle when close to context window limit. Should not be applied to models that support prompt caching since it would continuously break the cache.
|
||||
let shouldApplyMiddleOutTransform = !model.info.supportsPromptCache
|
||||
// except for deepseek (which we set supportsPromptCache to true for), where because the context window is so small our truncation algo might miss and we should use openrouter's middle-out transform as a fallback to ensure we don't exceed the context window (FIXME: once we have a more robust token estimator we should not rely on this)
|
||||
if (model.id === "deepseek/deepseek-chat") {
|
||||
shouldApplyMiddleOutTransform = true
|
||||
}
|
||||
|
||||
// @ts-ignore-next-line
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
max_tokens: maxTokens,
|
||||
temperature: temperature,
|
||||
top_p: topP,
|
||||
messages: openAiMessages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
transforms: shouldApplyMiddleOutTransform ? ["middle-out"] : undefined,
|
||||
include_reasoning: true,
|
||||
...(model.id === "openai/o3-mini" ? { reasoning_effort: o3MiniReasoningEffort || "medium" } : {}),
|
||||
...(reasoning ? { reasoning } : {}),
|
||||
...(openRouterProviderSorting ? { provider: { sort: openRouterProviderSorting } } : {}),
|
||||
})
|
||||
|
||||
return stream
|
||||
}
|
||||
@@ -175,6 +175,7 @@ export async function convertToAnthropicMessage(
|
||||
return {
|
||||
type: "text",
|
||||
text: part.value,
|
||||
citations: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +196,8 @@ export async function convertToAnthropicMessage(
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: null,
|
||||
cache_read_input_tokens: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+470
-206
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,7 @@ export const toolParamNames = [
|
||||
"arguments",
|
||||
"uri",
|
||||
"question",
|
||||
"options",
|
||||
"response",
|
||||
"result",
|
||||
] as const
|
||||
@@ -58,64 +59,3 @@ export interface ToolUse {
|
||||
params: Partial<Record<ToolParamName, string>>
|
||||
partial: boolean
|
||||
}
|
||||
|
||||
export interface ExecuteCommandToolUse extends ToolUse {
|
||||
name: "execute_command"
|
||||
// Pick<Record<ToolParamName, string>, "command"> makes "command" required, but Partial<> makes it optional
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "command" | "requires_approval">>
|
||||
}
|
||||
|
||||
export interface ReadFileToolUse extends ToolUse {
|
||||
name: "read_file"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path">>
|
||||
}
|
||||
|
||||
export interface WriteToFileToolUse extends ToolUse {
|
||||
name: "write_to_file"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path" | "content">>
|
||||
}
|
||||
|
||||
export interface ReplaceInFileToolUse extends ToolUse {
|
||||
name: "replace_in_file"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path" | "diff">>
|
||||
}
|
||||
|
||||
export interface SearchFilesToolUse extends ToolUse {
|
||||
name: "search_files"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path" | "regex" | "file_pattern">>
|
||||
}
|
||||
|
||||
export interface ListFilesToolUse extends ToolUse {
|
||||
name: "list_files"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path" | "recursive">>
|
||||
}
|
||||
|
||||
export interface ListCodeDefinitionNamesToolUse extends ToolUse {
|
||||
name: "list_code_definition_names"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path">>
|
||||
}
|
||||
|
||||
export interface BrowserActionToolUse extends ToolUse {
|
||||
name: "browser_action"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "action" | "url" | "coordinate" | "text">>
|
||||
}
|
||||
|
||||
export interface UseMcpToolToolUse extends ToolUse {
|
||||
name: "use_mcp_tool"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "tool_name" | "arguments">>
|
||||
}
|
||||
|
||||
export interface AccessMcpResourceToolUse extends ToolUse {
|
||||
name: "access_mcp_resource"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "uri">>
|
||||
}
|
||||
|
||||
export interface AskFollowupQuestionToolUse extends ToolUse {
|
||||
name: "ask_followup_question"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "question">>
|
||||
}
|
||||
|
||||
export interface AttemptCompletionToolUse extends ToolUse {
|
||||
name: "attempt_completion"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "result" | "command">>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ContextManager } from "./ContextManager"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { expect } from "chai"
|
||||
|
||||
describe("ContextManager", () => {
|
||||
function createMessages(count: number): Anthropic.Messages.MessageParam[] {
|
||||
const messages: Anthropic.Messages.MessageParam[] = []
|
||||
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: "Initial task message",
|
||||
})
|
||||
|
||||
let role: "user" | "assistant" = "assistant"
|
||||
for (let i = 1; i < count; i++) {
|
||||
messages.push({
|
||||
role,
|
||||
content: `Message ${i}`,
|
||||
})
|
||||
role = role === "user" ? "assistant" : "user"
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
describe("getNextTruncationRange", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("first truncation with half keep", () => {
|
||||
const messages = createMessages(11)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
expect(result).to.deep.equal([1, 4])
|
||||
})
|
||||
|
||||
it("first truncation with quarter keep", () => {
|
||||
const messages = createMessages(11)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "quarter")
|
||||
|
||||
expect(result).to.deep.equal([1, 6])
|
||||
})
|
||||
|
||||
it("sequential truncation with half keep", () => {
|
||||
const messages = createMessages(21)
|
||||
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
expect(firstRange).to.deep.equal([1, 10])
|
||||
|
||||
// Pass the previous range for sequential truncation
|
||||
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "half")
|
||||
expect(secondRange).to.deep.equal([1, 14])
|
||||
})
|
||||
|
||||
it("sequential truncation with quarter keep", () => {
|
||||
const messages = createMessages(41)
|
||||
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "quarter")
|
||||
|
||||
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "quarter")
|
||||
|
||||
expect(secondRange[0]).to.equal(1)
|
||||
expect(secondRange[1]).to.be.greaterThan(firstRange[1])
|
||||
})
|
||||
|
||||
it("ensures the last message in range is a user message", () => {
|
||||
const messages = createMessages(14)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
// Check if the message at the end of range is a user message
|
||||
const lastRemovedMessage = messages[result[1]]
|
||||
expect(lastRemovedMessage.role).to.equal("user")
|
||||
|
||||
// Check if the next message after the range is an assistant message
|
||||
const nextMessage = messages[result[1] + 1]
|
||||
expect(nextMessage.role).to.equal("assistant")
|
||||
})
|
||||
|
||||
it("handles small message arrays", () => {
|
||||
const messages = createMessages(3)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
expect(result).to.deep.equal([1, 0])
|
||||
})
|
||||
|
||||
it("preserves the message structure when truncating", () => {
|
||||
const messages = createMessages(20)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
// Get messages after removing the range
|
||||
const effectiveMessages = [...messages.slice(0, result[0]), ...messages.slice(result[1] + 1)]
|
||||
|
||||
// Check first message and alternating pattern
|
||||
expect(effectiveMessages[0].role).to.equal("user")
|
||||
for (let i = 1; i < effectiveMessages.length; i++) {
|
||||
const expectedRole = i % 2 === 1 ? "assistant" : "user"
|
||||
expect(effectiveMessages[i].role).to.equal(expectedRole)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTruncatedMessages", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("returns original messages when no range is provided", () => {
|
||||
const messages = createMessages(3)
|
||||
|
||||
const result = contextManager.getTruncatedMessages(messages, undefined)
|
||||
expect(result).to.deep.equal(messages)
|
||||
})
|
||||
|
||||
it("correctly removes messages in the specified range", () => {
|
||||
const messages = createMessages(5)
|
||||
|
||||
const range: [number, number] = [1, 3]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(2)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[4])
|
||||
})
|
||||
|
||||
it("works with a range that starts at the first message after task", () => {
|
||||
const messages = createMessages(4)
|
||||
|
||||
const range: [number, number] = [1, 2]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(2)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[3])
|
||||
})
|
||||
|
||||
it("correctly handles removing a range while preserving alternation pattern", () => {
|
||||
const messages = createMessages(5)
|
||||
|
||||
const range: [number, number] = [1, 2]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(3)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[3])
|
||||
expect(result[2]).to.deep.equal(messages[4])
|
||||
|
||||
expect(result[0].role).to.equal("user")
|
||||
expect(result[1].role).to.equal("assistant")
|
||||
expect(result[2].role).to.equal("user")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ClineApiReqInfo, ClineMessage } from "../../shared/ExtensionMessage"
|
||||
import { ApiHandler } from "../../api"
|
||||
import { OpenAiHandler } from "../../api/providers/openai"
|
||||
|
||||
export class ContextManager {
|
||||
getNewContextMessagesAndMetadata(
|
||||
apiConversationHistory: Anthropic.Messages.MessageParam[],
|
||||
clineMessages: ClineMessage[],
|
||||
api: ApiHandler,
|
||||
conversationHistoryDeletedRange: [number, number] | undefined,
|
||||
previousApiReqIndex: number,
|
||||
) {
|
||||
let updatedConversationHistoryDeletedRange = false
|
||||
|
||||
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = clineMessages[previousApiReqIndex]
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
let contextWindow = api.getModel().info.contextWindow || 128_000
|
||||
// FIXME: hack to get anyone using openai compatible with deepseek to have the proper context window instead of the default 128k. We need a way for the user to specify the context window for models they input through openai compatible
|
||||
if (api instanceof OpenAiHandler && api.getModel().id.toLowerCase().includes("deepseek")) {
|
||||
contextWindow = 64_000
|
||||
}
|
||||
let maxAllowedSize: number
|
||||
switch (contextWindow) {
|
||||
case 64_000: // deepseek models
|
||||
maxAllowedSize = contextWindow - 27_000
|
||||
break
|
||||
case 128_000: // most models
|
||||
maxAllowedSize = contextWindow - 30_000
|
||||
break
|
||||
case 200_000: // claude models
|
||||
maxAllowedSize = contextWindow - 40_000
|
||||
break
|
||||
default:
|
||||
maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8) // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
|
||||
}
|
||||
|
||||
// This is the most reliable way to know when we're close to hitting the context window.
|
||||
if (totalTokens >= maxAllowedSize) {
|
||||
// Since the user may switch between models with different context windows, truncating half may not be enough (ie if switching from claude 200k to deepseek 64k, half truncation will only remove 100k tokens, but we need to remove much more)
|
||||
// So if totalTokens/2 is greater than maxAllowedSize, we truncate 3/4 instead of 1/2
|
||||
// FIXME: truncating the conversation in a way that is optimal for prompt caching AND takes into account multi-context window complexity is something we need to improve
|
||||
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
|
||||
|
||||
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
|
||||
conversationHistoryDeletedRange = this.getNextTruncationRange(
|
||||
apiConversationHistory,
|
||||
conversationHistoryDeletedRange,
|
||||
keep,
|
||||
)
|
||||
|
||||
updatedConversationHistoryDeletedRange = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// conversationHistoryDeletedRange is updated only when we're close to hitting the context window, so we don't continuously break the prompt cache
|
||||
const truncatedConversationHistory = this.getTruncatedMessages(apiConversationHistory, conversationHistoryDeletedRange)
|
||||
|
||||
return {
|
||||
conversationHistoryDeletedRange: conversationHistoryDeletedRange,
|
||||
updatedConversationHistoryDeletedRange: updatedConversationHistoryDeletedRange,
|
||||
truncatedConversationHistory: truncatedConversationHistory,
|
||||
}
|
||||
}
|
||||
|
||||
public getNextTruncationRange(
|
||||
apiMessages: Anthropic.Messages.MessageParam[],
|
||||
currentDeletedRange: [number, number] | undefined,
|
||||
keep: "half" | "quarter",
|
||||
): [number, number] {
|
||||
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
|
||||
const rangeStartIndex = 1
|
||||
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
|
||||
|
||||
let messagesToRemove: number
|
||||
if (keep === "half") {
|
||||
// Remove half of remaining user-assistant pairs
|
||||
// We first calculate half of the messages then divide by 2 to get the number of pairs.
|
||||
// After flooring, we multiply by 2 to get the number of messages.
|
||||
// Note that this will also always be an even number.
|
||||
messagesToRemove = Math.floor((apiMessages.length - startOfRest) / 4) * 2 // Keep even number
|
||||
} else {
|
||||
// Remove 3/4 of remaining user-assistant pairs
|
||||
// We calculate 3/4ths of the messages then divide by 2 to get the number of pairs.
|
||||
// After flooring, we multiply by 2 to get the number of messages.
|
||||
// Note that this will also always be an even number.
|
||||
messagesToRemove = Math.floor(((apiMessages.length - startOfRest) * 3) / 4 / 2) * 2
|
||||
}
|
||||
|
||||
let rangeEndIndex = startOfRest + messagesToRemove - 1
|
||||
|
||||
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
|
||||
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
|
||||
if (apiMessages[rangeEndIndex].role !== "user") {
|
||||
rangeEndIndex -= 1
|
||||
}
|
||||
|
||||
// this is an inclusive range that will be removed from the conversation history
|
||||
return [rangeStartIndex, rangeEndIndex]
|
||||
}
|
||||
|
||||
public getTruncatedMessages(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
deletedRange: [number, number] | undefined,
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
if (!deletedRange) {
|
||||
return messages
|
||||
}
|
||||
|
||||
const [start, end] = deletedRange
|
||||
// the range is inclusive - both start and end indices and everything in between will be removed from the final result.
|
||||
// NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
|
||||
return [...messages.slice(0, start), ...messages.slice(end + 1)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function checkIsOpenRouterContextWindowError(error: any): boolean {
|
||||
return error.code === 400 && error.message?.includes("context length")
|
||||
}
|
||||
|
||||
export function checkIsAnthropicContextWindowError(response: any): boolean {
|
||||
return (
|
||||
response?.error?.error?.type === "invalid_request_error" &&
|
||||
response?.error?.error?.message?.includes("prompt is too long")
|
||||
)
|
||||
}
|
||||
@@ -72,7 +72,10 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
|
||||
}
|
||||
}
|
||||
|
||||
for (const mention of mentions) {
|
||||
// Filter out duplicate mentions while preserving order
|
||||
const uniqueMentions = Array.from(new Set(mentions))
|
||||
|
||||
for (const mention of uniqueMentions) {
|
||||
if (mention.startsWith("http")) {
|
||||
let result: string
|
||||
if (launchBrowserError) {
|
||||
|
||||
@@ -216,9 +216,13 @@ Usage:
|
||||
Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
|
||||
Parameters:
|
||||
- question: (required) The question to ask the user. This should be a clear, specific question that addresses the information you need.
|
||||
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible answer. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually.
|
||||
Usage:
|
||||
<ask_followup_question>
|
||||
<question>Your question here</question>
|
||||
<options>
|
||||
Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
|
||||
</options>
|
||||
</ask_followup_question>
|
||||
|
||||
## attempt_completion
|
||||
@@ -239,9 +243,13 @@ Your final result description here
|
||||
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should be used when you need to provide a response to a question or statement from the user about how you plan to accomplish the task. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN MODE then you should not use this tool. Depending on the user's message, you may ask questions to get clarification about the user's request, architect a solution to the task, and to brainstorm ideas with the user. For example, if the user's task is to create a website, you may start by asking some clarifying questions, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT MODE to implement the solution.
|
||||
Parameters:
|
||||
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response.
|
||||
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible choice or path forward in the planning process. This can help guide the discussion and make it easier for the user to provide input on key decisions. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually. Do NOT present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.
|
||||
Usage:
|
||||
<plan_mode_response>
|
||||
<response>Your response here</response>
|
||||
<options>
|
||||
Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
|
||||
</options>
|
||||
</plan_mode_response>
|
||||
|
||||
# Tool Use Examples
|
||||
@@ -813,7 +821,7 @@ You have access to two tools for working with files: **write_to_file** and **rep
|
||||
|
||||
## Important Considerations
|
||||
|
||||
- Using write_to_file requires providing the file’s complete final content.
|
||||
- Using write_to_file requires providing the file's complete final content.
|
||||
- If you only need to make small changes to an existing file, consider using replace_in_file instead to avoid unnecessarily rewriting the entire file.
|
||||
- While write_to_file should not be your default choice, don't hesitate to use it when the situation truly calls for it.
|
||||
|
||||
@@ -826,12 +834,12 @@ You have access to two tools for working with files: **write_to_file** and **rep
|
||||
## When to Use
|
||||
|
||||
- Small, localized changes like updating a few lines, function implementations, changing variable names, modifying a section of text, etc.
|
||||
- Targeted improvements where only specific portions of the file’s content needs to be altered.
|
||||
- Targeted improvements where only specific portions of the file's content needs to be altered.
|
||||
- Especially useful for long files where much of the file will remain unchanged.
|
||||
|
||||
## Advantages
|
||||
|
||||
- More efficient for minor edits, since you don’t need to supply the entire file content.
|
||||
- More efficient for minor edits, since you don't need to supply the entire file content.
|
||||
- Reduces the chance of errors that can occur when overwriting large files.
|
||||
|
||||
# Choosing the Appropriate Tool
|
||||
@@ -885,7 +893,7 @@ In each user message, the environment_details will specify the current mode. The
|
||||
- When starting in PLAN MODE, depending on the user's request, you may need to do some information gathering e.g. using read_file or search_files to get more context about the task. You may also ask the user clarifying questions to get a better understanding of the task. You may return mermaid diagrams to visually display your understanding.
|
||||
- Once you've gained more context about the user's request, you should architect a detailed plan for how you will accomplish the task. Returning mermaid diagrams may be helpful here as well.
|
||||
- Then you might ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and plan the best way to accomplish it.
|
||||
- If at any point a mermaid diagram would make your plan clearer to help the user quickly see the structure, you are encouraged to include a Mermaid code block in the response.
|
||||
- If at any point a mermaid diagram would make your plan clearer to help the user quickly see the structure, you are encouraged to include a Mermaid code block in the response. (Note: if you use colors in your mermaid diagrams, be sure to use high contrast colors so the text is readable.)
|
||||
- Finally once it seems like you've reached a good plan, ask the user to switch you back to ACT MODE to implement the solution.
|
||||
|
||||
====
|
||||
@@ -979,8 +987,12 @@ export function addUserInstructions(
|
||||
settingsCustomInstructions?: string,
|
||||
clineRulesFileInstructions?: string,
|
||||
clineIgnoreInstructions?: string,
|
||||
preferredLanguageInstructions?: string,
|
||||
) {
|
||||
let customInstructions = ""
|
||||
if (preferredLanguageInstructions) {
|
||||
customInstructions += preferredLanguageInstructions + "\n\n"
|
||||
}
|
||||
if (settingsCustomInstructions) {
|
||||
customInstructions += settingsCustomInstructions + "\n\n"
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
/*
|
||||
We can't implement a dynamically updating sliding window as it would break prompt cache
|
||||
every time. To maintain the benefits of caching, we need to keep conversation history
|
||||
static. This operation should be performed as infrequently as possible. If a user reaches
|
||||
a 200k context, we can assume that the first half is likely irrelevant to their current task.
|
||||
Therefore, this function should only be called when absolutely necessary to fit within
|
||||
context limits, not as a continuous process.
|
||||
*/
|
||||
// export function truncateHalfConversation(
|
||||
// messages: Anthropic.Messages.MessageParam[],
|
||||
// ): Anthropic.Messages.MessageParam[] {
|
||||
// // API expects messages to be in user-assistant order, and tool use messages must be followed by tool results. We need to maintain this structure while truncating.
|
||||
|
||||
// // Always keep the first Task message (this includes the project's file structure in environment_details)
|
||||
// const truncatedMessages = [messages[0]]
|
||||
|
||||
// // Remove half of user-assistant pairs
|
||||
// const messagesToRemove = Math.floor(messages.length / 4) * 2 // has to be even number
|
||||
|
||||
// const remainingMessages = messages.slice(messagesToRemove + 1) // has to start with assistant message since tool result cannot follow assistant message with no tool use
|
||||
// truncatedMessages.push(...remainingMessages)
|
||||
|
||||
// return truncatedMessages
|
||||
// }
|
||||
|
||||
/*
|
||||
getNextTruncationRange: Calculates the next range of messages to be "deleted"
|
||||
- Takes the full messages array and optional current deleted range
|
||||
- Always preserves the first message (task message)
|
||||
- Removes 1/2 of remaining messages (rounded down to even number) after current deleted range
|
||||
- Returns [startIndex, endIndex] representing inclusive range to delete
|
||||
|
||||
getTruncatedMessages: Constructs the truncated array using the deleted range
|
||||
- Takes full messages array and optional deleted range
|
||||
- Returns new array with messages in deleted range removed
|
||||
- Preserves order and structure of remaining messages
|
||||
|
||||
The range is represented as [startIndex, endIndex] where both indices are inclusive
|
||||
The functions maintain the original array integrity while allowing progressive truncation
|
||||
through the deletedRange parameter
|
||||
|
||||
Usage example:
|
||||
const messages = [user1, assistant1, user2, assistant2, user3, assistant3];
|
||||
let deletedRange = getNextTruncationRange(messages); // [1,2] (assistant1,user2)
|
||||
let truncated = getTruncatedMessages(messages, deletedRange);
|
||||
// [user1, assistant2, user3, assistant3]
|
||||
|
||||
deletedRange = getNextTruncationRange(messages, deletedRange); // [2,3] (assistant2,user3)
|
||||
truncated = getTruncatedMessages(messages, deletedRange);
|
||||
// [user1, assistant3]
|
||||
*/
|
||||
|
||||
export function getNextTruncationRange(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
currentDeletedRange: [number, number] | undefined = undefined,
|
||||
keep: "half" | "quarter" = "half",
|
||||
): [number, number] {
|
||||
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
|
||||
const rangeStartIndex = 1
|
||||
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
|
||||
|
||||
let messagesToRemove: number
|
||||
if (keep === "half") {
|
||||
// Remove half of user-assistant pairs
|
||||
messagesToRemove = Math.floor((messages.length - startOfRest) / 4) * 2 // Keep even number
|
||||
} else {
|
||||
// Remove 3/4 of user-assistant pairs
|
||||
messagesToRemove = Math.floor((messages.length - startOfRest) / 8) * 3 * 2
|
||||
}
|
||||
|
||||
let rangeEndIndex = startOfRest + messagesToRemove - 1
|
||||
|
||||
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
|
||||
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
|
||||
if (messages[rangeEndIndex].role !== "user") {
|
||||
rangeEndIndex -= 1
|
||||
}
|
||||
|
||||
// this is an inclusive range that will be removed from the conversation history
|
||||
return [rangeStartIndex, rangeEndIndex]
|
||||
}
|
||||
|
||||
export function getTruncatedMessages(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
deletedRange: [number, number] | undefined,
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
if (!deletedRange) {
|
||||
return messages
|
||||
}
|
||||
|
||||
const [start, end] = deletedRange
|
||||
// the range is inclusive - both start and end indices and everything in between will be removed from the final result.
|
||||
// NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
|
||||
return [...messages.slice(0, start), ...messages.slice(end + 1)]
|
||||
}
|
||||
+810
-265
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,288 @@
|
||||
import * as vscode from "vscode"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import { ClineMessage } from "../../shared/ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Registers development-only commands for task manipulation.
|
||||
* These are only activated in development mode.
|
||||
*/
|
||||
export function registerTaskCommands(context: vscode.ExtensionContext, provider: ClineProvider): vscode.Disposable[] {
|
||||
return [
|
||||
vscode.commands.registerCommand("cline.dev.createTestTasks", async () => {
|
||||
const count = await vscode.window.showInputBox({
|
||||
title: "Test Tasks",
|
||||
prompt: "How many test tasks to create?",
|
||||
value: "10",
|
||||
})
|
||||
|
||||
if (!count) {
|
||||
return
|
||||
}
|
||||
|
||||
const tasksCount = parseInt(count)
|
||||
const globalStoragePath = context.globalStorageUri.fsPath
|
||||
const tasksDir = path.join(globalStoragePath, "tasks")
|
||||
|
||||
vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: `Creating ${tasksCount} test tasks...`,
|
||||
cancellable: false,
|
||||
},
|
||||
async (progress) => {
|
||||
for (let i = 0; i < tasksCount; i++) {
|
||||
// Generate a timestamp to ensure unique IDs
|
||||
const timestamp = Date.now() + i
|
||||
const taskId = `${timestamp}`
|
||||
const taskDir = path.join(tasksDir, taskId)
|
||||
|
||||
await fs.mkdir(taskDir, { recursive: true })
|
||||
|
||||
// Generate a task prompt
|
||||
const taskName = getRandomTaskName(i)
|
||||
|
||||
// Create realistic message sequence
|
||||
const messages = createRealisticMessageSequence(timestamp, taskName, i)
|
||||
|
||||
// Create API conversation history file
|
||||
await fs.writeFile(
|
||||
path.join(taskDir, "api_conversation_history.json"),
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `<task>\n${taskName}\n</task>` }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `I'll help you ${taskName.toLowerCase()}. Let me break this down into steps.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
// Create UI messages file with realistic message sequence
|
||||
await fs.writeFile(path.join(taskDir, "ui_messages.json"), JSON.stringify(messages, null, 2))
|
||||
|
||||
// Create history item to be shown in the HistoryView
|
||||
const historyItem: HistoryItem = {
|
||||
id: taskId,
|
||||
ts: timestamp,
|
||||
task: taskName,
|
||||
tokensIn: Math.floor(100 + Math.random() * 900), // Random token count from 100-1000
|
||||
tokensOut: Math.floor(200 + Math.random() * 1800), // Random token count from 200-2000
|
||||
cacheWrites: i % 3 === 0 ? Math.floor(50 + Math.random() * 150) : undefined, // Only add cache writes to every 3rd task
|
||||
cacheReads: i % 3 === 0 ? Math.floor(20 + Math.random() * 80) : undefined, // Only add cache reads to every 3rd task
|
||||
totalCost: Number((0.0001 + Math.random() * 0.01).toFixed(5)), // Random cost from $0.0001 to $0.0101
|
||||
size: 1024 * 1024, // 1MB
|
||||
}
|
||||
|
||||
// Update task history in global state
|
||||
await provider.updateTaskHistory(historyItem)
|
||||
|
||||
progress.report({ increment: 100 / tasksCount })
|
||||
}
|
||||
|
||||
// Update the UI to show the new tasks
|
||||
await provider.postStateToWebview()
|
||||
|
||||
vscode.window.showInformationMessage(`Created ${tasksCount} test tasks`)
|
||||
},
|
||||
)
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a realistic sequence of messages that would occur in a typical task
|
||||
*/
|
||||
function createRealisticMessageSequence(baseTimestamp: number, taskPrompt: string, taskIndex: number): ClineMessage[] {
|
||||
// Use an incrementing timestamp to ensure messages appear in sequence
|
||||
let timestamp = baseTimestamp
|
||||
const getNextTimestamp = () => {
|
||||
timestamp += 1000 // Add 1 second between messages
|
||||
return timestamp
|
||||
}
|
||||
|
||||
// Variables to make different test tasks look unique
|
||||
const fileName = getRandomFileName(taskIndex)
|
||||
const commitHash = `commit${taskIndex}${Math.floor(Math.random() * 1000000).toString(16)}`
|
||||
|
||||
// Create a realistic message sequence
|
||||
const messages: ClineMessage[] = [
|
||||
// Initial task message - uses "say" with "text" which is the format used in Cline.ts
|
||||
{
|
||||
ts: baseTimestamp,
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: taskPrompt,
|
||||
},
|
||||
|
||||
// API request started
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify({
|
||||
request: `<task>\n${taskPrompt}\n</task>`,
|
||||
tokensIn: Math.floor(100 + Math.random() * 200),
|
||||
tokensOut: Math.floor(300 + Math.random() * 500),
|
||||
}),
|
||||
},
|
||||
|
||||
// Reasoning message
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "reasoning",
|
||||
text: `I'll approach this task by breaking it down into manageable steps. First, I'll analyze the requirements, then create a plan, and finally implement the solution systematically.`,
|
||||
},
|
||||
|
||||
// Text response
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: `I'll help you with this task. Let me start by creating the necessary files and implementing the core functionality.`,
|
||||
},
|
||||
]
|
||||
|
||||
// Add task-specific messages based on index modulo to create variety
|
||||
const messageType = taskIndex % 5
|
||||
|
||||
if (messageType === 0 || messageType === 2) {
|
||||
// Tool use - file operations
|
||||
messages.push({
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "newFileCreated",
|
||||
path: fileName,
|
||||
content: `// Sample code for ${taskPrompt}`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (messageType === 1 || messageType === 3) {
|
||||
// Command execution
|
||||
messages.push(
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "ask",
|
||||
ask: "command",
|
||||
text: `ls -la`,
|
||||
},
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "command_output",
|
||||
text: `total 24\ndrwxr-xr-x 3 user staff 96 Mar 10 12:34 .\ndrwxr-xr-x 8 user staff 256 Mar 10 12:30 ..\n-rw-r--r-- 1 user staff 158 Mar 10 12:34 ${fileName}`,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (messageType === 2 || messageType === 4) {
|
||||
// Browser actions
|
||||
messages.push(
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "ask",
|
||||
ask: "browser_action_launch",
|
||||
text: `https://example.com`,
|
||||
},
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "browser_action_result",
|
||||
text: JSON.stringify({
|
||||
logs: "Page loaded successfully",
|
||||
screenshot:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
}),
|
||||
},
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "browser_action",
|
||||
text: JSON.stringify({
|
||||
action: "close",
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Add checkpoint
|
||||
messages.push({
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "checkpoint_created",
|
||||
lastCheckpointHash: commitHash,
|
||||
})
|
||||
|
||||
// Add completion result (all tasks end with this)
|
||||
messages.push({
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "completion_result",
|
||||
text: `I've completed the task to ${taskPrompt.toLowerCase()}. The implementation includes all the required functionality and meets the specifications. ${"x".repeat(1024 * 1024)}`, // 1MB file
|
||||
lastCheckpointHash: commitHash,
|
||||
})
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a random task name for test data
|
||||
*/
|
||||
function getRandomTaskName(index: number): string {
|
||||
const tasks = [
|
||||
"Create a simple todo application",
|
||||
"Build a weather forecast widget",
|
||||
"Implement a markdown parser",
|
||||
"Design a responsive landing page",
|
||||
"Develop a currency converter",
|
||||
"Create a file upload component",
|
||||
"Build a data visualization dashboard",
|
||||
"Implement a search functionality",
|
||||
"Create a user authentication system",
|
||||
"Design a dark mode toggle",
|
||||
"Build a countdown timer",
|
||||
"Create a drag and drop interface",
|
||||
"Implement form validation",
|
||||
"Design a multi-step wizard",
|
||||
"Create a notification system",
|
||||
]
|
||||
|
||||
return tasks[index % tasks.length] + ` (Test ${index + 1})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a random file name for test data
|
||||
*/
|
||||
function getRandomFileName(index: number): string {
|
||||
const files = [
|
||||
"index.html",
|
||||
"styles.css",
|
||||
"script.js",
|
||||
"app.jsx",
|
||||
"main.ts",
|
||||
"utils.py",
|
||||
"config.json",
|
||||
"server.js",
|
||||
"data.csv",
|
||||
"README.md",
|
||||
]
|
||||
|
||||
return files[index % files.length]
|
||||
}
|
||||
+197
-6
@@ -1,12 +1,14 @@
|
||||
// The module 'vscode' contains the VS Code extensibility API
|
||||
// Import the module and reference it with the alias vscode in your code below
|
||||
import delay from "delay"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "./core/webview/ClineProvider"
|
||||
import { Logger } from "./services/logging/Logger"
|
||||
import { createClineAPI } from "./exports"
|
||||
import "./utils/path" // necessary to have access to String.prototype.toPosix
|
||||
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
|
||||
import assert from "node:assert"
|
||||
import { telemetryService } from "./services/telemetry/TelemetryService"
|
||||
|
||||
/*
|
||||
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
|
||||
@@ -30,6 +32,8 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
const sidebarProvider = new ClineProvider(context, outputChannel)
|
||||
|
||||
vscode.commands.executeCommand("setContext", "cline.isDevMode", IS_DEV && IS_DEV === "true")
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewViewProvider(ClineProvider.sideBarId, sidebarProvider, {
|
||||
webviewOptions: { retainContextWhenHidden: true },
|
||||
@@ -86,7 +90,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
tabProvider.resolveWebviewView(panel)
|
||||
|
||||
// Lock the editor group so clicking on files doesn't open them over the panel
|
||||
await delay(100)
|
||||
await setTimeoutPromise(100)
|
||||
await vscode.commands.executeCommand("workbench.action.lockEditorGroup")
|
||||
}
|
||||
|
||||
@@ -113,14 +117,20 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.accountLoginClicked", () => {
|
||||
vscode.commands.registerCommand("cline.accountButtonClicked", () => {
|
||||
sidebarProvider.postMessageToWebview({
|
||||
type: "action",
|
||||
action: "accountLoginClicked",
|
||||
action: "accountButtonClicked",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.openDocumentation", () => {
|
||||
vscode.env.openExternal(vscode.Uri.parse("https://docs.cline.bot/"))
|
||||
}),
|
||||
)
|
||||
|
||||
/*
|
||||
We use the text document content provider API to show the left side for diff view by creating a virtual document for the original content. This makes it readonly so users know to edit the right side if they want to keep their changes.
|
||||
|
||||
@@ -160,10 +170,12 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
case "/auth": {
|
||||
const token = query.get("token")
|
||||
const state = query.get("state")
|
||||
const apiKey = query.get("apiKey")
|
||||
|
||||
console.log("Auth callback received:", {
|
||||
token: token,
|
||||
state: state,
|
||||
apiKey: apiKey,
|
||||
})
|
||||
|
||||
// Validate state parameter
|
||||
@@ -172,8 +184,8 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
return
|
||||
}
|
||||
|
||||
if (token) {
|
||||
await visibleProvider.handleAuthCallback(token)
|
||||
if (token && apiKey) {
|
||||
await visibleProvider.handleAuthCallback(token, apiKey)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -183,10 +195,189 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
}
|
||||
context.subscriptions.push(vscode.window.registerUriHandler({ handleUri }))
|
||||
|
||||
// Register size testing commands in development mode
|
||||
if (IS_DEV && IS_DEV === "true") {
|
||||
// Use dynamic import to avoid loading the module in production
|
||||
import("./dev/commands/tasks")
|
||||
.then((module) => {
|
||||
const devTaskCommands = module.registerTaskCommands(context, sidebarProvider)
|
||||
context.subscriptions.push(...devTaskCommands)
|
||||
Logger.log("Cline dev task commands registered")
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.log("Failed to register dev task commands: " + error)
|
||||
})
|
||||
}
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.addToChat", async (range?: vscode.Range, diagnostics?: vscode.Diagnostic[]) => {
|
||||
const editor = vscode.window.activeTextEditor
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use provided range if available, otherwise use current selection
|
||||
// (vscode command passes an argument in the first param by default, so we need to ensure it's a Range object)
|
||||
const textRange = range instanceof vscode.Range ? range : editor.selection
|
||||
const selectedText = editor.document.getText(textRange)
|
||||
|
||||
if (!selectedText) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the file path and language ID
|
||||
const filePath = editor.document.uri.fsPath
|
||||
const languageId = editor.document.languageId
|
||||
|
||||
// Send to sidebar provider
|
||||
await sidebarProvider.addSelectedCodeToChat(
|
||||
selectedText,
|
||||
filePath,
|
||||
languageId,
|
||||
Array.isArray(diagnostics) ? diagnostics : undefined,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.addTerminalOutputToChat", async () => {
|
||||
const terminal = vscode.window.activeTerminal
|
||||
if (!terminal) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save current clipboard content
|
||||
const tempCopyBuffer = await vscode.env.clipboard.readText()
|
||||
|
||||
try {
|
||||
// Copy the *existing* terminal selection (without selecting all)
|
||||
await vscode.commands.executeCommand("workbench.action.terminal.copySelection")
|
||||
|
||||
// Get copied content
|
||||
let terminalContents = (await vscode.env.clipboard.readText()).trim()
|
||||
|
||||
// Restore original clipboard content
|
||||
await vscode.env.clipboard.writeText(tempCopyBuffer)
|
||||
|
||||
if (!terminalContents) {
|
||||
// No terminal content was copied (either nothing selected or some error)
|
||||
return
|
||||
}
|
||||
|
||||
// [Optional] Any additional logic to process multi-line content can remain here
|
||||
// For example:
|
||||
/*
|
||||
const lines = terminalContents.split("\n")
|
||||
const lastLine = lines.pop()?.trim()
|
||||
if (lastLine) {
|
||||
let i = lines.length - 1
|
||||
while (i >= 0 && !lines[i].trim().startsWith(lastLine)) {
|
||||
i--
|
||||
}
|
||||
terminalContents = lines.slice(Math.max(i, 0)).join("\n")
|
||||
}
|
||||
*/
|
||||
|
||||
// Send to sidebar provider
|
||||
await sidebarProvider.addSelectedTerminalOutputToChat(terminalContents, terminal.name)
|
||||
} catch (error) {
|
||||
// Ensure clipboard is restored even if an error occurs
|
||||
await vscode.env.clipboard.writeText(tempCopyBuffer)
|
||||
console.error("Error getting terminal contents:", error)
|
||||
vscode.window.showErrorMessage("Failed to get terminal contents")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Register code action provider
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerCodeActionsProvider(
|
||||
"*",
|
||||
new (class implements vscode.CodeActionProvider {
|
||||
public static readonly providedCodeActionKinds = [vscode.CodeActionKind.QuickFix]
|
||||
|
||||
provideCodeActions(
|
||||
document: vscode.TextDocument,
|
||||
range: vscode.Range,
|
||||
context: vscode.CodeActionContext,
|
||||
): vscode.CodeAction[] {
|
||||
// Expand range to include surrounding 3 lines
|
||||
const expandedRange = new vscode.Range(
|
||||
Math.max(0, range.start.line - 3),
|
||||
0,
|
||||
Math.min(document.lineCount - 1, range.end.line + 3),
|
||||
document.lineAt(Math.min(document.lineCount - 1, range.end.line + 3)).text.length,
|
||||
)
|
||||
|
||||
const addAction = new vscode.CodeAction("Add to Cline", vscode.CodeActionKind.QuickFix)
|
||||
addAction.command = {
|
||||
command: "cline.addToChat",
|
||||
title: "Add to Cline",
|
||||
arguments: [expandedRange, context.diagnostics],
|
||||
}
|
||||
|
||||
const fixAction = new vscode.CodeAction("Fix with Cline", vscode.CodeActionKind.QuickFix)
|
||||
fixAction.command = {
|
||||
command: "cline.fixWithCline",
|
||||
title: "Fix with Cline",
|
||||
arguments: [expandedRange, context.diagnostics],
|
||||
}
|
||||
|
||||
// Only show actions when there are errors
|
||||
if (context.diagnostics.length > 0) {
|
||||
return [addAction, fixAction]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
})(),
|
||||
{
|
||||
providedCodeActionKinds: [vscode.CodeActionKind.QuickFix],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Register the command handler
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.fixWithCline", async (range: vscode.Range, diagnostics: any[]) => {
|
||||
const editor = vscode.window.activeTextEditor
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedText = editor.document.getText(range)
|
||||
const filePath = editor.document.uri.fsPath
|
||||
const languageId = editor.document.languageId
|
||||
|
||||
// Send to sidebar provider with diagnostics
|
||||
await sidebarProvider.fixWithCline(selectedText, filePath, languageId, diagnostics)
|
||||
}),
|
||||
)
|
||||
|
||||
return createClineAPI(outputChannel, sidebarProvider)
|
||||
}
|
||||
|
||||
// This method is called when your extension is deactivated
|
||||
export function deactivate() {
|
||||
telemetryService.shutdown()
|
||||
Logger.log("Cline extension deactivated")
|
||||
}
|
||||
|
||||
// TODO: Find a solution for automatically removing DEV related content from production builds.
|
||||
// This type of code is fine in production to keep. We just will want to remove it from production builds
|
||||
// to bring down built asset sizes.
|
||||
//
|
||||
// This is a workaround to reload the extension when the source code changes
|
||||
// since vscode doesn't support hot reload for extensions
|
||||
const { IS_DEV, DEV_WORKSPACE_FOLDER } = process.env
|
||||
|
||||
if (IS_DEV && IS_DEV !== "false") {
|
||||
assert(DEV_WORKSPACE_FOLDER, "DEV_WORKSPACE_FOLDER must be set in development")
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(DEV_WORKSPACE_FOLDER, "src/**/*"))
|
||||
|
||||
watcher.onDidChange(({ scheme, path }) => {
|
||||
console.info(`${scheme} ${path} changed. Reloading VSCode...`)
|
||||
|
||||
vscode.commands.executeCommand("workbench.action.reloadWindow")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// NOTE: These are here temporarily until we find a better home for them
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
uiMessages: "ui_messages.json",
|
||||
openRouterModels: "openrouter_models.json",
|
||||
mcpSettings: "cline_mcp_settings.json",
|
||||
clineRules: ".clinerules",
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as vscode from "vscode"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import CheckpointTracker from "./CheckpointTracker"
|
||||
|
||||
export async function createTestEnvironment() {
|
||||
// Create temp directory structure
|
||||
const tempDir = path.join(os.tmpdir(), `checkpoint-test-${Date.now()}`)
|
||||
await fs.mkdir(tempDir, { recursive: true })
|
||||
|
||||
// Create storage path outside of working directory to avoid submodule issues
|
||||
const globalStoragePath = path.join(os.tmpdir(), `storage-${Date.now()}`)
|
||||
await fs.mkdir(globalStoragePath, { recursive: true })
|
||||
|
||||
// Create test file in a subdirectory
|
||||
const testDir = path.join(tempDir, "src")
|
||||
await fs.mkdir(testDir, { recursive: true })
|
||||
const testFilePath = path.join(testDir, "test.txt")
|
||||
|
||||
// Create .gitignore to prevent git from treating directories as submodules
|
||||
await fs.writeFile(path.join(tempDir, ".gitignore"), "storage/\n")
|
||||
|
||||
// Mock VS Code workspace
|
||||
const mockWorkspaceFolders = [
|
||||
{
|
||||
uri: { fsPath: tempDir },
|
||||
name: "test",
|
||||
index: 0,
|
||||
},
|
||||
]
|
||||
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(vscode.workspace, "workspaceFolders")
|
||||
Object.defineProperty(vscode.workspace, "workspaceFolders", {
|
||||
get: () => mockWorkspaceFolders,
|
||||
})
|
||||
|
||||
// Mock findFiles to return no nested git repos
|
||||
const originalFindFiles = vscode.workspace.findFiles
|
||||
vscode.workspace.findFiles = async () => []
|
||||
|
||||
// Mock VS Code configuration
|
||||
const originalGetConfiguration = vscode.workspace.getConfiguration
|
||||
vscode.workspace.getConfiguration = () =>
|
||||
({
|
||||
get: (key: string) => (key === "enableCheckpoints" ? true : undefined),
|
||||
}) as any
|
||||
|
||||
return {
|
||||
tempDir,
|
||||
globalStoragePath,
|
||||
testFilePath,
|
||||
originalDescriptor,
|
||||
originalFindFiles,
|
||||
originalGetConfiguration,
|
||||
cleanup: async () => {
|
||||
// Restore VS Code mocks
|
||||
if (originalDescriptor) {
|
||||
Object.defineProperty(vscode.workspace, "workspaceFolders", originalDescriptor)
|
||||
}
|
||||
vscode.workspace.getConfiguration = originalGetConfiguration
|
||||
vscode.workspace.findFiles = originalFindFiles
|
||||
|
||||
// Clean up temp directories
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
await fs.rm(globalStoragePath, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTestTracker(globalStoragePath?: string, taskId = "test-task-1") {
|
||||
return await CheckpointTracker.create(taskId, globalStoragePath)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { expect } from "chai"
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
|
||||
|
||||
describe("Checkpoint Commit Operations", () => {
|
||||
let env: Awaited<ReturnType<typeof createTestEnvironment>>
|
||||
|
||||
beforeEach(async () => {
|
||||
env = await createTestEnvironment()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await env.cleanup()
|
||||
})
|
||||
|
||||
it("should create commit with single file changes", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create initial file
|
||||
await fs.writeFile(env.testFilePath, "initial content")
|
||||
|
||||
// Create first commit
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.be.a("string").and.not.empty
|
||||
|
||||
// Modify file
|
||||
await fs.writeFile(env.testFilePath, "modified content")
|
||||
|
||||
// Create second commit
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.be.a("string").and.not.empty
|
||||
expect(secondCommit).to.not.equal(firstCommit)
|
||||
|
||||
// Verify commits are different
|
||||
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
|
||||
expect(diffSet).to.have.lengthOf(1)
|
||||
expect(diffSet[0].before).to.equal("initial content")
|
||||
expect(diffSet[0].after).to.equal("modified content")
|
||||
})
|
||||
|
||||
it("should create commit with multiple file changes", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create initial files with newlines
|
||||
const testFile2Path = path.join(env.tempDir, "src", "test2.txt")
|
||||
await fs.writeFile(env.testFilePath, "file1 initial\n")
|
||||
await fs.writeFile(testFile2Path, "file2 initial\n")
|
||||
|
||||
// Create first commit
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.be.a("string").and.not.empty
|
||||
|
||||
// Modify both files with newlines
|
||||
await fs.writeFile(env.testFilePath, "file1 modified\n")
|
||||
await fs.writeFile(testFile2Path, "file2 modified\n")
|
||||
|
||||
// Create second commit
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.be.a("string").and.not.empty
|
||||
expect(secondCommit).to.not.equal(firstCommit)
|
||||
|
||||
// Get diff between commits
|
||||
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
|
||||
expect(diffSet).to.have.lengthOf(2)
|
||||
|
||||
// Sort diffSet by path for consistent ordering
|
||||
const sortedDiffs = diffSet.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
|
||||
|
||||
// Verify file paths
|
||||
expect(sortedDiffs[0].relativePath).to.equal("src/test.txt")
|
||||
expect(sortedDiffs[1].relativePath).to.equal("src/test2.txt")
|
||||
|
||||
// Verify file contents
|
||||
expect(sortedDiffs[0].before).to.equal("file1 initial\n")
|
||||
expect(sortedDiffs[0].after).to.equal("file1 modified\n")
|
||||
expect(sortedDiffs[1].before).to.equal("file2 initial\n")
|
||||
expect(sortedDiffs[1].after).to.equal("file2 modified\n")
|
||||
})
|
||||
|
||||
it("should create commit when files are deleted", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create and commit initial file
|
||||
await fs.writeFile(env.testFilePath, "initial content")
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.be.a("string").and.not.empty
|
||||
|
||||
// Delete file
|
||||
await fs.unlink(env.testFilePath)
|
||||
|
||||
// Create second commit
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.be.a("string").and.not.empty
|
||||
expect(secondCommit).to.not.equal(firstCommit)
|
||||
|
||||
// Verify file deletion was committed
|
||||
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
|
||||
expect(diffSet).to.have.lengthOf(1)
|
||||
expect(diffSet[0].before).to.equal("initial content")
|
||||
expect(diffSet[0].after).to.equal("")
|
||||
})
|
||||
|
||||
it("should create empty commit when no changes", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create and commit initial file
|
||||
await fs.writeFile(env.testFilePath, "test content")
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.be.a("string").and.not.empty
|
||||
|
||||
// Create commit without changes
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.be.a("string").and.not.empty
|
||||
expect(secondCommit).to.not.equal(firstCommit)
|
||||
|
||||
// Verify no changes between commits
|
||||
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
|
||||
expect(diffSet).to.have.lengthOf(0)
|
||||
})
|
||||
|
||||
it("should handle files in nested directories", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create nested directory structure
|
||||
const nestedDir = path.join(env.tempDir, "src", "deep", "nested")
|
||||
await fs.mkdir(nestedDir, { recursive: true })
|
||||
const nestedFilePath = path.join(nestedDir, "nested.txt")
|
||||
|
||||
// Create and commit file in nested directory
|
||||
await fs.writeFile(nestedFilePath, "nested content")
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.be.a("string").and.not.empty
|
||||
|
||||
// Modify nested file
|
||||
await fs.writeFile(nestedFilePath, "modified nested content")
|
||||
|
||||
// Create second commit
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.be.a("string").and.not.empty
|
||||
|
||||
// Verify changes were committed
|
||||
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
|
||||
expect(diffSet).to.have.lengthOf(1)
|
||||
expect(diffSet[0].relativePath).to.equal("src/deep/nested/nested.txt")
|
||||
expect(diffSet[0].before).to.equal("nested content")
|
||||
expect(diffSet[0].after).to.equal("modified nested content")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect } from "chai"
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
|
||||
import CheckpointTracker from "./CheckpointTracker"
|
||||
|
||||
describe("Checkpoint Creation", () => {
|
||||
let env: Awaited<ReturnType<typeof createTestEnvironment>>
|
||||
|
||||
beforeEach(async () => {
|
||||
env = await createTestEnvironment()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await env.cleanup()
|
||||
})
|
||||
|
||||
it("should create a new checkpoint tracker", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
expect(tracker).to.not.be.undefined
|
||||
expect(tracker).to.be.instanceOf(CheckpointTracker)
|
||||
|
||||
// Verify shadow git config
|
||||
const configWorkTree = await tracker?.getShadowGitConfigWorkTree()
|
||||
expect(configWorkTree).to.not.be.undefined
|
||||
})
|
||||
|
||||
it("should throw error when globalStoragePath is missing", async () => {
|
||||
try {
|
||||
await createTestTracker(undefined)
|
||||
expect.fail("Expected error was not thrown")
|
||||
} catch (error: any) {
|
||||
expect(error.message).to.equal("Global storage path is required to create a checkpoint tracker")
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect } from "chai"
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import fs from "fs/promises"
|
||||
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
|
||||
|
||||
describe("Checkpoint Diff Operations", () => {
|
||||
let env: Awaited<ReturnType<typeof createTestEnvironment>>
|
||||
|
||||
beforeEach(async () => {
|
||||
env = await createTestEnvironment()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await env.cleanup()
|
||||
})
|
||||
|
||||
it("should detect file changes between commits", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create initial file
|
||||
await fs.writeFile(env.testFilePath, "initial content")
|
||||
|
||||
// Create first checkpoint
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.not.be.undefined
|
||||
|
||||
// Modify file
|
||||
await fs.writeFile(env.testFilePath, "modified content")
|
||||
|
||||
// Create second checkpoint
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.not.be.undefined
|
||||
|
||||
// Get diff between commits
|
||||
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
|
||||
|
||||
// Verify diff results
|
||||
expect(diffSet).to.have.lengthOf(1)
|
||||
expect(diffSet[0].relativePath).to.equal("src/test.txt")
|
||||
expect(diffSet[0].before).to.equal("initial content")
|
||||
expect(diffSet[0].after).to.equal("modified content")
|
||||
})
|
||||
|
||||
it("should detect changes between commit and working directory", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create initial file
|
||||
await fs.writeFile(env.testFilePath, "initial content")
|
||||
|
||||
// Create checkpoint
|
||||
const commit = await tracker.commit()
|
||||
expect(commit).to.not.be.undefined
|
||||
|
||||
// Modify file without committing
|
||||
await fs.writeFile(env.testFilePath, "working directory changes")
|
||||
|
||||
// Get diff between commit and working directory
|
||||
const diffSet = await tracker.getDiffSet(commit)
|
||||
|
||||
// Verify diff results
|
||||
expect(diffSet).to.have.lengthOf(1)
|
||||
expect(diffSet[0].relativePath).to.equal("src/test.txt")
|
||||
expect(diffSet[0].before).to.equal("initial content")
|
||||
expect(diffSet[0].after).to.equal("working directory changes")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect } from "chai"
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import fs from "fs/promises"
|
||||
import * as vscode from "vscode"
|
||||
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
|
||||
|
||||
describe("Checkpoint Disabled State", () => {
|
||||
let env: Awaited<ReturnType<typeof createTestEnvironment>>
|
||||
let originalGetConfiguration: typeof vscode.workspace.getConfiguration
|
||||
|
||||
beforeEach(async () => {
|
||||
env = await createTestEnvironment()
|
||||
originalGetConfiguration = vscode.workspace.getConfiguration
|
||||
|
||||
// Mock VS Code configuration to disable checkpoints
|
||||
vscode.workspace.getConfiguration = () =>
|
||||
({
|
||||
get: (key: string) => (key === "enableCheckpoints" ? false : undefined),
|
||||
}) as any
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await env.cleanup()
|
||||
// Restore original configuration
|
||||
vscode.workspace.getConfiguration = originalGetConfiguration
|
||||
})
|
||||
|
||||
it("should return undefined when creating tracker", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
expect(tracker).to.be.undefined
|
||||
})
|
||||
|
||||
it("should allow re-enabling checkpoints", async () => {
|
||||
// First verify disabled state
|
||||
const disabledTracker = await createTestTracker(env.globalStoragePath)
|
||||
expect(disabledTracker).to.be.undefined
|
||||
|
||||
// Mock configuration to enable checkpoints
|
||||
vscode.workspace.getConfiguration = () =>
|
||||
({
|
||||
get: (key: string) => (key === "enableCheckpoints" ? true : undefined),
|
||||
}) as any
|
||||
|
||||
// Verify tracker can be created when enabled
|
||||
const enabledTracker = await createTestTracker(env.globalStoragePath)
|
||||
expect(enabledTracker).to.not.be.undefined
|
||||
|
||||
// Verify operations work
|
||||
if (!enabledTracker) {throw new Error("Failed to create tracker")}
|
||||
await fs.writeFile(env.testFilePath, "test content")
|
||||
const commit = await enabledTracker.commit()
|
||||
expect(commit).to.be.a("string").and.not.empty
|
||||
})
|
||||
|
||||
it("should prevent operations when disabled mid-session", async () => {
|
||||
// Start with checkpoints enabled
|
||||
vscode.workspace.getConfiguration = () =>
|
||||
({
|
||||
get: (key: string) => (key === "enableCheckpoints" ? true : undefined),
|
||||
}) as any
|
||||
|
||||
// Create tracker and initial commit
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
expect(tracker).to.not.be.undefined
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
await fs.writeFile(env.testFilePath, "initial content")
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.be.a("string").and.not.empty
|
||||
|
||||
// Disable checkpoints
|
||||
vscode.workspace.getConfiguration = () =>
|
||||
({
|
||||
get: (key: string) => (key === "enableCheckpoints" ? false : undefined),
|
||||
}) as any
|
||||
|
||||
// Verify new tracker cannot be created
|
||||
const disabledTracker = await createTestTracker(env.globalStoragePath)
|
||||
expect(disabledTracker).to.be.undefined
|
||||
|
||||
// Verify existing tracker still works
|
||||
// This is expected behavior since the tracker was created when enabled
|
||||
await fs.writeFile(env.testFilePath, "modified content")
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.be.a("string").and.not.empty
|
||||
expect(secondCommit).to.not.equal(firstCommit)
|
||||
|
||||
// Verify diffs still work on existing tracker
|
||||
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
|
||||
expect(diffSet).to.have.lengthOf(1)
|
||||
expect(diffSet[0].before).to.equal("initial content")
|
||||
expect(diffSet[0].after).to.equal("modified content")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,325 @@
|
||||
import fs from "fs/promises"
|
||||
import { join } from "path"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { GIT_DISABLED_SUFFIX } from "./CheckpointGitOperations"
|
||||
|
||||
/**
|
||||
* CheckpointExclusions Module
|
||||
*
|
||||
* A specialized module within Cline's Checkpoints system that manages file exclusion rules
|
||||
* for the checkpoint tracking process. It provides:
|
||||
*
|
||||
* File Filtering:
|
||||
* - File types (build artifacts, media, cache files, etc.)
|
||||
* - Git LFS patterns from workspace
|
||||
* - Environment and configuration files
|
||||
* - Temporary and cache files
|
||||
*
|
||||
* Pattern Management:
|
||||
* - Extensible category-based pattern system
|
||||
* - Comprehensive file type coverage
|
||||
* - Easy pattern updates and maintenance
|
||||
*
|
||||
* Git Integration:
|
||||
* - Seamless integration with Git's exclude mechanism
|
||||
* - Support for workspace-specific LFS patterns
|
||||
* - Automatic pattern updates during checkpoints
|
||||
*
|
||||
* The module ensures efficient checkpoint creation by preventing unnecessary tracking
|
||||
* of large files, binary files, and temporary artifacts while maintaining a clean
|
||||
* and organized checkpoint history.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns the default list of file and directory patterns to exclude from checkpoints.
|
||||
* Combines built-in patterns with workspace-specific LFS patterns.
|
||||
*
|
||||
* @param lfsPatterns - Optional array of Git LFS patterns from workspace
|
||||
* @returns Array of glob patterns to exclude
|
||||
* @todo Make this configurable by the user
|
||||
*/
|
||||
export const getDefaultExclusions = (lfsPatterns: string[] = []): string[] => [
|
||||
// Build and Development Artifacts
|
||||
".git/",
|
||||
`.git${GIT_DISABLED_SUFFIX}/`,
|
||||
...getBuildArtifactPatterns(),
|
||||
|
||||
// Media Files
|
||||
...getMediaFilePatterns(),
|
||||
|
||||
// Cache and Temporary Files
|
||||
...getCacheFilePatterns(),
|
||||
|
||||
// Environment and Config Files
|
||||
...getConfigFilePatterns(),
|
||||
|
||||
// Large Data Files
|
||||
...getLargeDataFilePatterns(),
|
||||
|
||||
// Database Files
|
||||
...getDatabaseFilePatterns(),
|
||||
|
||||
// Geospatial Datasets
|
||||
...getGeospatialPatterns(),
|
||||
|
||||
// Log Files
|
||||
...getLogFilePatterns(),
|
||||
|
||||
...lfsPatterns,
|
||||
]
|
||||
|
||||
/**
|
||||
* Returns patterns for common build and development artifact directories
|
||||
* @returns Array of glob patterns for build artifacts
|
||||
*/
|
||||
function getBuildArtifactPatterns(): string[] {
|
||||
return [
|
||||
".gradle/",
|
||||
".idea/",
|
||||
".parcel-cache/",
|
||||
".pytest_cache/",
|
||||
".next/",
|
||||
".nuxt/",
|
||||
".sass-cache/",
|
||||
".vs/",
|
||||
".vscode/",
|
||||
"Pods/",
|
||||
"__pycache__/",
|
||||
"bin/",
|
||||
"build/",
|
||||
"bundle/",
|
||||
"coverage/",
|
||||
"deps/",
|
||||
"dist/",
|
||||
"env/",
|
||||
"node_modules/",
|
||||
"obj/",
|
||||
"out/",
|
||||
"pkg/",
|
||||
"pycache/",
|
||||
"target/dependency/",
|
||||
"temp/",
|
||||
"vendor/",
|
||||
"venv/",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for common media and image file types
|
||||
* @returns Array of glob patterns for media files
|
||||
*/
|
||||
function getMediaFilePatterns(): string[] {
|
||||
return [
|
||||
"*.jpg",
|
||||
"*.jpeg",
|
||||
"*.png",
|
||||
"*.gif",
|
||||
"*.bmp",
|
||||
"*.ico",
|
||||
"*.webp",
|
||||
"*.tiff",
|
||||
"*.tif",
|
||||
// "*.svg",
|
||||
"*.raw",
|
||||
"*.heic",
|
||||
"*.avif",
|
||||
"*.eps",
|
||||
"*.psd",
|
||||
"*.3gp",
|
||||
"*.aac",
|
||||
"*.aiff",
|
||||
"*.asf",
|
||||
"*.avi",
|
||||
"*.divx",
|
||||
"*.flac",
|
||||
"*.m4a",
|
||||
"*.m4v",
|
||||
"*.mkv",
|
||||
"*.mov",
|
||||
"*.mp3",
|
||||
"*.mp4",
|
||||
"*.mpeg",
|
||||
"*.mpg",
|
||||
"*.ogg",
|
||||
"*.opus",
|
||||
"*.rm",
|
||||
"*.rmvb",
|
||||
"*.vob",
|
||||
"*.wav",
|
||||
"*.webm",
|
||||
"*.wma",
|
||||
"*.wmv",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for cache, temporary, and system files
|
||||
* @returns Array of glob patterns for cache files
|
||||
*/
|
||||
function getCacheFilePatterns(): string[] {
|
||||
return [
|
||||
"*.DS_Store",
|
||||
"*.bak",
|
||||
"*.cache",
|
||||
"*.crdownload",
|
||||
"*.dmp",
|
||||
"*.dump",
|
||||
"*.eslintcache",
|
||||
"*.lock",
|
||||
"*.log",
|
||||
"*.old",
|
||||
"*.part",
|
||||
"*.partial",
|
||||
"*.pyc",
|
||||
"*.pyo",
|
||||
"*.stackdump",
|
||||
"*.swo",
|
||||
"*.swp",
|
||||
"*.temp",
|
||||
"*.tmp",
|
||||
"*.Thumbs.db",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for environment and configuration files
|
||||
* @returns Array of glob patterns for config files
|
||||
*/
|
||||
function getConfigFilePatterns(): string[] {
|
||||
return ["*.env*", "*.local", "*.development", "*.production"]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for common large binary and archive files
|
||||
* @returns Array of glob patterns for large data files
|
||||
*/
|
||||
function getLargeDataFilePatterns(): string[] {
|
||||
return [
|
||||
"*.zip",
|
||||
"*.tar",
|
||||
"*.gz",
|
||||
"*.rar",
|
||||
"*.7z",
|
||||
"*.iso",
|
||||
"*.bin",
|
||||
"*.exe",
|
||||
"*.dll",
|
||||
"*.so",
|
||||
"*.dylib",
|
||||
"*.dat",
|
||||
"*.dmg",
|
||||
"*.msi",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for database and data storage files
|
||||
* @returns Array of glob patterns for database files
|
||||
*/
|
||||
function getDatabaseFilePatterns(): string[] {
|
||||
return [
|
||||
"*.arrow",
|
||||
"*.accdb",
|
||||
"*.aof",
|
||||
"*.avro",
|
||||
"*.bak",
|
||||
"*.bson",
|
||||
"*.csv",
|
||||
"*.db",
|
||||
"*.dbf",
|
||||
"*.dmp",
|
||||
"*.frm",
|
||||
"*.ibd",
|
||||
"*.mdb",
|
||||
"*.myd",
|
||||
"*.myi",
|
||||
"*.orc",
|
||||
"*.parquet",
|
||||
"*.pdb",
|
||||
"*.rdb",
|
||||
"*.sql",
|
||||
"*.sqlite",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for geospatial and mapping data files
|
||||
* @returns Array of glob patterns for geospatial files
|
||||
*/
|
||||
function getGeospatialPatterns(): string[] {
|
||||
return [
|
||||
"*.shp",
|
||||
"*.shx",
|
||||
"*.dbf",
|
||||
"*.prj",
|
||||
"*.sbn",
|
||||
"*.sbx",
|
||||
"*.shp.xml",
|
||||
"*.cpg",
|
||||
"*.gdb",
|
||||
"*.mdb",
|
||||
"*.gpkg",
|
||||
"*.kml",
|
||||
"*.kmz",
|
||||
"*.gml",
|
||||
"*.geojson",
|
||||
"*.dem",
|
||||
"*.asc",
|
||||
"*.img",
|
||||
"*.ecw",
|
||||
"*.las",
|
||||
"*.laz",
|
||||
"*.mxd",
|
||||
"*.qgs",
|
||||
"*.grd",
|
||||
"*.csv",
|
||||
"*.dwg",
|
||||
"*.dxf",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for log and debug output files
|
||||
* @returns Array of glob patterns for log files
|
||||
*/
|
||||
function getLogFilePatterns(): string[] {
|
||||
return ["*.error", "*.log", "*.logs", "*.npm-debug.log*", "*.out", "*.stdout", "yarn-debug.log*", "yarn-error.log*"]
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the combined exclusion patterns to Git's exclude file.
|
||||
* Creates the info directory if it doesn't exist.
|
||||
*
|
||||
* @param gitPath - Path to the .git directory
|
||||
* @param lfsPatterns - Optional array of Git LFS patterns to include
|
||||
*/
|
||||
export const writeExcludesFile = async (gitPath: string, lfsPatterns: string[] = []): Promise<void> => {
|
||||
const excludesPath = join(gitPath, "info", "exclude")
|
||||
await fs.mkdir(join(gitPath, "info"), { recursive: true })
|
||||
|
||||
const patterns = getDefaultExclusions(lfsPatterns)
|
||||
await fs.writeFile(excludesPath, patterns.join("\n"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves Git LFS patterns from the workspace's .gitattributes file.
|
||||
* Returns an empty array if no patterns found or file doesn't exist.
|
||||
*
|
||||
* @param workspacePath - Path to the workspace root
|
||||
* @returns Array of Git LFS patterns found in .gitattributes
|
||||
*/
|
||||
export const getLfsPatterns = async (workspacePath: string): Promise<string[]> => {
|
||||
try {
|
||||
const attributesPath = join(workspacePath, ".gitattributes")
|
||||
if (await fileExistsAtPath(attributesPath)) {
|
||||
const attributesContent = await fs.readFile(attributesPath, "utf8")
|
||||
return attributesContent
|
||||
.split("\n")
|
||||
.filter((line) => line.includes("filter=lfs"))
|
||||
.map((line) => line.split(" ")[0].trim())
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to read .gitattributes:", error)
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import fs from "fs/promises"
|
||||
import { globby } from "globby"
|
||||
import * as path from "path"
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { getLfsPatterns, writeExcludesFile } from "./CheckpointExclusions"
|
||||
import { telemetryService } from "../../services/telemetry/TelemetryService"
|
||||
|
||||
interface CheckpointAddResult {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* GitOperations Class
|
||||
*
|
||||
* Handles git-specific operations for Cline's Checkpoints system.
|
||||
*
|
||||
* Key responsibilities:
|
||||
* - Git repository initialization and configuration
|
||||
* - Git settings management (user, LFS, etc.)
|
||||
* - Worktree configuration and management
|
||||
* - Managing nested git repositories during checkpoint operations
|
||||
* - File staging and checkpoint creation
|
||||
* - Shadow git repository maintenance and cleanup
|
||||
*/
|
||||
export class GitOperations {
|
||||
private cwd: string
|
||||
|
||||
/**
|
||||
* Creates a new GitOperations instance.
|
||||
*
|
||||
* @param cwd - The current working directory for git operations
|
||||
*/
|
||||
constructor(cwd: string) {
|
||||
this.cwd = cwd
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes or verifies a shadow Git repository for checkpoint tracking.
|
||||
* Creates a new repository if one doesn't exist, or verifies the worktree
|
||||
* configuration if it does.
|
||||
*
|
||||
* Key operations:
|
||||
* - Creates/verifies shadow git repository
|
||||
* - Configures git settings (user, LFS, etc.)
|
||||
* - Sets up worktree to point to workspace
|
||||
*
|
||||
* @param gitPath - Path to the .git directory
|
||||
* @param cwd - The current working directory for git operations
|
||||
* @returns Promise<string> Path to the initialized .git directory
|
||||
* @throws Error if:
|
||||
* - Worktree verification fails for existing repository
|
||||
* - Git initialization or configuration fails
|
||||
* - Unable to create initial commit
|
||||
* - LFS pattern setup fails
|
||||
*/
|
||||
public async initShadowGit(gitPath: string, cwd: string, taskId: string): Promise<string> {
|
||||
console.info(`Initializing shadow git`)
|
||||
|
||||
// If repo exists, just verify worktree
|
||||
if (await fileExistsAtPath(gitPath)) {
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
const worktree = await git.getConfig("core.worktree")
|
||||
if (worktree.value !== cwd) {
|
||||
throw new Error("Checkpoints can only be used in the original workspace: " + worktree.value)
|
||||
}
|
||||
console.warn(`Using existing shadow git at ${gitPath}`)
|
||||
|
||||
// shadow git repo already exists, but update the excludes just in case
|
||||
await writeExcludesFile(gitPath, await getLfsPatterns(this.cwd))
|
||||
|
||||
return gitPath
|
||||
}
|
||||
|
||||
// Initialize new repo
|
||||
const startTime = performance.now()
|
||||
const checkpointsDir = path.dirname(gitPath)
|
||||
console.warn(`Creating new shadow git in ${checkpointsDir}`)
|
||||
|
||||
const git = simpleGit(checkpointsDir)
|
||||
await git.init()
|
||||
|
||||
// Configure repo with git settings
|
||||
await git.addConfig("core.worktree", cwd)
|
||||
await git.addConfig("commit.gpgSign", "false")
|
||||
await git.addConfig("user.name", "Cline Checkpoint")
|
||||
await git.addConfig("user.email", "checkpoint@cline.bot")
|
||||
|
||||
// Set up LFS patterns
|
||||
const lfsPatterns = await getLfsPatterns(cwd)
|
||||
await writeExcludesFile(gitPath, lfsPatterns)
|
||||
|
||||
await this.addCheckpointFiles(git)
|
||||
|
||||
// Initial commit only on first repo creation
|
||||
await git.commit("initial commit", { "--allow-empty": null })
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized", durationMs)
|
||||
|
||||
console.warn(`Shadow git initialization completed`)
|
||||
|
||||
return gitPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the worktree path from the shadow git configuration.
|
||||
* The worktree path indicates where the shadow git repository is tracking files,
|
||||
* which should match the current workspace directory.
|
||||
*
|
||||
* @param gitPath - Path to the .git directory
|
||||
* @returns Promise<string | undefined> The worktree path or undefined if not found
|
||||
* @throws Error if unable to get worktree path
|
||||
*/
|
||||
public async getShadowGitConfigWorkTree(gitPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
const worktree = await git.getConfig("core.worktree")
|
||||
return worktree.value || undefined
|
||||
} catch (error) {
|
||||
console.error("Failed to get shadow git config worktree:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's
|
||||
* requirement of using submodules for nested repos.
|
||||
*
|
||||
* This method renames nested .git directories by adding/removing a suffix to temporarily disable/enable them.
|
||||
* The root .git directory is preserved. Uses VS Code's workspace API to find nested .git directories and
|
||||
* only processes actual directories (not files named .git).
|
||||
*
|
||||
* @param disable - If true, adds suffix to disable nested git repos. If false, removes suffix to re-enable them.
|
||||
* @throws Error if renaming any .git directory fails
|
||||
*/
|
||||
public async renameNestedGitRepos(disable: boolean) {
|
||||
// Find all .git directories that are not at the root level
|
||||
const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
|
||||
cwd: this.cwd,
|
||||
onlyDirectories: true,
|
||||
ignore: [".git"], // Ignore root level .git
|
||||
dot: true,
|
||||
markDirectories: false,
|
||||
})
|
||||
|
||||
// For each nested .git directory, rename it based on operation
|
||||
for (const gitPath of gitPaths) {
|
||||
const fullPath = path.join(this.cwd, gitPath)
|
||||
let newPath: string
|
||||
if (disable) {
|
||||
newPath = fullPath + GIT_DISABLED_SUFFIX
|
||||
} else {
|
||||
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rename(fullPath, newPath)
|
||||
console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
|
||||
} catch (error) {
|
||||
console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds files to the shadow git repository while handling nested git repos.
|
||||
* Uses git commands to list files and stages them for commit.
|
||||
* Respects .gitignore and handles LFS patterns.
|
||||
*
|
||||
* Process:
|
||||
* 1. Updates exclude patterns from LFS config
|
||||
* 2. Temporarily disables nested git repos
|
||||
* 3. Gets list of tracked and untracked files from git (respecting .gitignore)
|
||||
* 4. Adds all files to git staging
|
||||
* 5. Re-enables nested git repos
|
||||
*
|
||||
* @param git - SimpleGit instance configured for the shadow git repo
|
||||
* @returns Promise<CheckpointAddResult> Object containing success status, message, and file count
|
||||
* @throws Error if:
|
||||
* - File operations fail
|
||||
* - Git commands error
|
||||
* - LFS pattern updates fail
|
||||
* - Nested git repo handling fails
|
||||
*/
|
||||
public async addCheckpointFiles(git: SimpleGit): Promise<CheckpointAddResult> {
|
||||
const startTime = performance.now()
|
||||
try {
|
||||
// Update exclude patterns before each commit
|
||||
await this.renameNestedGitRepos(true)
|
||||
console.info("Starting checkpoint add operation...")
|
||||
|
||||
try {
|
||||
await git.add(".")
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
console.debug(`Checkpoint add operation completed in ${durationMs}ms`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Checkpoint add operation failed:", error)
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to add files to checkpoint", error)
|
||||
throw error
|
||||
} finally {
|
||||
await this.renameNestedGitRepos(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const GIT_DISABLED_SUFFIX = "_disabled"
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
|
||||
/**
|
||||
* Cleans up legacy checkpoints from task folders.
|
||||
* This is a one-time operation that runs when the extension is updated to use the new checkpoint system.
|
||||
*
|
||||
* @param globalStoragePath - Path to the extension's global storage
|
||||
* @param outputChannel - VSCode output channel for logging
|
||||
*/
|
||||
export async function cleanupLegacyCheckpoints(globalStoragePath: string, outputChannel: vscode.OutputChannel): Promise<void> {
|
||||
try {
|
||||
outputChannel.appendLine("Checking for legacy checkpoints...")
|
||||
|
||||
const tasksDir = path.join(globalStoragePath, "tasks")
|
||||
|
||||
// Check if tasks directory exists
|
||||
if (!(await fileExistsAtPath(tasksDir))) {
|
||||
return // No tasks directory, nothing to clean up
|
||||
}
|
||||
|
||||
// Get all task folders
|
||||
const taskFolders = await fs.readdir(tasksDir)
|
||||
if (taskFolders.length === 0) {
|
||||
return // No task folders, nothing to clean up
|
||||
}
|
||||
|
||||
// Get stats for each folder to sort by creation time
|
||||
const folderStats = await Promise.all(
|
||||
taskFolders.map(async (folder) => {
|
||||
const folderPath = path.join(tasksDir, folder)
|
||||
const stats = await fs.stat(folderPath)
|
||||
return { folder, path: folderPath, stats }
|
||||
}),
|
||||
)
|
||||
|
||||
// Sort by creation time, newest first
|
||||
folderStats.sort((a, b) => b.stats.birthtimeMs - a.stats.birthtimeMs)
|
||||
|
||||
// Check if the most recent task folder has a checkpoints directory
|
||||
if (folderStats.length > 0) {
|
||||
const mostRecentFolder = folderStats[0]
|
||||
const checkpointsDir = path.join(mostRecentFolder.path, "checkpoints")
|
||||
|
||||
if (await fileExistsAtPath(checkpointsDir)) {
|
||||
outputChannel.appendLine("Found legacy checkpoints directory, cleaning up...")
|
||||
|
||||
// Legacy checkpoints found, delete checkpoints directories in all task folders
|
||||
for (const folder of folderStats) {
|
||||
const folderCheckpointsDir = path.join(folder.path, "checkpoints")
|
||||
if (await fileExistsAtPath(folderCheckpointsDir)) {
|
||||
outputChannel.appendLine(`Deleting legacy checkpoints in ${folder.folder}`)
|
||||
try {
|
||||
await fs.rm(folderCheckpointsDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
// Ignore error if directory removal fails
|
||||
outputChannel.appendLine(`Warning: Failed to delete checkpoints in ${folder.folder}, continuing...`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputChannel.appendLine("Legacy checkpoints cleanup completed")
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
outputChannel.appendLine(`Error cleaning up legacy checkpoints: ${error}`)
|
||||
console.error("Error cleaning up legacy checkpoints:", error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { expect } from "chai"
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
|
||||
|
||||
describe("Checkpoint Revert Operations", () => {
|
||||
let env: Awaited<ReturnType<typeof createTestEnvironment>>
|
||||
|
||||
beforeEach(async () => {
|
||||
env = await createTestEnvironment()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await env.cleanup()
|
||||
})
|
||||
|
||||
it("should revert working directory to a previous checkpoint state", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create and commit initial state
|
||||
await fs.writeFile(env.testFilePath, "initial content")
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.not.be.undefined
|
||||
|
||||
// Create and commit changes
|
||||
await fs.writeFile(env.testFilePath, "modified content")
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.not.be.undefined
|
||||
|
||||
// Make more changes without committing
|
||||
await fs.writeFile(env.testFilePath, "uncommitted changes")
|
||||
|
||||
// Revert to first commit
|
||||
await tracker.resetHead(firstCommit!)
|
||||
|
||||
// Verify file content matches initial state
|
||||
const resetContent = await fs.readFile(env.testFilePath, "utf8")
|
||||
expect(resetContent).to.equal("initial content")
|
||||
})
|
||||
|
||||
it("should handle reverting with multiple files", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create and commit initial state with multiple files
|
||||
const testFile2Path = path.join(env.tempDir, "src", "test2.txt")
|
||||
await fs.writeFile(env.testFilePath, "file1 initial")
|
||||
await fs.writeFile(testFile2Path, "file2 initial")
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.not.be.undefined
|
||||
|
||||
// Modify both files and commit
|
||||
await fs.writeFile(env.testFilePath, "file1 modified")
|
||||
await fs.writeFile(testFile2Path, "file2 modified")
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.not.be.undefined
|
||||
|
||||
// Make more changes
|
||||
await fs.writeFile(env.testFilePath, "file1 uncommitted")
|
||||
await fs.writeFile(testFile2Path, "file2 uncommitted")
|
||||
|
||||
// Reset to first commit
|
||||
await tracker.resetHead(firstCommit!)
|
||||
|
||||
// Verify both files match initial state
|
||||
const file1Content = await fs.readFile(env.testFilePath, "utf8")
|
||||
const file2Content = await fs.readFile(testFile2Path, "utf8")
|
||||
expect(file1Content).to.equal("file1 initial")
|
||||
expect(file2Content).to.equal("file2 initial")
|
||||
})
|
||||
|
||||
it("should handle reverting when files are deleted", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create and commit initial state
|
||||
await fs.writeFile(env.testFilePath, "initial content")
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.not.be.undefined
|
||||
|
||||
// Delete file and commit
|
||||
await fs.unlink(env.testFilePath)
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.not.be.undefined
|
||||
|
||||
// Revert to first commit
|
||||
await tracker.resetHead(firstCommit!)
|
||||
|
||||
// Verify file is restored with original content
|
||||
const resetContent = await fs.readFile(env.testFilePath, "utf8")
|
||||
expect(resetContent).to.equal("initial content")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { expect } from "chai"
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import fs from "fs/promises"
|
||||
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import CheckpointTracker from "./CheckpointTracker"
|
||||
|
||||
describe("Checkpoint Task Switching", () => {
|
||||
let env: Awaited<ReturnType<typeof createTestEnvironment>>
|
||||
let taskId1: string
|
||||
let taskId2: string
|
||||
let tracker1: CheckpointTracker | undefined
|
||||
let tracker2: CheckpointTracker | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
env = await createTestEnvironment()
|
||||
taskId1 = "task-1"
|
||||
taskId2 = "task-2"
|
||||
tracker1 = await createTestTracker(env.globalStoragePath, taskId1)
|
||||
if (!tracker1) {throw new Error("Failed to create tracker1")}
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await env.cleanup()
|
||||
})
|
||||
|
||||
it("should maintain separate history for each task", async () => {
|
||||
if (!tracker1) {throw new Error("Failed to create tracker1")}
|
||||
|
||||
// Create and commit file in first task
|
||||
await fs.writeFile(env.testFilePath, "task1 initial")
|
||||
const task1Commit1 = await tracker1.commit()
|
||||
expect(task1Commit1).to.be.a("string").and.not.empty
|
||||
|
||||
// Modify and commit again in first task
|
||||
await fs.writeFile(env.testFilePath, "task1 modified")
|
||||
const task1Commit2 = await tracker1.commit()
|
||||
expect(task1Commit2).to.be.a("string").and.not.empty
|
||||
|
||||
// Create second task tracker
|
||||
tracker2 = await createTestTracker(env.globalStoragePath, taskId2)
|
||||
if (!tracker2) {throw new Error("Failed to create tracker2")}
|
||||
|
||||
// Create and commit file in second task
|
||||
await fs.writeFile(env.testFilePath, "task2 initial")
|
||||
const task2Commit1 = await tracker2.commit()
|
||||
expect(task2Commit1).to.be.a("string").and.not.empty
|
||||
|
||||
// Create another commit to establish history
|
||||
await fs.writeFile(env.testFilePath, "task2 modified")
|
||||
const task2Commit2 = await tracker2.commit()
|
||||
expect(task2Commit2).to.be.a("string").and.not.empty
|
||||
|
||||
// Verify second task's history
|
||||
const task2Diff = await tracker2.getDiffSet(task2Commit1, task2Commit2)
|
||||
expect(task2Diff).to.have.lengthOf(1)
|
||||
expect(task2Diff[0].before).to.equal("task2 initial")
|
||||
expect(task2Diff[0].after).to.equal("task2 modified")
|
||||
|
||||
// Switch back to first task by creating new tracker
|
||||
const tracker1Again = await createTestTracker(env.globalStoragePath, taskId1)
|
||||
if (!tracker1Again) {throw new Error("Failed to create tracker1Again")}
|
||||
|
||||
// Verify first task's history is preserved
|
||||
const task1Diff = await tracker1Again.getDiffSet(task1Commit1, task1Commit2)
|
||||
expect(task1Diff[0].before).to.equal("task1 initial")
|
||||
expect(task1Diff[0].after).to.equal("task1 modified")
|
||||
|
||||
// Reset first task to initial state
|
||||
if (!task1Commit1) {throw new Error("Failed to create initial commit")}
|
||||
await tracker1Again.resetHead(task1Commit1)
|
||||
const resetContent = await fs.readFile(env.testFilePath, "utf8")
|
||||
expect(resetContent).to.equal("task1 initial")
|
||||
})
|
||||
|
||||
it("should handle task deletion and recreation", async () => {
|
||||
if (!tracker1) {throw new Error("Failed to create tracker1")}
|
||||
|
||||
// Create and commit file in first task
|
||||
await fs.writeFile(env.testFilePath, "task1 content")
|
||||
const task1Commit = await tracker1.commit()
|
||||
expect(task1Commit).to.be.a("string").and.not.empty
|
||||
|
||||
// Create second task
|
||||
tracker2 = await createTestTracker(env.globalStoragePath, taskId2)
|
||||
if (!tracker2) {throw new Error("Failed to create tracker2")}
|
||||
await fs.writeFile(env.testFilePath, "task2 content")
|
||||
const task2Commit = await tracker2.commit()
|
||||
expect(task2Commit).to.be.a("string").and.not.empty
|
||||
|
||||
// Delete second task's checkpoints
|
||||
const historyItem: HistoryItem = {
|
||||
id: `test-${Date.now()}`,
|
||||
ts: Date.now(),
|
||||
task: taskId2,
|
||||
shadowGitConfigWorkTree: env.tempDir,
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
}
|
||||
await CheckpointTracker.deleteCheckpoints(taskId2, historyItem, env.globalStoragePath)
|
||||
|
||||
// Recreate second task
|
||||
const tracker2Again = await createTestTracker(env.globalStoragePath, taskId2)
|
||||
if (!tracker2Again) {throw new Error("Failed to create tracker2Again")}
|
||||
|
||||
// Create new commit in recreated task
|
||||
await fs.writeFile(env.testFilePath, "task2 new content")
|
||||
const newCommit = await tracker2Again.commit()
|
||||
expect(newCommit).to.be.a("string").and.not.empty
|
||||
|
||||
// Switch back to first task and verify its history is intact
|
||||
const tracker1Again = await createTestTracker(env.globalStoragePath, taskId1)
|
||||
if (!tracker1Again) {throw new Error("Failed to create tracker1Again")}
|
||||
if (!task1Commit) {throw new Error("Failed to create initial commit")}
|
||||
await tracker1Again.resetHead(task1Commit)
|
||||
const resetContent = await fs.readFile(env.testFilePath, "utf8")
|
||||
expect(resetContent).to.equal("task1 content")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,420 @@
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import * as path from "path"
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { globby } from "globby"
|
||||
|
||||
class CheckpointTracker {
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
private taskId: string
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private cwd: string
|
||||
private lastRetrievedShadowGitConfigWorkTree?: string
|
||||
lastCheckpointHash?: string
|
||||
|
||||
private constructor(provider: ClineProvider, taskId: string, cwd: string) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
this.taskId = taskId
|
||||
this.cwd = cwd
|
||||
}
|
||||
|
||||
public static async create(taskId: string, provider?: ClineProvider): Promise<CheckpointTracker | undefined> {
|
||||
try {
|
||||
if (!provider) {
|
||||
throw new Error("Provider is required to create a checkpoint tracker")
|
||||
}
|
||||
|
||||
// Check if checkpoints are disabled in VS Code settings
|
||||
const enableCheckpoints = vscode.workspace.getConfiguration("cline").get<boolean>("enableCheckpoints") ?? true
|
||||
if (!enableCheckpoints) {
|
||||
return undefined // Don't create tracker when disabled
|
||||
}
|
||||
|
||||
// Check if git is installed by attempting to get version
|
||||
try {
|
||||
await simpleGit().version()
|
||||
} catch (error) {
|
||||
throw new Error("Git must be installed to use checkpoints.") // FIXME: must match what we check for in TaskHeader to show link
|
||||
}
|
||||
|
||||
const cwd = await CheckpointTracker.getWorkingDirectory()
|
||||
const newTracker = new CheckpointTracker(provider, taskId, cwd)
|
||||
await newTracker.initShadowGit()
|
||||
return newTracker
|
||||
} catch (error) {
|
||||
console.error("Failed to create CheckpointTracker:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static async getWorkingDirectory(): Promise<string> {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
if (!cwd) {
|
||||
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
|
||||
}
|
||||
const homedir = os.homedir()
|
||||
const desktopPath = path.join(homedir, "Desktop")
|
||||
const documentsPath = path.join(homedir, "Documents")
|
||||
const downloadsPath = path.join(homedir, "Downloads")
|
||||
|
||||
switch (cwd) {
|
||||
case homedir:
|
||||
throw new Error("Cannot use checkpoints in home directory")
|
||||
case desktopPath:
|
||||
throw new Error("Cannot use checkpoints in Desktop directory")
|
||||
case documentsPath:
|
||||
throw new Error("Cannot use checkpoints in Documents directory")
|
||||
case downloadsPath:
|
||||
throw new Error("Cannot use checkpoints in Downloads directory")
|
||||
default:
|
||||
return cwd
|
||||
}
|
||||
}
|
||||
|
||||
private async getShadowGitPath(): Promise<string> {
|
||||
const globalStoragePath = this.providerRef.deref()?.context.globalStorageUri.fsPath
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage uri is invalid")
|
||||
}
|
||||
const checkpointsDir = path.join(globalStoragePath, "tasks", this.taskId, "checkpoints")
|
||||
await fs.mkdir(checkpointsDir, { recursive: true })
|
||||
const gitPath = path.join(checkpointsDir, ".git")
|
||||
return gitPath
|
||||
}
|
||||
|
||||
public static async doesShadowGitExist(taskId: string, provider?: ClineProvider): Promise<boolean> {
|
||||
const globalStoragePath = provider?.context.globalStorageUri.fsPath
|
||||
if (!globalStoragePath) {
|
||||
return false
|
||||
}
|
||||
const gitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
|
||||
return await fileExistsAtPath(gitPath)
|
||||
}
|
||||
|
||||
public async initShadowGit(): Promise<string> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
if (await fileExistsAtPath(gitPath)) {
|
||||
// Make sure it's the same cwd as the configured worktree
|
||||
const worktree = await this.getShadowGitConfigWorkTree()
|
||||
if (worktree !== this.cwd) {
|
||||
throw new Error("Checkpoints can only be used in the original workspace: " + worktree)
|
||||
}
|
||||
|
||||
return gitPath
|
||||
} else {
|
||||
const checkpointsDir = path.dirname(gitPath)
|
||||
const git = simpleGit(checkpointsDir)
|
||||
await git.init()
|
||||
|
||||
await git.addConfig("core.worktree", this.cwd) // sets the working tree to the current workspace
|
||||
|
||||
// Disable commit signing for shadow repo
|
||||
await git.addConfig("commit.gpgSign", "false")
|
||||
|
||||
// Get LFS patterns from workspace if they exist
|
||||
let lfsPatterns: string[] = []
|
||||
try {
|
||||
const attributesPath = path.join(this.cwd, ".gitattributes")
|
||||
if (await fileExistsAtPath(attributesPath)) {
|
||||
const attributesContent = await fs.readFile(attributesPath, "utf8")
|
||||
lfsPatterns = attributesContent
|
||||
.split("\n")
|
||||
.filter((line) => line.includes("filter=lfs"))
|
||||
.map((line) => line.split(" ")[0].trim())
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to read .gitattributes:", error)
|
||||
}
|
||||
|
||||
// Add basic excludes directly in git config, while respecting any .gitignore in the workspace
|
||||
// .git/info/exclude is local to the shadow git repo, so it's not shared with the main repo - and won't conflict with user's .gitignore
|
||||
// TODO: let user customize these
|
||||
const excludesPath = path.join(gitPath, "info", "exclude")
|
||||
await fs.mkdir(path.join(gitPath, "info"), { recursive: true })
|
||||
await fs.writeFile(
|
||||
excludesPath,
|
||||
[
|
||||
".git/", // ignore the user's .git
|
||||
`.git${GIT_DISABLED_SUFFIX}/`, // ignore the disabled nested git repos
|
||||
".DS_Store",
|
||||
"*.log",
|
||||
"node_modules/",
|
||||
"__pycache__/",
|
||||
"env/",
|
||||
"venv/",
|
||||
"target/dependency/",
|
||||
"build/dependencies/",
|
||||
"dist/",
|
||||
"out/",
|
||||
"bundle/",
|
||||
"vendor/",
|
||||
"tmp/",
|
||||
"temp/",
|
||||
"deps/",
|
||||
"pkg/",
|
||||
"Pods/",
|
||||
// Media files
|
||||
"*.jpg",
|
||||
"*.jpeg",
|
||||
"*.png",
|
||||
"*.gif",
|
||||
"*.bmp",
|
||||
"*.ico",
|
||||
// "*.svg",
|
||||
"*.mp3",
|
||||
"*.mp4",
|
||||
"*.wav",
|
||||
"*.avi",
|
||||
"*.mov",
|
||||
"*.wmv",
|
||||
"*.webm",
|
||||
"*.webp",
|
||||
"*.m4a",
|
||||
"*.flac",
|
||||
// Build and dependency directories
|
||||
"build/",
|
||||
"bin/",
|
||||
"obj/",
|
||||
".gradle/",
|
||||
".idea/",
|
||||
".vscode/",
|
||||
".vs/",
|
||||
"coverage/",
|
||||
".next/",
|
||||
".nuxt/",
|
||||
// Cache and temporary files
|
||||
"*.cache",
|
||||
"*.tmp",
|
||||
"*.temp",
|
||||
"*.swp",
|
||||
"*.swo",
|
||||
"*.pyc",
|
||||
"*.pyo",
|
||||
".pytest_cache/",
|
||||
".eslintcache",
|
||||
// Environment and config files
|
||||
".env*",
|
||||
"*.local",
|
||||
"*.development",
|
||||
"*.production",
|
||||
// Large data files
|
||||
"*.zip",
|
||||
"*.tar",
|
||||
"*.gz",
|
||||
"*.rar",
|
||||
"*.7z",
|
||||
"*.iso",
|
||||
"*.bin",
|
||||
"*.exe",
|
||||
"*.dll",
|
||||
"*.so",
|
||||
"*.dylib",
|
||||
// Database files
|
||||
"*.sqlite",
|
||||
"*.db",
|
||||
"*.sql",
|
||||
// Log files
|
||||
"*.logs",
|
||||
"*.error",
|
||||
"npm-debug.log*",
|
||||
"yarn-debug.log*",
|
||||
"yarn-error.log*",
|
||||
...lfsPatterns,
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
// Set up git identity (git throws an error if user.name or user.email is not set)
|
||||
await git.addConfig("user.name", "Cline Checkpoint")
|
||||
await git.addConfig("user.email", "noreply@example.com")
|
||||
|
||||
await this.addAllFiles(git)
|
||||
// Initial commit (--allow-empty ensures it works even with no files)
|
||||
await git.commit("initial commit", { "--allow-empty": null })
|
||||
|
||||
return gitPath
|
||||
}
|
||||
}
|
||||
|
||||
public async getShadowGitConfigWorkTree(): Promise<string | undefined> {
|
||||
if (this.lastRetrievedShadowGitConfigWorkTree) {
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
}
|
||||
try {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
const worktree = await git.getConfig("core.worktree")
|
||||
this.lastRetrievedShadowGitConfigWorkTree = worktree.value || undefined
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
} catch (error) {
|
||||
console.error("Failed to get shadow git config worktree:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
public async commit(): Promise<string | undefined> {
|
||||
try {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
await this.addAllFiles(git)
|
||||
const result = await git.commit("checkpoint", {
|
||||
"--allow-empty": null,
|
||||
})
|
||||
const commitHash = result.commit || ""
|
||||
this.lastCheckpointHash = commitHash
|
||||
return commitHash
|
||||
} catch (error) {
|
||||
console.error("Failed to create checkpoint:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
public async resetHead(commitHash: string): Promise<void> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
// Clean working directory and force reset
|
||||
// This ensures that the operation will succeed regardless of:
|
||||
// - Untracked files in the workspace
|
||||
// - Staged changes
|
||||
// - Unstaged changes
|
||||
// - Partial commits
|
||||
// - Merge conflicts
|
||||
await git.clean("f", ["-d", "-f"]) // Remove untracked files and directories
|
||||
await git.reset(["--hard", commitHash]) // Hard reset to target commit
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array describing changed files between one commit and either:
|
||||
* - another commit, or
|
||||
* - the current working directory (including uncommitted changes).
|
||||
*
|
||||
* If `rhsHash` is omitted, compares `lhsHash` to the working directory.
|
||||
* If you want truly untracked files to appear, `git add` them first.
|
||||
*
|
||||
* @param lhsHash - The commit to compare from (older commit)
|
||||
* @param rhsHash - The commit to compare to (newer commit).
|
||||
* If omitted, we compare to the working directory.
|
||||
* @returns Array of file changes with before/after content
|
||||
*/
|
||||
public async getDiffSet(
|
||||
lhsHash?: string,
|
||||
rhsHash?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
relativePath: string
|
||||
absolutePath: string
|
||||
before: string
|
||||
after: string
|
||||
}>
|
||||
> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
// If lhsHash is missing, use the initial commit of the repo
|
||||
let baseHash = lhsHash
|
||||
if (!baseHash) {
|
||||
const rootCommit = await git.raw(["rev-list", "--max-parents=0", "HEAD"])
|
||||
baseHash = rootCommit.trim()
|
||||
}
|
||||
|
||||
// Stage all changes so that untracked files appear in diff summary
|
||||
await this.addAllFiles(git)
|
||||
|
||||
const diffSummary = rhsHash ? await git.diffSummary([`${baseHash}..${rhsHash}`]) : await git.diffSummary([baseHash])
|
||||
|
||||
// For each changed file, gather before/after content
|
||||
const result = []
|
||||
const cwdPath = (await this.getShadowGitConfigWorkTree()) || this.cwd || ""
|
||||
|
||||
for (const file of diffSummary.files) {
|
||||
const filePath = file.file
|
||||
const absolutePath = path.join(cwdPath, filePath)
|
||||
|
||||
let beforeContent = ""
|
||||
try {
|
||||
beforeContent = await git.show([`${baseHash}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in older commit => remains empty
|
||||
}
|
||||
|
||||
let afterContent = ""
|
||||
if (rhsHash) {
|
||||
// if user provided a newer commit, use git.show at that commit
|
||||
try {
|
||||
afterContent = await git.show([`${rhsHash}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in newer commit => remains empty
|
||||
}
|
||||
} else {
|
||||
// otherwise, read from disk (includes uncommitted changes)
|
||||
try {
|
||||
afterContent = await fs.readFile(absolutePath, "utf8")
|
||||
} catch (_) {
|
||||
// file might be deleted => remains empty
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
relativePath: filePath,
|
||||
absolutePath,
|
||||
before: beforeContent,
|
||||
after: afterContent,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async addAllFiles(git: SimpleGit) {
|
||||
await this.renameNestedGitRepos(true)
|
||||
try {
|
||||
await git.add(".")
|
||||
} catch (error) {
|
||||
console.error("Failed to add files to git:", error)
|
||||
} finally {
|
||||
await this.renameNestedGitRepos(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's requirement of using submodules for nested repos.
|
||||
private async renameNestedGitRepos(disable: boolean) {
|
||||
// Find all .git directories that are not at the root level
|
||||
const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
|
||||
cwd: this.cwd,
|
||||
onlyDirectories: true,
|
||||
ignore: [".git"], // Ignore root level .git
|
||||
dot: true,
|
||||
markDirectories: false,
|
||||
})
|
||||
|
||||
// For each nested .git directory, rename it based on operation
|
||||
for (const gitPath of gitPaths) {
|
||||
const fullPath = path.join(this.cwd, gitPath)
|
||||
let newPath: string
|
||||
if (disable) {
|
||||
newPath = fullPath + GIT_DISABLED_SUFFIX
|
||||
} else {
|
||||
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rename(fullPath, newPath)
|
||||
console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
|
||||
} catch (error) {
|
||||
console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.disposables.forEach((d) => d.dispose())
|
||||
this.disposables = []
|
||||
}
|
||||
}
|
||||
|
||||
const GIT_DISABLED_SUFFIX = "_disabled"
|
||||
|
||||
export default CheckpointTracker
|
||||
@@ -1,31 +1,94 @@
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import * as path from "path"
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import simpleGit from "simple-git"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { globby } from "globby"
|
||||
import { telemetryService } from "../../services/telemetry/TelemetryService"
|
||||
import { GitOperations } from "./CheckpointGitOperations"
|
||||
import { getShadowGitPath, getWorkingDirectory, hashWorkingDir } from "./CheckpointUtils"
|
||||
|
||||
/**
|
||||
* CheckpointTracker Module
|
||||
*
|
||||
* Core implementation of Cline's Checkpoints system that provides version control
|
||||
* capabilities without interfering with the user's main Git repository. Key features:
|
||||
*
|
||||
* Shadow Git Repository:
|
||||
* - Creates and manages an isolated Git repository for tracking checkpoints
|
||||
* - Handles nested Git repositories by temporarily disabling them
|
||||
* - Configures Git settings automatically (identity, LFS, etc.)
|
||||
*
|
||||
* File Management:
|
||||
* - Integrates with CheckpointExclusions for file filtering
|
||||
* - Handles workspace validation and path resolution
|
||||
* - Manages Git worktree configuration
|
||||
*
|
||||
* Checkpoint Operations:
|
||||
* - Creates checkpoints (commits) of the current state
|
||||
* - Provides diff capabilities between checkpoints
|
||||
* - Supports resetting to previous checkpoints
|
||||
*
|
||||
* Safety Features:
|
||||
* - Prevents usage in sensitive directories (home, desktop, etc.)
|
||||
* - Validates workspace configuration
|
||||
* - Handles cleanup and resource disposal
|
||||
*
|
||||
* Checkpoint Architecture:
|
||||
* - Unique shadow git repository for each workspace
|
||||
* - Workspaces are identified by name, and hashed to a unique number
|
||||
* - All commits for a workspace are stored in one shadow git, under a single branch
|
||||
*/
|
||||
|
||||
class CheckpointTracker {
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
private globalStoragePath: string
|
||||
private taskId: string
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private cwd: string
|
||||
private cwdHash: string
|
||||
private lastRetrievedShadowGitConfigWorkTree?: string
|
||||
lastCheckpointHash?: string
|
||||
private gitOperations: GitOperations
|
||||
|
||||
private constructor(provider: ClineProvider, taskId: string, cwd: string) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
/**
|
||||
* Creates a new CheckpointTracker instance to manage checkpoints for a specific task.
|
||||
* The constructor is private - use the static create() method to instantiate.
|
||||
*
|
||||
* @param taskId - Unique identifier for the task being tracked
|
||||
* @param cwd - The current working directory to track files in
|
||||
* @param cwdHash - Hash of the working directory path for shadow git organization
|
||||
*/
|
||||
private constructor(globalStoragePath: string, taskId: string, cwd: string, cwdHash: string) {
|
||||
this.globalStoragePath = globalStoragePath
|
||||
this.taskId = taskId
|
||||
this.cwd = cwd
|
||||
this.cwdHash = cwdHash
|
||||
this.gitOperations = new GitOperations(cwd)
|
||||
}
|
||||
|
||||
public static async create(taskId: string, provider?: ClineProvider): Promise<CheckpointTracker | undefined> {
|
||||
/**
|
||||
* Creates a new CheckpointTracker instance for tracking changes in a task.
|
||||
* Handles initialization of the shadow git repository.
|
||||
*
|
||||
* @param taskId - Unique identifier for the task to track
|
||||
* @param globalStoragePath - the globalStorage path
|
||||
* @returns Promise resolving to new CheckpointTracker instance, or undefined if checkpoints are disabled
|
||||
* @throws Error if:
|
||||
* - globalStoragePath is not supplied
|
||||
* - Git is not installed
|
||||
* - Working directory is invalid or in a protected location
|
||||
* - Shadow git initialization fails
|
||||
*
|
||||
* Key operations:
|
||||
* - Validates git installation and settings
|
||||
* - Creates/initializes shadow git repository
|
||||
*
|
||||
* Configuration:
|
||||
* - Respects 'cline.enableCheckpoints' VS Code setting
|
||||
*/
|
||||
public static async create(taskId: string, globalStoragePath: string | undefined): Promise<CheckpointTracker | undefined> {
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage path is required to create a checkpoint tracker")
|
||||
}
|
||||
try {
|
||||
if (!provider) {
|
||||
throw new Error("Provider is required to create a checkpoint tracker")
|
||||
}
|
||||
console.info(`Creating new CheckpointTracker for task ${taskId}`)
|
||||
const startTime = performance.now()
|
||||
|
||||
// Check if checkpoints are disabled in VS Code settings
|
||||
const enableCheckpoints = vscode.workspace.getConfiguration("cline").get<boolean>("enableCheckpoints") ?? true
|
||||
@@ -40,9 +103,18 @@ class CheckpointTracker {
|
||||
throw new Error("Git must be installed to use checkpoints.") // FIXME: must match what we check for in TaskHeader to show link
|
||||
}
|
||||
|
||||
const cwd = await CheckpointTracker.getWorkingDirectory()
|
||||
const newTracker = new CheckpointTracker(provider, taskId, cwd)
|
||||
await newTracker.initShadowGit()
|
||||
const workingDir = await getWorkingDirectory()
|
||||
const cwdHash = hashWorkingDir(workingDir)
|
||||
console.debug(`Repository ID (cwdHash): ${cwdHash}`)
|
||||
|
||||
const newTracker = new CheckpointTracker(globalStoragePath, taskId, workingDir, cwdHash)
|
||||
|
||||
const gitPath = await getShadowGitPath(newTracker.globalStoragePath, newTracker.taskId, newTracker.cwdHash)
|
||||
await newTracker.gitOperations.initShadowGit(gitPath, workingDir, taskId)
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized", durationMs)
|
||||
|
||||
return newTracker
|
||||
} catch (error) {
|
||||
console.error("Failed to create CheckpointTracker:", error)
|
||||
@@ -50,203 +122,96 @@ class CheckpointTracker {
|
||||
}
|
||||
}
|
||||
|
||||
private static async getWorkingDirectory(): Promise<string> {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
if (!cwd) {
|
||||
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
|
||||
}
|
||||
const homedir = os.homedir()
|
||||
const desktopPath = path.join(homedir, "Desktop")
|
||||
const documentsPath = path.join(homedir, "Documents")
|
||||
const downloadsPath = path.join(homedir, "Downloads")
|
||||
/**
|
||||
* Creates a new checkpoint commit in the shadow git repository.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Creates commit with checkpoint files in shadow git repo
|
||||
* - Caches the created commit hash
|
||||
*
|
||||
* Commit structure:
|
||||
* - Commit message: "checkpoint-{cwdHash}-{taskId}"
|
||||
* - Always allows empty commits
|
||||
*
|
||||
* Dependencies:
|
||||
* - Requires initialized shadow git (getShadowGitPath)
|
||||
* - Uses addCheckpointFiles to stage changes using 'git add .'
|
||||
* - Relies on git's native exclusion handling via the exclude file
|
||||
*
|
||||
* @returns Promise<string | undefined> The created commit hash, or undefined if:
|
||||
* - Shadow git access fails
|
||||
* - Staging files fails
|
||||
* - Commit creation fails
|
||||
* @throws Error if unable to:
|
||||
* - Access shadow git path
|
||||
* - Initialize simple-git
|
||||
* - Stage or commit files
|
||||
*/
|
||||
public async commit(): Promise<string | undefined> {
|
||||
try {
|
||||
console.info(`Creating new checkpoint commit for task ${this.taskId}`)
|
||||
const startTime = performance.now()
|
||||
|
||||
switch (cwd) {
|
||||
case homedir:
|
||||
throw new Error("Cannot use checkpoints in home directory")
|
||||
case desktopPath:
|
||||
throw new Error("Cannot use checkpoints in Desktop directory")
|
||||
case documentsPath:
|
||||
throw new Error("Cannot use checkpoints in Documents directory")
|
||||
case downloadsPath:
|
||||
throw new Error("Cannot use checkpoints in Downloads directory")
|
||||
default:
|
||||
return cwd
|
||||
}
|
||||
}
|
||||
|
||||
private async getShadowGitPath(): Promise<string> {
|
||||
const globalStoragePath = this.providerRef.deref()?.context.globalStorageUri.fsPath
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage uri is invalid")
|
||||
}
|
||||
const checkpointsDir = path.join(globalStoragePath, "tasks", this.taskId, "checkpoints")
|
||||
await fs.mkdir(checkpointsDir, { recursive: true })
|
||||
const gitPath = path.join(checkpointsDir, ".git")
|
||||
return gitPath
|
||||
}
|
||||
|
||||
public static async doesShadowGitExist(taskId: string, provider?: ClineProvider): Promise<boolean> {
|
||||
const globalStoragePath = provider?.context.globalStorageUri.fsPath
|
||||
if (!globalStoragePath) {
|
||||
return false
|
||||
}
|
||||
const gitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
|
||||
return await fileExistsAtPath(gitPath)
|
||||
}
|
||||
|
||||
public async initShadowGit(): Promise<string> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
if (await fileExistsAtPath(gitPath)) {
|
||||
// Make sure it's the same cwd as the configured worktree
|
||||
const worktree = await this.getShadowGitConfigWorkTree()
|
||||
if (worktree !== this.cwd) {
|
||||
throw new Error("Checkpoints can only be used in the original workspace: " + worktree)
|
||||
}
|
||||
|
||||
return gitPath
|
||||
} else {
|
||||
const checkpointsDir = path.dirname(gitPath)
|
||||
const git = simpleGit(checkpointsDir)
|
||||
await git.init()
|
||||
|
||||
await git.addConfig("core.worktree", this.cwd) // sets the working tree to the current workspace
|
||||
|
||||
// Disable commit signing for shadow repo
|
||||
await git.addConfig("commit.gpgSign", "false")
|
||||
|
||||
// Get LFS patterns from workspace if they exist
|
||||
let lfsPatterns: string[] = []
|
||||
try {
|
||||
const attributesPath = path.join(this.cwd, ".gitattributes")
|
||||
if (await fileExistsAtPath(attributesPath)) {
|
||||
const attributesContent = await fs.readFile(attributesPath, "utf8")
|
||||
lfsPatterns = attributesContent
|
||||
.split("\n")
|
||||
.filter((line) => line.includes("filter=lfs"))
|
||||
.map((line) => line.split(" ")[0].trim())
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to read .gitattributes:", error)
|
||||
}
|
||||
|
||||
// Add basic excludes directly in git config, while respecting any .gitignore in the workspace
|
||||
// .git/info/exclude is local to the shadow git repo, so it's not shared with the main repo - and won't conflict with user's .gitignore
|
||||
// TODO: let user customize these
|
||||
const excludesPath = path.join(gitPath, "info", "exclude")
|
||||
await fs.mkdir(path.join(gitPath, "info"), { recursive: true })
|
||||
await fs.writeFile(
|
||||
excludesPath,
|
||||
[
|
||||
".git/", // ignore the user's .git
|
||||
`.git${GIT_DISABLED_SUFFIX}/`, // ignore the disabled nested git repos
|
||||
".DS_Store",
|
||||
"*.log",
|
||||
"node_modules/",
|
||||
"__pycache__/",
|
||||
"env/",
|
||||
"venv/",
|
||||
"target/dependency/",
|
||||
"build/dependencies/",
|
||||
"dist/",
|
||||
"out/",
|
||||
"bundle/",
|
||||
"vendor/",
|
||||
"tmp/",
|
||||
"temp/",
|
||||
"deps/",
|
||||
"pkg/",
|
||||
"Pods/",
|
||||
// Media files
|
||||
"*.jpg",
|
||||
"*.jpeg",
|
||||
"*.png",
|
||||
"*.gif",
|
||||
"*.bmp",
|
||||
"*.ico",
|
||||
// "*.svg",
|
||||
"*.mp3",
|
||||
"*.mp4",
|
||||
"*.wav",
|
||||
"*.avi",
|
||||
"*.mov",
|
||||
"*.wmv",
|
||||
"*.webm",
|
||||
"*.webp",
|
||||
"*.m4a",
|
||||
"*.flac",
|
||||
// Build and dependency directories
|
||||
"build/",
|
||||
"bin/",
|
||||
"obj/",
|
||||
".gradle/",
|
||||
".idea/",
|
||||
".vscode/",
|
||||
".vs/",
|
||||
"coverage/",
|
||||
".next/",
|
||||
".nuxt/",
|
||||
// Cache and temporary files
|
||||
"*.cache",
|
||||
"*.tmp",
|
||||
"*.temp",
|
||||
"*.swp",
|
||||
"*.swo",
|
||||
"*.pyc",
|
||||
"*.pyo",
|
||||
".pytest_cache/",
|
||||
".eslintcache",
|
||||
// Environment and config files
|
||||
".env*",
|
||||
"*.local",
|
||||
"*.development",
|
||||
"*.production",
|
||||
// Large data files
|
||||
"*.zip",
|
||||
"*.tar",
|
||||
"*.gz",
|
||||
"*.rar",
|
||||
"*.7z",
|
||||
"*.iso",
|
||||
"*.bin",
|
||||
"*.exe",
|
||||
"*.dll",
|
||||
"*.so",
|
||||
"*.dylib",
|
||||
// Database files
|
||||
"*.sqlite",
|
||||
"*.db",
|
||||
"*.sql",
|
||||
// Log files
|
||||
"*.logs",
|
||||
"*.error",
|
||||
"npm-debug.log*",
|
||||
"yarn-debug.log*",
|
||||
"yarn-error.log*",
|
||||
...lfsPatterns,
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
// Set up git identity (git throws an error if user.name or user.email is not set)
|
||||
await git.addConfig("user.name", "Cline Checkpoint")
|
||||
await git.addConfig("user.email", "noreply@example.com")
|
||||
|
||||
await this.addAllFiles(git)
|
||||
// Initial commit (--allow-empty ensures it works even with no files)
|
||||
await git.commit("initial commit", { "--allow-empty": null })
|
||||
|
||||
return gitPath
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
console.info(`Using shadow git at: ${gitPath}`)
|
||||
|
||||
await this.gitOperations.addCheckpointFiles(git)
|
||||
|
||||
const commitMessage = "checkpoint-" + this.cwdHash + "-" + this.taskId
|
||||
|
||||
console.info(`Creating checkpoint commit with message: ${commitMessage}`)
|
||||
const result = await git.commit(commitMessage, {
|
||||
"--allow-empty": null,
|
||||
"--no-verify": null,
|
||||
})
|
||||
const commitHash = result.commit || ""
|
||||
console.warn(`Checkpoint commit created.`)
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "commit_created", durationMs)
|
||||
|
||||
return commitHash
|
||||
} catch (error) {
|
||||
console.error("Failed to create checkpoint:", {
|
||||
taskId: this.taskId,
|
||||
error,
|
||||
})
|
||||
throw new Error(`Failed to create checkpoint: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the worktree path from the shadow git configuration.
|
||||
* The worktree path indicates where the shadow git repository is tracking files,
|
||||
* which should match the current workspace directory.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Caches result in lastRetrievedShadowGitConfigWorkTree to avoid repeated reads
|
||||
* - Returns cached value if available
|
||||
* - Reads git config if no cached value exists
|
||||
*
|
||||
* Configuration read:
|
||||
* - Uses simple-git to read core.worktree config
|
||||
* - Operates on shadow git at path from getShadowGitPath()
|
||||
*
|
||||
* @returns Promise<string | undefined> The configured worktree path, or undefined if:
|
||||
* - Shadow git repository doesn't exist
|
||||
* - Config read fails
|
||||
* - No worktree is configured
|
||||
* @throws Error if unable to:
|
||||
* - Access shadow git path
|
||||
* - Initialize simple-git
|
||||
* - Read git configuration
|
||||
*/
|
||||
public async getShadowGitConfigWorkTree(): Promise<string | undefined> {
|
||||
if (this.lastRetrievedShadowGitConfigWorkTree) {
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
}
|
||||
try {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
const worktree = await git.getConfig("core.worktree")
|
||||
this.lastRetrievedShadowGitConfigWorkTree = worktree.value || undefined
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
this.lastRetrievedShadowGitConfigWorkTree = await this.gitOperations.getShadowGitConfigWorkTree(gitPath)
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
} catch (error) {
|
||||
console.error("Failed to get shadow git config worktree:", error)
|
||||
@@ -254,36 +219,34 @@ class CheckpointTracker {
|
||||
}
|
||||
}
|
||||
|
||||
public async commit(): Promise<string | undefined> {
|
||||
try {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
await this.addAllFiles(git)
|
||||
const result = await git.commit("checkpoint", {
|
||||
"--allow-empty": null,
|
||||
})
|
||||
const commitHash = result.commit || ""
|
||||
this.lastCheckpointHash = commitHash
|
||||
return commitHash
|
||||
} catch (error) {
|
||||
console.error("Failed to create checkpoint:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the shadow git repository's HEAD to a specific checkpoint commit.
|
||||
* This will discard all changes after the target commit and restore the
|
||||
* working directory to that checkpoint's state.
|
||||
*
|
||||
* Dependencies:
|
||||
* - Requires initialized shadow git (getShadowGitPath)
|
||||
* - Must be called with a valid commit hash from this task's history
|
||||
*
|
||||
* @param commitHash - The hash of the checkpoint commit to reset to
|
||||
* @returns Promise<void> Resolves when reset is complete
|
||||
* @throws Error if unable to:
|
||||
* - Access shadow git path
|
||||
* - Initialize simple-git
|
||||
* - Reset to target commit
|
||||
*/
|
||||
public async resetHead(commitHash: string): Promise<void> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
console.info(`Resetting to checkpoint: ${commitHash}`)
|
||||
const startTime = performance.now()
|
||||
|
||||
// Clean working directory and force reset
|
||||
// This ensures that the operation will succeed regardless of:
|
||||
// - Untracked files in the workspace
|
||||
// - Staged changes
|
||||
// - Unstaged changes
|
||||
// - Partial commits
|
||||
// - Merge conflicts
|
||||
await git.clean("f", ["-d", "-f"]) // Remove untracked files and directories
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
console.debug(`Using shadow git at: ${gitPath}`)
|
||||
await git.reset(["--hard", commitHash]) // Hard reset to target commit
|
||||
console.debug(`Successfully reset to checkpoint: ${commitHash}`)
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "restored", durationMs)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -300,7 +263,7 @@ class CheckpointTracker {
|
||||
* @returns Array of file changes with before/after content
|
||||
*/
|
||||
public async getDiffSet(
|
||||
lhsHash?: string,
|
||||
lhsHash: string,
|
||||
rhsHash?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
@@ -310,46 +273,40 @@ class CheckpointTracker {
|
||||
after: string
|
||||
}>
|
||||
> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const startTime = performance.now()
|
||||
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
// If lhsHash is missing, use the initial commit of the repo
|
||||
let baseHash = lhsHash
|
||||
if (!baseHash) {
|
||||
const rootCommit = await git.raw(["rev-list", "--max-parents=0", "HEAD"])
|
||||
baseHash = rootCommit.trim()
|
||||
}
|
||||
console.info(`Getting diff between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
|
||||
|
||||
// Stage all changes so that untracked files appear in diff summary
|
||||
await this.addAllFiles(git)
|
||||
await this.gitOperations.addCheckpointFiles(git)
|
||||
|
||||
const diffSummary = rhsHash ? await git.diffSummary([`${baseHash}..${rhsHash}`]) : await git.diffSummary([baseHash])
|
||||
const diffRange = rhsHash ? `${lhsHash}..${rhsHash}` : lhsHash
|
||||
console.info(`Diff range: ${diffRange}`)
|
||||
const diffSummary = await git.diffSummary([diffRange])
|
||||
|
||||
// For each changed file, gather before/after content
|
||||
const result = []
|
||||
const cwdPath = (await this.getShadowGitConfigWorkTree()) || this.cwd || ""
|
||||
|
||||
for (const file of diffSummary.files) {
|
||||
const filePath = file.file
|
||||
const absolutePath = path.join(cwdPath, filePath)
|
||||
const absolutePath = path.join(this.cwd, filePath)
|
||||
|
||||
let beforeContent = ""
|
||||
try {
|
||||
beforeContent = await git.show([`${baseHash}:${filePath}`])
|
||||
beforeContent = await git.show([`${lhsHash}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in older commit => remains empty
|
||||
}
|
||||
|
||||
let afterContent = ""
|
||||
if (rhsHash) {
|
||||
// if user provided a newer commit, use git.show at that commit
|
||||
try {
|
||||
afterContent = await git.show([`${rhsHash}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in newer commit => remains empty
|
||||
}
|
||||
} else {
|
||||
// otherwise, read from disk (includes uncommitted changes)
|
||||
try {
|
||||
afterContent = await fs.readFile(absolutePath, "utf8")
|
||||
} catch (_) {
|
||||
@@ -365,56 +322,39 @@ class CheckpointTracker {
|
||||
})
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "diff_generated", durationMs)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async addAllFiles(git: SimpleGit) {
|
||||
await this.renameNestedGitRepos(true)
|
||||
try {
|
||||
await git.add(".")
|
||||
} catch (error) {
|
||||
console.error("Failed to add files to git:", error)
|
||||
} finally {
|
||||
await this.renameNestedGitRepos(false)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Returns the number of files changed between two commits.
|
||||
*
|
||||
* @param lhsHash - The commit to compare from (older commit)
|
||||
* @param rhsHash - The commit to compare to (newer commit).
|
||||
* If omitted, we compare to the working directory.
|
||||
* @returns The number of files changed between the commits
|
||||
*/
|
||||
public async getDiffCount(lhsHash: string, rhsHash?: string): Promise<number> {
|
||||
const startTime = performance.now()
|
||||
|
||||
// Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's requirement of using submodules for nested repos.
|
||||
private async renameNestedGitRepos(disable: boolean) {
|
||||
// Find all .git directories that are not at the root level
|
||||
const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
|
||||
cwd: this.cwd,
|
||||
onlyDirectories: true,
|
||||
ignore: [".git"], // Ignore root level .git
|
||||
dot: true,
|
||||
markDirectories: false,
|
||||
})
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
// For each nested .git directory, rename it based on operation
|
||||
for (const gitPath of gitPaths) {
|
||||
const fullPath = path.join(this.cwd, gitPath)
|
||||
let newPath: string
|
||||
if (disable) {
|
||||
newPath = fullPath + GIT_DISABLED_SUFFIX
|
||||
} else {
|
||||
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
|
||||
}
|
||||
console.info(`Getting diff count between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
|
||||
|
||||
try {
|
||||
await fs.rename(fullPath, newPath)
|
||||
console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
|
||||
} catch (error) {
|
||||
console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stage all changes so that untracked files appear in diff summary
|
||||
await this.gitOperations.addCheckpointFiles(git)
|
||||
|
||||
public dispose() {
|
||||
this.disposables.forEach((d) => d.dispose())
|
||||
this.disposables = []
|
||||
const diffRange = rhsHash ? `${lhsHash}..${rhsHash}` : lhsHash
|
||||
const diffSummary = await git.diffSummary([diffRange])
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "diff_generated", durationMs)
|
||||
|
||||
return diffSummary.files.length
|
||||
}
|
||||
}
|
||||
|
||||
const GIT_DISABLED_SUFFIX = "_disabled"
|
||||
|
||||
export default CheckpointTracker
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { mkdir } from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import os from "os"
|
||||
|
||||
/**
|
||||
* Gets the path to the shadow Git repository in globalStorage.
|
||||
*
|
||||
* Checkpoints path structure:
|
||||
* globalStorage/
|
||||
* checkpoints/
|
||||
* {cwdHash}/
|
||||
* .git/
|
||||
*
|
||||
* @param globalStoragePath - The VS Code global storage path
|
||||
* @param taskId - The ID of the task
|
||||
* @param cwdHash - Hash of the working directory path
|
||||
* @returns Promise<string> The absolute path to the shadow git directory
|
||||
* @throws Error if global storage path is invalid
|
||||
*/
|
||||
export async function getShadowGitPath(globalStoragePath: string, taskId: string, cwdHash: string): Promise<string> {
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage uri is invalid")
|
||||
}
|
||||
const checkpointsDir = path.join(globalStoragePath, "checkpoints", cwdHash)
|
||||
await mkdir(checkpointsDir, { recursive: true })
|
||||
const gitPath = path.join(checkpointsDir, ".git")
|
||||
return gitPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current working directory from the VS Code workspace.
|
||||
* Validates that checkpoints are not being used in protected directories
|
||||
* like home, Desktop, Documents, or Downloads.
|
||||
*
|
||||
* Protected directories:
|
||||
* - User's home directory
|
||||
* - Desktop
|
||||
* - Documents
|
||||
* - Downloads
|
||||
*
|
||||
* @returns Promise<string> The absolute path to the current working directory
|
||||
* @throws Error if no workspace is detected or if in a protected directory
|
||||
*/
|
||||
export async function getWorkingDirectory(): Promise<string> {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
if (!cwd) {
|
||||
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
|
||||
}
|
||||
const homedir = os.homedir()
|
||||
const desktopPath = path.join(homedir, "Desktop")
|
||||
const documentsPath = path.join(homedir, "Documents")
|
||||
const downloadsPath = path.join(homedir, "Downloads")
|
||||
|
||||
switch (cwd) {
|
||||
case homedir:
|
||||
throw new Error("Cannot use checkpoints in home directory")
|
||||
case desktopPath:
|
||||
throw new Error("Cannot use checkpoints in Desktop directory")
|
||||
case documentsPath:
|
||||
throw new Error("Cannot use checkpoints in Documents directory")
|
||||
case downloadsPath:
|
||||
throw new Error("Cannot use checkpoints in Downloads directory")
|
||||
default:
|
||||
return cwd
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashes the current working directory to a 13-character numeric hash.
|
||||
* @param workingDir - The absolute path to the working directory
|
||||
* @returns A 13-character numeric hash string used to identify the workspace
|
||||
* @throws {Error} If the working directory path is empty or invalid
|
||||
*/
|
||||
export function hashWorkingDir(workingDir: string): string {
|
||||
if (!workingDir) {
|
||||
throw new Error("Working directory path cannot be empty")
|
||||
}
|
||||
let hash = 0
|
||||
for (let i = 0; i < workingDir.length; i++) {
|
||||
hash = (hash * 31 + workingDir.charCodeAt(i)) >>> 0
|
||||
}
|
||||
const bigHash = BigInt(hash)
|
||||
const numericHash = bigHash.toString().slice(0, 13)
|
||||
return numericHash
|
||||
}
|
||||
@@ -158,7 +158,6 @@ export class DiffViewProvider {
|
||||
await updatedDocument.save()
|
||||
}
|
||||
|
||||
// await delay(100)
|
||||
// get text after save in case there is any auto-formatting done by the editor
|
||||
const postSaveContent = updatedDocument.getText()
|
||||
|
||||
|
||||
@@ -35,21 +35,26 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi
|
||||
})
|
||||
|
||||
if (saveUri) {
|
||||
// Write content to the selected location
|
||||
await vscode.workspace.fs.writeFile(saveUri, Buffer.from(markdownContent))
|
||||
vscode.window.showTextDocument(saveUri, { preview: true })
|
||||
try {
|
||||
// Write content to the selected location
|
||||
await vscode.workspace.fs.writeFile(saveUri, new TextEncoder().encode(markdownContent))
|
||||
vscode.window.showTextDocument(saveUri, { preview: true })
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to save markdown file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function formatContentBlockToMarkdown(
|
||||
block: Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam,
|
||||
// messages: Anthropic.MessageParam[]
|
||||
): string {
|
||||
export function formatContentBlockToMarkdown(block: Anthropic.ContentBlockParam): string {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return block.text
|
||||
case "image":
|
||||
return `[Image]`
|
||||
case "document":
|
||||
return `[Document]`
|
||||
case "tool_use":
|
||||
let input: string
|
||||
if (typeof block.input === "object" && block.input !== null) {
|
||||
@@ -61,32 +66,16 @@ export function formatContentBlockToMarkdown(
|
||||
}
|
||||
return `[Tool Use: ${block.name}]\n${input}`
|
||||
case "tool_result":
|
||||
// For now we're not doing tool name lookup since we don't use tools anymore
|
||||
// const toolName = findToolName(block.tool_use_id, messages)
|
||||
const toolName = "Tool"
|
||||
if (typeof block.content === "string") {
|
||||
return `[${toolName}${block.is_error ? " (Error)" : ""}]\n${block.content}`
|
||||
return `[Tool${block.is_error ? " (Error)" : ""}]\n${block.content}`
|
||||
} else if (Array.isArray(block.content)) {
|
||||
return `[${toolName}${block.is_error ? " (Error)" : ""}]\n${block.content
|
||||
return `[Tool${block.is_error ? " (Error)" : ""}]\n${block.content
|
||||
.map((contentBlock) => formatContentBlockToMarkdown(contentBlock))
|
||||
.join("\n")}`
|
||||
} else {
|
||||
return `[${toolName}${block.is_error ? " (Error)" : ""}]`
|
||||
return `[Tool${block.is_error ? " (Error)" : ""}]`
|
||||
}
|
||||
default:
|
||||
return "[Unexpected content type]"
|
||||
}
|
||||
}
|
||||
|
||||
export function findToolName(toolCallId: string, messages: Anthropic.MessageParam[]): string {
|
||||
for (const message of messages) {
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "tool_use" && block.id === toolCallId) {
|
||||
return block.name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Unknown Tool"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user