Compare commits

..
Author SHA1 Message Date
leokun 75b7ea9cc8 chore(release): bump desktop to 0.1.6 2026-09-02 14:03:35 +08:00
leokun 0fd5e9d6f2 fix(desktop): update content security policy to allow HTTPS images
- Modified the content security policy in `tauri.conf.json` to include `https:` in the `img-src` directive, enhancing security by allowing images from secure sources.
2026-09-02 13:56:28 +08:00
leookun ddaa61c827 fix(desktop): update Windows executable in place 2026-09-02 13:02:11 +08:00
leookun 919c1d8032 fix: update ADS_ENDPOINT to production URL
- Changed the `ADS_ENDPOINT` from a local server URL to the production URL for ads.
- Commented out the local server URL for clarity and future reference.
2026-09-02 12:49:57 +08:00
leokun 669f129dcd Merge branch 'main' of github.com:leookun/cursor-byok 2026-09-02 11:03:41 +08:00
leookun f22c7b6680 feat: integrate app version into control service and update ads endpoint
- Added `app_version` field to `ControlService` and updated its initialization to include the app version.
- Modified the `ADS_ENDPOINT` to point to a local server for development purposes.
- Refactored conversation command and output handling to utilize a new `RunFinish` enum for better state management.
- Enhanced the conversation runtime to handle queued user messages after a turn has ended, ensuring smooth transitions between turns.
- Added tests to validate the new behavior of queued messages and transport handling.
2026-09-02 10:23:50 +08:00
leookun 5cdf642dd1 feat: enhance bidi request handling and observability tracing
- Updated the `append` function to include a flag for replacing closing requests, improving request handling.
- Refactored the `run_sse_handler` and `bidi_handler` functions to utilize a new tracing mechanism, enhancing observability.
- Introduced a new `trace_outcome` function to standardize tracing outcomes for requests.
- Removed the `CursorTraceRecorder` in favor of a new `CursorTraceService` for better performance and non-blocking behavior.
- Added tests to validate the new tracing functionality and ensure correct behavior during request processing.
2026-09-02 01:04:54 +08:00
leokun 2dad593263 feat: add resource limits management and network client integration
- Introduced a new module for managing process resource limits, specifically for raising the open file limit on Unix systems.
- Added a `NetworkClients` struct to handle reusable outbound HTTP clients, improving network request management.
- Updated various components, including `ControlService` and `CursorProxy`, to utilize the new network client structure for better client handling.
- Enhanced the API router to accept network clients, ensuring consistent client usage across different services.
- Added tests to validate the integration of network clients and resource limits functionality.
2026-09-01 21:03:02 +08:00
leokun 76417e005b feat: add usage snapshot event and enhance compaction logic
- Introduced `UsageSnapshot` event to track token usage during conversation runs.
- Updated `RunEngine` to emit usage snapshots, providing better visibility into token consumption.
- Refactored compaction logic to utilize a new `compaction_estimate` function for improved token budget management.
- Added tests to validate timeout constants for blob synchronization and ensure correct behavior of usage tracking during compaction.
2026-09-01 20:04:35 +08:00
leokun e768980dad feat: enhance reasoning replay functionality and integrate call recording
- Added a new test to validate the projection of reasoning response items to valid input items in the Codex API.
- Introduced `CallRecorder` to track network requests and responses during plugin interactions.
- Updated the `PluginRegistry` and `PluginWorker` to support call recording, ensuring that reasoning items are correctly processed and recorded.
- Refactored the `responses_input` function to handle reasoning items more effectively, improving the overall response handling logic.
2026-09-01 17:43:36 +08:00
leokun 2c63bd845a feat: track interaction events during automatic compaction
- Added tracking for interaction events in the `Output` struct, including `summary_started` and `token_delta`.
- Updated the `run` function to push relevant interaction events to the `interaction_events` vector.
- Enhanced the automatic compaction test to verify the immediate reset of cursor usage and the correct logging of interaction events.
2026-09-01 16:54:45 +08:00
leokun 6e74637c69 Merge branch 'main' of github.com:leookun/cursor-byok 2026-09-01 16:08:34 +08:00
leookun d004139526 feat: implement context usage anchor for improved token estimation
- Introduced `ContextUsageAnchor` struct to track context input tokens and message count for conversations.
- Updated token estimation functions to utilize the context usage anchor, enhancing accuracy in estimating tokens for projected messages.
- Refactored compaction logic to incorporate context usage anchor, allowing for more efficient management of token budgets during model runs.
- Added tests to validate the behavior of the context usage anchor across different scenarios, including model switching and message additions.
2026-09-01 16:07:51 +08:00
leokun 8c6c415a84 feat: enhance token usage merging and total token calculation
- Updated the `merge_usage` function to include `total_tokens` in the usage merging process.
- Implemented logic to calculate `total_tokens` based on the sum of `context_input_tokens` and `output_tokens`.
- Added a new test to verify that streamed usage correctly includes cached input in the total token count.
2026-09-01 11:13:00 +08:00
leokun d83e14af9a refactor: remove retry_count from ProviderConfig and enhance error handling in tool execution
- Removed the `retry_count` field from `ProviderConfig` as it is no longer needed.
- Introduced `argument_error` field in `ToolCall` to capture errors related to tool arguments.
- Updated various components to handle argument errors more gracefully, including in the `ToolDispatcher` and `ConversationOutput`.
- Enhanced tests to validate the new error handling and ensure proper functionality of tool calls.
2026-09-01 10:14:53 +08:00
leokun 29fde7d7c7 feat: enhance context token estimation and compaction logic
- Added `estimate_context_tokens` function to calculate provider-visible context size based on prompt specifications and projected messages.
- Updated `CheckpointBuilder` to record estimated context tokens during message processing.
- Refactored compaction logic to utilize the new token estimation, ensuring proper context management during model runs.
- Introduced tests to validate context estimation and compaction behavior under various scenarios.
2026-09-01 10:10:09 +08:00
leokun ee2592c469 Merge branch 'main' of github.com:leookun/cursor-byok 2026-08-31 16:15:58 +08:00
leokun 49c1fb6378 feat: add Task tool functionality
- Introduced a new `Task` presentation type in the `ToolCallStream` to handle task-related projections.
- Implemented the `TaskProjection` struct with fields for description, prompt, subagent type, model, resume, and environment.
- Added a `project` method to `TaskProjection` to process task-related events and generate interaction updates.
- Created a `task_partial` function to format task updates for the agent server message.
- Included unit tests to verify the correct behavior of task description projections.
2026-08-31 16:15:42 +08:00
leokun 788868f8b9 Merge pull request #385 from kevin9327/fix/runtime-user-message-injection-leak
fix(conversation): clear pending runtime user-message injections
2026-08-31 16:14:27 +08:00
leokun 4c3fe230ce Merge remote-tracking branch 'origin/main' into pr-385-merge
# Conflicts:
#	server/tests/interrupt.rs
2026-08-31 16:12:11 +08:00
leokun ac14245d19 Merge pull request #383 from kevin9327/fix/editnotebook-empty-old-string
fix(tools): reject empty old_string in EditNotebook
2026-08-31 16:06:42 +08:00
leokun 84addec26a Merge pull request #384 from kevin9327/fix/bash-shell-alias
fix(tools): complete the bash shell alias in the tool codec
2026-08-31 16:06:18 +08:00
leokun 5de547041c Merge branch 'main' of github.com:leookun/cursor-byok 2026-08-31 15:38:11 +08:00
leokun 8bd0d70add fix: plugin effort compress 2026-08-31 15:38:02 +08:00
leokun 3ac4402a86 Update cursor.md 2026-08-31 15:18:43 +08:00
leokun e535a98945 Update cursor.md 2026-08-31 15:10:37 +08:00
leokun 45e694fd63 Merge pull request #386 from kevin9327/fix/empty-tool-arguments
fix: handle tool calls with empty arguments
2026-08-31 13:57:45 +08:00
leookun 9120b90be7 chore: remove deprecated server_backup files
- Deleted unused build script, Cargo.toml, and migration files to clean up the project structure.
- Removed prompt files related to cursor tools and agent modes to streamline the codebase.
- This cleanup helps improve maintainability and reduces clutter in the repository.
2026-08-30 23:54:19 +08:00
leookun b807608bf3 chore(release): bump desktop to v0.1.5 2026-08-30 23:38:50 +08:00
leookun e7a1cca4c6 feat: add group name functionality to models
- Introduced a new `group_name` field in the model configuration to allow for custom provider-group display names.
- Updated the `CursorModelCards`, `CursorModelEditor`, and `CursorSettingsPage` components to support group settings.
- Enhanced the UI to include group settings options, allowing users to modify group names and associated configurations.
- Added localization strings for new group settings features in both English and Chinese.
- Implemented a database migration to add the `group_name` column to the model configurations.
2026-08-30 23:28:05 +08:00
leookun 76baa3b0e7 Merge branch 'feat/plugin' 2026-08-30 22:19:55 +08:00
leookun 21048fb34b feat: add Grok authentication plugin with OAuth support
- Introduced a new Grok authentication plugin, including essential files such as main.ts, provider.ts, and resources.ts.
- Implemented OAuth2 device authorization flow in oauth.ts, allowing users to sign in with xAI.
- Added model discovery and quota management functionalities in models.ts and resources.ts.
- Created a JSON configuration file (plugin.json) for plugin metadata and permissions.
- Included SVG asset for the Grok icon.
- Developed comprehensive tests in grok_test.ts to ensure functionality and reliability of the plugin.
2026-08-30 22:19:21 +08:00
leokun fc79adbb43 Merge pull request #382 from leookun/feat/plugin
feat: add plugin mode
2026-08-30 21:29:54 +08:00
leookun 3184615719 refactor: improve file writing and error handling in PluginDataStore
- Replaced asynchronous file operations with a synchronous approach to ensure file handles are properly closed before replacement.
- Introduced a new `write_once` function for atomic file writing, including directory creation, temporary file writing, and target file replacement.
- Enhanced error handling to retry on transient errors specific to Windows, improving robustness during file operations.
- Updated the `clear` method in `PluginRegistry` to ensure OAuth sessions are only removed after successful persistence of resources.
2026-08-30 21:22:27 +08:00
leookun 5e405b31b2 feat: add copy button functionality to OAuthMethodCard
- Implemented a new button to copy the user code in the OAuthMethodCard, enhancing user experience.
- Added styles for the copy button to match the UI design.
- Updated localization files to include new strings for the copy action in both English and Chinese.
2026-08-30 21:15:03 +08:00
leookun 3a2d47954e refactor: improve error handling and logging in plugin and account services
- Added detailed error messages for plugin data read/write failures, including file paths for better debugging.
- Updated logging levels for upstream request rejections in account services to debug for less critical issues.
- Enhanced error handling in the plugin worker to provide clearer context when starting the plugin worker fails.
- Introduced new functions for merging extra parameters and applying body allowlists in provider services, improving request validation.
2026-08-30 21:10:08 +08:00
leookun 43a18377b6 feat: enhance progress bar UI in PluginManagementPage
- Replaced the native progress element with a custom-styled progress bar for improved visual feedback during downloads.
- Added new styles for progress bar and fill animations to enhance user experience.
- Updated the component to use ARIA roles for better accessibility.
2026-08-30 20:58:42 +08:00
leookun 453c120740 fix: plugin in windows 2026-08-30 20:57:02 +08:00
leookun a5bbe67845 refactor: replace Button with TruncatedButton in CursorModelCards and PluginManagementPage
- Updated the UI components in CursorModelCards and PluginManagementPage to use TruncatedButton for better text handling and display.
- Adjusted styles in CursorSettings and PluginManagementPage to ensure proper button layout and responsiveness.
- Added new ActionMenu component for handling additional actions in PluginManagementPage.
- Enhanced localization files to include new strings for the ActionMenu and TruncatedButton components.
2026-08-30 20:45:27 +08:00
leookun 05181f9e8a Merge branch 'main' into feat/plugin 2026-08-30 20:01:41 +08:00
leookun 4373507571 feat: add provider stream idle timeout and error handling
- Introduced a new `provider_stream_idle_timeout` configuration to manage idle timeouts for provider streams.
- Enhanced error handling in the `ProviderRouter` to include specific timeout errors for both request and stream idle scenarios.
- Updated the `AnthropicProvider` and `OpenAiChatProvider` to utilize the new error handling functions for improved SSE error reporting.
- Added tests to verify the correct behavior of timeout handling and error extraction from provider events.
2026-08-30 20:01:08 +08:00
leookun e6130e01a7 feat: plugin system 2026-08-30 20:00:53 +08:00
kevin9327andClaude Opus 4.8 e673a034df fix: handle tool calls with empty arguments
A tool call that carries no arguments streams no argument text, so
`arguments_text` is empty and `from_str("")` fails with `EOF while parsing
a value`, aborting the whole run. The model cycle already guards this, but
two other consumers did not:

- `ConversationOutput` re-parses the streamed text on `ToolCallEnd`; and
- `create_tool_round` stored the empty text verbatim in the
  `arguments_json` column, so re-loading the round (`commit_tool_result`
  and the round loader) then failed on `from_str("")`.

Treat empty argument text as an empty object in the output projection, and
persist `{}` for it so the `arguments_json` column always holds valid JSON.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 19:38:39 +09:00
kevin9327andClaude Opus 4.8 6ac666da4f fix(conversation): clear pending runtime user-message injections
Runtime user messages and context injections both queue into
`pending_injections`, but with different keys: injections use the raw
injection id (committed under `inject-context:{id}`) while user messages
use the full `user-message:{id}` event id. The commit-correlation handler
only stripped the `inject-context:` prefix, so a user message's entry was
never removed.

Consequences:
- the client never received `ContextInjectionDelivered` /
  `UserMessageAppended` for the message; and
- `pending_injections` stayed non-empty, so every later `ExecuteToolRound`
  was detached without dispatching its tools and `tool_round::execute`
  blocked forever -- a hung turn whenever the model made a tool call after
  the interruption.

Derive the lookup key by stripping the injection prefix when present and
otherwise using the event id verbatim, so both kinds are cleared and their
delivered/appended events fire.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 19:23:57 +09:00
kevin9327andClaude Opus 4.8 9e2d22418b fix(tools): complete the bash shell alias in the tool codec
The tool dispatcher already treats `bash`/`Bash` as an alias of `Shell`
(routing, `is_shell_tool`, and `block_until_ms` normalization), but the
codec only matched `shell`:

- `tool_placeholder` returned `unsupported tool: bash`, which aborts the
  turn while streaming the tool call, before it ever runs;
- `request` returned `tool bash is not executed through ExecServerMessage`
  (after already reserving an exec slot); and
- `stream_closed` built its shell-specific error result only for `Shell`.

Anthropic models frequently emit `Bash` even when the tool is advertised
as `Shell`, so the alias must hold across the codec. Match `bash` wherever
the codec special-cases `shell`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 19:15:29 +09:00
kevin9327andClaude Opus 4.8 97ee138de8 fix(tools): reject empty old_string in EditNotebook
StrReplace rejects an empty `old_string`, but the EditNotebook cell-edit
path did not. Because `str::match_indices("")` matches at every byte
boundary, editing a non-empty cell with an empty `old_string` failed with
a misleading "old_string is not unique in the notebook cell; found N
occurrences" error, and editing an empty cell silently prepended
`new_string`.

Add the same guard StrReplace already uses so both edit tools reject an
empty `old_string` consistently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 19:07:28 +09:00
leookun 1609b57433 plugin runtime 2026-08-30 13:35:03 +08:00
leookun e5bfcdd202 feat: start plugin mode 2026-08-30 12:34:28 +08:00
leokun 76003a907a Merge pull request #381 from leookun/feature/conversation-architecture
Feature/conversation architecture
2026-08-30 12:26:43 +08:00
390 changed files with 20472 additions and 49520 deletions
@@ -0,0 +1,71 @@
import { readFile, writeFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { pathToFileURL } from "node:url";
function readOptions(args) {
const options = new Map();
for (let index = 0; index < args.length; index += 2) {
const name = args[index];
const value = args[index + 1];
if (!name?.startsWith("--") || value === undefined) {
throw new Error(`invalid argument near ${name ?? "end of command"}`);
}
options.set(name.slice(2), value);
}
return options;
}
function required(options, name) {
const value = options.get(name)?.trim();
if (!value) throw new Error(`--${name} is required`);
return value;
}
export function generatePortableUpdate({ version, repository, assetName, signature }) {
const normalizedVersion = version.replace(/^v/, "");
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(normalizedVersion)) {
throw new Error(`invalid semantic version: ${normalizedVersion}`);
}
if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) {
throw new Error(`invalid GitHub repository: ${repository}`);
}
if (!assetName || basename(assetName) !== assetName) {
throw new Error("asset name must be a file name");
}
if (!signature.trim()) throw new Error("signature is required");
return {
version: normalizedVersion,
notes: `Cursor BYOK v${normalizedVersion}`,
pub_date: new Date().toISOString(),
platforms: {
"windows-x86_64": {
signature: signature.trim(),
url: `https://github.com/${repository}/releases/download/v${normalizedVersion}/${encodeURIComponent(assetName)}`,
},
},
};
}
async function main() {
const options = readOptions(process.argv.slice(2));
const version = required(options, "version");
const repository = required(options, "repository");
const asset = required(options, "asset");
const signaturePath = resolve(required(options, "signature"));
const output = resolve(required(options, "output"));
const manifest = generatePortableUpdate({
version,
repository,
assetName: basename(asset),
signature: await readFile(signaturePath, "utf8"),
});
await writeFile(output, `${JSON.stringify(manifest, null, 2)}\n`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import test from "node:test";
import { generatePortableUpdate } from "./generate-portable-update.mjs";
test("generates a signed Windows portable updater manifest", () => {
const manifest = generatePortableUpdate({
version: "v1.2.3-beta.1",
repository: "owner/repository",
assetName: "cursor-byok-1.2.3-beta.1-windows-amd64.zip",
signature: "signed-payload\n",
});
assert.equal(manifest.version, "1.2.3-beta.1");
assert.deepEqual(Object.keys(manifest.platforms), ["windows-x86_64"]);
assert.equal(manifest.platforms["windows-x86_64"].signature, "signed-payload");
assert.equal(
manifest.platforms["windows-x86_64"].url,
"https://github.com/owner/repository/releases/download/v1.2.3-beta.1/cursor-byok-1.2.3-beta.1-windows-amd64.zip",
);
});
test("rejects invalid inputs", () => {
assert.throws(() => generatePortableUpdate({
version: "latest",
repository: "owner/repository",
assetName: "update.zip",
signature: "signature",
}), /semantic version/);
assert.throws(() => generatePortableUpdate({
version: "1.2.3",
repository: "owner/repository",
assetName: "../update.zip",
signature: "signature",
}), /file name/);
assert.throws(() => generatePortableUpdate({
version: "1.2.3",
repository: "owner/repository",
assetName: "update.zip",
signature: " ",
}), /signature/);
});
+29 -2
View File
@@ -158,14 +158,27 @@ jobs:
mkdir -p legacy-update
tar -czf "legacy-update/cursor-byok-${VERSION}-linux-amd64.tar.gz" -C target/release cursor-byok-desktop
- name: Package legacy Windows updater asset
- name: Package and sign legacy Windows updater asset
if: matrix.platform == 'windows-x86_64'
shell: pwsh
env:
VERSION: ${{ needs.prepare.outputs.version }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
New-Item -ItemType Directory -Force legacy-update | Out-Null
Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip"
$asset = "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip"
Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath $asset
Push-Location apps/desktop
npm exec tauri signer sign -- "../../$asset"
Pop-Location
$entries = @(tar -tf $asset)
if ($entries.Count -ne 1 -or [System.IO.Path]::GetFileName($entries[0]) -ne 'cursor-byok-desktop.exe') {
throw "Windows updater archive must contain only cursor-byok-desktop.exe"
}
if (!(Test-Path "$asset.sig")) {
throw "Windows updater archive signature was not generated"
}
- name: Package legacy macOS updater asset
if: contains(matrix.platform, 'macos')
@@ -213,6 +226,20 @@ jobs:
--output legacy-update/update.json \
--notes "Cursor BYOK v${VERSION}"
- name: Generate signed Windows portable update manifest
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
asset="cursor-byok-${VERSION}-windows-amd64.zip"
test -f "legacy-update/${asset}"
test -f "legacy-update/${asset}.sig"
node .github/scripts/generate-portable-update.mjs \
--version "${VERSION}" \
--repository "${GITHUB_REPOSITORY}" \
--asset "${asset}" \
--signature "legacy-update/${asset}.sig" \
--output legacy-update/portable-latest.json
- name: Normalize Tauri updater download URLs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Generated
+27 -1
View File
@@ -1172,10 +1172,11 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "cursor-byok-desktop"
version = "0.1.5-beta.1"
version = "0.1.6"
dependencies = [
"axum",
"cursor-server",
"libc",
"rfd",
"serde",
"serde_json",
@@ -1187,12 +1188,15 @@ dependencies = [
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tempfile",
"tokio",
"tokio-util",
"tracing",
"tracing-appender",
"tracing-subscriber",
"url",
"windows-sys 0.61.2",
"zip",
]
[[package]]
@@ -1248,6 +1252,7 @@ dependencies = [
"uuid",
"windows-sys 0.61.2",
"x509-parser",
"zip",
]
[[package]]
@@ -1929,6 +1934,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
@@ -9079,16 +9085,36 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap 2.14.0",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.5-beta.1",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-byok-desktop",
"version": "0.1.5-beta.1",
"version": "0.1.6",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.5-beta.1",
"version": "0.1.6",
"description": "Cursor BYOK desktop management application",
"type": "module",
"scripts": {
@@ -8,7 +8,7 @@
"dev": "vite",
"typecheck": "tsc --noEmit",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
"i18n:scan": "STATIC_I18N_SCAN=true vite build",
"i18n:scan": "cross-env STATIC_I18N_SCAN=true vite build",
"build": "vite build",
"build:demo": "npm run typecheck && npm run typecheck:node && vite build --config vite.demo.config.ts",
"check": "npm run typecheck && npm run typecheck:node && npm run build",
+7 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cursor-byok-desktop"
version = "0.1.5-beta.1"
version = "0.1.6"
edition = "2021"
publish = false
@@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
axum = "0.8"
cursor-server = { path = "../../../server" }
libc = "0.2"
rfd = "0.15"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -24,9 +25,14 @@ tauri-plugin-opener = "2"
tauri-plugin-autostart = "2"
tauri-plugin-process = "2"
tauri-plugin-updater = "2"
tempfile = "3"
tokio = { version = "1", features = ["time"] }
tokio-util = "0.7"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
zip = { version = "4", default-features = false, features = ["deflate"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading"] }
+5 -1
View File
@@ -1,5 +1,9 @@
fn main() {
let manifest = tauri_build::AppManifest::new().commands(&["open_terminal_with_command"]);
let manifest = tauri_build::AppManifest::new().commands(&[
"open_terminal_with_command",
"check_portable_update",
"install_portable_update",
]);
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest))
.expect("failed to build Tauri application")
}
@@ -18,6 +18,8 @@
"core:window:allow-close",
"core:app:allow-set-dock-visibility",
"allow-open-terminal-with-command",
"allow-check-portable-update",
"allow-install-portable-update",
"clipboard-manager:allow-write-text",
"autostart:default",
"process:allow-restart",
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-check-portable-update"
description = "Enables the check_portable_update command without any pre-configured scope."
commands.allow = ["check_portable_update"]
[[permission]]
identifier = "deny-check-portable-update"
description = "Denies the check_portable_update command without any pre-configured scope."
commands.deny = ["check_portable_update"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-install-portable-update"
description = "Enables the install_portable_update command without any pre-configured scope."
commands.allow = ["install_portable_update"]
[[permission]]
identifier = "deny-install-portable-update"
description = "Denies the install_portable_update command without any pre-configured scope."
commands.deny = ["install_portable_update"]
+29 -2
View File
@@ -149,6 +149,23 @@ pub fn run() -> ExitCode {
return ExitCode::FAILURE;
}
};
#[cfg(unix)]
{
let open_file_limit = match crate::resource_limits::raise_open_file_limit() {
Ok(limit) => limit,
Err(error) => {
diagnostics.report_fatal(&error);
return ExitCode::FAILURE;
}
};
tracing::info!(
requested = crate::resource_limits::REQUESTED_OPEN_FILE_LIMIT,
previous = open_file_limit.previous,
effective = open_file_limit.effective,
hard = open_file_limit.hard,
"open file limit configured"
);
}
tracing::info!(
version = env!("CARGO_PKG_VERSION"),
os = std::env::consts::OS,
@@ -160,7 +177,11 @@ pub fn run() -> ExitCode {
let started_by_autostart = std::env::args_os().any(|arg| arg == AUTOSTART_ARG);
let app = tauri::Builder::default()
.invoke_handler(tauri::generate_handler![open_terminal_with_command])
.invoke_handler(tauri::generate_handler![
open_terminal_with_command,
crate::update::check_portable_update,
crate::update::install_portable_update,
])
.plugin(tauri_plugin_single_instance::init(|app, args, _| {
if !args.iter().any(|arg| arg == AUTOSTART_ARG) {
tray::show_main_window(app);
@@ -175,7 +196,12 @@ pub fn run() -> ExitCode {
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec![AUTOSTART_ARG]),
))?;
let config = Config::desktop()?;
let config = {
let mut config = Config::desktop()?;
// 插件的 minAppVersion 按桌面应用版本判定,而不是内嵌 server 库的版本。
config.app_version = env!("CARGO_PKG_VERSION").into();
config
};
#[cfg(dev)]
let config = {
let mut config = config;
@@ -223,6 +249,7 @@ pub fn run() -> ExitCode {
window.set_focus()?;
}
tray::create(app)?;
crate::update::signal_ready_if_requested()?;
Ok(())
})
.build(tauri::generate_context!());
+8 -1
View File
@@ -1,7 +1,14 @@
mod desktop;
#[cfg(not(dev))]
mod frontend;
mod resource_limits;
mod startup;
mod tray;
mod update;
pub use desktop::run;
pub fn run() -> std::process::ExitCode {
if let Some(exit_code) = update::run_replacement_if_requested() {
return exit_code;
}
desktop::run()
}
@@ -0,0 +1,56 @@
//! Configures process resource limits before the desktop runtime starts.
#[cfg(unix)]
use std::io;
#[cfg(unix)]
pub(crate) const REQUESTED_OPEN_FILE_LIMIT: u64 = 65_536;
#[cfg(unix)]
pub(crate) struct OpenFileLimit {
pub(crate) previous: u64,
pub(crate) effective: u64,
pub(crate) hard: u64,
}
#[cfg(unix)]
pub(crate) fn raise_open_file_limit() -> io::Result<OpenFileLimit> {
let mut limits = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
// SAFETY: `limits` points to writable memory for one `rlimit` value.
if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limits) } != 0 {
return Err(io::Error::last_os_error());
}
let previous = limits.rlim_cur;
let target = limits
.rlim_max
.min(REQUESTED_OPEN_FILE_LIMIT as libc::rlim_t);
if previous < target {
let requested = libc::rlimit {
rlim_cur: target,
rlim_max: limits.rlim_max,
};
// SAFETY: `requested` is a valid `rlimit` value and does not raise the hard limit.
if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &requested) } != 0 {
return Err(io::Error::last_os_error());
}
}
let mut effective = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
// SAFETY: `effective` points to writable memory for one `rlimit` value.
if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut effective) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(OpenFileLimit {
previous: previous as u64,
effective: effective.rlim_cur as u64,
hard: effective.rlim_max as u64,
})
}
+269
View File
@@ -0,0 +1,269 @@
use std::{
fs::{self, OpenOptions},
io::{Cursor, Read, Write},
path::{Path, PathBuf},
process::{Command, ExitCode},
};
use serde::Serialize;
use tauri::AppHandle;
#[cfg(target_os = "windows")]
use tauri_plugin_updater::UpdaterExt;
#[cfg(target_os = "windows")]
mod replacement;
const PORTABLE_UPDATE_ENDPOINT: &str =
"https://github.com/leookun/cursor-byok/releases/latest/download/portable-latest.json";
const WINDOWS_PAYLOAD_NAME: &str = "cursor-byok-desktop.exe";
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PortableUpdateInfo {
version: String,
}
pub fn run_replacement_if_requested() -> Option<ExitCode> {
#[cfg(target_os = "windows")]
{
match replacement::request_from_args() {
Ok(Some(request)) => return Some(replacement::run(request)),
Ok(None) => {}
Err(error) => {
eprintln!("invalid portable update replacement request: {error}");
return Some(ExitCode::FAILURE);
}
}
}
None
}
pub(crate) fn signal_ready_if_requested() -> std::io::Result<()> {
#[cfg(target_os = "windows")]
if let Some(path) = replacement::ready_marker_from_args() {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, b"ready")?;
}
Ok(())
}
#[tauri::command]
pub(crate) async fn check_portable_update(
app: AppHandle,
) -> Result<Option<PortableUpdateInfo>, String> {
#[cfg(target_os = "windows")]
{
let update = portable_update(&app).await?;
return Ok(update.map(|update| PortableUpdateInfo {
version: update.version,
}));
}
#[cfg(not(target_os = "windows"))]
{
let _ = app;
Err("portable updates are only supported on Windows".into())
}
}
#[tauri::command]
pub(crate) async fn install_portable_update(
app: AppHandle,
expected_version: String,
) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let update = portable_update(&app)
.await?
.ok_or_else(|| "the selected update is no longer available".to_string())?;
if update.version != expected_version {
return Err(format!(
"available update changed from {expected_version} to {}",
update.version
));
}
let target = std::env::current_exe()
.map_err(|error| format!("failed to locate the running executable: {error}"))?;
ensure_target_writable(&target)
.map_err(|error| format!("the application directory is not writable: {error}"))?;
let bytes = update
.download(|_, _| {}, || {})
.await
.map_err(|error| format!("failed to download or verify the update: {error}"))?;
let payload = extract_windows_payload(&bytes)
.map_err(|error| format!("invalid Windows update archive: {error}"))?;
let staged = stage_payload(&target, &payload)
.map_err(|error| format!("failed to stage the update: {error}"))?;
let handshake = staged.with_extension("started");
let _ = fs::remove_file(&handshake);
let mut replacement = Command::new(&staged)
.arg("--apply-portable-update")
.arg("--update-target")
.arg(&target)
.arg("--update-wait-pid")
.arg(std::process::id().to_string())
.arg("--update-handshake")
.arg(&handshake)
.spawn()
.map_err(|error| format!("failed to start the update replacement process: {error}"))?;
wait_for_replacement_start(&mut replacement, &handshake).await?;
app.exit(0);
Ok(())
}
#[cfg(not(target_os = "windows"))]
{
let _ = (app, expected_version);
Err("portable updates are only supported on Windows".into())
}
}
#[cfg(target_os = "windows")]
async fn portable_update(app: &AppHandle) -> Result<Option<tauri_plugin_updater::Update>, String> {
let endpoint = PORTABLE_UPDATE_ENDPOINT
.parse()
.map_err(|error| format!("invalid portable update endpoint: {error}"))?;
let updater = app
.updater_builder()
.endpoints(vec![endpoint])
.map_err(|error| format!("failed to configure the updater: {error}"))?
.build()
.map_err(|error| format!("failed to initialize the updater: {error}"))?;
updater
.check()
.await
.map_err(|error| format!("failed to check for updates: {error}"))
}
#[cfg(target_os = "windows")]
async fn wait_for_replacement_start(
child: &mut std::process::Child,
handshake: &Path,
) -> Result<(), String> {
let wait = async {
loop {
if handshake.is_file() {
return Ok(());
}
if let Some(status) = child
.try_wait()
.map_err(|error| format!("failed to inspect replacement process: {error}"))?
{
return Err(format!(
"update replacement process exited before it was ready: {status}"
));
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
};
let result = match tokio::time::timeout(std::time::Duration::from_secs(5), wait).await {
Ok(result) => result,
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return Err("update replacement process did not become ready".into());
}
};
if result.is_err() {
let _ = child.kill();
let _ = child.wait();
}
result
}
fn ensure_target_writable(target: &Path) -> std::io::Result<()> {
let parent = target
.parent()
.ok_or_else(|| std::io::Error::other("application executable has no parent directory"))?;
let probe = parent.join(format!(
".cursor-byok-update-write-test-{}",
std::process::id()
));
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&probe)?;
file.write_all(b"test")?;
drop(file);
fs::remove_file(probe)
}
fn extract_windows_payload(bytes: &[u8]) -> Result<Vec<u8>, String> {
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
.map_err(|error| format!("failed to open ZIP: {error}"))?;
if archive.len() != 1 {
return Err("archive must contain exactly one file".into());
}
let mut entry = archive
.by_index(0)
.map_err(|error| format!("failed to read ZIP entry: {error}"))?;
let name = Path::new(entry.name())
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| "archive entry has an invalid file name".to_string())?;
if name != WINDOWS_PAYLOAD_NAME || entry.is_dir() {
return Err(format!(
"expected {WINDOWS_PAYLOAD_NAME}, found {}",
entry.name()
));
}
let mut payload = Vec::with_capacity(entry.size() as usize);
entry
.read_to_end(&mut payload)
.map_err(|error| format!("failed to extract executable: {error}"))?;
if payload.len() < 2 || &payload[..2] != b"MZ" {
return Err("payload is not a Windows executable".into());
}
Ok(payload)
}
fn stage_payload(target: &Path, payload: &[u8]) -> std::io::Result<PathBuf> {
let directory = tempfile::Builder::new()
.prefix("cursor-byok-portable-update-")
.tempdir()?;
let name = target
.file_name()
.ok_or_else(|| std::io::Error::other("application executable has no file name"))?;
let path = directory.path().join(name);
fs::write(&path, payload)?;
let _ = directory.keep();
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn update_zip(name: &str, payload: &[u8]) -> Vec<u8> {
let mut bytes = Cursor::new(Vec::new());
{
let mut archive = zip::ZipWriter::new(&mut bytes);
archive
.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
archive.write_all(payload).unwrap();
archive.finish().unwrap();
}
bytes.into_inner()
}
#[test]
fn extracts_the_single_expected_windows_executable() {
let bytes = update_zip(WINDOWS_PAYLOAD_NAME, b"MZpayload");
assert_eq!(extract_windows_payload(&bytes).unwrap(), b"MZpayload");
}
#[test]
fn rejects_unexpected_or_non_executable_payloads() {
let wrong_name = update_zip("other.exe", b"MZpayload");
assert!(extract_windows_payload(&wrong_name).is_err());
let wrong_content = update_zip(WINDOWS_PAYLOAD_NAME, b"not an executable");
assert!(extract_windows_payload(&wrong_content).is_err());
}
}
@@ -0,0 +1,296 @@
use std::{
ffi::{OsStr, OsString},
fs, io,
path::{Path, PathBuf},
process::{Child, Command, ExitCode},
thread,
time::{Duration, Instant},
};
const APPLY_ARG: &str = "--apply-portable-update";
const TARGET_ARG: &str = "--update-target";
const PID_ARG: &str = "--update-wait-pid";
const HANDSHAKE_ARG: &str = "--update-handshake";
pub(super) const READY_ARG: &str = "--portable-update-ready";
const PROCESS_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
const READY_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
pub(super) struct ReplacementRequest {
target: PathBuf,
pid: u32,
handshake: PathBuf,
}
pub(super) fn request_from_args() -> Result<Option<ReplacementRequest>, String> {
let args = std::env::args_os().collect::<Vec<_>>();
if !args.iter().any(|arg| arg == APPLY_ARG) {
return Ok(None);
}
let target = PathBuf::from(
argument_value(&args, TARGET_ARG).ok_or_else(|| format!("{TARGET_ARG} is required"))?,
);
let pid = argument_value(&args, PID_ARG)
.ok_or_else(|| format!("{PID_ARG} is required"))?
.to_string_lossy()
.parse::<u32>()
.map_err(|error| format!("invalid {PID_ARG}: {error}"))?;
let handshake = PathBuf::from(
argument_value(&args, HANDSHAKE_ARG)
.ok_or_else(|| format!("{HANDSHAKE_ARG} is required"))?,
);
validate_target(&target).map_err(|error| error.to_string())?;
Ok(Some(ReplacementRequest {
target,
pid,
handshake,
}))
}
pub(super) fn ready_marker_from_args() -> Option<PathBuf> {
let args = std::env::args_os().collect::<Vec<_>>();
argument_value(&args, READY_ARG).map(PathBuf::from)
}
fn argument_value<'a>(args: &'a [OsString], name: &str) -> Option<&'a OsStr> {
args.iter()
.position(|arg| arg == name)
.and_then(|index| args.get(index + 1))
.map(OsString::as_os_str)
}
fn validate_target(target: &Path) -> io::Result<()> {
if !target.is_absolute() || !target.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"update target must be an existing absolute file",
));
}
let source_name = std::env::current_exe()?
.file_name()
.map(OsStr::to_os_string)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "updater has no file name"))?;
if target.file_name() != Some(source_name.as_os_str()) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"update target file name does not match the updater",
));
}
Ok(())
}
pub(super) fn run(request: ReplacementRequest) -> ExitCode {
match run_inner(request) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("portable update replacement failed: {error}");
ExitCode::FAILURE
}
}
}
fn run_inner(request: ReplacementRequest) -> io::Result<()> {
wait_for_process(request.pid, PROCESS_WAIT_TIMEOUT, &request.handshake)?;
remove_file_if_exists(&request.handshake)?;
let source = std::env::current_exe()?;
let backup = backup_path(&request.target);
let ready = source.with_extension("ready");
remove_file_if_exists(&ready)?;
if let Err(error) = install_staged(&source, &request.target, &backup) {
relaunch(&request.target);
return Err(error);
}
let mut child = match Command::new(&request.target)
.arg(READY_ARG)
.arg(&ready)
.spawn()
{
Ok(child) => child,
Err(error) => {
restore_backup(&request.target, &backup)?;
relaunch(&request.target);
return Err(error);
}
};
match wait_until_ready(&mut child, &ready, READY_WAIT_TIMEOUT) {
Ok(()) => {
let _ = fs::remove_file(&backup);
let _ = fs::remove_file(&ready);
Ok(())
}
Err(error) => {
let _ = child.kill();
let _ = child.wait();
restore_backup(&request.target, &backup)?;
relaunch(&request.target);
Err(error)
}
}
}
fn relaunch(target: &Path) {
if target.is_file() {
let _ = Command::new(target).spawn();
}
}
fn remove_file_if_exists(path: &Path) -> io::Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
fn backup_path(target: &Path) -> PathBuf {
path_with_suffix(target, ".old")
}
fn pending_path(target: &Path) -> PathBuf {
path_with_suffix(target, ".new")
}
fn path_with_suffix(target: &Path, suffix: &str) -> PathBuf {
let mut name = target.as_os_str().to_os_string();
name.push(suffix);
PathBuf::from(name)
}
fn install_staged(source: &Path, target: &Path, backup: &Path) -> io::Result<()> {
let pending = pending_path(target);
remove_file_if_exists(&pending)?;
fs::copy(source, &pending)?;
let result = activate_pending(&pending, target, backup);
if result.is_err() {
let _ = fs::remove_file(&pending);
}
result
}
fn activate_pending(pending: &Path, target: &Path, backup: &Path) -> io::Result<()> {
remove_file_if_exists(backup)?;
fs::rename(target, backup)?;
if let Err(error) = fs::rename(pending, target) {
if let Err(restore_error) = restore_backup(target, backup) {
return Err(io::Error::other(format!(
"failed to install update ({error}) and restore the original executable ({restore_error})"
)));
}
return Err(error);
}
Ok(())
}
fn restore_backup(target: &Path, backup: &Path) -> io::Result<()> {
remove_file_if_exists(target)?;
fs::rename(backup, target)
}
fn wait_until_ready(child: &mut Child, marker: &Path, timeout: Duration) -> io::Result<()> {
let deadline = Instant::now() + timeout;
loop {
if marker.is_file() {
return Ok(());
}
if let Some(status) = child.try_wait()? {
return Err(io::Error::other(format!(
"updated application exited before startup completed: {status}"
)));
}
if Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"updated application did not report a successful startup",
));
}
thread::sleep(Duration::from_millis(100));
}
}
#[cfg(windows)]
fn wait_for_process(pid: u32, timeout: Duration, handshake: &Path) -> io::Result<()> {
use windows_sys::Win32::{
Foundation::{
CloseHandle, GetLastError, ERROR_INVALID_PARAMETER, WAIT_OBJECT_0, WAIT_TIMEOUT,
},
System::Threading::{OpenProcess, WaitForSingleObject},
};
const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000;
let handle = unsafe { OpenProcess(SYNCHRONIZE_ACCESS, 0, pid) };
if handle.is_null() {
let error = unsafe { GetLastError() };
return if error == ERROR_INVALID_PARAMETER {
fs::write(handshake, b"started")
} else {
Err(io::Error::from_raw_os_error(error as i32))
};
}
if let Err(error) = fs::write(handshake, b"started") {
unsafe { CloseHandle(handle) };
return Err(error);
}
let milliseconds = timeout.as_millis().min(u32::MAX as u128) as u32;
let result = unsafe { WaitForSingleObject(handle, milliseconds) };
unsafe { CloseHandle(handle) };
match result {
WAIT_OBJECT_0 => Ok(()),
WAIT_TIMEOUT => Err(io::Error::new(
io::ErrorKind::TimedOut,
"running application did not exit before the update timeout",
)),
_ => Err(io::Error::last_os_error()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn staged_file_can_be_restored() {
let directory = tempfile::tempdir().unwrap();
let source = directory.path().join("source.exe");
let target = directory.path().join("target.exe");
let backup = backup_path(&target);
fs::write(&source, b"new").unwrap();
fs::write(&target, b"old").unwrap();
install_staged(&source, &target, &backup).unwrap();
assert_eq!(fs::read(&target).unwrap(), b"new");
assert_eq!(fs::read(&backup).unwrap(), b"old");
restore_backup(&target, &backup).unwrap();
assert_eq!(fs::read(&target).unwrap(), b"old");
assert!(!backup.exists());
}
#[test]
fn missing_pending_file_restores_original_after_backup() {
let directory = tempfile::tempdir().unwrap();
let target = directory.path().join("target.exe");
let pending = pending_path(&target);
let backup = backup_path(&target);
fs::write(&target, b"old").unwrap();
assert!(activate_pending(&pending, &target, &backup).is_err());
assert_eq!(fs::read(&target).unwrap(), b"old");
assert!(!backup.exists());
}
#[test]
fn missing_source_preserves_original_file() {
let directory = tempfile::tempdir().unwrap();
let source = directory.path().join("missing.exe");
let target = directory.path().join("target.exe");
let backup = backup_path(&target);
fs::write(&target, b"old").unwrap();
assert!(install_staged(&source, &target, &backup).is_err());
assert_eq!(fs::read(&target).unwrap(), b"old");
assert!(!backup.exists());
assert!(!pending_path(&target).exists());
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cursor BYOK",
"version": "0.1.5-beta.1",
"version": "0.1.6",
"identifier": "dev.cursorbyok.desktop",
"build": {
"beforeDevCommand": "npm run dev",
@@ -12,7 +12,7 @@
"app": {
"windows": [],
"security": {
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*",
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data: https:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*",
"dangerousDisableAssetCspModification": [
"style-src"
]
+2
View File
@@ -8,6 +8,7 @@ import { CallsPage } from "./features/calls/CallsPage";
import { CallDetailsPage } from "./features/calls/CallDetailsPage";
import { CursorSettingsPage } from "./features/models/CursorSettingsPage";
import { HomePage } from "./features/home/HomePage";
import { PluginManagementPage } from "./features/plugins/PluginManagementPage";
import { SettingsPage } from "./features/settings/SettingsPage";
import { useAppStore } from "./shared/store/appStore";
import { updateStore } from "./shared/store/updateStore";
@@ -23,6 +24,7 @@ export function App() {
<Route index element={<HomePage />} />
<Route path="calls" element={<CallsPage />} />
<Route path="harness/cursor" element={<CursorSettingsPage />} />
<Route path="plugins" element={<PluginManagementPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+1
View File
@@ -185,6 +185,7 @@ function createModel({ hash, order, name, type, url, modelId, endpoint = "/v1/re
model_hash: hash,
sort_order: order,
display_name: name,
group_name: null,
type,
base_url: url,
use_full_url: false,
+15 -8
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { api, type Overview } from "../../shared/api";
import { api, configuredPluginModels, type Overview } from "../../shared/api";
import { ContributionCalendarChart } from "./charts/ContributionCalendarChart";
import { DailyTokenUsageChart } from "./charts/DailyTokenUsageChart";
import { HomeMetrics } from "./metrics/HomeMetrics";
@@ -9,7 +9,7 @@ import { OverviewTimeRangeFilter, type OverviewRangePreset } from "./overview/Ov
import { PageActions } from "../../shell/PageActions";
import { appStore, useAppStore } from "../../shared/store/appStore";
import { formatTimeInput, parseTimeInput } from "../../shared/utils/parseTimeInput";
import { claudeIcon, openAiIcon } from "../../shared/ui/icons";
import { claudeIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
type TimeRange = { startMs: number; endMs: number };
@@ -30,7 +30,7 @@ function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new D
}
export function HomePage() {
const { overview, busy, models } = useAppStore();
const { overview, busy, models, plugins } = useAppStore();
const [preset, setPreset] = useState<OverviewRangePreset>("month");
const [customRange, setCustomRange] = useState<TimeRange | null>(null);
const [customOpen, setCustomOpen] = useState(false);
@@ -101,11 +101,18 @@ export function HomePage() {
setRefreshVersion((version) => version + 1);
};
const iconFor = (type: string) => type === "anthropic" ? claudeIcon : openAiIcon;
const modelOptions = models.map((model) => ({
value: model.model_hash,
label: model.display_name,
icon: iconFor(model.type),
}));
const modelOptions = [
...models.map((model) => ({
value: model.model_hash,
label: model.display_name,
icon: iconFor(model.type),
})),
...configuredPluginModels(plugins).map((model) => ({
value: model.id,
label: model.displayName,
icon: flatColorOrganizationIcon,
})),
];
const sections: VirtualPageSection[] = [
{
key: "daily-token-usage",
@@ -24,8 +24,9 @@ export function CursorCaGate({ busy, waitingForRefresh, onInitialize, onRefresh,
}
export function CursorModelProvider({ children }: { children: ReactNode }) {
const { models } = useAppStore();
return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>;
const { models, plugins } = useAppStore();
const hasConfiguredPlugin = plugins.some((plugin) => plugin.providers.some((provider) => provider.configured));
return <ModelsReady.Provider value={models.length > 0 || hasConfiguredPlugin}>{children}</ModelsReady.Provider>;
}
export function CursorModelGate({ busy, previewingImport, onAdd, onImport, children }: { busy: boolean; previewingImport: boolean; onAdd: () => void; onImport: () => void; children: ReactNode }) {
@@ -1,11 +1,11 @@
import type { IconifyIcon } from "@iconify/react/offline";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import Sortable from "sortablejs";
import type { Model } from "../../shared/api";
import { Button } from "../../shared/ui/Button";
import type { Model, PluginModelDescriptor } from "../../shared/api";
import { Card } from "../../shared/ui/Card";
import { Icon } from "../../shared/ui/Icon";
import { claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
import { chevronDownIcon, chevronRightIcon, claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
import { TruncatedButton } from "../../shared/ui/TruncatedButton";
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
import styles from "./CursorSettings.module.scss";
@@ -20,6 +20,7 @@ export type CursorModelGroup = {
type CursorModelCardsProps = {
models: Model[];
pluginModels: PluginModelDescriptor[];
grouping: CursorModelGrouping;
disabled: boolean;
testingModelHashes: Set<string>;
@@ -28,10 +29,13 @@ type CursorModelCardsProps = {
onEdit: (model: Model) => void;
onDuplicate: (model: Model) => void;
onDelete: (model: Model) => void;
onTestPluginModel: (model: PluginModelDescriptor) => void;
onPluginSettings: (model: PluginModelDescriptor) => void;
onReorder: (modelHashes: string[]) => void;
onGroupSettings: (group: CursorModelGroup) => void;
};
type ModelGridProps = Omit<CursorModelCardsProps, "grouping"> & {
type ModelGridProps = Omit<CursorModelCardsProps, "grouping" | "pluginModels" | "onTestPluginModel" | "onPluginSettings"> & {
sortable: boolean;
};
@@ -50,18 +54,139 @@ export function cursorModelGroups(models: Model[], grouping: Exclude<CursorModel
}
export function CursorModelCards(props: CursorModelCardsProps) {
if (props.grouping === "flat") return <div style={{ paddingTop: "10px" }}>
<ModelGrid {...props} sortable />
</div>;
const builtins = props.grouping === "flat"
? <div style={{ paddingTop: "10px" }}><ModelGrid {...props} sortable /></div>
: <div className={styles.modelGroups}>
{cursorModelGroups(props.models, props.grouping).map((group) => <CollapsibleGroup
key={group.key}
label={group.label}
icon={group.icon}
onSettings={props.grouping === "provider" ? () => props.onGroupSettings(group) : undefined}
>
{group.models.map((model) => <ModelListRow
key={model.model_hash}
model={model}
disabled={props.disabled}
testing={props.testingModelHashes.has(model.model_hash)}
result={props.testResults.get(model.model_hash)}
onTest={() => props.onTest(model)}
onEdit={() => props.onEdit(model)}
onDuplicate={() => props.onDuplicate(model)}
onDelete={() => props.onDelete(model)}
/>)}
</CollapsibleGroup>)}
</div>;
return <div className={styles.modelGroups}>
{cursorModelGroups(props.models, props.grouping).map((group) => <section className={styles.modelGroup} key={group.key}>
<div className={styles.modelGroupHeader}>
<Icon icon={group.icon} size="1.1em" />
<span>{group.label}</span>
</div>
<ModelGrid {...props} models={group.models} sortable={false} />
</section>)}
{builtins}
{pluginGroups(props.pluginModels).map((group) => <CollapsibleGroup
key={group.pluginId}
label={group.pluginName}
iconSrc={group.icon}
>
{group.models.map((model) => <PluginModelRow
key={model.id}
model={model}
disabled={props.disabled}
testing={props.testingModelHashes.has(model.id)}
result={props.testResults.get(model.id)}
onTest={() => props.onTestPluginModel(model)}
onSettings={() => props.onPluginSettings(model)}
/>)}
</CollapsibleGroup>)}
</div>;
}
function pluginGroups(models: PluginModelDescriptor[]) {
const groups: { pluginId: string; pluginName: string; icon: string; models: PluginModelDescriptor[] }[] = [];
for (const model of models) {
let group = groups.find((candidate) => candidate.pluginId === model.pluginId);
if (!group) {
group = { pluginId: model.pluginId, pluginName: model.pluginName, icon: model.icon, models: [] };
groups.push(group);
}
group.models.push(model);
}
return groups;
}
function CollapsibleGroup({ label, icon, iconSrc, onSettings, children }: {
label: string;
icon?: IconifyIcon;
iconSrc?: string;
onSettings?: () => void;
children: ReactNode;
}) {
const [open, setOpen] = useState(true);
return <Card className={styles.groupCard}>
<div className={styles.groupHeader}>
<button
type="button"
className={styles.groupToggle}
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
{icon && <Icon icon={icon} size="1.1em" />}
{iconSrc && <Icon src={iconSrc} size="1.1em" />}
<span className={styles.groupLabel}>{label}</span>
</button>
{onSettings && <button type="button" className={styles.groupSettings} onClick={onSettings}>{t("分组设置")}</button>}
<button
type="button"
className={styles.groupChevron}
tabIndex={-1}
aria-hidden="true"
onClick={() => setOpen((current) => !current)}
>
<Icon icon={open ? chevronDownIcon : chevronRightIcon} size="1em" />
</button>
</div>
{open && <div className={styles.modelList}>{children}</div>}
</Card>;
}
function ModelListRow({ model, disabled, testing, result, onTest, onEdit, onDuplicate, onDelete }: {
model: Model;
disabled: boolean;
testing: boolean;
result: CursorModelTestState | undefined;
onTest: () => void;
onEdit: () => void;
onDuplicate: () => void;
onDelete: () => void;
}) {
return <div className={styles.modelRow}>
<div className={styles.modelRowName}>
<span className={styles.modelRowNameText}>{model.display_name}</span>
<span className={styles.modelRowModelId}>{model.model_id}</span>
</div>
<CursorModelTestResult compact state={result} testing={testing} />
<div className={styles.modelCardActions}>
<TruncatedButton size="small" disabled={disabled && !testing} label={testing ? t("取消测试") : t("测试")} onClick={onTest} />
<TruncatedButton size="small" disabled={disabled} label={t("编辑")} onClick={onEdit} />
<TruncatedButton size="small" disabled={disabled} label={t("复制")} onClick={onDuplicate} />
<TruncatedButton size="small" className={styles.deleteButton} disabled={disabled} label={t("删除")} onClick={onDelete} />
</div>
</div>;
}
function PluginModelRow({ model, disabled, testing, result, onTest, onSettings }: {
model: PluginModelDescriptor;
disabled: boolean;
testing: boolean;
result: CursorModelTestState | undefined;
onTest: () => void;
onSettings: () => void;
}) {
return <div className={styles.modelRow}>
<div className={styles.modelRowName}>
<span className={styles.modelRowNameText}>{model.displayName}</span>
<span className={styles.modelRowModelId}>{model.modelId}</span>
</div>
<CursorModelTestResult compact state={result} testing={testing} />
<div className={styles.modelCardActions}>
<TruncatedButton size="small" disabled={disabled && !testing} label={testing ? t("取消测试") : t("测试")} onClick={onTest} />
<TruncatedButton size="small" disabled={disabled} label={t("设置")} onClick={onSettings} />
</div>
</div>;
}
@@ -150,10 +275,10 @@ function ModelGrid({
<CursorModelTestResult state={result} testing={testing} />
</div>
<div className={styles.modelCardActions}>
<Button size="small" disabled={disabled && !testing} onClick={() => onTest(model)}>{testing ? t("取消测试") : t("测试")}</Button>
<Button size="small" disabled={disabled} onClick={() => onEdit(model)}>{t("编辑")}</Button>
<Button size="small" disabled={disabled} onClick={() => onDuplicate(model)}>{t("复制")}</Button>
<Button size="small" className={styles.deleteButton} disabled={disabled} onClick={() => onDelete(model)}>{t("删除")}</Button>
<TruncatedButton size="small" disabled={disabled && !testing} label={testing ? t("取消测试") : t("测试")} onClick={() => onTest(model)} />
<TruncatedButton size="small" disabled={disabled} label={t("编辑")} onClick={() => onEdit(model)} />
<TruncatedButton size="small" disabled={disabled} label={t("复制")} onClick={() => onDuplicate(model)} />
<TruncatedButton size="small" className={styles.deleteButton} disabled={disabled} label={t("删除")} onClick={() => onDelete(model)} />
</div>
</div>
</Card>;
@@ -162,8 +287,9 @@ function ModelGrid({
}
function providerGroup(model: Model) {
const label = providerDomain(model.base_url);
return { key: label, label, icon: flatColorOrganizationIcon };
const key = providerDomain(model.base_url);
const label = model.group_name?.trim() || key;
return { key, label, icon: flatColorOrganizationIcon };
}
function providerDomain(baseUrl: string) {
@@ -12,6 +12,7 @@ import { CursorPresetChips } from "./CursorPresetChips";
import styles from "./CursorSettings.module.scss";
export type CursorModelDraft = {
providerId: string;
model: ModelInput;
openAIExtraParamsText: string;
customHeadersText: string;
@@ -19,9 +20,11 @@ export type CursorModelDraft = {
};
export const emptyCursorModelDraft = (): CursorModelDraft => ({
providerId: "builtin/openai",
model: {
sort_order: 0,
display_name: "",
group_name: null,
type: "openai",
base_url: "",
use_full_url: false,
@@ -63,6 +66,7 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
const endpoint = preset ? presetEndpoint(preset, type) : null;
onChange({
...draft,
providerId: `builtin/${type}`,
model: {
...draft.model,
type,
@@ -123,13 +127,19 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
? "https://api.anthropic.com"
: "https://api.openai.com";
const providerOptions = [
{ value: "builtin/openai", label: "OpenAI", icon: openAiIcon },
{ value: "builtin/anthropic", label: "Anthropic", icon: claudeIcon },
];
const setProvider = (providerId: string) => {
if (providerId === "builtin/openai") setType("openai");
if (providerId === "builtin/anthropic") setType("anthropic");
};
return <div className={styles.editor}>
<CursorPresetChips type={draft.model.type} baseUrl={draft.model.base_url} onPick={applyPreset} />
<div className={styles.grid}>
<FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.model.type} options={[
{ value: "openai", label: "OpenAI", icon: openAiIcon },
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
]} onChange={(value) => setType(value as ModelType)} /></FormField>
<FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.providerId} options={providerOptions} onChange={setProvider} /></FormField>
{draft.model.type === "openai" && <FormField label={t("请求协议")} hint={t("只决定请求与响应的格式,不会改变请求地址。")}> <Select ariaLabel={t("请求协议")} value={draft.model.openai_endpoint} options={[
{ value: "/v1/responses", label: "Responses API" },
{ value: "/v1/chat/completions", label: "Chat Completions API" },
@@ -37,6 +37,19 @@
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 28%, transparent);
}
.compact {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border: 1px solid transparent;
border-radius: 999px;
font-size: type.$font-size-2xs;
white-space: nowrap;
cursor: default;
}
.summary {
min-width: 0;
overflow: hidden;
@@ -9,10 +9,25 @@ export type CursorModelTestState =
| { status: "error"; error: string }
| { status: "cancelled" };
export function CursorModelTestResult({ state, testing = false }: { state?: CursorModelTestState; testing?: boolean }) {
if (testing) return <div className={`${styles.root} ${styles.testing}`}><span className={styles.summary}>{t("测试中…")}</span></div>;
if (!state) return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("未测试")}</span></div>;
if (state.status === "cancelled") return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("测试已取消")}</span></div>;
export function CursorModelTestResult({ state, testing = false, compact = false }: {
state?: CursorModelTestState;
testing?: boolean;
/** 列表行内的紧凑徽标形态:未测试时不渲染,详情放入悬浮提示。 */
compact?: boolean;
}) {
if (testing) {
return compact
? <span className={`${styles.compact} ${styles.testing}`}>{t("测试中…")}</span>
: <div className={`${styles.root} ${styles.testing}`}><span className={styles.summary}>{t("测试中…")}</span></div>;
}
if (!state) {
return compact ? null : <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("未测试")}</span></div>;
}
if (state.status === "cancelled") {
return compact
? <span className={`${styles.compact} ${styles.idle}`}>{t("测试已取消")}</span>
: <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("测试已取消")}</span></div>;
}
const success = state.status === "success";
const summary = success
@@ -29,6 +44,15 @@ export function CursorModelTestResult({ state, testing = false }: { state?: Curs
})
: t("测试失败:{error}", { error: state.error });
if (compact) {
return <TooltipTrigger label={detail}>
<span className={`${styles.compact} ${success ? styles.success : styles.error}`}>
{success ? `${formatSpeed(state.result.tokens_per_second)} tokens/s` : t("测试失败")}
<Icon icon={informationOutlineIcon} size="1em" />
</span>
</TooltipTrigger>;
}
return <div className={`${styles.root} ${success ? styles.success : styles.error}`}>
<span className={styles.summary}>{summary}</span>
<TooltipTrigger label={detail}><button type="button" className={styles.details}>{t("查看详情")}<Icon icon={informationOutlineIcon} size="1.1em" /></button></TooltipTrigger>
@@ -70,19 +70,87 @@
gap: 20px;
padding-top: 10px;
}
.modelGroup {
display: flex;
flex-direction: column;
gap: 10px;
.groupCard {
padding: 4px 12px 8px;
}
.modelGroupHeader {
.groupHeader {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
}
.groupSettings {
padding: 4px 2px;
color: var(--vscode-descriptionForeground);
background: none;
border: none;
font-size: type.$font-size-xs;
white-space: nowrap;
cursor: pointer;
&:hover { color: var(--vscode-foreground); }
}
.groupChevron {
display: flex;
align-items: center;
padding: 4px 0;
color: var(--vscode-foreground);
background: none;
border: none;
cursor: pointer;
}
.groupToggle {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 2px;
color: var(--vscode-foreground);
background: none;
border: none;
font-size: type.$font-size-base;
opacity: 0.6;
padding-left: 6px;
cursor: pointer;
}
.groupLabel {
flex: 1;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.modelList {
display: flex;
flex-direction: column;
}
.modelRow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 12px;
padding: 10px 2px;
border-top: 1px solid var(--vscode-sideBar-border);
}
.modelRowName {
min-width: 0;
flex: 1 1 200px;
display: flex;
flex-direction: column;
gap: 2px;
.modelRowNameText,
.modelRowModelId {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modelRowNameText {
color: var(--vscode-foreground);
font-size: type.$font-size-base;
}
.modelRowModelId {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-2xs;
}
}
.modelCard {
position: relative;
@@ -141,9 +209,15 @@
}
.modelCardActions {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 8px;
// 空间不足时按钮收缩显示省略号,而不是换行。
> button {
min-width: 0;
flex: 0 1 auto;
}
}
.deleteButton:hover {
color: var(--vscode-errorForeground, #f48771);
@@ -1,13 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api, type Model, type ModelInput } from "../../shared/api";
import { useNavigate } from "react-router-dom";
import { api, configuredPluginModels, type Model, type ModelInput } from "../../shared/api";
import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "./CursorGates";
import { CursorModelCards, cursorModelGroups, type CursorModelGrouping } from "./CursorModelCards";
import { CursorModelCards, cursorModelGroups, type CursorModelGroup, type CursorModelGrouping } from "./CursorModelCards";
import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "./CursorModelEditor";
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
import styles from "./CursorSettings.module.scss";
import { PageContent } from "../../shell/layout/PageContent";
import { LegacyModelImport } from "./LegacyModelImport";
import { ConfirmDialog } from "../../shared/ui/ConfirmDialog";
import { FormField, SecretTextInput, TextInput } from "../../shared/ui/FormControls";
import controls from "../../shared/ui/Controls.module.scss";
import { Icon } from "../../shared/ui/Icon";
import { Modal } from "../../shared/ui/Modal";
@@ -18,7 +20,8 @@ import { PageActions } from "../../shell/PageActions";
import { appStore, useAppStore } from "../../shared/store/appStore";
export function CursorSettingsPage() {
const { models, cursorHarness, cursorBusy } = useAppStore();
const { models, cursorHarness, cursorBusy, plugins } = useAppStore();
const navigate = useNavigate();
const message = useMessage();
const [draft, setDraft] = useState<CursorModelDraft | null>(null);
const [editing, setEditing] = useState<Model | null>(null);
@@ -32,8 +35,18 @@ export function CursorSettingsPage() {
const [savingAndTesting, setSavingAndTesting] = useState(false);
const [batchTesting, setBatchTesting] = useState(false);
const [grouping, setGrouping] = useState<CursorModelGrouping>("flat");
const [settingsGroup, setSettingsGroup] = useState<CursorModelGroup | null>(null);
const [groupNameDraft, setGroupNameDraft] = useState("");
const [groupBaseUrlDraft, setGroupBaseUrlDraft] = useState("");
const [groupApiKeyDraft, setGroupApiKeyDraft] = useState("");
const [groupSettingsBusy, setGroupSettingsBusy] = useState(false);
const activeModelTests = useRef(new Map<string, { testId: string; controller: AbortController; cancelling: boolean }>());
const caReady = cursorHarness?.ca === "ready";
const pluginModels = configuredPluginModels(plugins);
const testTargets = [
...models.map((model) => ({ model_hash: model.model_hash, display_name: model.display_name })),
...pluginModels.map((model) => ({ model_hash: model.id, display_name: model.displayName })),
];
const providerGroups = cursorModelGroups(models, "provider");
const typeGroups = cursorModelGroups(models, "type");
const canGroupByProvider = providerGroups.length > 1;
@@ -64,6 +77,7 @@ export function CursorSettingsPage() {
setEditing(model);
setModelOptions([model.model_id]);
setDraft({
providerId: `builtin/${model.type}`,
model: modelInput(model),
openAIExtraParamsText: JSON.stringify(model.openai_extra_params, null, 2),
customHeadersText: JSON.stringify(model.custom_headers, null, 2),
@@ -120,7 +134,7 @@ export function CursorSettingsPage() {
const cancelAllModelTests = async () => {
await Promise.all([...activeModelTests.current.keys()].map((modelHash) => cancelModelTest(modelHash)));
};
const testModel = async (model: Model, notify = true): Promise<"success" | "failure" | "cancelled"> => {
const testModel = async (model: { model_hash: string; display_name: string }, notify = true): Promise<"success" | "failure" | "cancelled"> => {
if (activeModelTests.current.has(model.model_hash)) {
await cancelModelTest(model.model_hash);
return "cancelled";
@@ -167,17 +181,17 @@ export function CursorSettingsPage() {
await appStore.refresh();
};
const testAllModels = async () => {
if (!models.length || batchTesting) return;
if (!testTargets.length || batchTesting) return;
setBatchTesting(true);
try {
const results = await Promise.all(models.map((model) => testModel(model, false)));
const results = await Promise.all(testTargets.map((model) => testModel(model, false)));
const successful = results.filter((result) => result === "success").length;
const failed = results.filter((result) => result === "failure").length;
const cancelled = results.filter((result) => result === "cancelled").length;
message(cancelled > 0
? t("连通性测试已取消:成功 {successful},失败 {failed}", { successful, failed })
: failed === 0
? t("全部 {count} 个模型连通性测试成功", { count: models.length })
? t("全部 {count} 个模型连通性测试成功", { count: testTargets.length })
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
{ duration: failed === 0 && cancelled === 0 ? 2400 : 5000 });
} finally {
@@ -200,6 +214,39 @@ export function CursorSettingsPage() {
}]);
if (created) message(t("模型已复制"));
};
const openGroupSettings = (group: CursorModelGroup) => {
setGroupNameDraft(group.models.find((model) => model.group_name?.trim())?.group_name?.trim() ?? "");
setGroupBaseUrlDraft(sharedValue(group.models.map((model) => model.base_url)) ?? "");
setGroupApiKeyDraft(sharedValue(group.models.map((model) => model.api_key)) ?? "");
setSettingsGroup(group);
};
const saveGroupSettings = async () => {
if (!settingsGroup) return;
const group_name = groupNameDraft.trim() || null;
const base_url = groupBaseUrlDraft.trim();
const api_key = groupApiKeyDraft.trim();
setGroupSettingsBusy(true);
try {
for (const model of settingsGroup.models) {
const input: ModelInput = {
...modelInput(model),
group_name,
...(base_url ? { base_url } : {}),
...(api_key ? { api_key } : {}),
};
if (input.group_name === (model.group_name ?? null)
&& input.base_url === model.base_url
&& input.api_key === model.api_key) continue;
await api.updateModel(model.model_hash, input);
}
await appStore.refresh();
setSettingsGroup(null);
} catch (cause) {
message(errorText(cause));
} finally {
setGroupSettingsBusy(false);
}
};
const reorderModels = useCallback(async (modelHashes: string[]) => {
if (!await appStore.reorderCursorModels(modelHashes)) {
message(appStore.getSnapshot().error || t("排序失败"));
@@ -208,6 +255,7 @@ export function CursorSettingsPage() {
const list = <CursorModelCards
models={models}
pluginModels={pluginModels}
grouping={grouping}
disabled={cursorBusy}
testingModelHashes={testingModelHashes}
@@ -216,7 +264,10 @@ export function CursorSettingsPage() {
onEdit={openEdit}
onDuplicate={(model) => void duplicateModel(model)}
onDelete={setDeleting}
onTestPluginModel={(model) => void testModel({ model_hash: model.id, display_name: model.displayName })}
onPluginSettings={() => navigate("/plugins")}
onReorder={reorderModels}
onGroupSettings={openGroupSettings}
/>;
const refreshCa = async () => {
@@ -239,12 +290,13 @@ export function CursorSettingsPage() {
const editorTestState = editing ? modelTestResults.get(editing.model_hash) : undefined;
const editorTesting = Boolean(editing && testingModelHashes.has(editing.model_hash));
const activeGroups = grouping === "provider" ? providerGroups : typeGroups;
const pluginSectionHeight = pluginModels.length > 0 ? 60 + pluginModels.length * 56 : 0;
const estimatedModelHeight = grouping === "flat"
? Math.max(380, Math.ceil(models.length / 3) * 196)
: Math.max(380, activeGroups.reduce((height, group) => height + Math.ceil(group.models.length / 3) * 196 + 34, 0) + Math.max(0, activeGroups.length - 1) * 20);
? Math.max(380, Math.ceil(models.length / 3) * 196 + pluginSectionHeight)
: Math.max(380, activeGroups.reduce((height, group) => height + 60 + group.models.length * 56, 0) + Math.max(0, activeGroups.length - 1) * 20 + pluginSectionHeight);
return <>
{models.length > 0 && <PageActions position="left">
{testTargets.length > 0 && <PageActions position="left">
<div className={styles.groupActions} role="group" aria-label={t("操作")}>
<button type="button" aria-pressed={grouping === "flat"} onClick={() => setGrouping("flat")}>{t("默认平铺")}</button>
{canGroupByProvider && <button type="button" aria-pressed={grouping === "provider"} onClick={() => setGrouping("provider")}>{t("按供应商")}</button>}
@@ -253,8 +305,8 @@ export function CursorSettingsPage() {
</div>
</PageActions>}
<PageActions><TooltipTrigger label={caReady ? t("添加模型") : t("请先初始化 CA")}><button className={controls.iconButton} aria-label={t("添加模型")} disabled={!caReady || cursorBusy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger></PageActions>
<PageContent title={t("Cursor 配置")} sections={[{ key: "cursor-settings", estimatedHeight: estimatedModelHeight, content }]} />
<Modal fullHeight open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => { if (editing && editorTesting) void cancelModelTest(editing.model_hash); setDraft(null); setEditing(null); }} onSubmit={() => void save()} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void (editorTesting && editing ? cancelModelTest(editing.model_hash) : saveAndTest())}>{savingAndTesting ? t("处理中…") : editorTesting ? t("取消测试") : t("保存并测试")}</button>}>
<PageContent title="Cursor" sections={[{ key: "cursor-settings", estimatedHeight: estimatedModelHeight, content }]} />
<Modal fullHeight open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => { if (editing && editorTesting) void cancelModelTest(editing.model_hash); setDraft(null); setEditing(null); }} onSubmit={() => void save()} submitLabel={t("保存")} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void (editorTesting && editing ? cancelModelTest(editing.model_hash) : saveAndTest())}>{savingAndTesting ? t("处理中…") : editorTesting ? t("取消测试") : t("保存并测试")}</button>}>
{draft && <>
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
</>}
@@ -262,6 +314,19 @@ export function CursorSettingsPage() {
<ConfirmDialog open={caCommand !== null} title={t("安装本地 CA")} cancelLabel={t("关闭")} confirmLabel={t("打开终端")} onCancel={() => setCaCommand(null)} onConfirm={openCaTerminal}>
<div className={styles.editor}><strong>{t("需要授权安装证书")}</strong><span>{t("安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。")}</span><pre className={styles.command}>{caCommand}</pre></div>
</ConfirmDialog>
<Modal open={settingsGroup !== null} title={t("分组设置")} busy={groupSettingsBusy || cursorBusy} onClose={() => setSettingsGroup(null)} onSubmit={() => void saveGroupSettings()} submitLabel={t("保存")}>
{settingsGroup && <div className={styles.editor}>
<FormField label={t("分组名称")} hint={t("应用于该分组下的全部模型,并作为 Cursor 模型选择器中的徽章标签;清空则恢复显示服务器域名。")}>
<TextInput placeholder={settingsGroup.key} value={groupNameDraft} onChange={(event) => setGroupNameDraft(event.target.value)} />
</FormField>
<FormField label={t("服务器地址")} hint={t("修改后应用于该分组下的全部模型;留空保持各模型现有配置不变。")}>
<TextInput placeholder={t("留空保持不变")} value={groupBaseUrlDraft} onChange={(event) => setGroupBaseUrlDraft(event.target.value)} />
</FormField>
<FormField label="API Key" hint={t("修改后应用于该分组下的全部模型;留空保持各模型现有配置不变。")}>
<SecretTextInput placeholder={t("留空保持不变")} autoComplete="off" value={groupApiKeyDraft} onChange={(event) => setGroupApiKeyDraft(event.target.value)} />
</FormField>
</div>}
</Modal>
<ConfirmDialog open={deleting !== null} title={t("删除模型")} cancelLabel={t("取消")} confirmLabel={t("删除")} onCancel={() => setDeleting(null)} onConfirm={() => { if (deleting) void appStore.deleteModel(deleting.model_hash); setDeleting(null); }}><p>{t("确定删除这个模型吗?")}</p></ConfirmDialog>
</>;
}
@@ -271,6 +336,13 @@ function modelInput(model: Model): ModelInput {
return input;
}
/** 组内所有模型取值一致时返回该值,否则返回 null(表单留空表示保持不变)。 */
function sharedValue(values: string[]): string | null {
const [first, ...rest] = values;
if (first === undefined) return null;
return rest.every((value) => value === first) ? first : null;
}
function draftInput(draft: CursorModelDraft): ModelInput {
const model = {
...draft.model,
@@ -0,0 +1,195 @@
@use "../../styles/typography" as type;
.pluginGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 12px;
padding-top: 10px;
}
.pluginCard {
min-width: 0;
min-height: 150px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 14px;
padding: 18px;
}
.pluginCardTop {
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
}
.pluginIcon {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 10px;
}
.pluginIdentity {
min-width: 0;
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 3px;
}
.pluginName,
.pluginId {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pluginName {
color: var(--vscode-foreground);
font-size: type.$font-size-base;
font-weight: 600;
}
.pluginId {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-2xs;
}
.stateBadge {
flex: 0 0 auto;
align-self: flex-start;
padding: 3px 8px;
color: var(--vscode-descriptionForeground);
border: 1px solid var(--vscode-sideBar-border);
border-radius: 999px;
font-size: type.$font-size-2xs;
white-space: nowrap;
}
.stateReady {
color: var(--vscode-testing-iconPassed, #73c991);
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 10%, transparent);
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 28%, transparent);
}
.pluginMeta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-2xs;
}
.pluginAuthor {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.85;
}
.cardActions {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 7px;
> button {
min-width: 0;
flex: 0 1 auto;
}
}
// 主操作靠左,"更多"推到行尾,两端对齐。
.moreAction {
display: flex;
flex: 0 0 auto;
margin-left: auto;
}
.gate,
.empty {
min-height: 250px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 9px;
color: var(--vscode-descriptionForeground);
text-align: center;
border: 1px dashed var(--vscode-sideBar-border);
border-radius: var(--oa-overlay-radius);
strong {
color: var(--vscode-foreground);
font-size: type.$font-size-base;
}
span {
max-width: 500px;
font-size: type.$font-size-xs;
}
}
.gate button {
margin-top: 6px;
}
.progressBar {
width: 100%;
height: 8px;
overflow: hidden;
background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent);
border-radius: 999px;
}
// 反向斜纹(-45°)+ 无限反向滚动;未知总量时以 100% 宽度作不确定态。
.progressFill {
height: 100%;
background-color: var(--vscode-progressBar-background, #0e70c0);
background-image: linear-gradient(
-45deg,
rgb(255 255 255 / 24%) 25%,
transparent 25% 50%,
rgb(255 255 255 / 24%) 50% 75%,
transparent 75%
);
background-size: 24px 24px;
border-radius: 999px;
transition: width 160ms ease;
animation: progress-stripes 0.7s linear infinite;
}
@keyframes progress-stripes {
to {
background-position: -24px 0;
}
}
.progressContent {
display: flex;
flex-direction: column;
gap: 12px;
strong {
font-size: type.$font-size-base;
}
span {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
.error {
color: var(--vscode-errorForeground);
overflow-wrap: anywhere;
}
}
@@ -0,0 +1,324 @@
import { useEffect, useRef, useState } from "react";
import { api, pluginText, type PluginDescriptor, type PluginImportFile, type PluginRuntimePhase, type PluginRuntimeStatus } from "../../shared/api";
import { useI18n } from "../../i18n/store";
import { PageContent } from "../../shell/layout/PageContent";
import { appStore, useAppStore } from "../../shared/store/appStore";
import { ActionMenu } from "../../shared/ui/ActionMenu";
import { Button } from "../../shared/ui/Button";
import { Card } from "../../shared/ui/Card";
import { Modal } from "../../shared/ui/Modal";
import { useMessage } from "../../shared/ui/message";
import { TruncatedButton } from "../../shared/ui/TruncatedButton";
import { PluginAddPanel, PluginSettingsPanel } from "./PluginResourcePanels";
import styles from "./PluginManagementPage.module.scss";
export function PluginManagementPage() {
const { pluginRuntime, plugins } = useAppStore();
const [progressOpen, setProgressOpen] = useState(false);
const [starting, setStarting] = useState(false);
const [selected, setSelected] = useState<{ pluginId: string; mode: "add" | "settings" } | null>(null);
const cancelRequested = useRef(false);
const selectedPlugin = selected ? plugins.find((plugin) => plugin.id === selected.pluginId) ?? null : null;
useEffect(() => {
if (!pluginRuntime) void appStore.refreshPluginRuntime();
}, [pluginRuntime]);
useEffect(() => {
if (pluginRuntime?.state !== "initializing") return;
if (!cancelRequested.current) setProgressOpen(true);
const timer = window.setInterval(() => void appStore.refreshPluginRuntime(), 300);
return () => window.clearInterval(timer);
}, [pluginRuntime?.state]);
const initialize = async () => {
if (starting) return;
cancelRequested.current = false;
setStarting(true);
setProgressOpen(true);
const status = await appStore.initializePluginRuntime();
setStarting(false);
if (!status) {
setProgressOpen(false);
} else if (cancelRequested.current && status.state === "initializing") {
void appStore.cancelPluginRuntimeInitialization();
}
};
const closeProgress = () => {
setProgressOpen(false);
cancelRequested.current = true;
if (pluginRuntime?.state === "initializing") {
void appStore.cancelPluginRuntimeInitialization();
}
};
const content = pluginRuntime?.state === "ready"
? <PluginCards plugins={plugins} onOpen={(pluginId, mode) => setSelected({ pluginId, mode })} />
: <RuntimeGate status={pluginRuntime} starting={starting} onInitialize={() => void initialize()} />;
const estimatedHeight = plugins.length > 0
? Math.max(320, Math.ceil(plugins.length / 3) * 180)
: 320;
return <>
<PageContent
title={t("插件配置")}
sections={[{ key: "installed-plugins", estimatedHeight, content }]}
/>
<RuntimeProgressModal
open={progressOpen}
status={pluginRuntime}
starting={starting}
onClose={closeProgress}
/>
<Modal
fullHeight
open={selectedPlugin !== null}
title={selected?.mode === "settings"
? t("{name} 账号管理", { name: selectedPlugin?.name ?? "" })
: t("添加 {name} 账号", { name: selectedPlugin?.name ?? "" })}
onClose={() => setSelected(null)}
>
{selected?.mode === "add" && selectedPlugin && <PluginAddPanel plugin={selectedPlugin} onConfigured={() => setSelected(null)} />}
{selected?.mode === "settings" && selectedPlugin && <PluginSettingsPanel plugin={selectedPlugin} />}
</Modal>
</>;
}
function RuntimeGate({ status, starting, onInitialize }: { status: PluginRuntimeStatus | null; starting: boolean; onInitialize: () => void }) {
const checking = status === null;
const initializing = starting || status?.state === "initializing";
const failed = status?.state === "failed";
const unsupported = status?.state === "unsupported";
const title = checking
? t("正在检查插件运行时")
: failed
? t("插件运行时初始化失败")
: unsupported
? t("当前系统不支持插件运行时")
: t("需要先初始化插件运行时");
const description = failed
? t("请重试初始化")
: unsupported
? status.error ?? t("当前操作系统或 CPU 架构暂不受支持")
: t("初始化将下载并安装插件运行时。");
return <div className={styles.gate}>
<strong>{title}</strong>
<span>{description}</span>
{!unsupported && <Button variant="primary" disabled={checking || initializing} onClick={onInitialize}>
{checking ? t("检查中…") : initializing ? t("初始化中…") : failed ? t("重新初始化插件") : t("初始化插件")}
</Button>}
</div>;
}
function PluginCards({ plugins, onOpen }: {
plugins: PluginDescriptor[];
onOpen: (pluginId: string, mode: "add" | "settings") => void;
}) {
if (plugins.length === 0) {
return <div className={styles.empty}>
<strong>{t("还没有安装插件")}</strong>
<span>{t("安装插件后会显示在这里。")}</span>
</div>;
}
return <div className={styles.pluginGrid}>
{plugins.map((plugin) => <PluginCard key={plugin.id} plugin={plugin} onOpen={onOpen} />)}
</div>;
}
function PluginCard({ plugin, onOpen }: {
plugin: PluginDescriptor;
onOpen: (pluginId: string, mode: "add" | "settings") => void;
}) {
const { locale } = useI18n();
const { ports } = useAppStore();
const message = useMessage();
const importInput = useRef<HTMLInputElement>(null);
const [importing, setImporting] = useState(false);
const configured = plugin.providers.some((provider) => provider.configured);
const accountCount = plugin.resources.reduce((count, resource) => count + resource.resources.length, 0);
const modelCount = plugin.providers.reduce((count, provider) => count + provider.models.length, 0);
const subtitle = plugin.providers.map((provider) => pluginText(provider.displayName, locale)).join(" · ") || plugin.id;
const importResource = plugin.resources.find((resource) => resource.import);
const exportResource = plugin.resources.find((resource) => resource.resources.length > 0);
const importFiles = async (files: FileList | null) => {
if (!files?.length || !importResource) return;
setImporting(true);
try {
const entries: PluginImportFile[] = await Promise.all(
[...files].map(async (file) => ({ name: file.name, content: await file.text() })),
);
const result = await api.importPluginResources(plugin.id, importResource.type, entries);
await appStore.refreshPlugins();
const summary = t("导入完成:新增 {added},更新 {updated}", { added: result.added, updated: result.updated });
if (result.modelSyncError) {
message(t("账号已保存,但同步模型失败:{error}", { error: result.modelSyncError }), { duration: 5000 });
} else if (result.warnings.length > 0) {
message(`${summary} · ${result.warnings.join("; ")}`, { duration: 5000 });
} else {
message(summary);
}
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause), { duration: 5000 });
} finally {
setImporting(false);
if (importInput.current) importInput.current.value = "";
}
};
return (
<Card className={styles.pluginCard}>
<div className={styles.pluginCardTop}>
<img className={styles.pluginIcon} src={plugin.icon} />
<div className={styles.pluginIdentity}>
<span className={styles.pluginName}>{plugin.name}</span>
<span className={styles.pluginId}>{subtitle}</span>
</div>
<span
className={`${styles.stateBadge} ${configured ? styles.stateReady : ""}`}
>
{configured ? t("已配置") : t("未配置")}
</span>
</div>
<div className={styles.pluginMeta}>
<span>
{t("{accounts} 个账号 · {models} 个模型", {
accounts: accountCount,
models: modelCount,
})}
</span>
<span className={styles.pluginAuthor}>
{[`v${plugin.version}`, plugin.author].filter(Boolean).join(" · ")}
</span>
</div>
<div className={styles.cardActions}>
<TruncatedButton
size="small"
variant="primary"
label={t("添加账号")}
onClick={() => onOpen(plugin.id, "add")}
/>
{configured && (
<TruncatedButton
size="small"
label={t("账号管理")}
onClick={() => onOpen(plugin.id, "settings")}
/>
)}
{(importResource || exportResource) && (
<span className={styles.moreAction}>
<ActionMenu
label={t("更多")}
items={[
...(importResource
? [
{
id: "import",
label: importing ? t("正在导入…") : t("批量导入"),
disabled: importing,
onSelect: () => importInput.current?.click(),
},
]
: []),
...(exportResource
? [
{
id: "export",
label: t("批量导出"),
onSelect: () =>
void api.openExternalUrl(
api.pluginResourceExportUrl(
ports.service_port,
plugin.id,
exportResource.type,
),
),
},
]
: []),
]}
/>
</span>
)}
{importResource && (
<input
ref={importInput}
type="file"
hidden
accept={importResource.import?.accept.join(",")}
multiple={importResource.import?.multiple ?? false}
onChange={(event) => void importFiles(event.target.files)}
/>
)}
</div>
</Card>
);
}
function RuntimeProgressModal({ open, status, starting, onClose }: { open: boolean; status: PluginRuntimeStatus | null; starting: boolean; onClose: () => void }) {
const initializing = starting || status?.state === "initializing";
const downloaded = status?.downloaded_bytes ?? 0;
const total = status?.total_bytes ?? null;
const percent = total && total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : null;
const stage = status?.state === "ready"
? t("插件运行时初始化完成")
: status?.state === "failed"
? t("插件运行时初始化失败")
: phaseText(status?.phase ?? null);
return <Modal
open={open}
title={t("初始化插件运行时")}
closeLabel={status?.state === "ready" ? t("完成") : initializing ? t("取消") : t("关闭")}
onClose={onClose}
>
<div className={styles.progressContent} aria-live="polite">
<strong>{stage}</strong>
{status?.phase === "downloading" && <>
<div
className={styles.progressBar}
role="progressbar"
aria-label={t("下载进度")}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent ?? undefined}
>
<div
className={styles.progressFill}
style={{ width: `${percent ?? 100}%` }}
/>
</div>
<span>
{total ? t("已下载 {downloaded} / {total}", { downloaded: formatBytes(downloaded), total: formatBytes(total) }) : t("已下载 {downloaded}", { downloaded: formatBytes(downloaded) })}
</span>
</>}
{status?.state === "failed" && <span className={styles.error}>{t("请重试初始化")}</span>}
{status?.state === "ready" && <span>{t("插件运行时 {version} 已安装,可以开始使用插件。", { version: status.version })}</span>}
</div>
</Modal>;
}
function phaseText(phase: PluginRuntimePhase | null) {
switch (phase) {
case "checking": return t("正在检查插件运行时");
case "downloading": return t("正在下载插件运行时");
case "verifying": return t("正在验证插件运行时下载文件");
case "installing": return t("正在安装插件运行时");
case "validating": return t("正在验证插件运行时");
default: return t("正在准备插件运行时");
}
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let value = bytes / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value < 10 ? value.toFixed(1) : value.toFixed(0)} ${units[unit]}`;
}
@@ -0,0 +1,156 @@
@use "../../styles/typography" as type;
.panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.methodCard {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
> span {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.actions,
.toolbar,
.pagination {
display: flex;
align-items: center;
gap: 8px;
}
.deviceCode {
display: flex;
align-items: center;
gap: 10px;
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
button {
padding: 6px 10px;
color: var(--vscode-foreground);
background: var(--vscode-textCodeBlock-background);
border: 1px solid var(--vscode-sideBar-border);
border-radius: 4px;
font-family: var(--vscode-editor-font-family);
letter-spacing: 0.08em;
cursor: pointer;
}
button.copy {
padding: 6px 2px;
color: var(--vscode-textLink-foreground);
background: none;
border: none;
font-family: inherit;
letter-spacing: normal;
font-size: type.$font-size-xs;
&:hover {
text-decoration: underline;
}
}
}
.fileButton {
align-self: flex-start;
padding: 5px 10px;
color: var(--vscode-button-foreground);
background: var(--vscode-button-background);
border-radius: 4px;
font-size: type.$font-size-xs;
cursor: pointer;
input {
display: none;
}
}
.toolbar {
flex-wrap: wrap;
input {
min-width: 180px;
flex: 1 1 220px;
}
}
.resourceSection {
display: flex;
flex-direction: column;
gap: 8px;
}
.resourceList {
display: flex;
flex-direction: column;
gap: 8px;
}
.providerRow,
.resourceRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
> div:first-child {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
span {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.ready {
color: var(--vscode-testing-iconPassed, #73c991) !important;
}
.cooling {
color: var(--vscode-editorWarning-foreground, #cca700) !important;
}
.invalid {
color: var(--vscode-errorForeground, #f48771) !important;
}
.success {
color: var(--vscode-testing-iconPassed, #73c991);
font-size: type.$font-size-xs;
}
.empty {
padding: 24px;
color: var(--vscode-descriptionForeground);
text-align: center;
}
.pagination {
justify-content: center;
span {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.error {
color: var(--vscode-errorForeground, #f48771);
font-size: type.$font-size-xs;
}
@@ -0,0 +1,288 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
api,
pluginText,
type PluginAddMethod,
type PluginDescriptor,
type PluginOAuthBegin,
type PluginProviderDescriptor,
type PluginResourceDescriptor,
type PluginResourceView,
} from "../../shared/api";
import { useI18n } from "../../i18n/store";
import { appStore } from "../../shared/store/appStore";
import { Button } from "../../shared/ui/Button";
import { Card } from "../../shared/ui/Card";
import { FormField, TextInput } from "../../shared/ui/FormControls";
import styles from "./PluginResourcePanels.module.scss";
const PAGE_SIZE = 10;
export function PluginAddPanel({ plugin, onConfigured }: { plugin: PluginDescriptor; onConfigured: () => void }) {
return <div className={styles.panel}>
{plugin.resources.map((resource) => <ResourceAddSection
key={resource.type}
plugin={plugin}
resource={resource}
onConfigured={onConfigured}
/>)}
{plugin.resources.length === 0 && <span className={styles.empty}>{t("该插件不需要添加资源")}</span>}
</div>;
}
function ResourceAddSection({ plugin, resource, onConfigured }: {
plugin: PluginDescriptor;
resource: PluginResourceDescriptor;
onConfigured: () => void;
}) {
return <>
{resource.add.map((method) => <OAuthMethodCard
key={method.id}
pluginId={plugin.id}
resourceType={resource.type}
method={method}
onConfigured={onConfigured}
/>)}
</>;
}
function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
pluginId: string;
resourceType: string;
method: PluginAddMethod;
onConfigured: () => void;
}) {
const { locale } = useI18n();
const [status, setStatus] = useState<"idle" | "starting" | "polling" | "success" | "error">("idle");
const [begun, setBegun] = useState<PluginOAuthBegin | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const stopped = useRef(false);
const copyCode = async (code: string) => {
await api.copyCursorText(code).catch(() => undefined);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
};
useEffect(() => () => { stopped.current = true; }, []);
useEffect(() => {
if (!begun || status !== "polling") return;
let timer = 0;
const poll = async (intervalMs: number) => {
if (stopped.current) return;
try {
const result = await api.pluginOAuthPoll(begun.sessionId);
if (stopped.current) return;
if (result.status === "pending") {
timer = window.setTimeout(() => void poll(result.pollIntervalMs), Math.max(1000, result.pollIntervalMs));
return;
}
if (result.status === "completed") {
await appStore.refreshPlugins();
if (result.modelSyncError) {
setStatus("error");
setError(t("账号已保存,但同步模型失败:{error}", { error: result.modelSyncError }));
return;
}
setStatus("success");
onConfigured();
return;
}
setStatus("error");
setError(result.message || t("授权被拒绝或已失败。"));
} catch (cause) {
if (stopped.current) return;
setError(errorText(cause));
timer = window.setTimeout(() => void poll(intervalMs), Math.max(1000, intervalMs));
}
};
timer = window.setTimeout(() => void poll(begun.pollIntervalMs), Math.max(1000, begun.pollIntervalMs));
return () => window.clearTimeout(timer);
}, [begun, onConfigured, status]);
const start = async () => {
setStatus("starting");
setError(null);
try {
const next = await api.pluginOAuthBegin(pluginId, resourceType, method.id);
setBegun(next);
setStatus("polling");
await api.copyCursorText(next.userCode).catch(() => undefined);
await api.openExternalUrl(next.verificationUrlComplete || next.verificationUrl);
} catch (cause) {
setStatus("error");
setError(errorText(cause));
}
};
return <Card className={styles.methodCard}>
<strong>{pluginText(method.displayName, locale)}</strong>
{method.description && <span>{pluginText(method.description, locale)}</span>}
{begun && status === "polling" && <div className={styles.deviceCode}>
<small>{t("设备验证码")}</small>
<button type="button" onClick={() => void copyCode(begun.userCode)}>{begun.userCode}</button>
<button type="button" className={styles.copy} onClick={() => void copyCode(begun.userCode)}>
{copied ? t("已复制") : t("复制")}
</button>
</div>}
<div className={styles.actions}>
<Button variant="primary" disabled={status === "starting" || status === "polling"} onClick={() => void start()}>
{status === "starting" ? t("正在申请授权码…") : status === "polling" ? t("等待网页端确认授权中…") : t("开始登录")}
</Button>
{begun && status === "polling" && <Button onClick={() => void api.openExternalUrl(begun.verificationUrlComplete || begun.verificationUrl)}>{t("打开授权网页")}</Button>}
</div>
{status === "success" && <span className={styles.success}>{t("账号已保存,模型目录已同步。")}</span>}
{error && <span className={styles.error} role="alert">{error}</span>}
</Card>;
}
export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const run = async (key: string, task: () => Promise<void>) => {
setBusy(key);
setError(null);
try {
await task();
await appStore.refreshPlugins();
} catch (cause) {
setError(errorText(cause));
} finally {
setBusy(null);
}
};
return <div className={styles.panel}>
{plugin.providers.map((provider) => <ProviderRow
key={provider.id}
provider={provider}
busy={busy !== null}
syncing={busy === `sync:${provider.id}`}
onSync={() => void run(`sync:${provider.id}`, async () => {
await api.syncPluginModels(plugin.id, provider.id);
})}
/>)}
{plugin.resources.map((resource) => <ResourceList
key={resource.type}
resource={resource}
busy={busy !== null}
onRefresh={(item) => void run(`refresh:${item.id}`, async () => {
await api.refreshPluginResource(plugin.id, resource.type, item.id);
})}
onDelete={(item) => void run(`delete:${item.id}`, async () => {
await api.deletePluginResource(plugin.id, resource.type, item.id);
})}
/>)}
{error && <span className={styles.error} role="alert">{error}</span>}
</div>;
}
function ProviderRow({ provider, busy, syncing, onSync }: {
provider: PluginProviderDescriptor;
busy: boolean;
syncing: boolean;
onSync: () => void;
}) {
const { locale } = useI18n();
return <Card className={styles.providerRow}>
<div>
<strong>{pluginText(provider.displayName, locale)}</strong>
<span>
{provider.providerType}
{" · "}
{provider.models.length > 0 ? t("{count} 个模型", { count: provider.models.length }) : t("尚未同步模型")}
{" · "}
{provider.configured ? t("可调用") : t("未就绪")}
</span>
</div>
{provider.hasModels && <Button size="small" disabled={busy} onClick={onSync}>
{syncing ? t("正在同步…") : t("同步模型")}
</Button>}
</Card>;
}
function ResourceList({ resource, busy, onRefresh, onDelete }: {
resource: PluginResourceDescriptor;
busy: boolean;
onRefresh: (item: PluginResourceView) => void;
onDelete: (item: PluginResourceView) => void;
}) {
const { locale } = useI18n();
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const filtered = useMemo(
() => resource.resources.filter((item) => item.displayName.toLowerCase().includes(query.trim().toLowerCase())),
[resource.resources, query],
);
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const visible = filtered.slice((Math.min(page, pageCount) - 1) * PAGE_SIZE, Math.min(page, pageCount) * PAGE_SIZE);
useEffect(() => setPage(1), [query]);
return <FormField label={pluginText(resource.displayName, locale)}>
<div className={styles.resourceSection}>
{resource.resources.length > PAGE_SIZE && <div className={styles.toolbar}>
<TextInput aria-label={t("搜索资源")} placeholder={t("搜索资源")} value={query} onChange={(event) => setQuery(event.target.value)} />
</div>}
<div className={styles.resourceList}>
{visible.map((item) => <ResourceRow
key={item.id}
item={item}
canRefresh={resource.canRefresh}
disabled={busy}
onRefresh={() => onRefresh(item)}
onDelete={() => onDelete(item)}
/>)}
{visible.length === 0 && <span className={styles.empty}>{t("还没有资源,请先添加。")}</span>}
</div>
{pageCount > 1 && <div className={styles.pagination}>
<Button size="small" disabled={page <= 1} onClick={() => setPage((current) => current - 1)}>{t("上一页")}</Button>
<span>{t("第 {page} / {total} 页", { page: Math.min(page, pageCount), total: pageCount })}</span>
<Button size="small" disabled={page >= pageCount} onClick={() => setPage((current) => current + 1)}>{t("下一页")}</Button>
</div>}
</div>
</FormField>;
}
function ResourceRow({ item, canRefresh, disabled, onRefresh, onDelete }: {
item: PluginResourceView;
canRefresh: boolean;
disabled: boolean;
onRefresh: () => void;
onDelete: () => void;
}) {
const { locale } = useI18n();
return <Card className={styles.resourceRow}>
<div>
<strong>{item.displayName}</strong>
{item.description && <span>{pluginText(item.description, locale)}</span>}
{item.metrics.map((metric) => <span key={metric.id}>
{metric.unit === "percent"
? t("{label} 剩余 {percent}%", { label: pluginText(metric.label, locale), percent: Math.round(metric.value) })
: `${pluginText(metric.label, locale)}: ${metric.value}`}
</span>)}
</div>
<div className={styles.actions}>
<StateBadge state={item.state} />
{canRefresh && <Button size="small" disabled={disabled} onClick={onRefresh}>{t("刷新")}</Button>}
<Button size="small" disabled={disabled} onClick={onDelete}>{t("删除")}</Button>
</div>
</Card>;
}
function StateBadge({ state }: { state: PluginResourceView["state"] }) {
if (state.status === "cooling") {
return <span className={styles.cooling} title={state.message ?? undefined}>{t("冷却中")}</span>;
}
if (state.status === "invalid") {
return <span className={styles.invalid} title={state.message ?? undefined}>{t("已失效")}</span>;
}
return <span className={styles.ready}>{t("可用")}</span>;
}
function errorText(cause: unknown) {
return cause instanceof Error ? cause.message : String(cause);
}
@@ -95,7 +95,8 @@ export function AppLifecycleSettingsCard() {
const nextVersion = await updateStore.check();
message(nextVersion ? t("发现新版本 {version}", { version: nextVersion }) : t("当前已是最新版本"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
const error = cause instanceof Error ? cause.message : String(cause);
message(t("检查更新失败:{error}", { error }));
}
};
@@ -103,7 +104,8 @@ export function AppLifecycleSettingsCard() {
try {
await updateStore.install();
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
const error = cause instanceof Error ? cause.message : String(cause);
message(t("安装更新失败:{error}", { error }));
}
};
File diff suppressed because it is too large Load Diff
+71 -3
View File
@@ -2,18 +2,23 @@
"0006d696d8e1ec28": "New",
"00929f23850e4ff0": "Successful calls: {count}",
"01f3e69a5a9b2c9b": "The key required to access the model service.",
"023810003eb4563d": "{count} models",
"028a4de61bff743d": "Regular input: {tokens} × ${price}/1M = {cost}",
"028c60a8a8e30a1b": "Page {page} / {total}",
"03ff62ab4b818492": "Cache write: {tokens} × ${price}/1M = {cost}",
"051836569928a9f9": "Edit",
"05468af47054d488": "Connectivity test for {model} succeeded ({duration} ms)",
"0580e0a99a6f1afc": "Artifacts",
"06619f339fa0ab46": "Preparing the plugin runtime",
"076832c1b2de22c3": "Cache write: {tokens}",
"07879e064ae16542": "Estimated output: {tokens}",
"07c657ed4747126e": "Anthropic extra parameters",
"08791ba06e7441de": "{accounts} accounts · {models} models",
"092b520558eff5f2": "Not tested",
"099008ea7a42ebd1": "All custom Header values must be strings",
"09ebc2643631ba25": "Estimated value",
"0b96da34f6fbdd3b": "Cache read: {tokens} × ${price}/1M = {cost}",
"0bbb2c0ce279d6d5": "Models not synced yet",
"0c70665b6eb65f1a": "No",
"0c72229b7db0e1a9": "Model output",
"0d2dab3d62eb73d6": "All statistics cleared",
@@ -33,8 +38,11 @@
"168e845a86bc3703": "Add model",
"16d0d7e2b332af72": "Total calls: {count}",
"1813d362a82fd437": "Maximize window",
"18165f8865eacc91": "No plugins installed",
"19658d9fa9aa8de4": "Installing…",
"1a3f0617d6de8e52": "Username",
"1a60c9eb3cf1dbb5": "Import finished: {added} added, {updated} updated",
"1aa65c55c6cc6163": "Device code",
"1ae6b0a0f8266382": "Close window",
"1b5932b8946d2d68": "Delete model",
"1b7d5b1a9315fc64": "Calculating…",
@@ -50,12 +58,14 @@
"22d7895ea5fca72e": "By provider",
"23ae7a90b1b9816d": "Clear scope",
"23e49479e15e6770": "Version {version} is available",
"2400fbd0aeab9e13": "Downloaded {downloaded}",
"24a0a24864454575": "Existing, skipped",
"2555d6c7fbb7e070": "Enter a model ID directly or load models returned by the API.",
"29585d7193539200": "Current version {version}",
"29fbbef32a6eb58b": "Do not show this ad again",
"2a2773134a829016": "Aggregated from historical LLM calls; in-progress calls are excluded.",
"2caeaec539e78898": "Thinking budget tokens",
"2cbc58108d78b06c": "Waiting for browser authorization…",
"2cd0f3be8738a86c": "Cancel",
"2d30c2a98ebb5278": "Current: {rate}",
"2eb2bf7c6597ab9a": "Detailed records",
@@ -67,12 +77,17 @@
"2f7ba5fd1d12f7f9": "Open the tutorial?",
"2f7dec3be28d7597": "{count} selected",
"2f9daa828907b93f": "Delete",
"2fe5a8d0eee9f14c": "Invalid",
"303c30f301514250": "Search resources",
"3260348163d03b8e": "Leave blank to keep unchanged",
"32896fdaaaa4c106": "Account saved and the model catalog is synced.",
"346ff60e6c7c5181": "Reading…",
"36f33adaf0942634": "Confirm",
"37125ef2e1d707cb": "Server address or complete request URL, API Key, model name, display name, and note are required",
"378bb0eec39fa8a2": "Last page",
"37cb98ff4d5dcfcc": "Successful {successful} / failed {failed}",
"382f2e3419a02fef": "Only clear detailed records",
"38844b135cf70dfc": "More",
"393e1241552b1870": "Request",
"398f8e6c6f0a0b97": "Continue selecting or typing",
"39f52eee100131d7": "Cached input",
@@ -88,6 +103,7 @@
"3f6c25aa329163a4": "The original endpoint path is appended to this service address.",
"3fd118e2ffe0b2b6": "Cancel all tests",
"3fd47edce45b3603": "Close",
"402495402ce333b1": "Reinitialize plugins",
"40a08e7cf320ae07": "Clear detailed records?",
"4125fc7ba333524c": "Default light",
"42655ed8e4108ae2": "Input (non-cached)",
@@ -97,6 +113,7 @@
"461d6a57900c2ed7": "Connectivity test failed: {error}",
"470049252e54de6a": "Success rate: {rate}",
"47d1c20aa017ff05": "Hide the main window on startup and keep only the tray icon.",
"48a3bf87eb254591": "Start sign-in",
"48b970b568a7f8f9": "Proxy settings",
"48d8db17bae06246": "{count} total",
"492042ed1fdc29ed": "Version {version} is ready to install",
@@ -104,9 +121,12 @@
"497c85690c4cc0fc": "No data",
"499c729eb09aa2a6": "Context window tokens",
"49be72e6045c007d": "Cancel test",
"4a861200ad513a3c": "Initialize plugin runtime",
"4a8d6841b4023edf": "Confirm import",
"4aca6a31090fe2b8": "Initializing…",
"4b458e6e147221d7": "The standard endpoint path is appended automatically for the selected protocol.",
"4d0680f9efaef147": "Unread",
"4d99c976beb8827e": "Ready",
"4e30d7c9ed2b0eee": "Not set",
"4eafa9e925b30bcd": "Custom",
"51d04bc3d286f018": "Last calendar day",
@@ -127,6 +147,7 @@
"5b17f59d33bde39e": "Error: {error}",
"5ba65a74c4e792c5": "By type",
"5c55a67935af8f45": "All",
"5c62e36c152dfc7c": "Plugin runtime initialized",
"5d59857bf039cac9": "Cursor Assistant v{version}",
"5f8d556a9c47da3c": "Launch at login disabled",
"5f9acfb945229062": "Are you sure you no longer want to see this ad?",
@@ -158,53 +179,72 @@
"72644ec4389da2f7": "Default layout",
"736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.",
"7392e20d61abaa07": "Also store complete requests and streamed responses; by default only timing, status, and usage are stored.",
"77c9e582e85583af": "Test failed",
"788db1cfec2a3db5": "Theme",
"7995087e5a3dfe66": "Restore window",
"7a2229f6a6d330a5": "Open a terminal from the desktop app to install the CA",
"7a3cec4ca715de80": "Call statistics",
"7ba2d6728fe2531b": "Confirm clear",
"7c10d97162c96dbd": "Validating the plugin runtime",
"7cea2f3c46565d29": "OpenAI extra parameters",
"7d9f043f8f7ab45c": "Version {version} is available in Settings",
"7e0891860c9e6374": "TAB service address is required",
"7e7df68f2a82e09e": "Importing the same configuration again will not create duplicate models. Existing models are skipped automatically.",
"7f3c8312816fe26a": "Refreshing…",
"7f68ebad19ba6bcd": "Check for updates",
"802b0faf0ceb513e": "{label}: {percent}% left",
"80a57e03f0717f91": "Not configured",
"811a3b22a5a7f2d5": "Unable to connect to the local management service",
"8213941f12320ce1": "This operating system or CPU architecture is not currently supported",
"83c4efccd9a6bf69": "Connectivity test cancelled: {successful} succeeded, {failed} failed",
"83e8d0b7aff2b394": "Downloaded {downloaded} / {total}",
"83fcfb4c1f2c1641": "Fetch models",
"842b9f11cdd96bda": "Launch at login",
"843ac7e15a5047a7": "Confirm legacy model configuration import",
"864597982c308d72": "Silent start enabled",
"86b7355ec3bd55ef": "Hide API Key",
"86de7c4ee8fa7689": "Sync models",
"8716e1344b0daddb": "Cursor official",
"878a8ab176429a86": "View instructions",
"8911e4f1407d58cb": "Downloading the plugin runtime",
"89a101b809be7cfc": "This address is used exactly as entered without changing or appending the request path.",
"8a8542f6964852dc": "Next page",
"8b6ff498515bcc2f": "Time",
"8cbcf741e727dbf7": "Models",
"8ccaf87ddb9ca3f4": "Legacy configuration",
"8d0c47eb9eac2d34": "Call type",
"8df48894086d6fbd": "Reason (optional)",
"8e2d04638a11a7cb": "Only determines the request and response format; it does not change the request URL.",
"8ea973394446abba": "Cursor Configuration",
"8f6f8d979c981ced": "Copied",
"8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.",
"90800c48a1dd0655": "{label} must be a JSON object",
"919cb0ce0c8db4e7": "Leave blank to keep the current password",
"91aaf184cfc17ffd": "Overview",
"91af6e57e7453fbe": "Add account",
"92156a483d4ba248": "Only request, response, and trace attachments are deleted; call summaries, metrics, and configuration are kept.",
"92e26b27d5ea8f0e": "Failed to check for updates: {error}",
"940a168911ade998": "Items per page",
"945fb1c67eca8493": "Installing the plugin runtime",
"946b3ffc02f026c0": "Delete this model?",
"954ec984cd4f49d1": "Syncing…",
"966498853d801a52": "TAB connection",
"9850ed41a5bfbb0c": "{count} selected",
"997ec8201c2adeda": "Open terminal to install CA",
"9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?",
"9b9bc9cd7c76406f": "Open authorization page",
"9c41b3a9e12ac994": "Reasoning effort",
"9db205c6055bacc4": "Plugin runtime initialization failed",
"9e356080c56877f8": "Silent start disabled",
"9e46da6923836182": "For example: 2026-08-23 09:00, 1 hour ago",
"9ebeab8c4532d671": "{name} accounts",
"9ec4caa5fe43b8e3": "Installed plugins will appear here.",
"9ed11266ead88f5b": "Verifying the plugin runtime download",
"9ef7da883941091c": "The account was saved, but model sync failed: {error}",
"9f6fee1aba17a565": "Language",
"9fb48101d237ff96": "Last week",
"a026f37e613cf48b": "Output Tokens",
"a03a1a0cb35414f8": " must be an integer from 0 to 65535",
"a0c42c24e74f8380": "{name} Copy",
"a12ee6a3e98a29c2": "Hide sensitive content",
"a1a42cd9b16e2162": "Application",
"a1b8c98f29374a2f": "Silent start",
"a3030bf8f16dc63c": "Save",
@@ -215,10 +255,12 @@
"a4d222236dc1003d": "Failed to cancel test: {error}",
"a5fb6189a8ad011d": "Open tutorial",
"a621ab606db2a11f": "Password",
"a66e11477dcc97c1": "Add {name} account",
"a693d69af48bfe48": "Save and test",
"a748cc074f78de00": "View details",
"a7617f42f898b2bf": "Use complete request URL",
"a8036485f9227f2c": "Drag to reorder",
"a80b53f8848e6d27": "Failed to install update: {error}",
"a98585871c5313ff": "Display name",
"ab9084a640fbb864": "Deselect all",
"abecab6701177721": "Launch at login enabled",
@@ -230,7 +272,10 @@
"aee88743413144a2": "Refresh",
"b06325c5660f0c29": "Direct",
"b16c3b2ecedd6fe1": "Cursor integration is active. Add a model configuration to use a BYOK model.",
"b254ff315d861346": "Try initializing again",
"b2617bf9ae663752": "Group settings",
"b4411558b932266f": "Provider type",
"b4c9e08870d41aa2": "Initialize the plugin runtime first",
"b502b1d414664337": "Prompt: {tokens}",
"b5141d3d19e9a048": "Yes",
"b6725f218ebaef26": "Dock icon shown",
@@ -243,14 +288,20 @@
"b9670c85a4ab939e": "Route",
"b9af2de88d903be7": "Proxy address",
"ba5865fbc734e672": "For example: Primary model",
"ba6403d22876d626": "Cooling down",
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
"bb2b7736433ae867": "Cursor tracing",
"bb7efdcb6af6e805": "Default dark",
"bda62ce1d5e4ace9": "Tell us why",
"bda74b5674b6a57d": "Initialize plugins",
"be961dc60ab610da": "Applies to every model in this group when changed; leave blank to keep each model's current configuration.",
"bf57afd709694b55": "Overview time range",
"bfc01caf9fe0c841": "Cache hit rate {rate}",
"c0b3fbff51ccc40b": "Done",
"c1e98892a77f7a19": "{count} per page",
"c3760858cdb6d9f4": "Request body",
"c54863655e879b36": "Plugin runtime is not supported on this system",
"c6e7e1a9da356efc": "No resources yet. Add one first.",
"c7ea2c9bc43134bd": "Edit model",
"c8c14507b2d37395": "Reasoning effort",
"c8df3c14a003bfcd": "Unable to load call details",
@@ -258,6 +309,7 @@
"c9b9ae7a61444ab7": "Previous page",
"c9d146d006993cc1": "Cache statistics policy: default ({rate})",
"cb2f1709f983d2f4": "Model name",
"cb99f0138b032687": "Initialization downloads and installs the plugin runtime.",
"cea1aafe9416de7b": "Request headers",
"cfae1a14d2120c57": "Detailed mode",
"cfe085015632e9c8": "The local management service port used by the desktop frontend. Enter 0 to select a random port at startup.",
@@ -265,12 +317,17 @@
"d1251cd752d4ec25": "Leave blank to use adaptive thinking.",
"d1a3d72618d1ed27": "All call summaries, detailed content, and trace records will be deleted. Model configuration, CA, and application settings are unaffected. This action cannot be undone.",
"d2d648bd1c94b7f9": "Authentication",
"d2fcdde81f06645c": "Bulk export",
"d34335433395cd3a": "Start Cursor BYOK automatically after signing in.",
"d3716cc5a2f5a810": "Server address",
"d3d21191f32e79a5": "Processing…",
"d507652243a2151e": "Show sensitive content",
"d58c88688e1a949d": "Presets",
"d59e47070f7f358e": "Callable",
"d60669bb26a22f5d": "Leave blank to use the default",
"d6b1f203680f5496": "Leave blank to use adaptive thinking",
"d766536c18e8e990": "Plugin runtime {version} is installed and ready to use.",
"d7e266bdc8064193": "Group name",
"d86fa42c3848c680": "Use system proxy",
"d8c47e9776cf1082": "Main menu",
"da521d1c1cbd36af": "Authorization is required to install the certificate",
@@ -279,18 +336,22 @@
"db340a9896306d08": "Test cancelled",
"dbd3596e4a86f3c2": "Configured models",
"ddde16f8839da3ce": "Total requests",
"de8184da1ef88d03": "Configured",
"dea7749c4cd77e6d": "Total request Tokens include the prompt and model output.",
"df1baa9f706d970b": "To add",
"df3d58c7d84b85f2": "Settings",
"df8b71c74d9b8478": "Response stream",
"dfb802238b38fbd4": "Enabled",
"e025f1ff71996425": "Set",
"e049096ab5614581": "This plugin does not need any resources.",
"e0fae77446a389a3": "Speed: {speed} tokens/s",
"e1295adecbb77755": "Close ad",
"e14115de7f7c5795": "Token usage over the past year",
"e14f20d572c02611": "Provider call sequence",
"e17a5b9c90cda6ab": "Model duplicated",
"e18516550b9a5105": "No usage",
"e231f1f3428d1c93": "Requesting an authorization code…",
"e24096c81b1a8af4": "Authorization was denied or failed.",
"e24ebe4a866d69bf": "Test failed: {error}",
"e25bf3f419bb68f0": "Call history",
"e3fee05f688708b4": "LLM calls",
@@ -298,6 +359,7 @@
"e5043c7a2b408271": "Last 10 minutes",
"e59ae97924d62f01": "First page",
"e5b9961a0d5242e3": "Port settings saved. Restart the app to apply them.",
"e5c84c9aa7826566": "Not ready",
"e77e3d58b0dcffaa": "Duration",
"e825a2a42c22380e": "Model type",
"e828bd3a0151edc2": "The local CA must be trusted by the system",
@@ -305,17 +367,20 @@
"ea26b760e930a7ca": "Call observability",
"eb11e2df1d8ae387": "Provider URL",
"eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.",
"eb4a3db23661fb52": "Applies to every model in this group and is used as the badge label in Cursor's model picker; clear it to fall back to the server domain.",
"eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.",
"eba54690937bc532": "Manage accounts",
"ed31fbb483ee1b0a": "Actions",
"edc70de18c6da1a6": "Install local CA",
"ee239f3943293f87": "Sunday",
"ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.",
"f2bdc88464c51c2e": "Show API Key",
"f4694c46b1e19602": "Final request type",
"f4dcb6a3ceb32247": "Page {page} of {count}",
"f4fa9f31ea2ae58d": "Token usage calendar for the past year",
"f50276449943286c": "End time",
"f69273dbbebfb3a1": "Format",
"f6dc1b1641600dd0": "Importing…",
"f78265089144369a": "Plugins",
"f78413c36d36f090": "Uses the operating system language by default; unsupported languages fall back to English. Current: {language}",
"f85537d1fd2ef6f6": "Custom overview filters",
"f95ea7f4c063eea7": "Disabled",
@@ -323,11 +388,14 @@
"f9aa11dbb15ce647": "Saturday",
"f9b55ca75425161b": "Response content was not recorded. Enable detailed records and try again.",
"fa5b4b8a751c7d1b": "The local proxy port used by Cursor. Enter 0 to select a random port at startup.",
"fad86bf65f72c747": "Download progress",
"fb11aa6f29827095": "Checking…",
"fbe8778fa8b9bab5": "Initialize the local CA first",
"fc22d1ab9ac73c6f": "Checking the plugin runtime",
"fc3947ebe6b2177b": "Default {defaultRate} / include creation {reuseRate}",
"fcd311fd8ad42462": "Open model list",
"fd415f8e0097c832": "Cache reads and writes are included in prompt-side statistics.",
"fd77192739703811": "Bulk import",
"fdc4cabc370fa3f7": "No options",
"fea405f9b01d1416": "Summary",
"fec45092945f8790": "User guide",
+71 -3
View File
@@ -2,18 +2,23 @@
"0006d696d8e1ec28": "新增",
"00929f23850e4ff0": "成功调用:{count}",
"01f3e69a5a9b2c9b": "访问模型服务所需的密钥。",
"023810003eb4563d": "{count} 个模型",
"028a4de61bff743d": "普通输入:{tokens} × ${price}/1M = {cost}",
"028c60a8a8e30a1b": "第 {page} / {total} 页",
"03ff62ab4b818492": "缓存写入:{tokens} × ${price}/1M = {cost}",
"051836569928a9f9": "编辑",
"05468af47054d488": "模型 {model} 连通性测试成功({duration} ms)",
"0580e0a99a6f1afc": "工件数",
"06619f339fa0ab46": "正在准备插件运行时",
"076832c1b2de22c3": "缓存写入:{tokens}",
"07879e064ae16542": "输出推算:{tokens}",
"07c657ed4747126e": "Anthropic 额外参数",
"08791ba06e7441de": "{accounts} 个账号 · {models} 个模型",
"092b520558eff5f2": "未测试",
"099008ea7a42ebd1": "自定义 Headers 的值必须都是字符串",
"09ebc2643631ba25": "价值估算",
"0b96da34f6fbdd3b": "缓存读取:{tokens} × ${price}/1M = {cost}",
"0bbb2c0ce279d6d5": "尚未同步模型",
"0c70665b6eb65f1a": "否",
"0c72229b7db0e1a9": "模型输出",
"0d2dab3d62eb73d6": "全部统计数据已清理",
@@ -33,8 +38,11 @@
"168e845a86bc3703": "添加模型",
"16d0d7e2b332af72": "总调用:{count}",
"1813d362a82fd437": "最大化窗口",
"18165f8865eacc91": "还没有安装插件",
"19658d9fa9aa8de4": "安装中…",
"1a3f0617d6de8e52": "用户名",
"1a60c9eb3cf1dbb5": "导入完成:新增 {added},更新 {updated}",
"1aa65c55c6cc6163": "设备验证码",
"1ae6b0a0f8266382": "关闭窗口",
"1b5932b8946d2d68": "删除模型",
"1b7d5b1a9315fc64": "计算中…",
@@ -50,12 +58,14 @@
"22d7895ea5fca72e": "按供应商",
"23ae7a90b1b9816d": "清理范围",
"23e49479e15e6770": "发现新版本 {version}",
"2400fbd0aeab9e13": "已下载 {downloaded}",
"24a0a24864454575": "已存在,跳过",
"2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。",
"29585d7193539200": "当前版本 {version}",
"29fbbef32a6eb58b": "不再显示此广告",
"2a2773134a829016": "按历史 LLM 调用记录汇总,进行中的调用不计入。",
"2caeaec539e78898": "思考预算 Token",
"2cbc58108d78b06c": "等待网页端确认授权中…",
"2cd0f3be8738a86c": "取消",
"2d30c2a98ebb5278": "当前:{rate}",
"2eb2bf7c6597ab9a": "详细记录",
@@ -67,12 +77,17 @@
"2f7ba5fd1d12f7f9": "打开使用教程?",
"2f7dec3be28d7597": "已选择 {count} 个",
"2f9daa828907b93f": "删除",
"2fe5a8d0eee9f14c": "已失效",
"303c30f301514250": "搜索资源",
"3260348163d03b8e": "留空保持不变",
"32896fdaaaa4c106": "账号已保存,模型目录已同步。",
"346ff60e6c7c5181": "读取中…",
"36f33adaf0942634": "确认",
"37125ef2e1d707cb": "服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空",
"378bb0eec39fa8a2": "最后一页",
"37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}",
"382f2e3419a02fef": "仅清理详细记录",
"38844b135cf70dfc": "更多",
"393e1241552b1870": "请求",
"398f8e6c6f0a0b97": "继续选择或输入",
"39f52eee100131d7": "缓存输入",
@@ -88,6 +103,7 @@
"3f6c25aa329163a4": "原接口路径会追加到此服务地址。",
"3fd118e2ffe0b2b6": "取消全部测试",
"3fd47edce45b3603": "关闭",
"402495402ce333b1": "重新初始化插件",
"40a08e7cf320ae07": "确定要清理详细记录吗?",
"4125fc7ba333524c": "默认亮色",
"42655ed8e4108ae2": "输入(非缓存)",
@@ -97,6 +113,7 @@
"461d6a57900c2ed7": "连通性测试失败:{error}",
"470049252e54de6a": "成功占比:{rate}",
"47d1c20aa017ff05": "开机启动时不显示主窗口,仅保留系统托盘图标。",
"48a3bf87eb254591": "开始登录",
"48b970b568a7f8f9": "代理设置",
"48d8db17bae06246": "共 {count} 条",
"492042ed1fdc29ed": "版本 {version} 可以安装",
@@ -104,9 +121,12 @@
"497c85690c4cc0fc": "暂无数据",
"499c729eb09aa2a6": "上下文窗口 Token",
"49be72e6045c007d": "取消测试",
"4a861200ad513a3c": "初始化插件运行时",
"4a8d6841b4023edf": "确认导入",
"4aca6a31090fe2b8": "初始化中…",
"4b458e6e147221d7": "系统会根据请求协议自动追加标准端点路径。",
"4d0680f9efaef147": "未读",
"4d99c976beb8827e": "可用",
"4e30d7c9ed2b0eee": "不设置",
"4eafa9e925b30bcd": "自定义",
"51d04bc3d286f018": "近1自然日",
@@ -127,6 +147,7 @@
"5b17f59d33bde39e": "错误:{error}",
"5ba65a74c4e792c5": "按类型",
"5c55a67935af8f45": "全部",
"5c62e36c152dfc7c": "插件运行时初始化完成",
"5d59857bf039cac9": "Cursor 助手 v{version}",
"5f8d556a9c47da3c": "已关闭开机启动",
"5f9acfb945229062": "你确认不想再看到此广告吗?",
@@ -158,53 +179,72 @@
"72644ec4389da2f7": "默认平铺",
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
"7392e20d61abaa07": "额外保存完整请求和流响应;默认只保存时间、状态与用量。",
"77c9e582e85583af": "测试失败",
"788db1cfec2a3db5": "主题",
"7995087e5a3dfe66": "还原窗口",
"7a2229f6a6d330a5": "请在桌面应用中打开终端安装 CA",
"7a3cec4ca715de80": "调用统计",
"7ba2d6728fe2531b": "确认清理",
"7c10d97162c96dbd": "正在验证插件运行时",
"7cea2f3c46565d29": "OpenAI 额外参数",
"7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装",
"7e0891860c9e6374": "TAB 服务地址不能为空",
"7e7df68f2a82e09e": "重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。",
"7f3c8312816fe26a": "刷新中…",
"7f68ebad19ba6bcd": "检查更新",
"802b0faf0ceb513e": "{label} 剩余 {percent}%",
"80a57e03f0717f91": "未配置",
"811a3b22a5a7f2d5": "无法连接本地管理服务",
"8213941f12320ce1": "当前操作系统或 CPU 架构暂不受支持",
"83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}",
"83e8d0b7aff2b394": "已下载 {downloaded} / {total}",
"83fcfb4c1f2c1641": "获取模型",
"842b9f11cdd96bda": "开机启动",
"843ac7e15a5047a7": "确认导入旧版模型配置",
"864597982c308d72": "已开启静默启动",
"86b7355ec3bd55ef": "隐藏 API Key",
"86de7c4ee8fa7689": "同步模型",
"8716e1344b0daddb": "Cursor 官方",
"878a8ab176429a86": "查看说明",
"8911e4f1407d58cb": "正在下载插件运行时",
"89a101b809be7cfc": "系统会原样使用此地址,不追加或修改请求路径。",
"8a8542f6964852dc": "下一页",
"8b6ff498515bcc2f": "时间",
"8cbcf741e727dbf7": "模型配置",
"8ccaf87ddb9ca3f4": "旧版配置",
"8d0c47eb9eac2d34": "调用类型",
"8df48894086d6fbd": "原因(可选)",
"8e2d04638a11a7cb": "只决定请求与响应的格式,不会改变请求地址。",
"8ea973394446abba": "Cursor 配置",
"8f6f8d979c981ced": "已复制",
"8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。",
"90800c48a1dd0655": "{label} 必须是 JSON 对象",
"919cb0ce0c8db4e7": "留空表示保留当前密码",
"91aaf184cfc17ffd": "数据概览",
"91af6e57e7453fbe": "添加账号",
"92156a483d4ba248": "仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。",
"92e26b27d5ea8f0e": "检查更新失败:{error}",
"940a168911ade998": "每页条数",
"945fb1c67eca8493": "正在安装插件运行时",
"946b3ffc02f026c0": "确定删除这个模型吗?",
"954ec984cd4f49d1": "正在同步…",
"966498853d801a52": "TAB 选择",
"9850ed41a5bfbb0c": "已选 {count} 项",
"997ec8201c2adeda": "打开终端安装 CA",
"9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?",
"9b9bc9cd7c76406f": "打开授权网页",
"9c41b3a9e12ac994": "思考强度",
"9db205c6055bacc4": "插件运行时初始化失败",
"9e356080c56877f8": "已关闭静默启动",
"9e46da6923836182": "如:2026-08-23 09:00、1小时前",
"9ebeab8c4532d671": "{name} 账号管理",
"9ec4caa5fe43b8e3": "安装插件后会显示在这里。",
"9ed11266ead88f5b": "正在验证插件运行时下载文件",
"9ef7da883941091c": "账号已保存,但同步模型失败:{error}",
"9f6fee1aba17a565": "语言",
"9fb48101d237ff96": "近一周",
"a026f37e613cf48b": "输出 Token",
"a03a1a0cb35414f8": "必须是 0–65535 之间的整数",
"a0c42c24e74f8380": "{name} 副本",
"a12ee6a3e98a29c2": "隐藏敏感内容",
"a1a42cd9b16e2162": "应用设置",
"a1b8c98f29374a2f": "静默启动",
"a3030bf8f16dc63c": "保存",
@@ -215,10 +255,12 @@
"a4d222236dc1003d": "取消测试失败:{error}",
"a5fb6189a8ad011d": "打开教程",
"a621ab606db2a11f": "密码",
"a66e11477dcc97c1": "添加 {name} 账号",
"a693d69af48bfe48": "保存并测试",
"a748cc074f78de00": "查看详情",
"a7617f42f898b2bf": "使用完整请求地址",
"a8036485f9227f2c": "拖动排序",
"a80b53f8848e6d27": "安装更新失败:{error}",
"a98585871c5313ff": "显示名称",
"ab9084a640fbb864": "全不选",
"abecab6701177721": "已开启开机启动",
@@ -230,7 +272,10 @@
"aee88743413144a2": "刷新",
"b06325c5660f0c29": "直连",
"b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。",
"b254ff315d861346": "请重试初始化",
"b2617bf9ae663752": "分组设置",
"b4411558b932266f": "上游类型",
"b4c9e08870d41aa2": "需要先初始化插件运行时",
"b502b1d414664337": "提示词:{tokens}",
"b5141d3d19e9a048": "是",
"b6725f218ebaef26": "已显示 Dock 栏图标",
@@ -243,14 +288,20 @@
"b9670c85a4ab939e": "路由",
"b9af2de88d903be7": "代理地址",
"ba5865fbc734e672": "例如:主力模型",
"ba6403d22876d626": "冷却中",
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
"bb2b7736433ae867": "Cursor 追踪",
"bb7efdcb6af6e805": "默认暗色",
"bda62ce1d5e4ace9": "可以告诉我们原因",
"bda74b5674b6a57d": "初始化插件",
"be961dc60ab610da": "修改后应用于该分组下的全部模型;留空保持各模型现有配置不变。",
"bf57afd709694b55": "概览时间范围",
"bfc01caf9fe0c841": "缓存命中率 {rate}",
"c0b3fbff51ccc40b": "完成",
"c1e98892a77f7a19": "{count} 条/页",
"c3760858cdb6d9f4": "请求体",
"c54863655e879b36": "当前系统不支持插件运行时",
"c6e7e1a9da356efc": "还没有资源,请先添加。",
"c7ea2c9bc43134bd": "编辑模型",
"c8c14507b2d37395": "推理强度",
"c8df3c14a003bfcd": "无法加载调用详情",
@@ -258,6 +309,7 @@
"c9b9ae7a61444ab7": "上一页",
"c9d146d006993cc1": "缓存统计策略:默认口径({rate})",
"cb2f1709f983d2f4": "模型名称",
"cb99f0138b032687": "初始化将下载并安装插件运行时。",
"cea1aafe9416de7b": "请求头",
"cfae1a14d2120c57": "详细模式",
"cfe085015632e9c8": "桌面前端连接的本地管理服务端口;填写 0 时启动时随机选择。",
@@ -265,12 +317,17 @@
"d1251cd752d4ec25": "留空时使用 adaptive thinking。",
"d1a3d72618d1ed27": "所有调用汇总、详细内容和追踪记录都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
"d2d648bd1c94b7f9": "认证",
"d2fcdde81f06645c": "批量导出",
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
"d3716cc5a2f5a810": "服务器地址",
"d3d21191f32e79a5": "处理中…",
"d507652243a2151e": "显示敏感内容",
"d58c88688e1a949d": "常用预设",
"d59e47070f7f358e": "可调用",
"d60669bb26a22f5d": "留空使用默认值",
"d6b1f203680f5496": "留空使用 adaptive thinking",
"d766536c18e8e990": "插件运行时 {version} 已安装,可以开始使用插件。",
"d7e266bdc8064193": "分组名称",
"d86fa42c3848c680": "使用系统代理",
"d8c47e9776cf1082": "主菜单",
"da521d1c1cbd36af": "需要授权安装证书",
@@ -279,18 +336,22 @@
"db340a9896306d08": "测试已取消",
"dbd3596e4a86f3c2": "配置模型",
"ddde16f8839da3ce": "总请求",
"de8184da1ef88d03": "已配置",
"dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。",
"df1baa9f706d970b": "将新增",
"df3d58c7d84b85f2": "设置",
"df8b71c74d9b8478": "响应流",
"dfb802238b38fbd4": "已启用",
"e025f1ff71996425": "已设置",
"e049096ab5614581": "该插件不需要添加资源",
"e0fae77446a389a3": "速度:{speed} tokens/s",
"e1295adecbb77755": "关闭广告",
"e14115de7f7c5795": "过去一年的 Token 用量",
"e14f20d572c02611": "上游调用序号",
"e17a5b9c90cda6ab": "模型已复制",
"e18516550b9a5105": "无用量",
"e231f1f3428d1c93": "正在申请授权码…",
"e24096c81b1a8af4": "授权被拒绝或已失败。",
"e24ebe4a866d69bf": "测试失败:{error}",
"e25bf3f419bb68f0": "调用详细",
"e3fee05f688708b4": "LLM 调用",
@@ -298,6 +359,7 @@
"e5043c7a2b408271": "近10分钟",
"e59ae97924d62f01": "第一页",
"e5b9961a0d5242e3": "端口设置已保存,重启软件后生效",
"e5c84c9aa7826566": "未就绪",
"e77e3d58b0dcffaa": "耗时",
"e825a2a42c22380e": "模型类型",
"e828bd3a0151edc2": "需要在系统中信任本地 CA",
@@ -305,17 +367,20 @@
"ea26b760e930a7ca": "调用观测",
"eb11e2df1d8ae387": "上游地址",
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
"eb4a3db23661fb52": "应用于该分组下的全部模型,并作为 Cursor 模型选择器中的徽章标签;清空则恢复显示服务器域名。",
"eb77492c9f76a7e1": "安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。",
"eba54690937bc532": "账号管理",
"ed31fbb483ee1b0a": "操作",
"edc70de18c6da1a6": "安装本地 CA",
"ee239f3943293f87": "周日",
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
"f2bdc88464c51c2e": "显示 API Key",
"f4694c46b1e19602": "最终请求类型",
"f4dcb6a3ceb32247": "第 {page} / {count} 页",
"f4fa9f31ea2ae58d": "过去一年的 Token 用量日历",
"f50276449943286c": "结束时间",
"f69273dbbebfb3a1": "格式化",
"f6dc1b1641600dd0": "正在导入…",
"f78265089144369a": "插件配置",
"f78413c36d36f090": "默认跟随操作系统;不支持的系统语言使用英文。当前:{language}",
"f85537d1fd2ef6f6": "自定义概览筛选",
"f95ea7f4c063eea7": "未启用",
@@ -323,11 +388,14 @@
"f9aa11dbb15ce647": "周六",
"f9b55ca75425161b": "未记录响应内容,请开启详细记录后重试。",
"fa5b4b8a751c7d1b": "Cursor 使用的本地代理端口;填写 0 时启动时随机选择。",
"fad86bf65f72c747": "下载进度",
"fb11aa6f29827095": "检查中…",
"fbe8778fa8b9bab5": "需要先初始化本地 CA",
"fc22d1ab9ac73c6f": "正在检查插件运行时",
"fc3947ebe6b2177b": "默认 {defaultRate} / 计入创建 {reuseRate}",
"fcd311fd8ad42462": "打开模型列表",
"fd415f8e0097c832": "缓存读写已计入提示词侧统计。",
"fd77192739703811": "批量导入",
"fdc4cabc370fa3f7": "暂无选项",
"fea405f9b01d1416": "概览",
"fec45092945f8790": "使用教程",
+170 -2
View File
@@ -7,6 +7,7 @@ export interface Model {
model_hash: string;
sort_order: number;
display_name: string;
group_name: string | null;
type: ModelType;
base_url: string;
use_full_url: boolean;
@@ -33,6 +34,7 @@ export interface Model {
export interface ModelInput {
sort_order: number;
display_name: string;
group_name: string | null;
type: ModelType;
base_url: string;
use_full_url: boolean;
@@ -148,6 +150,160 @@ export interface DesktopSettings {
show_dock_icon: boolean;
}
export type PluginRuntimeState = "uninitialized" | "initializing" | "ready" | "failed" | "unsupported";
export type PluginRuntimePhase = "checking" | "downloading" | "verifying" | "installing" | "validating";
export interface PluginRuntimeStatus {
state: PluginRuntimeState;
version: string;
target: string | null;
phase: PluginRuntimePhase | null;
downloaded_bytes: number;
total_bytes: number | null;
error: string | null;
}
/** 插件提供的显示文本:纯字符串或 locale → 文本映射。 */
export type PluginLocalizedText = string | Record<string, string>;
export function pluginText(value: PluginLocalizedText | null | undefined, locale: string): string {
if (!value) return "";
if (typeof value === "string") return value;
if (value[locale]) return value[locale];
const language = locale.split("-")[0].toLowerCase();
for (const [key, text] of Object.entries(value)) {
const normalized = key.toLowerCase();
if (normalized === language || normalized.startsWith(`${language}-`)) return text;
}
return value["en-US"] ?? value["en"] ?? Object.values(value)[0] ?? "";
}
export interface PluginResourceState {
status: "ready" | "cooling" | "invalid";
retryAtMs?: number | null;
message?: string | null;
}
export interface PluginResourceMetric {
id: string;
label: PluginLocalizedText;
unit: "percent" | "count";
value: number;
resetAtMs?: number | null;
}
export interface PluginResourceView {
id: string;
state: PluginResourceState;
displayName: string;
description: PluginLocalizedText | null;
metrics: PluginResourceMetric[];
createdAtMs: number;
}
export interface PluginAddMethod {
type: "oauth2.0";
id: string;
displayName: PluginLocalizedText;
description: PluginLocalizedText | null;
}
export interface PluginImportDescriptor {
displayName: PluginLocalizedText;
description: PluginLocalizedText | null;
accept: string[];
multiple: boolean;
}
export interface PluginResourceDescriptor {
type: string;
displayName: PluginLocalizedText;
add: PluginAddMethod[];
import: PluginImportDescriptor | null;
canRefresh: boolean;
canRemove: boolean;
resources: PluginResourceView[];
}
export interface PluginModelDescriptor {
id: string;
pluginId: string;
pluginName: string;
providerId: string;
modelId: string;
displayName: string;
description: string | null;
icon: string;
providerType: string;
maxOutputTokens: number | null;
images: boolean;
}
export interface PluginProviderDescriptor {
id: string;
pluginId: string;
displayName: PluginLocalizedText;
description: PluginLocalizedText | null;
providerType: string;
resourceType: string | null;
hasModels: boolean;
configured: boolean;
models: PluginModelDescriptor[];
}
export interface PluginDescriptor {
id: string;
name: string;
version: string;
author: string | null;
icon: string;
providers: PluginProviderDescriptor[];
resources: PluginResourceDescriptor[];
}
export interface PluginOAuthBegin {
sessionId: string;
userCode: string;
verificationUrl: string;
verificationUrlComplete: string | null;
expiresAtMs: number;
pollIntervalMs: number;
}
export type PluginOAuthPoll =
| { status: "pending"; pollIntervalMs: number }
| { status: "completed"; added: number; updated: number; modelSyncError: string | null }
| { status: "denied"; message: string | null }
| { status: "failed"; message: string };
export interface PluginImportFile {
name: string;
content: string;
}
export interface PluginImportResult {
added: number;
updated: number;
warnings: string[];
modelSyncError: string | null;
}
export type ConfiguredModel =
| { kind: "builtin"; id: string; name: string; builtin: Model }
| { kind: "plugin"; id: string; name: string; plugin: PluginModelDescriptor };
export function configuredPluginModels(plugins: PluginDescriptor[]): PluginModelDescriptor[] {
return plugins.flatMap((plugin) =>
plugin.providers.flatMap((provider) => provider.configured ? provider.models : []));
}
export function configuredModels(models: Model[], plugins: PluginDescriptor[]): ConfiguredModel[] {
return [
...models.map((model): ConfiguredModel => ({ kind: "builtin", id: model.model_hash, name: model.display_name, builtin: model })),
...configuredPluginModels(plugins).map((model): ConfiguredModel => ({ kind: "plugin", id: model.id, name: model.displayName, plugin: model })),
];
}
export interface OverviewMetrics {
llm_calls: number;
successful_calls: number;
@@ -295,8 +451,8 @@ export const api = {
importV0049Models: () => request<LegacyModelImportResult>("/models/import-v0049", { method: "POST" }),
updateModel: (hash: string, model: ModelInput) => request<Model>(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }),
deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }),
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => {
const params = new URLSearchParams();
if (filter) {
@@ -309,6 +465,18 @@ export const api = {
},
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
plugins: () => request<PluginDescriptor[]>("/plugins"),
pluginOAuthBegin: (pluginId: string, resourceType: string, methodId: string) => request<PluginOAuthBegin>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/add/${encodeURIComponent(methodId)}/begin`, { method: "POST" }),
pluginOAuthPoll: (sessionId: string, signal?: AbortSignal) => request<PluginOAuthPoll>(`/plugins/oauth/${encodeURIComponent(sessionId)}/poll`, { method: "POST", signal }),
importPluginResources: (pluginId: string, resourceType: string, files: PluginImportFile[]) => request<PluginImportResult>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/import`, { method: "POST", body: JSON.stringify(files) }),
refreshPluginResource: (pluginId: string, resourceType: string, resourceId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/refresh`, { method: "POST" }),
deletePluginResource: (pluginId: string, resourceType: string, resourceId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}`, { method: "DELETE" }),
syncPluginModels: (pluginId: string, providerId: string) => request<{ models: number }>(`/plugins/${encodeURIComponent(pluginId)}/providers/${encodeURIComponent(providerId)}/models/sync`, { method: "POST" }),
pluginResourceExportUrl: (servicePort: number, pluginId: string, resourceType: string) => `http://127.0.0.1:${servicePort}${API_ROOT}/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/export`,
removePluginConfiguration: (pluginId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}`, { method: "DELETE" }),
pluginRuntime: () => request<PluginRuntimeStatus>("/plugins/runtime"),
initializePluginRuntime: () => request<PluginRuntimeStatus>("/plugins/runtime", { method: "POST" }),
cancelPluginRuntimeInitialization: () => request<PluginRuntimeStatus>("/plugins/runtime", { method: "DELETE" }),
openCursorCaInstallTerminal: async (command: string) => {
if (!packagedDesktop) throw new Error(t("请在桌面应用中打开终端安装 CA"));
const { invoke } = await import("@tauri-apps/api/core");
+34 -6
View File
@@ -1,5 +1,5 @@
import { getVersion, setDockVisibility } from "@tauri-apps/api/app";
import { isTauri } from "@tauri-apps/api/core";
import { invoke, isTauri } from "@tauri-apps/api/core";
import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart";
import { relaunch } from "@tauri-apps/plugin-process";
import { check, type Update } from "@tauri-apps/plugin-updater";
@@ -46,11 +46,39 @@ export async function writeDockIconVisibility(visible: boolean): Promise<void> {
}
}
export async function checkForUpdate(): Promise<Update | null> {
return check();
export type AppUpdate = {
version: string;
install(): Promise<void>;
close(): Promise<void>;
};
type PortableUpdateInfo = {
version: string;
};
export async function checkForUpdate(): Promise<AppUpdate | null> {
if (desktopPlatform() === "windows") {
const update = await invoke<PortableUpdateInfo | null>("check_portable_update");
if (!update) return null;
return {
version: update.version,
install: () => invoke("install_portable_update", { expectedVersion: update.version }),
close: async () => {},
};
}
const update: Update | null = await check();
if (!update) return null;
return {
version: update.version,
install: async () => {
await update.downloadAndInstall();
await relaunch();
},
close: () => update.close(),
};
}
export async function installUpdate(update: Update): Promise<void> {
await update.downloadAndInstall();
await relaunch();
export async function installUpdate(update: AppUpdate): Promise<void> {
await update.install();
}
+58 -3
View File
@@ -1,5 +1,5 @@
import { useSyncExternalStore } from "react";
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PortSettings } from "../api";
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings } from "../api";
import { applyTheme, isThemeId, type ThemeId } from "../theme/theme";
export type AppSnapshot = {
@@ -13,6 +13,8 @@ export type AppSnapshot = {
theme: ThemeId;
cursorHarness: CursorHarnessStatus | null;
cursorBusy: boolean;
pluginRuntime: PluginRuntimeStatus | null;
plugins: PluginDescriptor[];
};
const savedTheme = (): ThemeId => {
@@ -45,6 +47,8 @@ let snapshot: AppSnapshot = {
theme: savedTheme(),
cursorHarness: null,
cursorBusy: false,
pluginRuntime: null,
plugins: [],
};
const listeners = new Set<() => void>();
@@ -73,15 +77,17 @@ export const appStore = {
async refresh() {
update({ busy: true, error: null });
try {
const [models, calls, overview, settings, ports, cursorHarness] = await Promise.all([
const [models, calls, overview, settings, ports, cursorHarness, pluginRuntime, plugins] = await Promise.all([
api.models(),
api.calls(),
api.overview(),
api.observability(),
api.ports(),
api.cursorHarness(),
api.pluginRuntime(),
api.plugins(),
]);
update({ models, calls, overview, detailed: settings.detailed, ports, cursorHarness });
update({ models, calls, overview, detailed: settings.detailed, ports, cursorHarness, pluginRuntime, plugins });
} catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) });
} finally {
@@ -107,6 +113,55 @@ export const appStore = {
return null;
} finally { update({ cursorBusy: false }); }
},
async initializePluginRuntime() {
update({ error: null });
try {
const pluginRuntime = await api.initializePluginRuntime();
update({ pluginRuntime });
return pluginRuntime;
} catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) });
return null;
}
},
async refreshPluginRuntime() {
try {
const wasReady = snapshot.pluginRuntime?.state === "ready";
const pluginRuntime = await api.pluginRuntime();
update({ pluginRuntime });
if (!wasReady && pluginRuntime.state === "ready") {
const plugins = await api.plugins();
update({ plugins });
}
return pluginRuntime;
} catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) });
return null;
}
},
async cancelPluginRuntimeInitialization() {
try {
const pluginRuntime = await api.cancelPluginRuntimeInitialization();
update({ pluginRuntime });
return pluginRuntime;
} catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) });
return null;
}
},
async refreshPlugins() {
try {
update({ plugins: await api.plugins() });
} catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) });
}
},
async removePluginConfiguration(pluginId: string) {
await perform(async () => {
await api.removePluginConfiguration(pluginId);
update({ plugins: await api.plugins() });
});
},
async setCursorEnabled(enabled: boolean) {
update({ cursorBusy: true, error: null });
try { update({ cursorHarness: await api.setCursorEnabled(enabled) }); }
+3 -3
View File
@@ -1,9 +1,9 @@
import { useSyncExternalStore } from "react";
import type { Update } from "@tauri-apps/plugin-updater";
import {
checkForUpdate,
hasNativeAppLifecycle,
installUpdate,
type AppUpdate,
} from "../native/appLifecycle";
export type UpdateSnapshot = {
@@ -17,7 +17,7 @@ let snapshot: UpdateSnapshot = {
checking: false,
installing: false,
};
let availableUpdate: Update | null = null;
let availableUpdate: AppUpdate | null = null;
let pendingCheck: Promise<string | null> | null = null;
const listeners = new Set<() => void>();
@@ -26,7 +26,7 @@ function update(patch: Partial<UpdateSnapshot>) {
listeners.forEach((listener) => listener());
}
async function replaceAvailableUpdate(next: Update | null) {
async function replaceAvailableUpdate(next: AppUpdate | null) {
const previous = availableUpdate;
availableUpdate = next;
update({ availableVersion: next?.version ?? null });
@@ -0,0 +1,41 @@
@use "../../styles/typography" as type;
.menu {
position: fixed;
z-index: 14000;
min-width: 132px;
overflow: hidden;
padding: 4px;
background: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-dropdown-border);
border-radius: 6px;
box-shadow: var(--oa-dropdown-shadow);
button {
width: 100%;
min-height: 30px;
display: block;
padding: 5px 8px;
color: var(--vscode-dropdown-foreground);
text-align: left;
background: transparent;
border: 0;
border-radius: 4px;
font-size: type.$font-size-xs;
white-space: nowrap;
&:hover:not(:disabled) {
background: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
}
&:disabled {
color: var(--vscode-descriptionForeground);
cursor: not-allowed;
}
}
}
.openIcon {
transform: rotate(180deg);
}
+99
View File
@@ -0,0 +1,99 @@
import { autoUpdate, computePosition, flip, offset, shift } from "@floating-ui/dom";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "./Button";
import { Icon } from "./Icon";
import { chevronDownIcon } from "./icons";
import styles from "./ActionMenu.module.scss";
export type ActionMenuItem = {
id: string;
label: string;
disabled?: boolean;
onSelect: () => void;
};
/** 触发器 + 动作列表的下拉菜单,用于容纳卡片上的次要操作。 */
export function ActionMenu({ label, items, disabled }: {
label: string;
items: ActionMenuItem[];
disabled?: boolean;
}) {
const trigger = useRef<HTMLButtonElement>(null);
const menu = useRef<HTMLDivElement>(null);
const menuId = useId();
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({ left: 0, top: 0 });
useLayoutEffect(() => {
if (!open || !trigger.current || !menu.current) return;
return autoUpdate(trigger.current, menu.current, () =>
void computePosition(trigger.current!, menu.current!, {
placement: "bottom-end",
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 })],
}).then(({ x, y }) => setPosition({ left: x, top: y })));
}, [open]);
useEffect(() => {
if (!open) return;
const outside = (event: PointerEvent) => {
if (!trigger.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener("pointerdown", outside);
return () => document.removeEventListener("pointerdown", outside);
}, [open]);
const close = () => {
setOpen(false);
trigger.current?.focus();
};
return <>
<Button
ref={trigger}
size="small"
disabled={disabled}
aria-haspopup="menu"
aria-controls={open ? menuId : undefined}
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
onKeyDown={(event) => {
if (event.key === "Escape") close();
}}
>
{label}
<Icon icon={chevronDownIcon} size="1em" className={open ? styles.openIcon : undefined} />
</Button>
{open && createPortal(
<div
id={menuId}
ref={menu}
className={styles.menu}
role="menu"
style={{ left: position.left, top: position.top }}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
close();
}
}}
>
{items.map((item) => <button
key={item.id}
type="button"
role="menuitem"
disabled={item.disabled}
onClick={() => {
setOpen(false);
item.onSelect();
}}
>
{item.label}
</button>)}
</div>,
document.body,
)}
</>;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { ButtonHTMLAttributes } from "react";
import type { ComponentProps } from "react";
import controls from "./Controls.module.scss";
export type ButtonVariant = "primary" | "secondary";
@@ -10,7 +10,7 @@ export function Button({
className,
type = "button",
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
}: ComponentProps<"button"> & {
variant?: ButtonVariant;
size?: ButtonSize;
}) {
@@ -8,6 +8,7 @@
background: var(--vscode-button-background);
border: 0;
border-radius: 5px;
&:disabled { opacity: .5; cursor: not-allowed; }
}
.secondary {
+1 -1
View File
@@ -12,7 +12,7 @@ export function SecretTextInput({ className, ...props }: InputHTMLAttributes<HTM
const [visible, setVisible] = useState(false);
return <div className={styles.secret}>
<input {...props} type={visible ? "text" : "password"} className={[styles.input, className].filter(Boolean).join(" ")} />
<button type="button" className={styles.secretToggle} aria-label={visible ? t("隐藏 API Key") : t("显示 API Key")} onClick={() => setVisible((current) => !current)}>
<button type="button" className={styles.secretToggle} aria-label={visible ? t("隐藏敏感内容") : t("显示敏感内容")} onClick={() => setVisible((current) => !current)}>
<Icon icon={visible ? eyeOffIcon : eyeIcon} size="1.1em" />
</button>
</div>;
+3 -2
View File
@@ -21,6 +21,7 @@ type ModalProps = {
secondaryAction?: ReactNode;
closeLabel?: string;
submitLabel?: string;
submitDisabled?: boolean;
};
const focusableSelector = [
@@ -37,7 +38,7 @@ function focusableElements(root: HTMLElement) {
.filter((element) => element.getClientRects().length > 0);
}
export function Modal({ id, open, title, children, banner, busy, wide, fullHeight, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: ModalProps) {
export function Modal({ id, open, title, children, banner, busy, wide, fullHeight, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存"), submitDisabled = false }: ModalProps) {
const dialog = useRef<HTMLDivElement>(null);
const submitButton = useRef<HTMLButtonElement>(null);
const closeRef = useRef(onClose);
@@ -95,7 +96,7 @@ export function Modal({ id, open, title, children, banner, busy, wide, fullHeigh
<footer>
<button type="button" className={controls.primary} disabled={busy} onClick={onClose}>{closeLabel}</button>
{secondaryAction}
{onSubmit && <button ref={submitButton} type="button" className={controls.primary} disabled={busy} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
{onSubmit && <button ref={submitButton} type="button" className={controls.primary} disabled={busy || submitDisabled} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
</footer>
</div>
</div>, document.body);
+3 -3
View File
@@ -7,7 +7,7 @@ import { Icon, type IconProps } from "./Icon";
import { checkIcon, chevronDownIcon } from "./icons";
import styles from "./Select.module.scss";
export type SelectOption = { value: string; label: string; icon?: IconProps["icon"] };
export type SelectOption = { value: string; label: string; icon?: IconProps["icon"]; iconSrc?: string };
export function Select({ value, options, disabled, ariaLabel, onChange }: { value: string; options: SelectOption[]; disabled?: boolean; ariaLabel: string; onChange: (value: string) => void }) {
const button = useRef<HTMLButtonElement>(null);
@@ -48,10 +48,10 @@ export function Select({ value, options, disabled, ariaLabel, onChange }: { valu
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
if (event.key === "Enter" && open) { event.preventDefault(); choose(options[active]); }
if (event.key === "Escape") setOpen(false);
}}><span className={styles.optionContent}>{selected?.icon && <Icon icon={selected.icon} />}<span>{selected?.label ?? value}</span></span><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
}}><span className={styles.optionContent}>{(selected?.icon || selected?.iconSrc) && <Icon icon={selected.icon} src={selected.iconSrc} />}<span>{selected?.label ?? value}</span></span><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
{open && createPortal(<div id={menuId} ref={menu} className={styles.menu} role="listbox" style={{ left: position.left, top: position.top, width: position.width }}>
<VirtualList items={options} itemKey="value" estimatedItemHeight={30} onReady={(api) => { listApi.current = api; api.scrollToIndex(active); }} style={{ height: Math.min(options.length * 30, Math.max(30, position.maxHeight - 8)) }}>
{(option, index) => <button type="button" role="option" aria-selected={option.value === value} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onClick={() => choose(option)}><span className={styles.optionContent}>{option.icon && <Icon icon={option.icon} />}<span>{option.label}</span></span></button>}
{(option, index) => <button type="button" role="option" aria-selected={option.value === value} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onClick={() => choose(option)}><span className={styles.optionContent}>{(option.icon || option.iconSrc) && <Icon icon={option.icon} src={option.iconSrc} />}<span>{option.label}</span></span></button>}
</VirtualList>
</div>, document.body)}
</>;
@@ -0,0 +1,6 @@
.label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,22 @@
import { useRef, useState, type ComponentProps } from "react";
import { Button } from "./Button";
import { TooltipTrigger } from "./TooltipTrigger";
import styles from "./TruncatedButton.module.scss";
/**
* 文本被省略号截断时才显示完整文案悬浮提示的按钮。
* 按钮是 flex 容器,省略号只作用在内层文本 span 上;
* 截断在悬停/聚焦时现测——挂载时字体可能未加载,提前测会得到错误结果。
*/
export function TruncatedButton({ label, ...props }: ComponentProps<typeof Button> & { label: string }) {
const element = useRef<HTMLSpanElement>(null);
const [truncated, setTruncated] = useState(false);
const measure = () => {
const text = element.current;
if (text) setTruncated(text.scrollWidth > text.clientWidth);
};
const button = <Button {...props} onPointerEnter={measure} onFocus={measure}>
<span ref={element} className={styles.label}>{label}</span>
</Button>;
return truncated ? <TooltipTrigger label={label}>{button}</TooltipTrigger> : button;
}
+2
View File
@@ -13,6 +13,8 @@ export const flatColorComboChartIcon = icon('<path fill="#00BCD4" d="M37 18h6v24
export const flatColorDataConfigurationIcon = icon('<path fill="#D1C4E9" d="M38 7H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2m0 12H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2v-6c0-1.1-.9-2-2-2m0 12H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2v-6c0-1.1-.9-2-2-2"/><path fill="#607D8B" d="M45.2 38.1c.1-.4.1-.8.1-1.1s0-.8-.1-1.1l2.3-1.7c.2-.2.3-.5.2-.7l-2.3-3.9c-.1-.2-.4-.3-.7-.2l-2.6 1.2c-.6-.5-1.3-.9-2-1.2l-.3-2.9c0-.3-.3-.5-.5-.5h-4.5c-.3 0-.5.2-.5.5l-.3 2.9c-.7.3-1.4.7-2 1.2l-2.6-1.2c-.3-.1-.6 0-.7.2l-2.3 3.9c-.1.2-.1.6.2.7l2.3 1.7c-.1.4-.1.8-.1 1.1s0 .8.1 1.1l-2.3 1.7c-.2.2-.3.5-.2.7l2.3 3.9c.1.2.4.3.7.2l2.6-1.2c.6.5 1.3.9 2 1.2l.3 2.9c0 .3.3.5.5.5h4.5c.3 0 .5-.2.5-.5l.3-2.9c.7-.3 1.4-.7 2-1.2l2.6 1.2c.3.1.6 0 .7-.2l2.3-3.9c.1-.2.1-.6-.2-.7zM37 42.2c-2.9 0-5.2-2.3-5.2-5.2s2.3-5.2 5.2-5.2s5.2 2.3 5.2 5.2s-2.3 5.2-5.2 5.2"/><path fill="#455A64" d="M37 31c-3.3 0-6 2.7-6 6s2.7 6 6 6s6-2.7 6-6s-2.7-6-6-6m0 9c-1.7 0-3-1.3-3-3s1.3-3 3-3s3 1.3 3 3s-1.3 3-3 3"/>', 48, 48);
export const flatColorSettingsIcon = icon('<path fill="#607D8B" d="M39.6 27.2c.1-.7.2-1.4.2-2.2s-.1-1.5-.2-2.2l4.5-3.2c.4-.3.6-.9.3-1.4L40 10.8c-.3-.5-.8-.7-1.3-.4l-5 2.3c-1.2-.9-2.4-1.6-3.8-2.2L29.4 5c-.1-.5-.5-.9-1-.9h-8.6c-.5 0-1 .4-1 .9l-.5 5.5c-1.4.6-2.7 1.3-3.8 2.2l-5-2.3c-.5-.2-1.1 0-1.3.4l-4.3 7.4c-.3.5-.1 1.1.3 1.4l4.5 3.2c-.1.7-.2 1.4-.2 2.2s.1 1.5.2 2.2L4 30.4c-.4.3-.6.9-.3 1.4L8 39.2c.3.5.8.7 1.3.4l5-2.3c1.2.9 2.4 1.6 3.8 2.2l.5 5.5c.1.5.5.9 1 .9h8.6c.5 0 1-.4 1-.9l.5-5.5c1.4-.6 2.7-1.3 3.8-2.2l5 2.3c.5.2 1.1 0 1.3-.4l4.3-7.4c.3-.5.1-1.1-.3-1.4zM24 35c-5.5 0-10-4.5-10-10s4.5-10 10-10s10 4.5 10 10s-4.5 10-10 10"/><path fill="#455A64" d="M24 13c-6.6 0-12 5.4-12 12s5.4 12 12 12s12-5.4 12-12s-5.4-12-12-12m0 17c-2.8 0-5-2.2-5-5s2.2-5 5-5s5 2.2 5 5s-2.2 5-5 5"/>', 48, 48);
export const flatColorCrystalOscillatorIcon = icon('<path fill="#FF9800" d="M3 28h26v4H3zm0-12h26v4H3z"/><path fill="#2196F3" d="M43 11H20v26h23c1.1 0 2-.9 2-2V13c0-1.1-.9-2-2-2"/><path fill="#64B5F6" d="M20 9h-2v30h2c1.1 0 2-.9 2-2V11c0-1.1-.9-2-2-2"/>', 48, 48); // flat-color-icons:crystal-oscillator
export const claudeIcon = icon('<path fill="#d97757" d="m50.228 170.321l50.357-28.257l.843-2.463l-.843-1.361h-2.462l-8.426-.518l-28.775-.778l-24.952-1.037l-24.175-1.296l-6.092-1.297L0 125.796l.583-3.759l5.12-3.434l7.324.648l16.202 1.101l24.304 1.685l17.629 1.037l26.118 2.722h4.148l.583-1.685l-1.426-1.037l-1.101-1.037l-25.147-17.045l-27.22-18.017l-14.258-10.37l-7.713-5.25l-3.888-4.925l-1.685-10.758l7-7.713l9.397.649l2.398.648l9.527 7.323l20.35 15.75L94.817 91.9l3.889 3.24l1.555-1.102l.195-.777l-1.75-2.917l-14.453-26.118l-15.425-26.572l-6.87-11.018l-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0l10.63 1.426l4.472 3.888l6.61 15.101l10.694 23.786l16.591 32.34l4.861 9.592l2.592 8.879l.973 2.722h1.685v-1.556l1.36-18.211l2.528-22.36l2.463-28.776l.843-8.1l4.018-9.722l7.971-5.25l6.222 2.981l5.12 7.324l-.713 4.73l-3.046 19.768l-5.962 30.98l-3.889 20.739h2.268l2.593-2.593l10.499-13.934l17.628-22.036l7.778-8.749l9.073-9.657l5.833-4.601h11.018l8.1 12.055l-3.628 12.443l-11.342 14.388l-9.398 12.184l-13.48 18.147l-8.426 14.518l.778 1.166l2.01-.194l30.46-6.481l16.462-2.982l19.637-3.37l8.88 4.148l.971 4.213l-3.5 8.62l-20.998 5.184l-24.628 4.926l-36.682 8.685l-.454.324l.519.648l16.526 1.555l7.065.389h17.304l32.21 2.398l8.426 5.574l5.055 6.805l-.843 5.184l-12.962 6.611l-17.498-4.148l-40.83-9.721l-14-3.5h-1.944v1.167l11.666 11.406l21.387 19.314l26.767 24.887l1.36 6.157l-3.434 4.86l-3.63-.518l-23.526-17.693l-9.073-7.972l-20.545-17.304h-1.36v1.814l4.73 6.935l25.017 37.59l1.296 11.536l-1.814 3.76l-6.481 2.268l-7.13-1.297l-14.647-20.544l-15.1-23.138l-12.185-20.739l-1.49.843l-7.194 77.448l-3.37 3.953l-7.778 2.981l-6.48-4.925l-3.436-7.972l3.435-15.749l4.148-20.544l3.37-16.333l3.046-20.285l1.815-6.74l-.13-.454l-1.49.194l-15.295 20.999l-23.267 31.433l-18.406 19.702l-4.407 1.75l-7.648-3.954l.713-7.064l4.277-6.286l25.47-32.405l15.36-20.092l9.917-11.6l-.065-1.686h-.583L44.07 198.125l-12.055 1.555l-5.185-4.86l.648-7.972l2.463-2.593l20.35-13.999z"/>', 256, 257);
export const openAiIcon = icon('<path fill="currentColor" d="M20.57 10.18c.45-1.36.3-2.85-.43-4.09a5.03 5.03 0 0 0-5.42-2.42a5.05 5.05 0 0 0-7.14-.38c-.66.59-1.15 1.35-1.43 2.19A4.98 4.98 0 0 0 2.82 7.9c-1.1 1.9-.85 4.29.62 5.91c-.45 1.36-.3 2.85.43 4.09a5.045 5.045 0 0 0 5.43 2.42A5.03 5.03 0 0 0 13.06 22c2.19 0 4.14-1.41 4.81-3.5a4.98 4.98 0 0 0 3.33-2.42a5.04 5.04 0 0 0-.62-5.89Zm-7.52 10.51c-.88 0-1.72-.31-2.4-.87l.12-.07l3.98-2.3c.2-.12.33-.33.33-.57v-5.61l1.68.97s.03.02.03.04v4.65c0 2.07-1.68 3.74-3.75 3.75ZM5 17.25c-.44-.76-.6-1.65-.45-2.51l.12.07l3.99 2.3c.2.12.45.12.65 0l4.87-2.81v1.94s-.01.04-.03.05l-4.03 2.33A3.756 3.756 0 0 1 5 17.25M3.95 8.58a3.7 3.7 0 0 1 1.97-1.64v4.73c0 .23.12.45.32.56l4.85 2.8l-1.68.97h-.06l-4.03-2.32a3.754 3.754 0 0 1-1.37-5.12zm13.83 3.21l-4.86-2.82L14.6 8h.06l4.03 2.33a3.743 3.743 0 0 1 1.37 5.12a3.8 3.8 0 0 1-1.94 1.64v-4.73a.67.67 0 0 0-.34-.56Zm1.68-2.52l-.12-.07l-3.98-2.32a.63.63 0 0 0-.65 0L9.84 9.69V7.75s0-.04.02-.05l4.03-2.32a3.75 3.75 0 0 1 5.12 1.38c.44.76.59 1.64.45 2.51v.02ZM8.93 12.72l-1.68-.97s-.03-.03-.03-.05V7.06c0-2.07 1.68-3.75 3.75-3.74c.87 0 1.72.31 2.39.87l-.12.07l-3.98 2.3c-.2.12-.33.33-.33.57v5.6Zm.91-1.97l2.17-1.25l2.17 1.25v2.5l-2.16 1.25l-2.17-1.25v-2.5Z"/>');
// Menu icons intentionally use filled glyphs from different collections so they
+6 -4
View File
@@ -13,7 +13,7 @@ import { ConfirmDialog } from "../shared/ui/ConfirmDialog";
import controls from "../shared/ui/Controls.module.scss";
import { Icon } from "../shared/ui/Icon";
import { TooltipTrigger } from "../shared/ui/TooltipTrigger";
import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../shared/ui/icons";
import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorCrystalOscillatorIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../shared/ui/icons";
import { useMessage } from "../shared/ui/message";
import { VirtualList } from "../shared/virtual/VirtualList";
import { useI18n } from "../i18n/store";
@@ -27,7 +27,7 @@ type MenuItem =
| { kind: "external"; id: string; label: string; icon: IconifyIcon | string }
| { kind: "group"; label: string };
const keptAlivePages = ["/", "/calls", "/settings", "/harness/cursor"];
const keptAlivePages = ["/", "/calls", "/settings", "/harness/cursor", "/plugins"];
const readAdStorageKey = "cursor-byok:read-ad-ids";
const dismissedAdStorageKey = "cursor-byok:dismissed-ad-ids";
const tutorialReadStorageKey = "cursor-byok:tutorial-read";
@@ -72,8 +72,10 @@ export function AppLayout() {
const menuItems: MenuItem[] = [
{ kind: "page", path: "/", label: t("数据概览"), icon: flatColorAreaChartIcon },
{ kind: "page", path: "/calls", label: t("调用详细"), icon: flatColorSalesPerformanceIcon },
{ kind: "group", label: "Harness" },
{ kind: "page", path: "/harness/cursor", label: t("Cursor 配置"), icon: cursorIconUrl },
{ kind: "group", label: t("模型配置") },
{ kind: "page", path: "/harness/cursor", label: "Cursor", icon: cursorIconUrl },
{ kind: "group", label: t("设置") },
{ kind: "page", path: "/plugins", label: t("插件配置"), icon: flatColorCrystalOscillatorIcon },
{ kind: "page", path: "/settings", label: t("系统设置"), icon: flatColorSettingsIcon },
{ kind: "external", id: "tutorial", label: t("使用教程"), icon: flatColorAboutIcon },
];
+1 -1
View File
@@ -1,5 +1,5 @@
{
"title": "User Guide",
"root": true,
"pages": ["index", "installation", "model-configuration", "tab-service", "faq"]
"pages": ["index", "installation", "model-configuration", "plugin-development", "tab-service", "faq"]
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"title": "使用指南",
"root": true,
"pages": ["index", "installation", "model-configuration", "tab-service", "faq"]
"pages": ["index", "installation", "model-configuration", "plugin-development", "tab-service", "faq"]
}
@@ -0,0 +1,145 @@
---
title: Plugin Development
description: Build stateless TypeScript plugins that execute providers, enumerate models, and manage credential resources with OAuth sign-in.
icon: Blocks
---
Plugins implement three capability interfaces defined by the core: **Provider** (execute one LLM call), **Model** (enumerate available models), and **Resource** (credential resources such as accounts). Plugins hold no persistent state — resources and model catalogs are stored by the core, and every call receives the data it needs as arguments.
## Layout
```text
~/.cursor-byok-v3/plugins/
├── installed/
│ └── com.example.subscription/
│ ├── plugin.json # static identity, entry, icon, HTTPS host allowlist
│ ├── main.ts # defineProviderPlugin composing providers and resources
│ └── assets/icon.svg # local icon, 1 MiB max
└── data/
└── com.example.subscription/
├── resources-<type>.json # resource records persisted by the core (0600)
└── models-<provider>.json # model catalogs persisted by the core
```
Built-in plugin sources live at `server/plugins/build-in/` (such as `codex-auth`); they are bundled into the binary and pre-installed into `plugins/installed/` keyed by version. Debug builds load the source directory first so edits take effect immediately.
## Static manifest
```json
{
"apiVersion": 1,
"id": "com.example.subscription",
"name": "Example Subscription",
"version": "0.1.0",
"author": "@example",
"minAppVersion": "0.1.0",
"icon": "assets/icon.svg",
"entry": "main.ts",
"permissions": {
"network": ["auth.example.com", "api.example.com"]
}
}
```
`permissions.network` accepts exact hostnames only. All plugin network requests must use HTTPS and hit this allowlist. `version` is required; the plugin is ignored when the app version is older than `minAppVersion`. Built-in plugins are pre-installed into `plugins/installed/` keyed by `version`: startup writes nothing when the version matches and resyncs the whole directory (pruning stale files) when it changes.
## Entry and capabilities
The host injects `cursor-byok:plugin`, `cursor-byok:provider`, `cursor-byok:model`, `cursor-byok:resource`, and the protocol helper `cursor-byok:protocol/openai-responses`.
```ts
import { defineProviderPlugin } from "cursor-byok:plugin";
import { streamOpenAiResponses, HttpError } from "cursor-byok:protocol/openai-responses";
export default defineProviderPlugin({
providers: [{
id: "subscription",
displayName: "Example Subscription",
providerType: "openai",
resourceType: "account",
models: {
list: async ({ resource }, context) => {
// Discover upstream models with the first ready resource; the return
// value replaces the core-side catalog.
return [{ id: "model-1", displayName: "Model 1", capabilities: { thinking: true } }];
},
},
invoke: async (input, output, context) => {
try {
await streamOpenAiResponses({
url: "https://api.example.com/v1/responses",
model: input.model.id,
request: input.request,
headers: { authorization: `Bearer ${token(input.resource)}` },
}, output, context);
return { status: "completed" };
} catch (error) {
if (error instanceof HttpError && error.status === 401) {
return {
status: "resource-error",
message: error.message,
patch: { state: { status: "invalid", message: "sign in again" } },
};
}
return { status: "request-error", message: String(error) };
}
},
}],
resources: [{
type: "account",
displayName: "Accounts",
add: [{
type: "oauth2.0",
id: "device",
displayName: "Sign in",
begin: async (context) => ({
session: { deviceCode: "..." },
userCode: "ABCD-EFGH",
verificationUrl: "https://auth.example.com/device",
expiresAtMs: Date.now() + 900_000,
pollIntervalMs: 5_000,
}),
poll: async (session, context) => ({
status: "completed",
resources: [{ key: "account:1", privateData: { accessToken: "..." } }],
}),
}],
present: (resource) => ({
displayName: "person@example.com",
metrics: [{ id: "weekly", label: "Weekly quota", unit: "percent", value: 75 }],
}),
refresh: async (resource, context) => ({ privateData: { /* updated quota */ } }),
}],
});
```
## Ownership boundaries
- **The core owns**: resource persistence and dedupe (upsert by `draft.key`), resource list UI, the OAuth poll loop (interval, slow-down, expiry), model catalog storage, per-call resource selection (currently the first ready resource; cooling expires automatically), call records and statistics.
- **The plugin owns**: authentication HTTP transitions (`begin`/`poll`), credential parsing (`import.parse`), resource presentation (`present`), quota refresh (`refresh`), and protocol adaptation with streaming execution (`invoke`).
`invoke` receives the full `LlmRequest` (instructions, message history, tools, reasoning config), parses the upstream SSE while emitting normalized events through `output.emit()` (text/thinking boundaries, incremental tool arguments, replay state, usage, finish reason), and finally returns `completed` or a typed error. The `patch` carried by `resource-error` is applied atomically to the selected resource and is the basis for future load-balanced retries.
Stable model IDs take the form `plugin:<plugin-id>/<provider-id>/<model-id>`; every enumerated model enters the Cursor catalog independently.
## Host context
- `context.network.fetch(url, init)`: one-shot HTTPS request, strictly allowlisted.
- `context.network.stream(url, init)`: streaming response iterated line by line (for SSE).
- `context.signal`: fires when the host cancels the call.
## Sandbox
The Deno process can only read its own plugin directory and the host SDK directory. Remote modules, npm packages, direct network access, environment variables, subprocesses, and file writes are all disabled. The worker multiplexes requests by request ID; a crash fails the current request and restarts on demand.
## Validation
```bash
deno check --no-config --no-lock --no-npm --no-remote \
--import-map=server/src/plugin/sdk/import-map.json \
server/plugins/build-in/my-plugin/main.ts
deno test --no-config --no-lock --no-npm --no-remote \
--import-map=server/src/plugin/sdk/import-map.json \
server/plugins/build-in/my-plugin/plugin_test.ts
```
@@ -0,0 +1,144 @@
---
title: 插件开发
description: 用无状态 TypeScript 插件实现 Provider 执行、模型枚举、资源接入与 OAuth 登录。
icon: Blocks
---
插件实现核心定义的三种能力接口:**Provider**(执行一次 LLM 调用)、**Model**(枚举可用模型)、**Resource**(账号等凭证资源)。插件不持有任何持久状态——资源与模型目录由核心存储,每次调用所需数据都通过参数传入。
## 目录结构
```text
~/.cursor-byok-v3/plugins/
├── installed/
│ └── com.example.subscription/
│ ├── plugin.json # 静态身份、入口、图标和 HTTPS 主机白名单
│ ├── main.ts # defineProviderPlugin 组合 providers 与 resources
│ └── assets/icon.svg # 本地图标,最大 1 MiB
└── data/
└── com.example.subscription/
├── resources-<type>.json # 核心持久化的资源记录(0600)
└── models-<provider>.json # 核心持久化的模型目录
```
内置插件源码位于 `server/plugins/build-in/`(如 `codex-auth`),随二进制打包并按版本预装进 `plugins/installed/`;Debug 构建下源码目录优先加载,便于热改。
## 静态清单
```json
{
"apiVersion": 1,
"id": "com.example.subscription",
"name": "Example Subscription",
"version": "0.1.0",
"author": "@example",
"minAppVersion": "0.1.0",
"icon": "assets/icon.svg",
"entry": "main.ts",
"permissions": {
"network": ["auth.example.com", "api.example.com"]
}
}
```
`permissions.network` 只能包含精确主机名。所有插件网络请求都必须是 HTTPS 且命中该白名单。`version` 必填;应用版本低于 `minAppVersion` 时插件会被忽略。内置插件按 `version` 预装进 `plugins/installed/`:版本一致时启动零写盘,版本变化时整目录同步并清理旧文件。
## 入口与能力
宿主注入 `cursor-byok:plugin`、`cursor-byok:provider`、`cursor-byok:model`、`cursor-byok:resource` 与协议帮助库 `cursor-byok:protocol/openai-responses`。
```ts
import { defineProviderPlugin } from "cursor-byok:plugin";
import { streamOpenAiResponses, HttpError } from "cursor-byok:protocol/openai-responses";
export default defineProviderPlugin({
providers: [{
id: "subscription",
displayName: "Example Subscription",
providerType: "openai",
resourceType: "account",
models: {
list: async ({ resource }, context) => {
// 用首个可用资源发现上游模型;返回值整体替换核心目录。
return [{ id: "model-1", displayName: "Model 1", capabilities: { thinking: true } }];
},
},
invoke: async (input, output, context) => {
try {
await streamOpenAiResponses({
url: "https://api.example.com/v1/responses",
model: input.model.id,
request: input.request,
headers: { authorization: `Bearer ${token(input.resource)}` },
}, output, context);
return { status: "completed" };
} catch (error) {
if (error instanceof HttpError && error.status === 401) {
return {
status: "resource-error",
message: error.message,
patch: { state: { status: "invalid", message: "sign in again" } },
};
}
return { status: "request-error", message: String(error) };
}
},
}],
resources: [{
type: "account",
displayName: "Accounts",
add: [{
type: "oauth2.0",
id: "device",
displayName: "Sign in",
begin: async (context) => ({
session: { deviceCode: "..." },
userCode: "ABCD-EFGH",
verificationUrl: "https://auth.example.com/device",
expiresAtMs: Date.now() + 900_000,
pollIntervalMs: 5_000,
}),
poll: async (session, context) => ({
status: "completed",
resources: [{ key: "account:1", privateData: { accessToken: "..." } }],
}),
}],
present: (resource) => ({
displayName: "person@example.com",
metrics: [{ id: "weekly", label: "Weekly quota", unit: "percent", value: 75 }],
}),
refresh: async (resource, context) => ({ privateData: { /* 更新额度 */ } }),
}],
});
```
## 职责边界
- **核心负责**:资源持久化与去重(按 `draft.key` upsert)、资源列表 UI、OAuth 轮询循环(间隔、slow-down、超时)、模型目录存储、每次调用的资源选择(当前取首个可用,冷却到期自动恢复)、调用记录与统计。
- **插件负责**:认证 HTTP 转移(`begin`/`poll`)、凭证解析(`import.parse`)、资源展示投影(`present`)、额度刷新(`refresh`)、协议适配与流式执行(`invoke`)。
`invoke` 接收完整 `LlmRequest`(指令、消息历史、工具、思考配置),边解析上游 SSE 边 `output.emit()` 标准化事件(文本/思考边界、工具参数增量、回放状态、用量、结束原因),最后返回 `completed` 或类型化错误。`resource-error` 携带的 `patch` 会被核心原子应用到选中的资源,是未来负载均衡换资源重试的依据。
稳定模型 ID 为 `plugin:<plugin-id>/<provider-id>/<model-id>`,每个枚举出的模型都独立进入 Cursor 模型目录。
## 宿主上下文
- `context.network.fetch(url, init)`:一次性 HTTPS 请求,严格执行清单白名单。
- `context.network.stream(url, init)`:流式响应,按行异步迭代(用于 SSE)。
- `context.signal`:宿主取消本次调用时触发。
## 沙箱
Deno 进程只能读取自身插件目录和宿主 SDK 目录。远程模块、npm 包、直接网络、环境变量、子进程和文件写入均被禁用。Worker 按请求 ID 多路复用,崩溃时当前请求失败并按需重启。
## 验证
```bash
deno check --no-config --no-lock --no-npm --no-remote \
--import-map=server/src/plugin/sdk/import-map.json \
server/plugins/build-in/my-plugin/main.ts
deno test --no-config --no-lock --no-npm --no-remote \
--import-map=server/src/plugin/sdk/import-map.json \
server/plugins/build-in/my-plugin/plugin_test.ts
```
+2 -54
View File
@@ -1,9 +1,4 @@
以下是重构后完整目标版本
实现时,先创建所有目录和文件固化,每个文件头部都写好注释再实现
旧服务已被备份为server_backup,/Users/leokun/Documents/cursor-byok/server 目录已创建
行数均为目标估算,使用 `≈` 标记;不包含测试、生成代码和空行。
实现时可做略微调整,测试要求相对于目标文件旁边的独立文件,禁止码内测试
本文档目录 /Users/leokun/Documents/cursor-byok/cursor.md
## 完整目录
```text
@@ -656,54 +651,7 @@ store ─X→ cursor
model ─X→ cursor
```
## 当前代码迁移
```text
当前 目标
cursor/bidi_append.rs → api/cursor/bidi.rs
cursor/run_sse.rs → api/cursor/run_sse.rs
cursor/handlers.rs → api/cursor/handlers.rs
cursor/proxy.rs → api/cursor/proxy.rs
cursor/sessions.rs → cursor/transport/registry.rs
+ cursor/transport/handle.rs
+ cursor/transport/output.rs
cursor/inbox.rs → cursor/transport/inbox.rs
cursor/actor.rs → cursor/transport/
+ cursor/conversation/runtime.rs
+ cursor/conversation/delivery.rs
cursor/session.rs → cursor/conversation/runtime.rs
+ cursor/conversation/output.rs
+ cursor/checkpoint/
+ cursor/tools/
cursor/request/prepare.rs → cursor/compile/run.rs
cursor/request/context.rs → cursor/compile/context.rs
cursor/request/background.rs → cursor/compile/insert_messages.rs
cursor/request/runtime.rs → cursor/compile/break_messages.rs
cursor/request/images.rs → cursor/compile/images.rs
cursor/request/model.rs → cursor/compile/model.rs
cursor/interaction/mod.rs → cursor/protocol/events.rs
cursor/interaction/query.rs → cursor/tools/codec/query.rs
cursor/interaction/render.rs → cursor/tools/codec/render.rs
cursor/projection/decode.rs → cursor/checkpoint/messages/decode.rs
cursor/projection/encode.rs → cursor/checkpoint/messages/encode.rs
cursor/projection/tests.rs → cursor/checkpoint/messages/tests.rs
cursor/presentation.rs → cursor/checkpoint/steps.rs
run/runtime.rs RunRegistry → cursor/conversation/registry.rs
run/runtime.rs RunActor → run/engine.rs + run/handle.rs
run/port.rs → run/command.rs + run/event.rs + run/port.rs
store/revisions.rs → store/checkpoints.rs
```
## 最终核心
@@ -719,4 +667,4 @@ Bidi
→ Checkpoint
→ Transport
→ RunSSE
```
```
+2 -1
View File
@@ -48,7 +48,7 @@ similar = "2"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
thiserror = "2"
time = "0.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync", "time", "net"] }
tokio = { version = "1", features = ["fs", "io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time", "net"] }
tokio-stream = { version = "0.1", features = ["sync"] }
tokio-util = "0.7"
tracing = "0.1"
@@ -57,6 +57,7 @@ tower-http = { version = "0.6", features = ["cors", "decompression-gzip", "fs"]
url = "2"
uuid = { version = "1", features = ["v4"] }
x509-parser = "0.18"
zip = { version = "4", default-features = false, features = ["deflate"] }
[build-dependencies]
prost-build = "0.13"
protoc-bin-vendored = "3"
@@ -0,0 +1,107 @@
-- llm_calls 是历史记录:model_hash 现在既可指向内置 model_configs,
-- 也可携带插件稳定模型 ID(plugin:<plugin>/<provider>/<model>)。
-- 去掉指向 model_configs 的外键;SQLite 不支持删除约束,按整表重建执行。
PRAGMA defer_foreign_keys = ON;
CREATE TABLE llm_calls_new (
call_id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
provider_call_index INTEGER NOT NULL,
model_hash TEXT,
provider_type TEXT NOT NULL,
provider_url TEXT NOT NULL,
request_type TEXT NOT NULL,
request_url TEXT NOT NULL,
model_id TEXT NOT NULL,
display_name TEXT NOT NULL,
status TEXT NOT NULL,
finish_reason TEXT,
created_at_ms INTEGER NOT NULL,
request_started_at_ms INTEGER,
response_headers_at_ms INTEGER,
first_event_at_ms INTEGER,
first_text_at_ms INTEGER,
finished_at_ms INTEGER,
queue_ms INTEGER,
ttfb_ms INTEGER,
ttft_ms INTEGER,
duration_ms INTEGER,
input_tokens INTEGER,
output_tokens INTEGER,
total_tokens INTEGER,
cache_read_tokens INTEGER,
cache_write_tokens INTEGER,
reasoning_tokens INTEGER,
usage_json TEXT,
message_count INTEGER NOT NULL,
tool_count INTEGER NOT NULL,
request_bytes INTEGER,
response_bytes INTEGER NOT NULL DEFAULT 0,
stream_event_count INTEGER NOT NULL DEFAULT 0,
http_status INTEGER,
error_kind TEXT,
error_message TEXT,
detailed INTEGER NOT NULL,
reasoning_effort TEXT,
fast INTEGER NOT NULL DEFAULT 0 CHECK (fast IN (0, 1)),
first_valid_response_at_ms INTEGER,
ttfr_ms INTEGER
);
INSERT INTO llm_calls_new (
call_id, run_id, conversation_id, provider_call_index, model_hash, provider_type,
provider_url, request_type, request_url, model_id, display_name, status, finish_reason,
created_at_ms, request_started_at_ms, response_headers_at_ms, first_event_at_ms,
first_text_at_ms, finished_at_ms, queue_ms, ttfb_ms, ttft_ms, duration_ms,
input_tokens, output_tokens, total_tokens, cache_read_tokens, cache_write_tokens,
reasoning_tokens, usage_json, message_count, tool_count, request_bytes, response_bytes,
stream_event_count, http_status, error_kind, error_message, detailed, reasoning_effort, fast,
first_valid_response_at_ms, ttfr_ms
)
SELECT
call_id, run_id, conversation_id, provider_call_index, model_hash, provider_type,
provider_url, request_type, request_url, model_id, display_name, status, finish_reason,
created_at_ms, request_started_at_ms, response_headers_at_ms, first_event_at_ms,
first_text_at_ms, finished_at_ms, queue_ms, ttfb_ms, ttft_ms, duration_ms,
input_tokens, output_tokens, total_tokens, cache_read_tokens, cache_write_tokens,
reasoning_tokens, usage_json, message_count, tool_count, request_bytes, response_bytes,
stream_event_count, http_status, error_kind, error_message, detailed, reasoning_effort, fast,
first_valid_response_at_ms, ttfr_ms
FROM llm_calls;
CREATE TABLE llm_call_requests_new (
call_id TEXT PRIMARY KEY,
headers_json TEXT NOT NULL,
body_json TEXT NOT NULL,
byte_count INTEGER NOT NULL,
FOREIGN KEY(call_id) REFERENCES llm_calls_new(call_id) ON DELETE CASCADE
);
INSERT INTO llm_call_requests_new(call_id, headers_json, body_json, byte_count)
SELECT call_id, headers_json, body_json, byte_count FROM llm_call_requests;
CREATE TABLE llm_call_response_chunks_new (
call_id TEXT NOT NULL,
seq INTEGER NOT NULL,
received_offset_ms INTEGER NOT NULL,
data BLOB NOT NULL,
byte_count INTEGER NOT NULL,
PRIMARY KEY(call_id, seq),
FOREIGN KEY(call_id) REFERENCES llm_calls_new(call_id) ON DELETE CASCADE
);
INSERT INTO llm_call_response_chunks_new(call_id, seq, received_offset_ms, data, byte_count)
SELECT call_id, seq, received_offset_ms, data, byte_count FROM llm_call_response_chunks;
DROP TABLE llm_call_requests;
DROP TABLE llm_call_response_chunks;
DROP TABLE llm_calls;
ALTER TABLE llm_calls_new RENAME TO llm_calls;
ALTER TABLE llm_call_requests_new RENAME TO llm_call_requests;
ALTER TABLE llm_call_response_chunks_new RENAME TO llm_call_response_chunks;
CREATE INDEX llm_calls_created ON llm_calls(created_at_ms DESC);
CREATE INDEX llm_calls_run ON llm_calls(run_id, provider_call_index);
CREATE INDEX llm_calls_model ON llm_calls(model_hash, created_at_ms DESC);
@@ -0,0 +1,4 @@
-- Custom provider-group display name shared by models with the same upstream host.
-- NULL means no custom name; the UI falls back to the base_url hostname and the
-- Cursor model picker badge falls back to the model type label.
ALTER TABLE model_configs ADD COLUMN group_name TEXT;
@@ -0,0 +1 @@
ALTER TABLE tool_round_calls ADD COLUMN argument_error TEXT;
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 43 KiB

@@ -0,0 +1,421 @@
import type {
JsonValue,
NetworkEventStream,
NetworkResponse,
PluginContext,
} from "cursor-byok:plugin";
import type { LlmRequest, ModelEvent } from "cursor-byok:provider";
import type { ResourceSnapshot } from "cursor-byok:resource";
import { codexDeviceOAuth } from "./oauth.ts";
import { parseOfficialModels } from "./models.ts";
import { buildResponsesBody } from "cursor-byok:protocol/openai-responses";
import { codexProvider, isQuotaError } from "./provider.ts";
import {
accountIdentity,
credentialDraft,
parseCodexUsage,
parseCredentialFiles,
presentAccount,
quotaState,
RESOURCE_TYPE,
} from "./resources.ts";
function assert(condition: unknown, message = "assertion failed"): asserts condition {
if (!condition) throw new Error(message);
}
function assertEquals(actual: unknown, expected: unknown): void {
const left = JSON.stringify(actual);
const right = JSON.stringify(expected);
if (left !== right) throw new Error(`expected ${right}, received ${left}`);
}
function jwt(payload: Record<string, unknown>): string {
const encoded = btoa(JSON.stringify(payload)).replace(/=/g, "").replace(/\+/g, "-").replace(
/\//g,
"_",
);
return `header.${encoded}.signature`;
}
type RequestInit = { body?: string; headers?: Record<string, string> };
type FetchHandler = (url: string, init?: RequestInit) => NetworkResponse;
type StreamHandler = (url: string, init?: RequestInit) => NetworkEventStream;
function context(handlers: { fetch?: FetchHandler; stream?: StreamHandler }): PluginContext {
return {
network: {
fetch: (url, init) => {
if (!handlers.fetch) throw new Error("fetch was not expected");
return Promise.resolve(handlers.fetch(url, init));
},
stream: (url, init) => {
if (!handlers.stream) throw new Error("stream was not expected");
return Promise.resolve(handlers.stream(url, init));
},
},
signal: new AbortController().signal,
};
}
function snapshot(privateData: JsonValue): ResourceSnapshot {
return {
id: "resource-1",
type: RESOURCE_TYPE,
key: "codex:acct-1",
privateData,
state: { status: "ready" },
};
}
async function* sse(lines: string[]): AsyncGenerator<string> {
for (const line of lines) yield line;
}
function request(): LlmRequest {
return {
instructions: "You are a coding assistant.",
messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
tools: [],
reasoning: { enabled: true, effort: "medium" },
latency: "fast",
maxOutputTokens: 128_000,
cacheKey: "conversation-1",
};
}
Deno.test("account identity prioritizes ChatGPT account ID and drafts keep tokens private-side", async () => {
const token = jwt({
"https://api.openai.com/auth": { chatgpt_account_id: "acct-1" },
sub: "subject-1",
email: "person@example.com",
});
assertEquals(await accountIdentity(token), {
key: "codex:acct-1",
displayName: "person@example.com",
});
const draft = await credentialDraft({
accessToken: token,
refreshToken: null,
displayName: null,
});
assertEquals(draft.key, "codex:acct-1");
const view = presentAccount(snapshot(draft.privateData));
assert(!JSON.stringify(view).includes(token), "resource view exposed an access token");
assertEquals(view.displayName, "person@example.com");
});
Deno.test("credential import accepts Codex auth JSON files", () => {
const { credentials, warnings } = parseCredentialFiles([
{
name: "auth.json",
content: JSON.stringify({
tokens: {
access_token: "access-secret",
refresh_token: "refresh-secret",
id_token: jwt({ email: "person@example.com" }),
},
}),
},
{ name: "broken.json", content: "{not json" },
]);
assertEquals(credentials, [{
accessToken: "access-secret",
refreshToken: "refresh-secret",
displayName: "person@example.com",
}]);
assertEquals(warnings, ["broken.json: not valid JSON"]);
});
Deno.test("usage maps secondary to weekly and primary to five-hour quota", () => {
const quota = parseCodexUsage({
plan_type: "plus",
rate_limit: {
primary_window: { used_percent: 80, reset_at: 1_800_000_000 },
secondary_window: { used_percent: 25, reset_at: 1_900_000_000 },
},
}, 1_700_000_000_000);
assertEquals(quota.planLabel, "ChatGPT Plus");
assertEquals(quota.weekly?.remainingPercent, 75);
assertEquals(quota.fiveHour?.remainingPercent, 20);
assertEquals(quota.weekly?.resetAtMs, 1_900_000_000_000);
assertEquals(quotaState(quota, 1_700_000_000_000), { status: "ready" });
});
Deno.test("exhausted quota projects a cooling state until the latest reset", () => {
const quota = parseCodexUsage({
rate_limit: {
primary_window: { used_percent: 100, reset_at: 1_800_000_000 },
secondary_window: { used_percent: 100, reset_at: 1_900_000_000 },
},
}, 1_700_000_000_000);
assertEquals(quotaState(quota, 1_700_000_000_000), {
status: "cooling",
retryAtMs: 1_900_000_000_000,
message: "ChatGPT quota is exhausted",
});
});
Deno.test("official model discovery excludes hidden models and puts the default first", () => {
const models = parseOfficialModels({
default_model: "gpt-second",
models: [
{
slug: "gpt-first",
display_name: "GPT First",
supported_in_api: true,
visibility: "list",
supported_reasoning_levels: [
{ effort: "low", description: "Fast responses" },
{ effort: "medium", description: "Balanced" },
],
},
{ slug: "gpt-second", supported_in_api: true, visibility: "list" },
{ slug: "gpt-hidden", supported_in_api: true, visibility: "hidden" },
{ slug: "gpt-internal", supported_in_api: false, visibility: "list" },
],
});
assertEquals(models.map((model) => model.id), ["gpt-second", "gpt-first"]);
assertEquals(models[1].capabilities, { images: true });
assertEquals(models[1].privateData, { reasoningEfforts: ["low", "medium"] });
});
Deno.test("device OAuth begins with a host-held session and completes with a resource draft", async () => {
const accessToken = jwt({
"https://api.openai.com/auth": { chatgpt_account_id: "acct-oauth" },
email: "oauth@example.com",
});
let requestNumber = 0;
const flowContext = context({
fetch: (url, init) => {
requestNumber += 1;
if (requestNumber === 1) {
assertEquals(url, "https://auth.openai.com/api/accounts/deviceauth/usercode");
return {
status: 200,
headers: {},
body: JSON.stringify({
device_auth_id: "private-device-id",
user_code: "ABCD-EFGH",
expires_in: 900,
interval: 5,
}),
};
}
if (requestNumber === 2) {
assertEquals(url, "https://auth.openai.com/api/accounts/deviceauth/token");
return {
status: 200,
headers: {},
body: JSON.stringify({
authorization_code: "authorization-code",
code_verifier: "pkce-verifier",
}),
};
}
assertEquals(url, "https://auth.openai.com/oauth/token");
assert(init?.body?.includes("grant_type=authorization_code"));
assert(init?.body?.includes("code_verifier=pkce-verifier"));
return {
status: 200,
headers: {},
body: JSON.stringify({ access_token: accessToken, refresh_token: "refresh-secret" }),
};
},
});
const begun = await codexDeviceOAuth.begin(flowContext);
assertEquals(begun.userCode, "ABCD-EFGH");
assertEquals(begun.pollIntervalMs, 5000);
const polled = await codexDeviceOAuth.poll(begun.session, flowContext);
assert(polled.status === "completed", `expected completed, received ${polled.status}`);
assertEquals(polled.resources[0].key, "codex:acct-oauth");
assertEquals(requestNumber, 3);
});
Deno.test("invoke streams normalized events from the Codex Responses API", async () => {
const token = jwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } });
const draft = await credentialDraft({
accessToken: token,
refreshToken: null,
displayName: null,
});
let requestBody = "";
let requestHeaders: Record<string, string> = {};
const events: ModelEvent[] = [];
const result = await codexProvider.invoke(
{
model: {
id: "gpt-test",
displayName: "GPT Test",
privateData: { reasoningEfforts: ["medium"] },
},
resource: snapshot(draft.privateData),
request: request(),
},
{ emit: (event) => events.push(event) },
context({
stream: (url, init) => {
assertEquals(url, "https://chatgpt.com/backend-api/codex/responses");
requestBody = init?.body ?? "";
requestHeaders = init?.headers ?? {};
return {
status: 200,
headers: {},
lines: sse([
'data: {"type":"response.output_text.delta","delta":"Hel"}',
'data: {"type":"response.output_text.delta","delta":"lo"}',
'data: {"type":"response.completed","response":{"usage":{"input_tokens":10,"output_tokens":2,"input_tokens_details":{"cached_tokens":4}}}}',
]),
};
},
}),
);
assertEquals(result, { status: "completed" });
const body = JSON.parse(requestBody) as Record<string, unknown>;
assertEquals(body.model, "gpt-test");
assertEquals(body.store, false);
assertEquals(body.reasoning, { summary: "auto", effort: "medium" });
assertEquals(body.instructions, "You are a coding assistant.");
assertEquals(body.include, ["reasoning.encrypted_content"]);
assert(!("max_output_tokens" in body), "Codex endpoint rejects max_output_tokens");
assertEquals(body.service_tier, "priority");
assertEquals(body.prompt_cache_key, "conversation-1");
// 缓存亲和头与 prompt_cache_key 同源。
assertEquals(requestHeaders["session-id"], "conversation-1");
assertEquals(requestHeaders["thread-id"], "conversation-1");
assertEquals(requestHeaders["x-client-request-id"], "conversation-1");
assertEquals(events, [
{ type: "text-start" },
{ type: "text-delta", text: "Hel" },
{ type: "text-delta", text: "lo" },
{
type: "usage",
usage: {
inputTokens: 10,
outputTokens: 2,
totalTokens: null,
cacheReadTokens: 4,
cacheWriteTokens: null,
reasoningTokens: null,
},
},
{ type: "text-end" },
{ type: "done", reason: "stop" },
]);
});
Deno.test("reasoning replay projects response items to valid input items", () => {
const replayRequest = request();
replayRequest.messages = [{
role: "assistant",
text: "",
thinking: "",
replayState: {
providerKind: "openai_responses",
value: {
items: [{
type: "reasoning",
id: "item-1",
status: "completed",
summary: [{ type: "summary_text", text: "why" }],
content: [],
encrypted_content: "opaque",
output_only: true,
}],
},
},
toolCalls: [],
}];
const body = buildResponsesBody({
url: "https://example.com/responses",
model: "gpt-test",
request: replayRequest,
});
assertEquals(body.input, [{
type: "reasoning",
id: "item-1",
summary: [{ type: "summary_text", text: "why" }],
content: [],
encrypted_content: "opaque",
}]);
});
Deno.test("invoke streams incremental tool calls and replays reasoning items", async () => {
const token = jwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } });
const draft = await credentialDraft({
accessToken: token,
refreshToken: null,
displayName: null,
});
const events: ModelEvent[] = [];
const result = await codexProvider.invoke(
{
model: { id: "gpt-test", displayName: "GPT Test" },
resource: snapshot(draft.privateData),
request: request(),
},
{ emit: (event) => events.push(event) },
context({
stream: () => ({
status: 200,
headers: {},
lines: sse([
'data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call-1","name":"read_file"}}',
'data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\\"path\\":"}',
'data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"\\"a.ts\\"}"}',
'data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call-1","name":"read_file","arguments":"{\\"path\\":\\"a.ts\\"}"}}',
'data: {"type":"response.output_item.done","output_index":1,"item":{"type":"reasoning","encrypted_content":"opaque"}}',
'data: {"type":"response.completed","response":{}}',
]),
}),
}),
);
assertEquals(result, { status: "completed" });
assertEquals(events, [
{ type: "tool-call-start", index: 0, callId: "call-1", name: "read_file" },
{ type: "tool-call-arguments-delta", index: 0, delta: '{"path":' },
{ type: "tool-call-arguments-delta", index: 0, delta: '"a.ts"}' },
{ type: "tool-call-end", index: 0 },
{
type: "replay-state",
providerKind: "openai_responses",
value: { items: [{ type: "reasoning", encrypted_content: "opaque" }] },
},
{ type: "done", reason: "tool-use" },
]);
});
Deno.test("invoke maps quota failures to a cooling resource error", async () => {
assert(!isQuotaError("429 rate_limit_reached"));
assert(isQuotaError("429 usage_limit_reached: 5-hour limit"));
const token = jwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } });
const draft = await credentialDraft({
accessToken: token,
refreshToken: null,
displayName: null,
});
const result = await codexProvider.invoke(
{
model: { id: "gpt-test", displayName: "GPT Test" },
resource: snapshot(draft.privateData),
request: request(),
},
{ emit: () => {} },
context({
stream: () => ({
status: 429,
headers: {},
lines: sse(['{"detail":"usage_limit_reached","reset_after_seconds":600}']),
}),
}),
);
assert(result.status === "resource-error", `expected resource-error, received ${result.status}`);
assert(result.patch.state?.status === "cooling", "quota failure should cool the resource");
assert(
result.patch.state.retryAtMs !== undefined && result.patch.state.retryAtMs > Date.now(),
"cooling should carry the parsed reset time",
);
});
@@ -0,0 +1,13 @@
{
"imports": {
"cursor-byok:plugin": "../../../src/plugin/sdk/plugin.ts",
"cursor-byok:provider": "../../../src/plugin/sdk/provider.ts",
"cursor-byok:model": "../../../src/plugin/sdk/model.ts",
"cursor-byok:resource": "../../../src/plugin/sdk/resource.ts",
"cursor-byok:protocol/openai-responses": "../../../src/plugin/sdk/protocol/openai_responses.ts"
},
"fmt": {
"lineWidth": 100,
"exclude": ["assets"]
}
}
@@ -0,0 +1,16 @@
import { defineProviderPlugin } from "cursor-byok:plugin";
import { codexDeviceOAuth } from "./oauth.ts";
import { codexProvider } from "./provider.ts";
import { credentialImport, presentAccount, refreshAccount, RESOURCE_TYPE } from "./resources.ts";
export default defineProviderPlugin({
providers: [codexProvider],
resources: [{
type: RESOURCE_TYPE,
displayName: { "en-US": "ChatGPT accounts", "zh-CN": "ChatGPT 账号" },
add: [codexDeviceOAuth],
import: credentialImport,
present: presentAccount,
refresh: refreshAccount,
}],
});
@@ -0,0 +1,128 @@
import type { JsonValue } from "cursor-byok:plugin";
import type { ModelDefinition, ModelSnapshot, ModelSupport } from "cursor-byok:model";
import { accountData, accountHeaders } from "./resources.ts";
const MODELS_URL = "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0";
function object(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function positiveInteger(value: unknown): number | null {
const parsed = typeof value === "number"
? value
: typeof value === "string"
? Number(value)
: NaN;
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null;
}
function parseReasoningEfforts(model: Record<string, unknown>): string[] {
const source = model.supported_reasoning_levels ??
model.supportedReasoningLevels ??
model.reasoning_levels ??
model.reasoningLevels ??
model.supported_reasoning_efforts ??
model.supportedReasoningEfforts ??
model.reasoning_efforts ??
model.reasoningEfforts;
if (!Array.isArray(source)) return [];
const values = source.flatMap((item) => {
if (typeof item === "string") return [item.trim()];
const entry = object(item);
const value = text(entry?.effort ?? entry?.id ?? entry?.value ?? entry?.name);
return value ? [value] : [];
}).filter(Boolean);
return [...new Set(values)];
}
function modelId(value: unknown): string | null {
if (typeof value === "string") return text(value);
const model = object(value);
return model ? text(model.slug ?? model.id ?? model.model ?? model.name) : null;
}
export function parseOfficialModels(body: unknown): ModelDefinition[] {
const root = object(body);
const source = root?.models ?? root?.data ?? body;
if (!Array.isArray(source)) {
throw new Error("Codex model discovery response does not contain a model list");
}
const seen = new Set<string>();
const models: ModelDefinition[] = [];
for (const raw of source) {
const model = object(raw);
if (!model || model.supported_in_api === false || model.supportedInApi === false) continue;
if (text(model.visibility)?.toLowerCase() === "hidden") continue;
const id = modelId(model);
if (!id || seen.has(id)) continue;
seen.add(id);
const efforts = parseReasoningEfforts(model);
const description = text(model.description);
const maxOutputTokens = positiveInteger(
model.max_output_tokens ?? model.maxOutputTokens ?? model.max_completion_tokens ??
model.maxCompletionTokens,
);
models.push({
id,
displayName: text(model.display_name ?? model.displayName ?? model.title ?? model.name) ??
id,
...(description ? { description } : {}),
...(maxOutputTokens !== null ? { maxOutputTokens } : {}),
capabilities: { images: true },
privateData: { reasoningEfforts: efforts },
});
}
const defaultModel = modelId(
root?.default_model ??
root?.defaultModel ??
root?.default_model_slug ??
root?.defaultModelSlug ??
root?.primary_model ??
root?.primaryModel,
);
// 把上游默认模型排在最前,让宿主自然选中它。
if (defaultModel) {
models.sort((left, right) =>
Number(right.id === defaultModel) - Number(left.id === defaultModel)
);
}
return models;
}
export function reasoningEfforts(model: ModelSnapshot): string[] {
const data = object(model.privateData);
const efforts = data?.reasoningEfforts;
return Array.isArray(efforts) ? efforts.filter((item) => typeof item === "string") : [];
}
export const codexModels: ModelSupport = {
list: async ({ resource }, context): Promise<ModelDefinition[]> => {
if (!resource) throw new Error("add a ChatGPT account before syncing Codex models");
const data = accountData(resource);
const response = await context.network.fetch(MODELS_URL, {
method: "GET",
headers: accountHeaders(data),
});
if (response.status < 200 || response.status >= 300) {
throw new Error(`Codex model discovery failed (HTTP ${response.status}): ${response.body}`);
}
let body: unknown;
try {
body = JSON.parse(response.body) as JsonValue;
} catch {
throw new Error("Codex model discovery returned invalid JSON");
}
const models = parseOfficialModels(body);
if (models.length === 0) {
throw new Error("Codex model discovery returned no supported models");
}
return models;
},
};
+210
View File
@@ -0,0 +1,210 @@
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
import type { OAuth2AddMethod, OAuth2Begin, OAuth2Poll } from "cursor-byok:resource";
import { type CredentialCandidate, credentialDraft } from "./resources.ts";
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const DEVICE_CODE_URL = "https://auth.openai.com/api/accounts/deviceauth/usercode";
const DEVICE_TOKEN_URL = "https://auth.openai.com/api/accounts/deviceauth/token";
const OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token";
const REDIRECT_URI = "https://auth.openai.com/deviceauth/callback";
const VERIFICATION_URI = "https://auth.openai.com/codex/device";
type Session = {
deviceAuthId: string;
userCode: string;
};
function object(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function number(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function parseBody(body: string): Record<string, unknown> {
try {
return object(JSON.parse(body)) ?? {};
} catch {
return {};
}
}
function parseSession(value: JsonValue): Session {
const session = object(value);
const deviceAuthId = text(session?.deviceAuthId);
const userCode = text(session?.userCode);
if (!deviceAuthId || !userCode) throw new Error("Codex OAuth session is invalid");
return { deviceAuthId, userCode };
}
function errorCode(body: Record<string, unknown>): string {
const error = body.error;
if (typeof error === "string") return error;
const nested = object(error);
return text(nested?.code ?? nested?.type ?? body.status ?? body.state) ?? "";
}
function errorMessage(body: Record<string, unknown>): string | null {
const error = object(body.error);
return text(body.error_description ?? body.message ?? error?.message);
}
function pendingMessage(message: string): boolean {
const lower = message.toLowerCase();
return lower.includes("authorization is pending") ||
lower.includes("authorization_pending") ||
lower.includes("device authorization is pending");
}
async function begin(context: PluginContext): Promise<OAuth2Begin> {
const response = await context.network.fetch(DEVICE_CODE_URL, {
method: "POST",
headers: { accept: "application/json", "content-type": "application/json" },
body: JSON.stringify({ client_id: CLIENT_ID }),
});
const body = parseBody(response.body);
if (response.status < 200 || response.status >= 300) {
throw new Error(
`Failed to request OpenAI Codex device code (HTTP ${response.status}): ${response.body}`,
);
}
const deviceAuthId = text(body.device_auth_id ?? body.device_code);
const userCode = text(body.user_code ?? body.usercode);
if (!deviceAuthId || !userCode) {
throw new Error("OpenAI Codex device authorization response is incomplete");
}
const session: Session = { deviceAuthId, userCode };
return {
session: session as unknown as JsonValue,
userCode,
verificationUrl: VERIFICATION_URI,
verificationUrlComplete: VERIFICATION_URI,
expiresAtMs: Date.now() + Math.max(1, number(body.expires_in) ?? 900) * 1000,
pollIntervalMs: Math.max(1, number(body.interval) ?? 5) * 1000,
};
}
async function exchangeAuthorizationCode(
context: PluginContext,
authorizationCode: string,
codeVerifier: string,
): Promise<CredentialCandidate> {
const response = await context.network.fetch(OAUTH_TOKEN_URL, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: authorizationCode,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: codeVerifier,
}).toString(),
});
const body = parseBody(response.body);
const accessToken = text(body.access_token);
if (!accessToken) {
throw new Error(
errorMessage(body) ?? `Failed to exchange Codex authorization code (HTTP ${response.status})`,
);
}
return { accessToken, refreshToken: text(body.refresh_token), displayName: null };
}
async function completed(credential: CredentialCandidate): Promise<OAuth2Poll> {
return { status: "completed", resources: [await credentialDraft(credential)] };
}
async function poll(sessionValue: JsonValue, context: PluginContext): Promise<OAuth2Poll> {
const session = parseSession(sessionValue);
const response = await context.network.fetch(DEVICE_TOKEN_URL, {
method: "POST",
headers: { accept: "application/json", "content-type": "application/json" },
body: JSON.stringify({
device_auth_id: session.deviceAuthId,
user_code: session.userCode,
}),
});
const body = parseBody(response.body);
// 该端点用 403/404 表示"尚未完成授权"。
if (response.status === 403 || response.status === 404) return { status: "pending" };
const code = errorCode(body);
const message = errorMessage(body);
if (
["authorization_pending", "pending", "waiting", "in_progress", "device_authorization_pending"]
.includes(code) ||
(message !== null && pendingMessage(message))
) {
return { status: "pending" };
}
if (code === "slow_down") return { status: "slow-down" };
if (code === "expired_token" || code === "expired") {
return { status: "failed", message: message ?? "Device authorization code expired" };
}
if (code === "access_denied" || code === "denied") {
return { status: "denied", message: message ?? undefined };
}
const directToken = text(body.access_token);
if (directToken) {
return await completed({
accessToken: directToken,
refreshToken: text(body.refresh_token),
displayName: null,
});
}
const authorizationCode = text(body.authorization_code);
const codeVerifier = text(body.code_verifier);
if (response.status >= 200 && response.status < 300 && authorizationCode && codeVerifier) {
try {
return await completed(
await exchangeAuthorizationCode(context, authorizationCode, codeVerifier),
);
} catch (error) {
return {
status: "failed",
message: error instanceof Error ? error.message : String(error),
};
}
}
if (!code && body.error === undefined && response.status >= 400) return { status: "pending" };
return {
status: "failed",
message: message ??
(code
? `OAuth error: ${code}`
: `Codex device authorization failed (HTTP ${response.status})`),
};
}
export const codexDeviceOAuth: OAuth2AddMethod = {
type: "oauth2.0",
id: "chatgpt-device",
displayName: {
"en-US": "Sign in with ChatGPT",
"zh-CN": "使用 ChatGPT 登录",
},
description: {
"en-US": "Authorize this device with OpenAI, then add the resulting ChatGPT account.",
"zh-CN": "在 OpenAI 完成设备授权后,自动添加对应的 ChatGPT 账号。",
},
begin,
poll,
};
@@ -0,0 +1,16 @@
{
"apiVersion": 1,
"id": "dev.cursorbyok.examples.codex-auth",
"name": "Codex",
"version": "0.1.0",
"author": "@leookun",
"minAppVersion": "0.1.0",
"icon": "assets/codex.svg",
"entry": "main.ts",
"permissions": {
"network": [
"auth.openai.com",
"chatgpt.com"
]
}
}
@@ -0,0 +1,144 @@
import type {
ProviderInvokeInput,
ProviderOutput,
ProviderResult,
ProviderSupport,
} from "cursor-byok:provider";
import type { PluginContext } from "cursor-byok:plugin";
import { HttpError, streamOpenAiResponses } from "cursor-byok:protocol/openai-responses";
import { codexModels, reasoningEfforts } from "./models.ts";
import {
type AccountData,
accountData,
chatGptAccountId,
quotaExhaustedPatch,
RESOURCE_TYPE,
} from "./resources.ts";
const RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
/** 流内错误只有文本可用,按额度关键词分类。 */
export function isQuotaError(error: string): boolean {
const message = error.toLowerCase();
return message.includes("insufficient_quota") ||
message.includes("usage_limit_reached") ||
message.includes("exceeded your current quota") ||
message.includes("quota_exceeded") ||
message.includes("5-hour") ||
message.includes("5 hour") ||
(message.includes("429") &&
(message.includes("quota") || message.includes("usage_limit") ||
message.includes("insufficient")));
}
/** HTTP 失败携带结构化状态码,429 时放宽响应体的匹配条件。 */
function isQuotaHttpError(error: HttpError): boolean {
const body = error.body.toLowerCase();
return body.includes("insufficient_quota") ||
body.includes("usage_limit_reached") ||
body.includes("exceeded your current quota") ||
body.includes("quota_exceeded") ||
body.includes("5-hour") ||
body.includes("5 hour") ||
(error.status === 429 &&
(body.includes("quota") || body.includes("usage_limit") || body.includes("insufficient")));
}
function invalidResult(message: string, stateMessage: string): ProviderResult {
return {
status: "resource-error",
message,
patch: { state: { status: "invalid", message: stateMessage } },
};
}
function headers(data: AccountData, cacheKey: string | null): Record<string, string> {
const result: Record<string, string> = {
authorization: `Bearer ${data.accessToken}`,
originator: "codex_cli_rs",
};
const accountId = chatGptAccountId(data.accessToken);
if (accountId) result["ChatGPT-Account-Id"] = accountId;
// Codex 后端的缓存亲和契约:session-id / thread-id / prompt_cache_key
// 三者同源(见 codex-rs client.rs);缺头会导致请求落在随机分片上。
if (cacheKey !== null) {
result["session-id"] = cacheKey;
result["thread-id"] = cacheKey;
result["x-client-request-id"] = cacheKey;
}
return result;
}
async function invoke(
input: ProviderInvokeInput,
output: ProviderOutput,
context: PluginContext,
): Promise<ProviderResult> {
if (!input.resource) {
return { status: "request-error", message: "add a ChatGPT account before calling Codex" };
}
let data: AccountData;
try {
data = accountData(input.resource);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return invalidResult(message, message);
}
const efforts = reasoningEfforts(input.model);
const reasoning = input.request.reasoning;
const effort = reasoning.effort !== null && efforts.includes(reasoning.effort)
? reasoning.effort
: null;
try {
await streamOpenAiResponses(
{
url: RESPONSES_URL,
model: input.model.id,
// Codex 订阅端点不接受 max_output_tokens;fast 档位经协议库映射为
// service_tier: "priority" 后透传。
request: {
...input.request,
reasoning: { enabled: reasoning.enabled, effort },
maxOutputTokens: null,
},
headers: headers(data, input.request.cacheKey),
extraBody: { store: false },
},
output,
context,
);
return { status: "completed" };
} catch (error) {
if (error instanceof HttpError) {
if (error.status === 401) {
return invalidResult(error.message, "ChatGPT authorization expired; sign in again");
}
if (isQuotaHttpError(error)) {
return {
status: "resource-error",
message: error.message,
patch: quotaExhaustedPatch(data, error.body),
};
}
return { status: "request-error", message: error.message };
}
const message = error instanceof Error ? error.message : String(error);
if (isQuotaError(message)) {
return { status: "resource-error", message, patch: quotaExhaustedPatch(data, message) };
}
return { status: "request-error", message };
}
}
export const codexProvider: ProviderSupport = {
id: "codex",
displayName: "OpenAI Codex",
description: {
"en-US": "ChatGPT subscription access through the official Codex Responses API.",
"zh-CN": "通过官方 Codex Responses API 使用 ChatGPT 订阅。",
},
providerType: "openai",
resourceType: RESOURCE_TYPE,
models: codexModels,
invoke,
};
@@ -0,0 +1,432 @@
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
import type {
ResourceDraft,
ResourceImportFile,
ResourceImportResult,
ResourceImportSupport,
ResourceMetric,
ResourcePatch,
ResourceSnapshot,
ResourceState,
ResourceView,
} from "cursor-byok:resource";
export const RESOURCE_TYPE = "chatgpt-account";
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
export type QuotaWindow = {
usedPercent: number | null;
remainingPercent: number | null;
resetAtMs: number | null;
};
export type AccountQuota = {
planLabel: string | null;
weekly: QuotaWindow | null;
fiveHour: QuotaWindow | null;
limitReached: boolean;
updatedAtMs: number;
};
/** 单条 chatgpt-account 资源的 privateData 形状。 */
export type AccountData = {
accessToken: string;
refreshToken: string | null;
displayName: string;
quota: AccountQuota | null;
};
export type CredentialCandidate = {
accessToken: string;
refreshToken: string | null;
displayName: string | null;
};
function object(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function number(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
const encoded = token.split(".")[1];
if (!encoded) return null;
try {
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
return object(JSON.parse(new TextDecoder().decode(bytes)));
} catch {
return null;
}
}
function claim(payload: Record<string, unknown> | null, key: string): string | null {
return payload ? text(payload[key]) : null;
}
export function chatGptAccountId(accessToken: string): string | null {
const payload = decodeJwtPayload(accessToken);
const auth = object(payload?.["https://api.openai.com/auth"]);
return text(auth?.chatgpt_account_id) ?? claim(payload, "chatgpt_account_id");
}
async function tokenFingerprint(token: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
return Array.from(
new Uint8Array(digest).slice(0, 8),
(byte) => byte.toString(16).padStart(2, "0"),
).join("");
}
/** ChatGPT access token 的邮箱通常在 OpenAI 的 profile 声明里,而不是顶层 email。 */
function profileEmail(payload: Record<string, unknown> | null): string | null {
const profile = object(payload?.["https://api.openai.com/profile"]);
return text(profile?.email);
}
export async function accountIdentity(
accessToken: string,
): Promise<{ key: string; displayName: string }> {
const payload = decodeJwtPayload(accessToken);
const identity = chatGptAccountId(accessToken) ??
claim(payload, "sub") ??
claim(payload, "email") ??
await tokenFingerprint(accessToken);
const displayName = claim(payload, "email") ??
profileEmail(payload) ??
claim(payload, "preferred_username") ??
claim(payload, "name") ??
identity;
return { key: `codex:${identity}`, displayName };
}
export async function credentialDraft(credential: CredentialCandidate): Promise<ResourceDraft> {
const identity = await accountIdentity(credential.accessToken);
const data: AccountData = {
accessToken: credential.accessToken,
refreshToken: credential.refreshToken,
displayName: credential.displayName ?? identity.displayName,
quota: null,
};
return { key: identity.key, privateData: data as unknown as JsonValue };
}
export function accountData(resource: ResourceSnapshot): AccountData {
const data = object(resource.privateData);
const accessToken = text(data?.accessToken);
if (!accessToken) throw new Error("ChatGPT account resource is missing its access token");
return {
accessToken,
refreshToken: text(data?.refreshToken),
displayName: text(data?.displayName) ?? "ChatGPT account",
quota: (data?.quota ?? null) as AccountQuota | null,
};
}
export function accountHeaders(data: AccountData): Record<string, string> {
const headers: Record<string, string> = {
accept: "application/json",
originator: "codex_cli_rs",
authorization: `Bearer ${data.accessToken}`,
};
const accountId = chatGptAccountId(data.accessToken);
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
return headers;
}
function clampPercent(value: number): number {
return Math.max(0, Math.min(100, value));
}
function resetAtMs(window: Record<string, unknown>, nowMs: number): number | null {
const resetAt = window.reset_at ?? window.resetAt;
const numeric = number(resetAt);
if (numeric !== null) return numeric > 10_000_000_000 ? numeric : numeric * 1000;
if (typeof resetAt === "string") {
const parsed = Date.parse(resetAt);
if (Number.isFinite(parsed)) return parsed;
}
const afterSeconds = number(window.reset_after_seconds ?? window.resetAfterSeconds);
return afterSeconds === null ? null : nowMs + afterSeconds * 1000;
}
function quotaWindow(value: unknown, nowMs: number): QuotaWindow | null {
const window = object(value);
if (!window) return null;
const used = number(window.used_percent ?? window.usedPercent);
const remaining = used === null
? number(window.remaining_percent ?? window.remainingPercent)
: clampPercent(100 - used);
return {
usedPercent: used === null
? (remaining === null ? null : clampPercent(100 - remaining))
: clampPercent(used),
remainingPercent: remaining === null ? null : clampPercent(remaining),
resetAtMs: resetAtMs(window, nowMs),
};
}
function planLabel(value: unknown): string | null {
const plan = text(value);
if (!plan) return null;
const labels: Record<string, string> = {
plus: "ChatGPT Plus",
pro: "ChatGPT Pro",
team: "ChatGPT Team",
business: "ChatGPT Business",
enterprise: "ChatGPT Enterprise",
free: "ChatGPT Free",
go: "ChatGPT Go",
};
return labels[plan.toLowerCase()] ?? plan;
}
export function parseCodexUsage(body: unknown, nowMs = Date.now()): AccountQuota {
const root = object(body) ?? {};
const rateLimit = object(root.rate_limit ?? root.rateLimit) ?? root;
const primary = rateLimit.primary_window ?? rateLimit.primaryWindow;
const secondary = rateLimit.secondary_window ?? rateLimit.secondaryWindow;
const weekly = quotaWindow(secondary ?? primary, nowMs);
const fiveHour = secondary === undefined || secondary === null
? null
: quotaWindow(primary, nowMs);
const explicitLimit = rateLimit.limit_reached ?? rateLimit.limitReached;
return {
planLabel: planLabel(root.plan_type ?? root.planType),
weekly,
fiveHour,
limitReached: typeof explicitLimit === "boolean" ? explicitLimit : [weekly, fiveHour].some(
(window) => window?.remainingPercent !== null && window?.remainingPercent === 0,
),
updatedAtMs: nowMs,
};
}
function windowCoolingUntil(window: QuotaWindow | null, nowMs: number): number | null {
if (!window || window.remainingPercent === null || window.remainingPercent > 0) return null;
if (window.resetAtMs !== null && window.resetAtMs <= nowMs) return null;
return window.resetAtMs ?? nowMs + FIVE_HOURS_MS;
}
export function quotaCoolingUntil(quota: AccountQuota, nowMs = Date.now()): number | null {
const resets = [
windowCoolingUntil(quota.weekly, nowMs),
windowCoolingUntil(quota.fiveHour, nowMs),
].filter((value): value is number => value !== null);
if (resets.length > 0) return Math.max(...resets);
return quota.limitReached ? nowMs + FIVE_HOURS_MS : null;
}
export function quotaState(quota: AccountQuota | null, nowMs = Date.now()): ResourceState {
if (!quota) return { status: "ready" };
const coolingUntil = quotaCoolingUntil(quota, nowMs);
return coolingUntil === null
? { status: "ready" }
: { status: "cooling", retryAtMs: coolingUntil, message: "ChatGPT quota is exhausted" };
}
/** 从上游错误文本中提取重置时间;拿不到时回退 5 小时。 */
function resetFromError(error: string, nowMs: number): number {
const resetAt = error.match(/["']?reset_at["']?\s*[:=]\s*["']?(\d+(?:\.\d+)?)/i)?.[1];
if (resetAt) {
const value = Number(resetAt);
if (Number.isFinite(value)) return value > 10_000_000_000 ? value : value * 1000;
}
const resetAfter = error.match(/["']?reset_after_seconds["']?\s*[:=]\s*["']?(\d+(?:\.\d+)?)/i)
?.[1];
if (resetAfter) {
const value = Number(resetAfter);
if (Number.isFinite(value)) return nowMs + value * 1000;
}
return nowMs + FIVE_HOURS_MS;
}
/** 额度耗尽时的资源补丁:标记 5 小时窗口耗尽并按重置时间进入冷却。 */
export function quotaExhaustedPatch(
data: AccountData,
error: string,
nowMs = Date.now(),
): ResourcePatch {
const quota: AccountQuota = {
planLabel: data.quota?.planLabel ?? null,
weekly: data.quota?.weekly ?? null,
fiveHour: {
usedPercent: 100,
remainingPercent: 0,
resetAtMs: resetFromError(error, nowMs),
},
limitReached: true,
updatedAtMs: nowMs,
};
return {
privateData: { ...data, quota } as unknown as JsonValue,
state: quotaState(quota, nowMs),
};
}
export function presentAccount(resource: ResourceSnapshot): ResourceView {
const data = accountData(resource);
const metrics: ResourceMetric[] = [];
const weekly = data.quota?.weekly;
if (weekly && weekly.remainingPercent !== null) {
metrics.push({
id: "weekly",
label: { "en-US": "Weekly quota", "zh-CN": "周额度" },
unit: "percent",
value: weekly.remainingPercent,
...(weekly.resetAtMs !== null ? { resetAtMs: weekly.resetAtMs } : {}),
});
}
const fiveHour = data.quota?.fiveHour;
if (fiveHour && fiveHour.remainingPercent !== null) {
metrics.push({
id: "five-hour",
label: { "en-US": "5-hour window", "zh-CN": "5 小时窗口" },
unit: "percent",
value: fiveHour.remainingPercent,
...(fiveHour.resetAtMs !== null ? { resetAtMs: fiveHour.resetAtMs } : {}),
});
}
return {
// 旧记录可能存的是账号 ID;展示时优先从 token 现算邮箱。
displayName: jwtDisplayName(data.accessToken) ?? data.displayName,
...(data.quota?.planLabel ? { description: data.quota.planLabel } : {}),
...(metrics.length > 0 ? { metrics } : {}),
};
}
export async function refreshAccount(
resource: ResourceSnapshot,
context: PluginContext,
): Promise<ResourcePatch> {
const data = accountData(resource);
const response = await context.network.fetch(USAGE_URL, {
method: "GET",
headers: accountHeaders(data),
});
if (response.status < 200 || response.status >= 300) {
if (response.status === 401) {
return {
state: { status: "invalid", message: "ChatGPT authorization expired; sign in again" },
};
}
throw new Error(`Codex usage lookup failed (HTTP ${response.status}): ${response.body}`);
}
let body: unknown;
try {
body = JSON.parse(response.body);
} catch {
throw new Error("Codex usage lookup returned invalid JSON");
}
const quota = parseCodexUsage(body);
return {
privateData: { ...data, quota } as unknown as JsonValue,
state: quotaState(quota),
};
}
function firstText(source: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = text(source[key]);
if (value) return value;
}
return null;
}
function jwtDisplayName(token: string | null): string | null {
if (!token) return null;
const payload = decodeJwtPayload(token);
return claim(payload, "email") ?? profileEmail(payload) ??
claim(payload, "preferred_username") ?? claim(payload, "name");
}
function collectCredentials(value: unknown, output: CredentialCandidate[]): void {
if (Array.isArray(value)) {
for (const item of value) collectCredentials(item, output);
return;
}
const item = object(value);
if (!item || item.disabled === true) return;
for (const key of ["accounts", "credentials", "items"]) {
if (Array.isArray(item[key])) {
collectCredentials(item[key], output);
return;
}
}
const tokens = object(item.tokens) ?? item;
const accessToken = firstText(tokens, ["access_token", "accessToken", "token", "key"]) ??
firstText(item, ["access_token", "accessToken", "token", "key", "OPENAI_API_KEY"]);
if (!accessToken) return;
const refreshToken = firstText(tokens, ["refresh_token", "refreshToken"]) ??
firstText(item, ["refresh_token", "refreshToken"]);
const idToken = firstText(tokens, ["id_token", "idToken"]) ??
firstText(item, ["id_token", "idToken"]);
const displayName = firstText(item, ["email", "display_name", "displayName", "name"]) ??
firstText(tokens, ["email", "display_name", "displayName", "name"]) ??
jwtDisplayName(idToken);
output.push({ accessToken, refreshToken, displayName });
}
export function parseCredentialFiles(files: ResourceImportFile[]): {
credentials: CredentialCandidate[];
warnings: string[];
} {
const credentials: CredentialCandidate[] = [];
const warnings: string[] = [];
for (const file of files) {
let content: unknown;
try {
content = JSON.parse(file.content);
} catch {
warnings.push(`${file.name}: not valid JSON`);
continue;
}
const found: CredentialCandidate[] = [];
collectCredentials(content, found);
if (found.length === 0) {
warnings.push(`${file.name}: no ChatGPT access token found`);
continue;
}
credentials.push(...found);
}
return { credentials, warnings };
}
export const credentialImport: ResourceImportSupport = {
displayName: {
"en-US": "Import Codex credentials",
"zh-CN": "导入 Codex 凭证",
},
description: {
"en-US": "Import one or more Codex JSON credential files.",
"zh-CN": "导入一个或多个 Codex JSON 凭证文件。",
},
accept: [".json"],
multiple: true,
parse: async (files: ResourceImportFile[]): Promise<ResourceImportResult> => {
const { credentials, warnings } = parseCredentialFiles(files);
if (credentials.length === 0) {
throw new Error(warnings.join("; ") || "credential JSON does not contain an access token");
}
return {
resources: await Promise.all(credentials.map(credentialDraft)),
...(warnings.length > 0 ? { warnings } : {}),
};
},
};
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="230" height="230" viewBox="0 0 230 230" xmlns="http://www.w3.org/2000/svg">
<title>grok</title>
<rect x="22" y="21" width="187" height="187" rx="42" fill="#000000"/>
<g fill="#FFFFFF">
<path d="M96.5 137.5 L152 82 L166 96 L110.5 151.5 Z"/>
<path d="M64 82 L106 124 L92 138 L64 110 Z"/>
<path d="M166 148 L166 96 L152 110 L152 148 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 418 B

@@ -0,0 +1,13 @@
{
"imports": {
"cursor-byok:plugin": "../../../src/plugin/sdk/plugin.ts",
"cursor-byok:provider": "../../../src/plugin/sdk/provider.ts",
"cursor-byok:model": "../../../src/plugin/sdk/model.ts",
"cursor-byok:resource": "../../../src/plugin/sdk/resource.ts",
"cursor-byok:protocol/openai-chat": "../../../src/plugin/sdk/protocol/openai_chat.ts"
},
"fmt": {
"lineWidth": 100,
"exclude": ["assets"]
}
}
@@ -0,0 +1,379 @@
import type {
JsonValue,
NetworkEventStream,
NetworkResponse,
PluginContext,
} from "cursor-byok:plugin";
import type { LlmRequest, ModelEvent } from "cursor-byok:provider";
import type { ResourceSnapshot } from "cursor-byok:resource";
import { grokDeviceOAuth } from "./oauth.ts";
import { FALLBACK_MODELS, grokModels, parseGrokModels } from "./models.ts";
import { grokProvider, isQuotaError } from "./provider.ts";
import {
accountIdentity,
credentialDraft,
parseCredentialFiles,
parseGrokUsage,
presentAccount,
quotaState,
RESOURCE_TYPE,
} from "./resources.ts";
function assert(condition: unknown, message = "assertion failed"): asserts condition {
if (!condition) throw new Error(message);
}
function assertEquals(actual: unknown, expected: unknown): void {
const left = JSON.stringify(actual);
const right = JSON.stringify(expected);
if (left !== right) throw new Error(`expected ${right}, received ${left}`);
}
function jwt(payload: Record<string, unknown>): string {
const encoded = btoa(JSON.stringify(payload)).replace(/=/g, "").replace(/\+/g, "-").replace(
/\//g,
"_",
);
return `header.${encoded}.signature`;
}
type RequestInit = { body?: string; headers?: Record<string, string> };
type FetchHandler = (url: string, init?: RequestInit) => NetworkResponse;
type StreamHandler = (url: string, init?: RequestInit) => NetworkEventStream;
function context(handlers: { fetch?: FetchHandler; stream?: StreamHandler }): PluginContext {
return {
network: {
fetch: (url, init) => {
if (!handlers.fetch) throw new Error("fetch was not expected");
return Promise.resolve(handlers.fetch(url, init));
},
stream: (url, init) => {
if (!handlers.stream) throw new Error("stream was not expected");
return Promise.resolve(handlers.stream(url, init));
},
},
signal: new AbortController().signal,
};
}
function snapshot(privateData: JsonValue): ResourceSnapshot {
return {
id: "resource-1",
type: RESOURCE_TYPE,
key: "grok:user-1",
privateData,
state: { status: "ready" },
};
}
async function* sse(lines: string[]): AsyncGenerator<string> {
for (const line of lines) yield line;
}
function request(): LlmRequest {
return {
instructions: "You are a coding assistant.",
messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
tools: [],
reasoning: { enabled: true, effort: "medium" },
latency: "fast",
maxOutputTokens: 32_000,
cacheKey: "conversation-1",
};
}
Deno.test("account identity uses the JWT subject and drafts keep tokens private-side", async () => {
const token = jwt({ sub: "user-1", email: "person@x.ai" });
assertEquals(await accountIdentity(token), {
key: "grok:user-1",
displayName: "person@x.ai",
});
const draft = await credentialDraft({
accessToken: token,
refreshToken: null,
displayName: null,
});
assertEquals(draft.key, "grok:user-1");
const view = presentAccount(snapshot(draft.privateData));
assert(!JSON.stringify(view).includes(token), "resource view exposed an access token");
assertEquals(view.displayName, "person@x.ai");
});
Deno.test("credential import accepts Grok credential JSON files", () => {
const { credentials, warnings } = parseCredentialFiles([
{
name: "accounts.json",
content: JSON.stringify({
accounts: [
{ access_token: "token-1", refresh_token: "refresh-1", email: "a@x.ai" },
{ access_token: "token-2", disabled: true },
],
}),
},
{ name: "broken.json", content: "{not json" },
]);
assertEquals(credentials, [{
accessToken: "token-1",
refreshToken: "refresh-1",
displayName: "a@x.ai",
}]);
assertEquals(warnings, ["broken.json: not valid JSON"]);
});
Deno.test("credit usage percent is inverted to remaining and drives cooling", () => {
const quota = parseGrokUsage({
config: {
creditUsagePercent: 34,
subscriptionTierDisplay: "SuperGrok",
currentPeriod: { end: "2026-09-01T00:00:00Z" },
},
}, 1_700_000_000_000);
assertEquals(quota.planLabel, "SuperGrok");
assertEquals(quota.remainingPercent, 66);
assertEquals(quota.resetAtMs, Date.parse("2026-09-01T00:00:00Z"));
assertEquals(quotaState(quota, 1_700_000_000_000), { status: "ready" });
const exhausted = parseGrokUsage({
config: { creditUsagePercent: 100, currentPeriod: { end: 1_900_000_000 } },
}, 1_700_000_000_000);
assertEquals(quotaState(exhausted, 1_700_000_000_000), {
status: "cooling",
retryAtMs: 1_900_000_000_000,
message: "Grok credits are exhausted",
});
});
Deno.test("missing usage with a billing period counts as unused", () => {
const quota = parseGrokUsage({ config: { currentPeriod: { end: 1_900_000_000 } } });
assertEquals(quota.remainingPercent, 100);
assertEquals(quota.limitReached, false);
});
Deno.test("model discovery parses both language-models and standard list shapes", () => {
const richModels = parseGrokModels({
models: [
{ id: "grok-4", input_modalities: ["text", "image"], context_window: 256_000 },
{ id: "grok-3-mini", input_modalities: ["text"] },
{ id: "grok-4" },
],
});
assertEquals(richModels.map((model) => model.id), ["grok-4", "grok-3-mini"]);
assertEquals(richModels[0].displayName, "Grok 4");
assertEquals(richModels[0].capabilities, { images: true });
assertEquals(richModels[1].capabilities, { images: false });
const plainModels = parseGrokModels({ data: [{ id: "grok-4-fast" }] });
assertEquals(plainModels.map((model) => model.id), ["grok-4-fast"]);
assertEquals(plainModels[0].displayName, "Grok 4 Fast");
});
Deno.test("model discovery falls back to known models when the account cannot list", async () => {
const token = jwt({ sub: "user-1" });
const draft = await credentialDraft({
accessToken: token,
refreshToken: null,
displayName: null,
});
const models = await grokModels.list(
{ resource: snapshot(draft.privateData) },
context({
fetch: () => ({
status: 403,
headers: {},
body: JSON.stringify({ code: "personal-team-blocked:spending-limit" }),
}),
}),
);
assertEquals(models, FALLBACK_MODELS);
});
Deno.test("device OAuth begins with a host-held session and completes with a resource draft", async () => {
const accessToken = jwt({ sub: "user-oauth", email: "oauth@x.ai" });
let requestNumber = 0;
const flowContext = context({
fetch: (url, init) => {
requestNumber += 1;
if (requestNumber === 1) {
assertEquals(url, "https://auth.x.ai/oauth2/device/code");
assert(init?.body?.includes("scope="), "device code request must carry the scope");
return {
status: 200,
headers: {},
body: JSON.stringify({
device_code: "private-device-code",
user_code: "ABCD-EFGH",
verification_uri: "https://accounts.x.ai/activate",
verification_uri_complete: "https://accounts.x.ai/activate?code=ABCD-EFGH",
expires_in: 900,
interval: 5,
}),
};
}
assertEquals(url, "https://auth.x.ai/oauth2/token");
assert(init?.body?.includes("device_code=private-device-code"));
if (requestNumber === 2) {
return {
status: 400,
headers: {},
body: JSON.stringify({ error: "authorization_pending" }),
};
}
return {
status: 200,
headers: {},
body: JSON.stringify({ access_token: accessToken, refresh_token: "refresh-secret" }),
};
},
});
const begun = await grokDeviceOAuth.begin(flowContext);
assertEquals(begun.userCode, "ABCD-EFGH");
assertEquals(begun.pollIntervalMs, 5000);
const pending = await grokDeviceOAuth.poll(begun.session, flowContext);
assertEquals(pending.status, "pending");
const polled = await grokDeviceOAuth.poll(begun.session, flowContext);
assert(polled.status === "completed", `expected completed, received ${polled.status}`);
assertEquals(polled.resources[0].key, "grok:user-oauth");
assertEquals(requestNumber, 3);
});
Deno.test("invoke streams normalized events from the xAI Chat Completions API", async () => {
const token = jwt({ sub: "user-1" });
const draft = await credentialDraft({
accessToken: token,
refreshToken: null,
displayName: null,
});
let requestBody = "";
let requestHeaders: Record<string, string> = {};
const events: ModelEvent[] = [];
const result = await grokProvider.invoke(
{
model: { id: "grok-4", displayName: "Grok 4" },
resource: snapshot(draft.privateData),
request: request(),
},
{ emit: (event) => events.push(event) },
context({
stream: (url, init) => {
assertEquals(url, "https://api.x.ai/v1/chat/completions");
requestBody = init?.body ?? "";
requestHeaders = init?.headers ?? {};
return {
status: 200,
headers: {},
lines: sse([
'data: {"choices":[{"delta":{"content":"Hel"}}]}',
'data: {"choices":[{"delta":{"content":"lo"}}]}',
'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":2,"prompt_tokens_details":{"cached_tokens":4}}}',
"data: [DONE]",
]),
};
},
}),
);
assertEquals(result, { status: "completed" });
const body = JSON.parse(requestBody) as Record<string, unknown>;
assertEquals(body.model, "grok-4");
assertEquals(body.stream, true);
assertEquals(body.prompt_cache_key, "conversation-1");
assert(!("reasoning_effort" in body), "xAI endpoint rejects reasoning_effort");
assert(!("service_tier" in body), "xAI endpoint rejects service_tier");
assertEquals(requestHeaders["authorization"], `Bearer ${token}`);
assertEquals(events, [
{ type: "text-start" },
{ type: "text-delta", text: "Hel" },
{ type: "text-delta", text: "lo" },
{ type: "text-end" },
{
type: "usage",
usage: {
inputTokens: 10,
outputTokens: 2,
totalTokens: null,
cacheReadTokens: 4,
cacheWriteTokens: null,
reasoningTokens: null,
},
},
{ type: "done", reason: "stop" },
]);
});
Deno.test("invoke streams incremental tool calls and reasoning replay state", async () => {
const token = jwt({ sub: "user-1" });
const draft = await credentialDraft({
accessToken: token,
refreshToken: null,
displayName: null,
});
const events: ModelEvent[] = [];
const result = await grokProvider.invoke(
{
model: { id: "grok-4", displayName: "Grok 4" },
resource: snapshot(draft.privateData),
request: request(),
},
{ emit: (event) => events.push(event) },
context({
stream: () => ({
status: 200,
headers: {},
lines: sse([
'data: {"choices":[{"delta":{"reasoning_content":"thinking"}}]}',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","function":{"name":"read_file","arguments":"{\\"path\\":"}}]}}]}',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"a.ts\\"}"}}]}}]}',
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}',
"data: [DONE]",
]),
}),
}),
);
assertEquals(result, { status: "completed" });
assertEquals(events, [
{ type: "thinking-start" },
{ type: "thinking-delta", text: "thinking" },
{ type: "tool-call-start", index: 0, callId: "call-1", name: "read_file" },
{ type: "tool-call-arguments-delta", index: 0, delta: '{"path":' },
{ type: "tool-call-arguments-delta", index: 0, delta: '"a.ts"}' },
{ type: "thinking-end" },
{ type: "tool-call-end", index: 0 },
{
type: "replay-state",
providerKind: "openai_chat",
value: { reasoning_content: "thinking" },
},
{ type: "done", reason: "tool-use" },
]);
});
Deno.test("invoke maps quota failures to a cooling resource error", async () => {
assert(!isQuotaError("400 invalid request"));
assert(isQuotaError("429 credits exhausted"));
const token = jwt({ sub: "user-1" });
const draft = await credentialDraft({
accessToken: token,
refreshToken: null,
displayName: null,
});
const result = await grokProvider.invoke(
{
model: { id: "grok-4", displayName: "Grok 4" },
resource: snapshot(draft.privateData),
request: request(),
},
{ emit: () => {} },
context({
stream: () => ({
status: 429,
headers: {},
lines: sse(['{"error":"credits exhausted"}']),
}),
}),
);
assert(result.status === "resource-error", `expected resource-error, received ${result.status}`);
assert(result.patch.state?.status === "cooling", "quota failure should cool the resource");
});
+16
View File
@@ -0,0 +1,16 @@
import { defineProviderPlugin } from "cursor-byok:plugin";
import { grokDeviceOAuth } from "./oauth.ts";
import { grokProvider } from "./provider.ts";
import { credentialImport, presentAccount, refreshAccount, RESOURCE_TYPE } from "./resources.ts";
export default defineProviderPlugin({
providers: [grokProvider],
resources: [{
type: RESOURCE_TYPE,
displayName: { "en-US": "Grok accounts", "zh-CN": "Grok 账号" },
add: [grokDeviceOAuth],
import: credentialImport,
present: presentAccount,
refresh: refreshAccount,
}],
});
@@ -0,0 +1,96 @@
import type { ModelDefinition, ModelSupport } from "cursor-byok:model";
import { accountData } from "./resources.ts";
const LANGUAGE_MODELS_URL = "https://api.x.ai/v1/language-models";
const MODELS_URL = "https://api.x.ai/v1/models";
/** 免费账号无权调用模型列表接口(403 spending-limit);退回已知模型。 */
export const FALLBACK_MODELS: ModelDefinition[] = [
{
id: "grok-4.6",
displayName: "Grok 4.6",
capabilities: { images: true },
},
{
id: "grok-4.5",
displayName: "Grok 4.5",
capabilities: { images: true },
},
];
function object(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function modalities(value: unknown): string[] {
return Array.isArray(value)
? value.flatMap((item) => (typeof item === "string" ? [item.toLowerCase()] : []))
: [];
}
/** 把模型 ID 变成可读名称,如 grok-4-fast → Grok 4 Fast。 */
function displayName(id: string): string {
return id
.split("-")
.map((part) => (/^\d/.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1)))
.join(" ");
}
/** 兼容 /v1/language-models 的 models 数组与 /v1/models 的 data 数组。 */
export function parseGrokModels(body: unknown): ModelDefinition[] {
const root = object(body);
const source = root?.models ?? root?.data ?? body;
if (!Array.isArray(source)) {
throw new Error("Grok model discovery response does not contain a model list");
}
const seen = new Set<string>();
const models: ModelDefinition[] = [];
for (const raw of source) {
const model = object(raw);
const id = model ? text(model.id ?? model.name) : null;
if (!id || seen.has(id)) continue;
seen.add(id);
const inputs = modalities(model?.input_modalities ?? model?.inputModalities);
models.push({
id,
displayName: displayName(id),
capabilities: {
images: inputs.length === 0 || inputs.includes("image"),
},
});
}
return models;
}
export const grokModels: ModelSupport = {
list: async ({ resource }, context): Promise<ModelDefinition[]> => {
if (!resource) throw new Error("add a Grok account before syncing models");
const data = accountData(resource);
const headers = {
accept: "application/json",
authorization: `Bearer ${data.accessToken}`,
};
// language-models 带模态与上下文元数据;不可用时回退到标准列表。
let response = await context.network.fetch(LANGUAGE_MODELS_URL, { method: "GET", headers });
if (response.status < 200 || response.status >= 300) {
response = await context.network.fetch(MODELS_URL, { method: "GET", headers });
}
if (response.status < 200 || response.status >= 300) {
return FALLBACK_MODELS;
}
let body: unknown;
try {
body = JSON.parse(response.body);
} catch {
throw new Error("Grok model discovery returned invalid JSON");
}
const models = parseGrokModels(body);
return models.length > 0 ? models : FALLBACK_MODELS;
},
};
+148
View File
@@ -0,0 +1,148 @@
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
import type { OAuth2AddMethod, OAuth2Begin, OAuth2Poll } from "cursor-byok:resource";
import { credentialDraft } from "./resources.ts";
const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
const DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code";
const TOKEN_URL = "https://auth.x.ai/oauth2/token";
const SCOPE = "openid profile email offline_access grok-cli:access api:access";
type Session = {
deviceCode: string;
};
function object(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function number(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function parseBody(body: string): Record<string, unknown> {
try {
return object(JSON.parse(body)) ?? {};
} catch {
return {};
}
}
function parseSession(value: JsonValue): Session {
const session = object(value);
const deviceCode = text(session?.deviceCode);
if (!deviceCode) throw new Error("Grok OAuth session is invalid");
return { deviceCode };
}
async function begin(context: PluginContext): Promise<OAuth2Begin> {
const response = await context.network.fetch(DEVICE_CODE_URL, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({ client_id: CLIENT_ID, scope: SCOPE }).toString(),
});
const body = parseBody(response.body);
if (response.status < 200 || response.status >= 300) {
throw new Error(
`Failed to request xAI device code (HTTP ${response.status}): ${response.body}`,
);
}
const deviceCode = text(body.device_code);
const userCode = text(body.user_code);
const verificationUrl = text(body.verification_uri);
if (!deviceCode || !userCode || !verificationUrl) {
throw new Error("xAI device authorization response is incomplete");
}
const session: Session = { deviceCode };
return {
session: session as unknown as JsonValue,
userCode,
verificationUrl,
...(text(body.verification_uri_complete)
? { verificationUrlComplete: text(body.verification_uri_complete)! }
: {}),
expiresAtMs: Date.now() + Math.max(1, number(body.expires_in) ?? 900) * 1000,
pollIntervalMs: Math.max(1, number(body.interval) ?? 5) * 1000,
};
}
async function poll(sessionValue: JsonValue, context: PluginContext): Promise<OAuth2Poll> {
const session = parseSession(sessionValue);
const response = await context.network.fetch(TOKEN_URL, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: CLIENT_ID,
device_code: session.deviceCode,
}).toString(),
});
const body = parseBody(response.body);
if (response.status >= 200 && response.status < 300) {
const accessToken = text(body.access_token);
if (!accessToken) {
return { status: "failed", message: "xAI token response is missing access_token" };
}
return {
status: "completed",
resources: [
await credentialDraft({
accessToken,
refreshToken: text(body.refresh_token),
displayName: null,
}),
],
};
}
const code = text(body.error) ?? "";
const message = text(body.error_description);
switch (code) {
case "authorization_pending":
return { status: "pending" };
case "slow_down":
return { status: "slow-down" };
case "expired_token":
return { status: "failed", message: message ?? "Device authorization code expired" };
case "access_denied":
return { status: "denied", ...(message ? { message } : {}) };
default:
return {
status: "failed",
message: message ??
(code
? `OAuth error: ${code}`
: `xAI device authorization failed (HTTP ${response.status})`),
};
}
}
export const grokDeviceOAuth: OAuth2AddMethod = {
type: "oauth2.0",
id: "xai-device",
displayName: {
"en-US": "Sign in with xAI",
"zh-CN": "使用 xAI 登录",
},
description: {
"en-US": "Authorize this device with xAI, then add the resulting Grok account.",
"zh-CN": "在 xAI 完成设备授权后,自动添加对应的 Grok 账号。",
},
begin,
poll,
};
@@ -0,0 +1,17 @@
{
"apiVersion": 1,
"id": "dev.cursorbyok.examples.grok-auth",
"name": "Grok",
"version": "0.1.0",
"author": "@leookun",
"minAppVersion": "0.1.0",
"icon": "assets/grok.svg",
"entry": "main.ts",
"permissions": {
"network": [
"auth.x.ai",
"api.x.ai",
"cli-chat-proxy.grok.com"
]
}
}
@@ -0,0 +1,112 @@
import type {
ProviderInvokeInput,
ProviderOutput,
ProviderResult,
ProviderSupport,
} from "cursor-byok:provider";
import type { PluginContext } from "cursor-byok:plugin";
import { HttpError, streamOpenAiChat } from "cursor-byok:protocol/openai-chat";
import { grokModels } from "./models.ts";
import { type AccountData, accountData, quotaExhaustedPatch, RESOURCE_TYPE } from "./resources.ts";
const CHAT_URL = "https://api.x.ai/v1/chat/completions";
/** 流内错误只有文本可用,按积分/额度关键词分类。 */
export function isQuotaError(error: string): boolean {
const message = error.toLowerCase();
return message.includes("insufficient_quota") ||
message.includes("credits exhausted") ||
message.includes("out of credits") ||
message.includes("quota_exceeded") ||
(message.includes("429") &&
(message.includes("quota") || message.includes("credit") ||
message.includes("insufficient")));
}
/** HTTP 失败携带结构化状态码,429 一律按额度耗尽处理并冷却账号。 */
function isQuotaHttpError(error: HttpError): boolean {
if (error.status === 429) return true;
const body = error.body.toLowerCase();
return body.includes("insufficient_quota") ||
body.includes("credits exhausted") ||
body.includes("out of credits") ||
// 免费账号触达消费上限时返回 403 spending-limit,属于额度而非授权问题。
body.includes("spending-limit") ||
body.includes("run out of credits") ||
body.includes("quota_exceeded");
}
function invalidResult(message: string, stateMessage: string): ProviderResult {
return {
status: "resource-error",
message,
patch: { state: { status: "invalid", message: stateMessage } },
};
}
async function invoke(
input: ProviderInvokeInput,
output: ProviderOutput,
context: PluginContext,
): Promise<ProviderResult> {
if (!input.resource) {
return { status: "request-error", message: "add a Grok account before calling Grok" };
}
let data: AccountData;
try {
data = accountData(input.resource);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return invalidResult(message, message);
}
try {
await streamOpenAiChat(
{
url: CHAT_URL,
model: input.model.id,
// xAI 不接受 reasoning_effort 与 service_tier;思考由模型自身决定。
request: {
...input.request,
reasoning: { enabled: false, effort: null },
latency: "standard",
},
headers: { authorization: `Bearer ${data.accessToken}` },
},
output,
context,
);
return { status: "completed" };
} catch (error) {
if (error instanceof HttpError) {
if ((error.status === 401 || error.status === 403) && !isQuotaHttpError(error)) {
return invalidResult(error.message, "Grok authorization expired; sign in again");
}
if (isQuotaHttpError(error)) {
return {
status: "resource-error",
message: error.message,
patch: quotaExhaustedPatch(data),
};
}
return { status: "request-error", message: error.message };
}
const message = error instanceof Error ? error.message : String(error);
if (isQuotaError(message)) {
return { status: "resource-error", message, patch: quotaExhaustedPatch(data) };
}
return { status: "request-error", message };
}
}
export const grokProvider: ProviderSupport = {
id: "grok",
displayName: "xAI Grok",
description: {
"en-US": "SuperGrok subscription access through the official Grok CLI endpoint.",
"zh-CN": "通过官方 Grok CLI 接口使用 SuperGrok 订阅。",
},
providerType: "xai",
resourceType: RESOURCE_TYPE,
models: grokModels,
invoke,
};
@@ -0,0 +1,342 @@
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
import type {
ResourceDraft,
ResourceImportFile,
ResourceImportResult,
ResourceImportSupport,
ResourceMetric,
ResourcePatch,
ResourceSnapshot,
ResourceState,
ResourceView,
} from "cursor-byok:resource";
export const RESOURCE_TYPE = "grok-account";
const CREDITS_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
const ONE_HOUR_MS = 60 * 60 * 1000;
export type AccountQuota = {
planLabel: string | null;
usedPercent: number | null;
remainingPercent: number | null;
resetAtMs: number | null;
limitReached: boolean;
updatedAtMs: number;
};
/** 单条 grok-account 资源的 privateData 形状。 */
export type AccountData = {
accessToken: string;
refreshToken: string | null;
displayName: string;
quota: AccountQuota | null;
};
export type CredentialCandidate = {
accessToken: string;
refreshToken: string | null;
displayName: string | null;
};
function object(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function number(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
const encoded = token.split(".")[1];
if (!encoded) return null;
try {
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
return object(JSON.parse(new TextDecoder().decode(bytes)));
} catch {
return null;
}
}
function claim(payload: Record<string, unknown> | null, key: string): string | null {
return payload ? text(payload[key]) : null;
}
async function tokenFingerprint(token: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
return Array.from(
new Uint8Array(digest).slice(0, 8),
(byte) => byte.toString(16).padStart(2, "0"),
).join("");
}
export async function accountIdentity(
accessToken: string,
): Promise<{ key: string; displayName: string }> {
const payload = decodeJwtPayload(accessToken);
const identity = claim(payload, "sub") ??
claim(payload, "email") ??
await tokenFingerprint(accessToken);
const displayName = claim(payload, "email") ??
claim(payload, "preferred_username") ??
claim(payload, "name") ??
identity;
return { key: `grok:${identity}`, displayName };
}
export async function credentialDraft(credential: CredentialCandidate): Promise<ResourceDraft> {
const identity = await accountIdentity(credential.accessToken);
const data: AccountData = {
accessToken: credential.accessToken,
refreshToken: credential.refreshToken,
displayName: credential.displayName ?? identity.displayName,
quota: null,
};
return { key: identity.key, privateData: data as unknown as JsonValue };
}
export function accountData(resource: ResourceSnapshot): AccountData {
const data = object(resource.privateData);
const accessToken = text(data?.accessToken);
if (!accessToken) throw new Error("Grok account resource is missing its access token");
return {
accessToken,
refreshToken: text(data?.refreshToken),
displayName: text(data?.displayName) ?? "Grok account",
quota: (data?.quota ?? null) as AccountQuota | null,
};
}
function clampPercent(value: number): number {
return Math.max(0, Math.min(100, value));
}
function resetAtMs(value: unknown): number | null {
const numeric = number(value);
if (numeric !== null) return numeric > 10_000_000_000 ? numeric : numeric * 1000;
if (typeof value === "string") {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
/** 解析 Grok CLI 计费接口的积分响应;creditUsagePercent 表示已用占比。 */
export function parseGrokUsage(body: unknown, nowMs = Date.now()): AccountQuota {
const root = object(body) ?? {};
const config = object(root.config) ?? root;
let used = number(config.creditUsagePercent ?? config.credit_usage_percent);
if (used === null) {
const onDemandUsed = number(config.onDemandUsed ?? config.on_demand_used);
const onDemandCap = number(config.onDemandCap ?? config.on_demand_cap);
if (onDemandUsed !== null && onDemandCap !== null && onDemandCap > 0) {
used = (onDemandUsed / onDemandCap) * 100;
}
}
// 存在计费周期但没有用量字段时视为未使用。
if (used === null && (config.currentPeriod ?? config.current_period) !== undefined) {
used = 0;
}
const remaining = used === null ? null : clampPercent(100 - used);
const period = object(config.currentPeriod ?? config.current_period);
return {
planLabel: text(
config.subscriptionTierDisplay ?? config.subscription_tier_display ??
config.subscriptionTier ?? config.product,
),
usedPercent: used === null ? null : clampPercent(used),
remainingPercent: remaining,
resetAtMs: resetAtMs(period?.end ?? config.billingPeriodEnd ?? config.billing_period_end),
limitReached: remaining !== null && remaining <= 0,
updatedAtMs: nowMs,
};
}
export function quotaState(quota: AccountQuota | null, nowMs = Date.now()): ResourceState {
if (!quota || !quota.limitReached) return { status: "ready" };
if (quota.resetAtMs !== null && quota.resetAtMs <= nowMs) return { status: "ready" };
return {
status: "cooling",
retryAtMs: quota.resetAtMs ?? nowMs + ONE_HOUR_MS,
message: "Grok credits are exhausted",
};
}
/** 额度耗尽时的资源补丁:标记积分耗尽并进入冷却,重置时间未知时回退 1 小时。 */
export function quotaExhaustedPatch(data: AccountData, nowMs = Date.now()): ResourcePatch {
const quota: AccountQuota = {
planLabel: data.quota?.planLabel ?? null,
usedPercent: 100,
remainingPercent: 0,
resetAtMs: data.quota?.resetAtMs !== undefined && data.quota?.resetAtMs !== null &&
data.quota.resetAtMs > nowMs
? data.quota.resetAtMs
: null,
limitReached: true,
updatedAtMs: nowMs,
};
return {
privateData: { ...data, quota } as unknown as JsonValue,
state: quotaState(quota, nowMs),
};
}
export function accountHeaders(data: AccountData): Record<string, string> {
return {
accept: "application/json",
authorization: `Bearer ${data.accessToken}`,
// Grok CLI 计费接口要求该头标识客户端来源。
"x-xai-token-auth": "xai-grok-cli",
};
}
function jwtDisplayName(token: string | null): string | null {
if (!token) return null;
const payload = decodeJwtPayload(token);
return claim(payload, "email") ?? claim(payload, "preferred_username") ??
claim(payload, "name");
}
export function presentAccount(resource: ResourceSnapshot): ResourceView {
const data = accountData(resource);
const metrics: ResourceMetric[] = [];
const quota = data.quota;
if (quota && quota.remainingPercent !== null) {
metrics.push({
id: "credits",
label: { "en-US": "Credits", "zh-CN": "积分额度" },
unit: "percent",
value: quota.remainingPercent,
...(quota.resetAtMs !== null ? { resetAtMs: quota.resetAtMs } : {}),
});
}
return {
// 旧记录可能存的是账号 ID;展示时优先从 token 现算邮箱。
displayName: jwtDisplayName(data.accessToken) ?? data.displayName,
...(quota?.planLabel ? { description: quota.planLabel } : {}),
...(metrics.length > 0 ? { metrics } : {}),
};
}
export async function refreshAccount(
resource: ResourceSnapshot,
context: PluginContext,
): Promise<ResourcePatch> {
const data = accountData(resource);
const response = await context.network.fetch(CREDITS_URL, {
method: "GET",
headers: accountHeaders(data),
});
if (response.status < 200 || response.status >= 300) {
if (response.status === 401 || response.status === 403) {
return {
state: { status: "invalid", message: "Grok authorization expired; sign in again" },
};
}
throw new Error(`Grok usage lookup failed (HTTP ${response.status}): ${response.body}`);
}
let body: unknown;
try {
body = JSON.parse(response.body);
} catch {
throw new Error("Grok usage lookup returned invalid JSON");
}
const quota = parseGrokUsage(body);
return {
privateData: { ...data, quota } as unknown as JsonValue,
state: quotaState(quota),
};
}
function firstText(source: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = text(source[key]);
if (value) return value;
}
return null;
}
function collectCredentials(value: unknown, output: CredentialCandidate[]): void {
if (Array.isArray(value)) {
for (const item of value) collectCredentials(item, output);
return;
}
const item = object(value);
if (!item || item.disabled === true) return;
for (const key of ["accounts", "credentials", "items"]) {
if (Array.isArray(item[key])) {
collectCredentials(item[key], output);
return;
}
}
const tokens = object(item.tokens) ?? item;
const accessToken = firstText(tokens, ["access_token", "accessToken", "token", "key"]) ??
firstText(item, ["access_token", "accessToken", "token", "key", "XAI_API_KEY"]);
if (!accessToken) return;
const refreshToken = firstText(tokens, ["refresh_token", "refreshToken"]) ??
firstText(item, ["refresh_token", "refreshToken"]);
const displayName = firstText(item, ["email", "display_name", "displayName", "name"]) ??
firstText(tokens, ["email", "display_name", "displayName", "name"]);
output.push({ accessToken, refreshToken, displayName });
}
export function parseCredentialFiles(files: ResourceImportFile[]): {
credentials: CredentialCandidate[];
warnings: string[];
} {
const credentials: CredentialCandidate[] = [];
const warnings: string[] = [];
for (const file of files) {
let content: unknown;
try {
content = JSON.parse(file.content);
} catch {
warnings.push(`${file.name}: not valid JSON`);
continue;
}
const found: CredentialCandidate[] = [];
collectCredentials(content, found);
if (found.length === 0) {
warnings.push(`${file.name}: no Grok access token found`);
continue;
}
credentials.push(...found);
}
return { credentials, warnings };
}
export const credentialImport: ResourceImportSupport = {
displayName: {
"en-US": "Import Grok credentials",
"zh-CN": "导入 Grok 凭证",
},
description: {
"en-US": "Import one or more Grok JSON credential files.",
"zh-CN": "导入一个或多个 Grok JSON 凭证文件。",
},
accept: [".json"],
multiple: true,
parse: async (files: ResourceImportFile[]): Promise<ResourceImportResult> => {
const { credentials, warnings } = parseCredentialFiles(files);
if (credentials.length === 0) {
throw new Error(warnings.join("; ") || "credential JSON does not contain an access token");
}
return {
resources: await Promise.all(credentials.map(credentialDraft)),
...(warnings.length > 0 ? { warnings } : {}),
};
},
};
+5 -1
View File
@@ -184,7 +184,11 @@ pub async fn append(
request: DecodedAppend,
parent: Option<TransportParent>,
) -> Result<ai::BidiAppendResponse> {
let handle = registry.get_or_create(&request.request_id).await?;
let replace_closing = request.model_id().is_some();
let handle = registry
.get_or_create_for_append(&request.request_id, replace_closing)
.await?;
let _admission = handle.admit()?;
if let Some(conversation_id) = request.conversation_id() {
handle.set_conversation_id(conversation_id)?;
}
+117 -29
View File
@@ -19,18 +19,26 @@ use crate::{
connect,
proto::{agent::v1 as agent, aiserver::v1 as ai},
},
services::{account, analytics, model_catalog, observability::CursorTraceRecorder, tab},
services::{account, analytics, knowledge, model_catalog, tab},
transport::{TransportParent, TransportRegistry},
},
Result,
};
pub fn router(registry: TransportRegistry) -> Result<Router> {
let proxy = CursorProxy::cursor(registry.store().clone())?;
Ok(router_with_proxy(registry, proxy))
pub fn router(
registry: TransportRegistry,
clients: crate::network::NetworkClients,
) -> Result<Router> {
let proxy = CursorProxy::cursor(clients);
let knowledge = knowledge::KnowledgeService::managed()?;
Ok(router_with_proxy(registry, proxy, knowledge))
}
fn router_with_proxy(registry: TransportRegistry, proxy: CursorProxy) -> Router {
fn router_with_proxy(
registry: TransportRegistry,
proxy: CursorProxy,
knowledge_service: knowledge::KnowledgeService,
) -> Router {
let web_cache = registry.web_cache().router();
Router::new()
.route("/__byok-api__/healthz", get(health))
@@ -69,6 +77,22 @@ fn router_with_proxy(registry: TransportRegistry, proxy: CursorProxy) -> Router
"/aiserver.v1.DashboardService/GetUsageLimitStatusAndActiveGrants",
post(account::usage_limit_status),
)
.route(
"/aiserver.v1.AiService/KnowledgeBaseAdd",
post(knowledge::add),
)
.route(
"/aiserver.v1.AiService/KnowledgeBaseList",
post(knowledge::list),
)
.route(
"/aiserver.v1.AiService/KnowledgeBaseUpdate",
post(knowledge::update),
)
.route(
"/aiserver.v1.AiService/KnowledgeBaseRemove",
post(knowledge::remove),
)
.route(
analytics::BOOTSTRAP_STATSIG_PATH,
post(analytics::bootstrap_statsig),
@@ -80,6 +104,7 @@ fn router_with_proxy(registry: TransportRegistry, proxy: CursorProxy) -> Router
.fallback(proxy::forward)
.method_not_allowed_fallback(proxy::forward)
.layer(Extension(proxy))
.layer(Extension(knowledge_service))
.with_state(registry)
.merge(web_cache)
}
@@ -96,16 +121,13 @@ async fn run_sse_handler(
let (parts, body) = buffered(request).await?;
let request: agent::BidiRequestId = connect::decode_unary(&body)?;
let route = registry.wait_route(&request.request_id).await;
let trace = CursorTraceRecorder::resume(registry.store().clone(), &request.request_id).await;
if let Some(trace) = &trace {
trace
.request(
"run_sse_request",
&body,
serde_json::json!({"request_id": request.request_id}),
)
.await;
}
let trace = registry.trace(&request.request_id);
trace.resume();
trace.request(
"run_sse_request",
body.clone(),
serde_json::json!({"request_id": request.request_id}),
);
match route {
crate::cursor::transport::TransportRoute::Local => {
run_sse::stream(&registry, &request.request_id).await
@@ -116,7 +138,14 @@ async fn run_sse_handler(
Request::from_parts(parts, Body::from(body)),
)
.await?;
Ok(run_sse::upstream(registry, request.request_id, generation, response, trace).await)
Ok(run_sse::upstream(
registry,
request.request_id,
generation,
response,
Some(trace),
)
.await)
}
}
}
@@ -132,8 +161,12 @@ async fn bidi_handler(
let first_model = decoded.model_id().map(str::to_owned);
let conversation_id = decoded.conversation_id().map(str::to_owned);
let trace_metadata = decoded.trace_metadata();
let trace = registry.trace(&decoded.request_id);
let local = if let Some(model_id) = decoded.model_id() {
if registry.store().model(model_id).await?.is_some() {
// 插件模型 ID 只在本地有意义,永远不转发到 Cursor 官方上游。
if model_id.starts_with(crate::plugin::ADAPTER_ID_PREFIX)
|| registry.store().model(model_id).await?.is_some()
{
tracing::info!(
request_id = decoded.request_id,
model_id,
@@ -153,14 +186,18 @@ async fn bidi_handler(
} else if registry.upstream(&decoded.request_id).await {
false
} else {
trace.resume();
trace.request(
"bidi_request",
body.clone(),
trace_outcome(trace_metadata, false, "missing_transport", None),
);
return Err(crate::Error::Protocol(
"first BidiAppend message must select a model".into(),
));
};
let trace = if first_model.is_some() {
CursorTraceRecorder::begin(
registry.store().clone(),
&decoded.request_id,
if first_model.is_some() {
trace.begin(
conversation_id.as_deref(),
if local {
"local_byok"
@@ -168,26 +205,61 @@ async fn bidi_handler(
"cursor_official"
},
first_model.as_deref(),
)
.await
);
} else {
CursorTraceRecorder::resume(registry.store().clone(), &decoded.request_id).await
};
if let Some(trace) = &trace {
trace.request("bidi_request", &body, trace_metadata).await;
trace.resume();
}
if !local {
if first_model.is_some() {
registry.mark_upstream(&decoded.request_id).await;
}
trace.request(
"bidi_request",
body.clone(),
trace_outcome(trace_metadata, true, "upstream", None),
);
return proxy::forward(
Extension(proxy),
Request::from_parts(parts, Body::from(body)),
)
.await;
}
let parent = parent_headers(&parts.headers)?;
bidi::append(&registry, decoded, parent).await?;
let parent = match parent_headers(&parts.headers) {
Ok(parent) => parent,
Err(error) => {
trace.request(
"bidi_request",
body,
trace_outcome(
trace_metadata,
false,
"invalid_parent",
Some(error.to_string()),
),
);
return Err(error);
}
};
match bidi::append(&registry, decoded, parent).await {
Ok(_) => trace.request(
"bidi_request",
body,
trace_outcome(trace_metadata, true, "local", None),
),
Err(error) => {
trace.request(
"bidi_request",
body,
trace_outcome(
trace_metadata,
false,
"command_rejected",
Some(error.to_string()),
),
);
return Err(error);
}
}
let mut response = Response::new(axum::body::Body::empty());
*response.status_mut() = StatusCode::OK;
response.headers_mut().insert(
@@ -197,6 +269,22 @@ async fn bidi_handler(
Ok(response)
}
fn trace_outcome(
mut metadata: serde_json::Value,
accepted: bool,
route_outcome: &str,
error: Option<String>,
) -> serde_json::Value {
if let Some(metadata) = metadata.as_object_mut() {
metadata.insert("accepted".into(), accepted.into());
metadata.insert("route_outcome".into(), route_outcome.into());
if let Some(error) = error {
metadata.insert("error".into(), error.into());
}
}
metadata
}
async fn buffered(request: Request<Body>) -> Result<(axum::http::request::Parts, Bytes)> {
let (parts, body) = request.into_parts();
let body = to_bytes(body, usize::MAX)
+6 -15
View File
@@ -14,8 +14,7 @@ pub const UPSTREAM_URL_HEADER: &str = "x-server-upstream-url";
#[derive(Clone)]
pub struct CursorProxy {
client: Option<reqwest::Client>,
store: Option<crate::store::Store>,
clients: crate::network::NetworkClients,
upstream: String,
}
@@ -47,23 +46,15 @@ impl BufferedResponse {
}
impl CursorProxy {
pub fn cursor(store: crate::store::Store) -> Result<Self> {
Ok(Self {
client: None,
store: Some(store),
pub fn cursor(clients: crate::network::NetworkClients) -> Self {
Self {
clients,
upstream: CURSOR_UPSTREAM.into(),
})
}
}
async fn client(&self) -> Result<reqwest::Client> {
match (&self.client, &self.store) {
(Some(client), _) => Ok(client.clone()),
(_, Some(store)) => Ok(crate::network::client_builder(store)
.await?
.redirect(reqwest::redirect::Policy::none())
.build()?),
_ => unreachable!("Cursor proxy always has a client or store"),
}
self.clients.cursor_client().await
}
}
+5 -5
View File
@@ -22,7 +22,7 @@ pub async fn stream(registry: &TransportRegistry, request_id: &str) -> Result<Re
let receiver = handle.subscribe();
let trace = handle.trace().cloned();
if let Some(trace) = &trace {
trace.response_started(StatusCode::OK.as_u16()).await;
trace.response_started(StatusCode::OK.as_u16());
}
let body_stream = local_body_stream(receiver, handle, trace);
let mut response = Response::new(Body::from_stream(body_stream));
@@ -133,7 +133,7 @@ pub async fn upstream(
) -> Response<Body> {
let (parts, body) = response.into_parts();
if let Some(trace) = &trace {
trace.response_started(parts.status.as_u16()).await;
trace.response_started(parts.status.as_u16());
}
let stream = async_stream::stream! {
let _guard = UpstreamRunGuard {
@@ -180,15 +180,15 @@ impl TraceStreamSink {
while let Some(event) = receiver.recv().await {
match event {
TraceStreamEvent::Chunk(chunk) => {
trace.response_chunk(source, &chunk).await;
trace.response_chunk(source, chunk);
}
TraceStreamEvent::Finish(error) => {
trace.finish(error.as_deref()).await;
trace.finish(error.as_deref());
return;
}
}
}
trace.finish(None).await;
trace.finish(None);
});
Self {
sender: Some(sender),
+3 -3
View File
@@ -1,7 +1,7 @@
//! Builds the top-level server router.
use crate::{cursor::transport::TransportRegistry, Result};
use crate::{cursor::transport::TransportRegistry, network::NetworkClients, Result};
pub fn router(registry: TransportRegistry) -> Result<axum::Router> {
super::cursor::router(registry)
pub fn router(registry: TransportRegistry, clients: NetworkClients) -> Result<axum::Router> {
super::cursor::router(registry, clients)
}
+23 -3
View File
@@ -13,6 +13,7 @@ use crate::{
transport::TransportRegistry,
},
local_app::CursorHarness,
plugin::{PluginRegistry, PluginRuntime},
provider::ProviderRouter,
search::WebCache,
store::Store,
@@ -37,19 +38,38 @@ impl App {
}
let assets = PromptAssets::embedded()?;
let compiler = PromptCompiler::new(assets);
let plugin_runtime = PluginRuntime::managed()?;
let plugins = PluginRegistry::managed(
store.clone(),
plugin_runtime.clone(),
config.app_version.clone(),
)?;
let clients = crate::network::NetworkClients::new(store.clone());
let provider = std::sync::Arc::new(ProviderRouter::new(
store.clone(),
plugins.clone(),
clients.clone(),
config.provider_request_timeout,
config.provider_stream_idle_timeout,
));
let registry = TransportRegistry::with_web_cache(
let registry = TransportRegistry::with_plugins(
store.clone(),
provider.clone(),
compiler,
WebCache::managed()?,
plugins.clone(),
crate::config::managed_data_dir()?.join("rules"),
);
let control = control::ControlService::new(store.clone(), provider)?;
let control = control::ControlService::new(
store.clone(),
provider,
plugin_runtime,
plugins,
clients.clone(),
config.app_version.clone(),
)?;
let harness = control.cursor_harness().clone();
let mut router = api::router(registry.clone())?;
let mut router = api::router(registry.clone(), clients)?;
router = match &config.console {
Some(ConsoleSource::Directory(directory)) => {
router.merge(control::web_router(control.clone(), directory))
+27 -1
View File
@@ -10,7 +10,8 @@ const DATA_DIR_NAME: &str = ".cursor-byok-v3";
const DATABASE_FILE_NAME: &str = "cursor-byok.db";
const V0049_DATA_DIR_NAME: &str = ".cursor-local-assistant-v2";
const V0049_CONFIG_FILE_NAME: &str = "config.yaml";
const DEFAULT_PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(3000);
const DEFAULT_PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(60 * 60);
const DEFAULT_PROVIDER_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60);
pub fn managed_data_dir() -> Result<PathBuf> {
let home_dir = dirs::home_dir()
@@ -45,6 +46,7 @@ pub struct ProviderConfig {
pub custom_headers: reqwest::header::HeaderMap,
pub max_output_tokens: Option<u64>,
pub request_timeout: Duration,
pub allowed_body_fields: Option<std::collections::HashSet<String>>,
}
#[derive(Clone)]
@@ -52,8 +54,11 @@ pub struct Config {
pub listen_addr: SocketAddr,
pub database_url: String,
pub provider_request_timeout: Duration,
pub provider_stream_idle_timeout: Duration,
pub console: Option<ConsoleSource>,
pub use_persisted_ports: bool,
/// 面向用户的应用版本;桌面壳会覆盖为自身版本,用于插件 minAppVersion 门控。
pub app_version: String,
}
#[derive(Clone)]
@@ -102,8 +107,10 @@ impl Config {
listen_addr,
database_url: database_url_from_env()?,
provider_request_timeout: request_timeout,
provider_stream_idle_timeout: DEFAULT_PROVIDER_STREAM_IDLE_TIMEOUT,
console,
use_persisted_ports: false,
app_version: env!("CARGO_PKG_VERSION").into(),
})
}
@@ -114,8 +121,10 @@ impl Config {
.expect("desktop listen address is static"),
database_url: default_database_url()?,
provider_request_timeout: DEFAULT_PROVIDER_REQUEST_TIMEOUT,
provider_stream_idle_timeout: DEFAULT_PROVIDER_STREAM_IDLE_TIMEOUT,
console: None,
use_persisted_ports: true,
app_version: env!("CARGO_PKG_VERSION").into(),
})
}
}
@@ -142,3 +151,20 @@ fn database_url_for_dir(data_dir: &std::path::Path) -> Result<String> {
.ok_or_else(|| Error::Config("database path is not valid UTF-8".into()))?;
Ok(format!("sqlite://{database_path}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_timeout_defaults_match_runtime_boundaries() {
assert_eq!(
DEFAULT_PROVIDER_STREAM_IDLE_TIMEOUT,
Duration::from_secs(30 * 60)
);
assert_eq!(
DEFAULT_PROVIDER_REQUEST_TIMEOUT,
Duration::from_secs(60 * 60)
);
}
}
+1
View File
@@ -17,6 +17,7 @@ use super::ControlService;
// 开源项目广告为作者唯一收入来源,PR时请勿去除此广告
pub(super) const ADS_ENDPOINT: &str = "https://tab.leokun.cn/api/v1/ads?placement=menu";
// pub(super) const ADS_ENDPOINT: &str = "http://localhost:8080/api/v1/ads?placement=menu";
pub(super) const DEVICE_ID_HEADER: &str = "X-Cursor-Assistant-Device-ID";
pub(super) const OS_HEADER: &str = "X-Cursor-Assistant-OS";
pub(super) const APP_VERSION_HEADER: &str = "X-Cursor-Assistant-Version";
+40
View File
@@ -4,6 +4,7 @@ mod calls;
mod harness;
mod models;
mod overview;
mod plugins;
mod service;
mod settings;
@@ -136,6 +137,45 @@ pub fn api_router(service: ControlService) -> Router {
)
.route("/__byok-api__/api/llm-calls", get(calls::list))
.route("/__byok-api__/api/llm-calls/{call_id}", get(calls::detail))
.route("/__byok-api__/api/plugins", get(plugins::list))
.route(
"/__byok-api__/api/plugins/runtime",
get(plugins::runtime_status)
.post(plugins::initialize_runtime)
.delete(plugins::cancel_runtime_initialization),
)
.route(
"/__byok-api__/api/plugins/oauth/{session_id}/poll",
post(plugins::oauth_poll),
)
.route(
"/__byok-api__/api/plugins/{plugin_id}",
axum::routing::delete(plugins::remove),
)
.route(
"/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/add/{method_id}/begin",
post(plugins::oauth_begin),
)
.route(
"/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/import",
post(plugins::import),
)
.route(
"/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/export",
get(plugins::export_resources),
)
.route(
"/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/{resource_id}",
axum::routing::delete(plugins::delete_resource),
)
.route(
"/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/{resource_id}/refresh",
post(plugins::refresh_resource),
)
.route(
"/__byok-api__/api/plugins/{plugin_id}/providers/{provider_id}/models/sync",
post(plugins::sync_models),
)
.route(
"/__byok-api__/api/settings/observability",
get(settings::get).put(settings::update),
+124
View File
@@ -0,0 +1,124 @@
//! Exposes plugin discovery, resource lifecycle, model sync, and runtime endpoints.
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use crate::{
plugin::{
ImportResponse, OAuthBeginResponse, OAuthPollResponse, PluginDescriptor,
PluginRuntimeStatus,
},
Result,
};
use super::ControlService;
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<PluginDescriptor>>> {
Ok(Json(service.plugins().await))
}
pub async fn remove(
State(service): State<ControlService>,
Path(plugin_id): Path<String>,
) -> Result<StatusCode> {
service.remove_plugin_configuration(&plugin_id).await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn oauth_begin(
State(service): State<ControlService>,
Path((plugin_id, resource_type, method_id)): Path<(String, String, String)>,
) -> Result<Json<OAuthBeginResponse>> {
Ok(Json(
service
.plugin_oauth_begin(&plugin_id, &resource_type, &method_id)
.await?,
))
}
pub async fn oauth_poll(
State(service): State<ControlService>,
Path(session_id): Path<String>,
) -> Result<Json<OAuthPollResponse>> {
Ok(Json(service.plugin_oauth_poll(&session_id).await?))
}
pub async fn import(
State(service): State<ControlService>,
Path((plugin_id, resource_type)): Path<(String, String)>,
Json(files): Json<serde_json::Value>,
) -> Result<Json<ImportResponse>> {
Ok(Json(
service
.plugin_import(&plugin_id, &resource_type, files)
.await?,
))
}
/// 以附件形式返回账号资源导出文件,便于浏览器直接下载。
pub async fn export_resources(
State(service): State<ControlService>,
Path((plugin_id, resource_type)): Path<(String, String)>,
) -> Result<axum::response::Response> {
let value = service
.plugin_export_resources(&plugin_id, &resource_type)
.await?;
let body = serde_json::to_vec_pretty(&value)?;
let response = axum::response::Response::builder()
.header(axum::http::header::CONTENT_TYPE, "application/json")
.header(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{plugin_id}-{resource_type}.json\""),
)
.body(axum::body::Body::from(body))
.expect("static export response");
Ok(response)
}
pub async fn refresh_resource(
State(service): State<ControlService>,
Path((plugin_id, resource_type, resource_id)): Path<(String, String, String)>,
) -> Result<StatusCode> {
service
.plugin_refresh_resource(&plugin_id, &resource_type, &resource_id)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn delete_resource(
State(service): State<ControlService>,
Path((plugin_id, resource_type, resource_id)): Path<(String, String, String)>,
) -> Result<StatusCode> {
service
.plugin_delete_resource(&plugin_id, &resource_type, &resource_id)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn sync_models(
State(service): State<ControlService>,
Path((plugin_id, provider_id)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let count = service.plugin_sync_models(&plugin_id, &provider_id).await?;
Ok(Json(serde_json::json!({ "models": count })))
}
pub async fn runtime_status(
State(service): State<ControlService>,
) -> Result<Json<PluginRuntimeStatus>> {
Ok(Json(service.plugin_runtime_status()))
}
pub async fn initialize_runtime(
State(service): State<ControlService>,
) -> Result<Json<PluginRuntimeStatus>> {
Ok(Json(service.initialize_plugin_runtime()))
}
pub async fn cancel_runtime_initialization(
State(service): State<ControlService>,
) -> Result<Json<PluginRuntimeStatus>> {
Ok(Json(service.cancel_plugin_runtime_initialization()))
}
+128 -14
View File
@@ -25,6 +25,7 @@ use crate::{
ModelRequest, ModelSpec, ModelType, Overview, ProjectedContent, ProjectedMessage,
PromptSpec, ProviderType, Role,
},
plugin::{PluginDescriptor, PluginRegistry, PluginRuntime, PluginRuntimeStatus},
provider::{is_valid_response_event, ModelEvent, Provider},
store::{
DesktopSettings, PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store,
@@ -38,6 +39,10 @@ pub struct ControlService {
store: Store,
cursor_harness: CursorHarness,
provider: Arc<dyn Provider>,
plugin_runtime: PluginRuntime,
plugins: PluginRegistry,
clients: crate::network::NetworkClients,
app_version: String,
model_tests: Arc<Mutex<BTreeMap<String, CancellationToken>>>,
}
@@ -143,11 +148,22 @@ pub struct ObservabilitySettings {
}
impl ControlService {
pub fn new(store: Store, provider: Arc<dyn Provider>) -> Result<Self> {
pub fn new(
store: Store,
provider: Arc<dyn Provider>,
plugin_runtime: PluginRuntime,
plugins: PluginRegistry,
clients: crate::network::NetworkClients,
app_version: String,
) -> Result<Self> {
Ok(Self {
cursor_harness: CursorHarness::new(store.clone())?,
store,
provider,
plugin_runtime,
plugins,
clients,
app_version,
model_tests: Arc::new(Mutex::new(BTreeMap::new())),
})
}
@@ -156,18 +172,103 @@ impl ControlService {
&self.cursor_harness
}
pub async fn plugins(&self) -> Vec<PluginDescriptor> {
self.plugins.plugins().await
}
pub async fn plugin_oauth_begin(
&self,
plugin_id: &str,
resource_type: &str,
method_id: &str,
) -> Result<crate::plugin::OAuthBeginResponse> {
self.plugins
.oauth_begin(plugin_id, resource_type, method_id)
.await
}
pub async fn plugin_oauth_poll(
&self,
session_id: &str,
) -> Result<crate::plugin::OAuthPollResponse> {
self.plugins.oauth_poll(session_id).await
}
pub async fn plugin_import(
&self,
plugin_id: &str,
resource_type: &str,
files: serde_json::Value,
) -> Result<crate::plugin::ImportResponse> {
self.plugins
.import_resources(plugin_id, resource_type, files)
.await
}
pub async fn plugin_export_resources(
&self,
plugin_id: &str,
resource_type: &str,
) -> Result<serde_json::Value> {
self.plugins
.export_resources(plugin_id, resource_type)
.await
}
pub async fn plugin_refresh_resource(
&self,
plugin_id: &str,
resource_type: &str,
resource_id: &str,
) -> Result<()> {
self.plugins
.refresh_resource(plugin_id, resource_type, resource_id)
.await
}
pub async fn plugin_delete_resource(
&self,
plugin_id: &str,
resource_type: &str,
resource_id: &str,
) -> Result<()> {
self.plugins
.delete_resource(plugin_id, resource_type, resource_id)
.await
}
pub async fn plugin_sync_models(&self, plugin_id: &str, provider_id: &str) -> Result<usize> {
self.plugins.sync_models(plugin_id, provider_id).await
}
pub async fn remove_plugin_configuration(&self, plugin_id: &str) -> Result<()> {
self.plugins.remove(plugin_id).await
}
pub fn plugin_runtime_status(&self) -> PluginRuntimeStatus {
self.plugin_runtime.status()
}
pub fn initialize_plugin_runtime(&self) -> PluginRuntimeStatus {
self.plugin_runtime.initialize(self.store.clone())
}
pub fn cancel_plugin_runtime_initialization(&self) -> PluginRuntimeStatus {
self.plugin_runtime.cancel_initialization()
}
pub(super) async fn ads(
&self,
disabled_ad_ids: Option<&str>,
language: &str,
) -> Result<AdRuntime> {
let client = crate::network::client(&self.store).await?;
let client = self.clients.default_client().await?;
let installation_id = self.store.installation_id().await?;
let mut request = client
.get(ADS_ENDPOINT)
.header(DEVICE_ID_HEADER, installation_id)
.header(OS_HEADER, std::env::consts::OS)
.header(APP_VERSION_HEADER, env!("CARGO_PKG_VERSION"))
.header(APP_VERSION_HEADER, &self.app_version)
.header(LANGUAGE_HEADER, language)
.timeout(std::time::Duration::from_secs(60));
if let Some(disabled_ad_ids) = disabled_ad_ids.filter(|value| !value.is_empty()) {
@@ -186,7 +287,7 @@ impl ControlService {
}
pub(super) async fn dismiss_ad(&self, ad_id: &str, input: &AdDismissalInput) -> Result<()> {
let client = crate::network::client(&self.store).await?;
let client = self.clients.default_client().await?;
let installation_id = self.store.installation_id().await?;
let mut endpoint = Url::parse(ADS_ENDPOINT).map_err(|error| {
Error::Config(format!("advertisement endpoint is invalid: {error}"))
@@ -201,7 +302,7 @@ impl ControlService {
.post(endpoint)
.header(DEVICE_ID_HEADER, installation_id)
.header(OS_HEADER, std::env::consts::OS)
.header(APP_VERSION_HEADER, env!("CARGO_PKG_VERSION"))
.header(APP_VERSION_HEADER, &self.app_version)
.json(input)
.timeout(std::time::Duration::from_secs(5))
.send()
@@ -293,14 +394,20 @@ impl ControlService {
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
const TEST_PROMPT: &str = "Output the numbers 1 through 120 separated by a single space. No commas, no newlines, no explanation.";
let configured = self
.store
.model(model_hash)
.await?
.ok_or_else(|| Error::RunNotFound(format!("model {model_hash}")))?;
let mut model = ModelSpec::new(model_hash);
configured.configure(&mut model);
model.max_output_tokens = Some(configured.max_output_tokens().unwrap_or(65_536));
if model_hash.starts_with(crate::plugin::ADAPTER_ID_PREFIX) {
let descriptor = self.plugins.model_descriptor(model_hash).await?;
model.display_name = Some(descriptor.display_name);
model.max_output_tokens = Some(descriptor.max_output_tokens.unwrap_or(65_536));
} else {
let configured = self
.store
.model(model_hash)
.await?
.ok_or_else(|| Error::RunNotFound(format!("model {model_hash}")))?;
configured.configure(&mut model);
model.max_output_tokens = Some(configured.max_output_tokens().unwrap_or(65_536));
}
let call_id = format!("model-test-{}", uuid::Uuid::new_v4());
let invocation = ModelInvocation {
call_id: call_id.clone(),
@@ -409,7 +516,7 @@ impl ControlService {
}
pub async fn discover_models(&self, input: &ModelDiscoveryInput) -> Result<DiscoveredModels> {
let client = crate::network::client(&self.store).await?;
let client = self.clients.default_client().await?;
let base_url = crate::model::normalize_request_url(&input.base_url)?;
discover_models_from_endpoint(
&client,
@@ -576,7 +683,9 @@ impl ControlService {
}
pub async fn set_proxy_settings(&self, settings: ProxySettingsInput) -> Result<ProxySettings> {
self.store.set_proxy_settings(settings).await
let settings = self.store.set_proxy_settings(settings).await?;
self.clients.invalidate().await;
Ok(settings)
}
pub async fn tab_settings(&self) -> Result<TabSettings> {
@@ -699,6 +808,11 @@ async fn discover_models_from_endpoint(
ProviderType::Anthropic => {
anthropic_models(client, base_url, api_key, custom_headers).await?
}
ProviderType::Plugin => {
return Err(Error::Config(
"plugin providers discover models through their plugin".into(),
))
}
};
models.sort();
models.dedup();
+11 -13
View File
@@ -278,19 +278,17 @@ impl CheckpointBuilder {
),
});
if let Some(trace) = handle.trace() {
trace
.artifact(
"checkpoint",
"byok_server",
&checkpoint.encode_to_vec(),
serde_json::json!({
"root_message_count": checkpoint.root_prompt_messages_json.len(),
"turn_count": checkpoint.turns.len(),
"pending_tool_call_count": checkpoint.pending_tool_calls.len(),
"emit_status": if result.is_ok() { "sent" } else { "error" },
}),
)
.await;
trace.artifact(
"checkpoint",
"byok_server",
&checkpoint.encode_to_vec(),
serde_json::json!({
"root_message_count": checkpoint.root_prompt_messages_json.len(),
"turn_count": checkpoint.turns.len(),
"pending_tool_call_count": checkpoint.pending_tool_calls.len(),
"emit_status": if result.is_ok() { "sent" } else { "error" },
}),
);
}
result
}
@@ -102,6 +102,12 @@ pub fn decode_pending(value: &str) -> Result<RecoveredToolRound> {
.into_iter()
.enumerate()
.map(|(index, call)| {
let argument_error = wire
.pointer("/providerOptions/cursor/pendingToolExecutionContracts")
.and_then(|contracts| contracts.get(&call.call_id))
.and_then(|contract| contract.get("argumentError"))
.and_then(Value::as_str)
.map(str::to_string);
Ok(ToolCall {
index,
call_id: call.call_id,
@@ -109,6 +115,7 @@ pub fn decode_pending(value: &str) -> Result<RecoveredToolRound> {
name: call.name,
arguments_text: serde_json::to_string(&call.arguments)?,
arguments: call.arguments,
argument_error,
})
})
.collect::<Result<Vec<_>>>()?;
+20 -10
View File
@@ -71,6 +71,7 @@ pub fn staged_tool_round(
allowed_tools,
dynamic_tools,
started_at_ms,
tool_calls: Some(calls),
}),
)?)?)
}
@@ -96,6 +97,7 @@ pub fn staged_final(
allowed_tools,
dynamic_tools,
started_at_ms,
tool_calls: None,
}),
)?)?)
}
@@ -105,6 +107,7 @@ pub(super) struct PendingContext<'a> {
allowed_tools: &'a [String],
dynamic_tools: &'a HashSet<String>,
started_at_ms: u64,
tool_calls: Option<&'a [ToolCall]>,
}
pub(super) fn wire_message(
@@ -132,16 +135,23 @@ pub(super) fn wire_message(
calls
.iter()
.map(|call| {
(
call.call_id.clone(),
json!({
"toolCallId": call.call_id,
"outerToolName": call.name,
"toolIdentifier": tool_identifier(&call.name, pending.dynamic_tools),
"isDynamic": pending.dynamic_tools.contains(&call.name),
"allowedToolNames": pending.allowed_tools,
}),
)
let mut contract = json!({
"toolCallId": call.call_id,
"outerToolName": call.name,
"toolIdentifier": tool_identifier(&call.name, pending.dynamic_tools),
"isDynamic": pending.dynamic_tools.contains(&call.name),
"allowedToolNames": pending.allowed_tools,
});
if let Some(error) = pending
.tool_calls
.and_then(|calls| {
calls.iter().find(|candidate| candidate.call_id == call.call_id)
})
.and_then(|call| call.argument_error.as_deref())
{
contract["argumentError"] = Value::String(error.into());
}
(call.call_id.clone(), contract)
})
.collect(),
),
@@ -30,6 +30,7 @@ fn pending_tool_round_is_one_complete_assistant_message_and_round_trips() {
name: "Read".into(),
arguments_text: r#"{"path":"/a"}"#.into(),
arguments: json!({"path":"/a"}),
argument_error: Some("Read arguments are not valid JSON".into()),
},
ToolCall {
index: 1,
@@ -38,6 +39,7 @@ fn pending_tool_round_is_one_complete_assistant_message_and_round_trips() {
name: "Grep".into(),
arguments_text: r#"{"pattern":"x"}"#.into(),
arguments: json!({"pattern":"x"}),
argument_error: None,
},
];
let pending = staged_tool_round(
@@ -55,6 +57,10 @@ fn pending_tool_round_is_one_complete_assistant_message_and_round_trips() {
wire["providerOptions"]["cursor"]["pendingToolExecutionContracts"]["a"]["toolIdentifier"],
"READ"
);
assert_eq!(
wire["providerOptions"]["cursor"]["pendingToolExecutionContracts"]["a"]["argumentError"],
"Read arguments are not valid JSON"
);
assert_eq!(wire["role"], "assistant");
assert_eq!(
wire["providerOptions"]["cursor"]["pendingToolExecutionContracts"]
@@ -77,6 +83,10 @@ fn pending_tool_round_is_one_complete_assistant_message_and_round_trips() {
assert_eq!(recovered.assistant.replay_state, Some(replay_state));
assert_eq!(recovered.calls.len(), 2);
assert_eq!(recovered.calls[0].call_id, "a");
assert_eq!(
recovered.calls[0].argument_error.as_deref(),
Some("Read arguments are not valid JSON")
);
assert_eq!(recovered.calls[1].call_id, "b");
}
+27
View File
@@ -75,6 +75,11 @@ impl StepBuffer {
});
}
pub fn finish_model_attempt(&mut self) {
self.finish_text();
self.finish_thinking(Duration::ZERO);
}
pub fn discard_model_output(&mut self) {
self.text.clear();
self.thinking.clear();
@@ -101,6 +106,28 @@ impl StepBuffer {
mod tests {
use super::*;
#[test]
fn failed_attempt_output_is_retained_for_the_next_checkpoint() {
let mut buffer = StepBuffer::default();
buffer.text_delta("partial answer");
buffer.thinking_delta("partial reasoning");
buffer.finish_model_attempt();
let steps = buffer.take().steps;
assert_eq!(steps.len(), 2);
assert!(matches!(
&steps[0].message,
Some(pb::conversation_step::Message::AssistantMessage(message))
if message.text == "partial answer"
));
assert!(matches!(
&steps[1].message,
Some(pb::conversation_step::Message::ThinkingMessage(message))
if message.text == "partial reasoning"
));
}
#[test]
fn interrupted_model_output_is_not_persisted_as_checkpoint_steps() {
let mut buffer = StepBuffer::default();
+7 -1
View File
@@ -3,7 +3,7 @@ use prost::Message;
use crate::{
cursor::{checkpoint::PendingSteps, protocol::proto::agent::v1 as pb},
model::CanonicalMessage,
model::{estimate_context_tokens, project_messages, CanonicalMessage, PromptSpec},
store::{BlobEdge, BlobId},
Error, Result,
};
@@ -85,6 +85,12 @@ impl CheckpointBuilder {
.push(archive_id.as_bytes().to_vec());
}
self.base.self_summary_count = self.base.self_summary_count.saturating_add(1);
let projected = project_messages(messages)?;
let prompt = PromptSpec {
instructions: self.instructions.clone(),
tools: self.tool_definitions.clone(),
};
self.record_context_tokens(Some(estimate_context_tokens(&prompt, &projected)));
if let Some(details) = self.base.token_details.as_mut() {
details.breakdown = Some(crate::cursor::services::usage::breakdown(
details.used_tokens,
+78 -14
View File
@@ -12,7 +12,7 @@ use crate::{
protocol::proto::agent::v1 as pb, services::context_sync::RequestContextSynchronizer,
tools::runtime::McpRoute,
},
model::ToolDefinition,
model::{normalize_tool_name, ToolDefinition},
store::BlobId,
Error, Result,
};
@@ -146,6 +146,37 @@ async fn decode_part<T: Message + Default>(
.map_err(|error| Error::Protocol(format!("invalid {name} context Blob: {error}")))
}
/// 把本地 md 规则目录(rules 服务的存储)合并进请求上下文,
/// 使 BYOK 运行在 IDE 未携带这些规则时也能消费它们。
/// 与 IDE 已发规则按内容去重;读取失败只告警,不影响运行。
pub fn merge_local_rules(context: &mut pb::RequestContext, rules_dir: &Path) {
let records = match crate::cursor::services::knowledge::RuleStore::open(rules_dir.into())
.and_then(|store| store.list())
{
Ok(records) => records,
Err(error) => {
tracing::warn!(%error, "cannot read local rules; continuing without them");
return;
}
};
let existing = context
.rules
.iter()
.chain(context.non_file_rules.iter())
.map(|rule| rule.content.trim().to_owned())
.chain(context.cloud_rule.iter().map(|rule| rule.trim().to_owned()))
.collect::<HashSet<_>>();
for record in records {
if record.knowledge.trim().is_empty() || existing.contains(record.knowledge.trim()) {
continue;
}
context.non_file_rules.push(pb::CursorRule {
content: record.knowledge,
..Default::default()
});
}
}
pub fn request_context(request: &pb::AgentRunRequest) -> Option<&pb::RequestContext> {
let action = request.action.as_ref()?;
action
@@ -462,7 +493,7 @@ pub fn dynamic_mcp(
})?),
};
let parameters = normalize_mcp_parameters(&wire.name, parameters)?;
let name = model_tool_name(&wire.name);
let name = normalize_tool_name(&wire.name);
let definition = ToolDefinition {
name: name.clone(),
description: wire.description.clone(),
@@ -522,18 +553,6 @@ fn invalid_mcp_parameters(tool_name: &str) -> Error {
))
}
fn model_tool_name(name: &str) -> String {
name.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') {
character
} else {
'_'
}
})
.collect()
}
fn prost_value(value: &prost_types::Value) -> Value {
use prost_types::value::Kind;
match value.kind.as_ref() {
@@ -563,3 +582,48 @@ fn xml(value: &str) -> String {
.replace('<', "&lt;")
.replace('>', "&gt;")
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(content: &str) -> pb::CursorRule {
pb::CursorRule {
content: content.into(),
..Default::default()
}
}
#[test]
fn merge_local_rules_appends_and_dedupes_by_content() {
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join("a.md"), "shared rule").unwrap();
std::fs::write(directory.path().join("b.md"), "local only rule").unwrap();
std::fs::write(directory.path().join("c.md"), " \n").unwrap();
let mut context = pb::RequestContext {
non_file_rules: vec![rule(" shared rule ")],
..Default::default()
};
merge_local_rules(&mut context, directory.path());
let contents = context
.non_file_rules
.iter()
.map(|rule| rule.content.as_str())
.collect::<Vec<_>>();
assert_eq!(
contents,
[" shared rule ", "local only rule"],
"IDE-sent duplicate is kept once and blank local rules are skipped"
);
}
#[test]
fn merge_local_rules_survives_a_missing_directory() {
let directory = tempfile::tempdir().unwrap();
let mut context = pb::RequestContext::default();
merge_local_rules(&mut context, &directory.path().join("nested/rules"));
assert!(context.non_file_rules.is_empty());
}
}
+8 -4
View File
@@ -51,6 +51,7 @@ pub(crate) struct PrepareDependencies<'a> {
pub checkpoint: &'a CheckpointBuilder,
pub blob_sync: &'a BlobSynchronizer,
pub context_sync: &'a RequestContextSynchronizer,
pub local_rules_dir: Option<&'a std::path::Path>,
}
pub(crate) async fn prepare(
@@ -64,6 +65,7 @@ pub(crate) async fn prepare(
checkpoint,
blob_sync,
context_sync,
local_rules_dir,
} = dependencies;
checkpoint
.import_prefetched(&request.pre_fetched_blobs)
@@ -115,11 +117,13 @@ pub(crate) async fn prepare(
"selected_source": "root_prompt_messages_json",
});
let encoded = serde_json::to_vec(&summary)?;
trace
.artifact("history_projection", "byok_server", &encoded, summary)
.await;
trace.artifact("history_projection", "byok_server", &encoded, summary);
}
let request_context = context::hydrate(request, context_sync).await?;
let mut request_context = context::hydrate(request, context_sync).await?;
if let Some(rules_dir) = local_rules_dir {
context::merge_local_rules(&mut request_context, rules_dir);
}
let request_context = request_context;
let ActionProjection {
mode: mode_number,
mut turn_user,
+18 -2
View File
@@ -1,6 +1,19 @@
//! Defines commands accepted by a Conversation runtime.
use crate::cursor::protocol::proto::agent::v1 as pb;
use crate::{cursor::protocol::proto::agent::v1 as pb, Error};
#[derive(Debug)]
pub enum RunFinish {
TurnCompleted,
Transport(TransportFinish),
}
#[derive(Debug)]
pub enum TransportFinish {
Success,
Failed(Error),
Cancelled,
}
#[derive(Debug)]
pub enum TransportCommand {
@@ -8,6 +21,9 @@ pub enum TransportCommand {
seqno: i64,
message: Box<pb::AgentClientMessage>,
},
RunFinished {
generation: u64,
finish: RunFinish,
},
Disconnect,
Close,
}
+128 -37
View File
@@ -21,7 +21,7 @@ use crate::{
protocol::proto::agent::v1 as pb,
services::blob_sync::BlobSynchronizer,
tools::{
codec,
codec, compat,
runtime::CursorToolRuntime,
stream::ToolCallStream,
tool_call_result::{ToolCompletion, ToolResultReceiver},
@@ -34,7 +34,7 @@ use crate::{
Error, Result,
};
use super::{CompiledMessages, ConversationRegistry, MessageDelivery};
use super::{CompiledMessages, ConversationRegistry, MessageDelivery, RunFinish, TransportFinish};
use crate::cursor::transport::TransportHandle;
pub struct ConversationOutput {
@@ -110,7 +110,7 @@ impl ConversationOutput {
}
}
pub async fn run(mut self) -> Result<()> {
pub async fn run(mut self) -> Result<RunFinish> {
let result = self.run_inner().await;
if let Err(error) = &result {
if !self.superseded.is_cancelled() {
@@ -143,7 +143,7 @@ impl ConversationOutput {
result
}
async fn run_inner(&mut self) -> Result<()> {
async fn run_inner(&mut self) -> Result<RunFinish> {
if self.context.compacting {
self.handle.emit(&events::summary_started())?;
}
@@ -175,7 +175,7 @@ impl ConversationOutput {
if self.superseded.is_cancelled() {
worker.abort();
self.abort_execs().await;
return Ok(());
return Ok(RunFinish::Transport(TransportFinish::Cancelled));
}
let input = if let Ok(action) = self.runtime_actions.try_recv() {
Input::RuntimeAction(Some(Box::new(action)))
@@ -187,7 +187,7 @@ impl ConversationOutput {
_ = self.superseded.cancelled() => {
worker.abort();
self.abort_execs().await;
return Ok(());
return Ok(RunFinish::Transport(TransportFinish::Cancelled));
}
action = self.runtime_actions.recv() => Input::RuntimeAction(action.map(Box::new)),
event = self.core.events.recv() => Input::Event(event),
@@ -264,6 +264,32 @@ impl ConversationOutput {
streams.clear();
presentation.discard_model_output();
}
RunEvent::ModelAttemptFailed { attempt, message } => {
tracing::warn!(
run_id = %self.run.run_id(),
attempt,
%message,
"retrying model call from current checkpoint"
);
presentation.finish_model_attempt();
for call in calls.values_mut() {
if call.arguments.is_null() {
call.arguments = serde_json::from_str(&call.arguments_text)
.unwrap_or_else(|_| serde_json::json!({}));
}
let completion = compat::failure_with_message(
call,
format!("Model attempt failed before tool completion: {message}"),
);
self.handle
.emit(&codec::tool_completed(call, &completion))?;
presentation.tool_completed(&completion);
}
response_text.clear();
response_thinking.clear();
calls.clear();
streams.clear();
}
RunEvent::TextStart => {}
RunEvent::TextEnd => {
if !self.context.compacting {
@@ -312,6 +338,7 @@ impl ConversationOutput {
name: name.clone(),
arguments_text: String::new(),
arguments: serde_json::Value::Null,
argument_error: None,
};
self.emit_model_event(
crate::provider::ModelEvent::ToolCallStart {
@@ -335,15 +362,50 @@ impl ConversationOutput {
let stream = streams.get_mut(&index).ok_or_else(|| {
Error::Protocol(format!("missing Cursor tool stream: {index}"))
})?;
for message in stream.arguments_delta(call, &delta)? {
self.handle.emit(&message)?;
match stream.arguments_delta(call, &delta) {
Ok(messages) => {
for message in messages {
self.handle.emit(&message)?;
}
}
Err(Error::Protocol(message)) => {
tracing::warn!(
call_id = %call.call_id,
%message,
"ignoring invalid streaming tool arguments until completion"
);
}
Err(Error::Json(error)) => {
tracing::warn!(
call_id = %call.call_id,
%error,
"ignoring invalid streaming tool arguments until completion"
);
}
Err(error) => return Err(error),
}
}
RunEvent::ToolCallEnd { index } => {
let call = calls.get_mut(&index).ok_or_else(|| {
Error::Protocol(format!("unknown completed tool index: {index}"))
})?;
call.arguments = serde_json::from_str(&call.arguments_text)?;
// A tool call with no arguments streams no argument text.
// Treat empty text as an empty object, matching the model
// cycle, instead of failing the run on `from_str("")`.
call.arguments = if call.arguments_text.trim().is_empty() {
serde_json::json!({})
} else {
serde_json::from_str(&call.arguments_text)
.unwrap_or_else(|_| serde_json::json!({}))
};
}
RunEvent::UsageSnapshot(usage) => {
if !self.context.compacting {
if let Some(output_tokens) = usage.output_tokens {
self.handle.emit(&events::token_delta(output_tokens))?;
}
context_tokens = usage.context_input_tokens;
}
}
RunEvent::Usage(usage) => {
if !self.context.compacting {
@@ -352,10 +414,7 @@ impl ConversationOutput {
}
}
if !self.context.compacting {
context_tokens = usage
.input_tokens
.zip(usage.output_tokens)
.and_then(|(input, output)| input.checked_add(output));
context_tokens = usage.context_input_tokens;
}
match &mut turn_usage {
Some(total) => *total += usage,
@@ -424,21 +483,28 @@ impl ConversationOutput {
streams.clear();
}
if let CommitCause::RuntimeEvent { event_id } = &state.cause {
if let Some(injection_id) = event_id.strip_prefix("inject-context:") {
if let Some(pending) = self.pending_injections.remove(injection_id)
{
let delivered_at_ms = crate::cursor::tools::runtime::now_ms()
.min(i64::MAX as u64)
as i64;
self.handle.emit(&events::context_injection_delivered(
injection_id.to_owned(),
pending.delivery_batch_id.clone(),
delivered_at_ms,
))?;
if let Some(user_message) = pending.user_message {
self.handle
.emit(&events::user_message_appended(user_message))?;
}
// Injections key `pending_injections` by their raw
// injection id and commit under `inject-context:{id}`,
// while runtime user messages key it by (and commit
// under) the full `user-message:{id}` event id. Strip
// the injection prefix when present and otherwise use
// the event id verbatim so both are cleared and emit
// their delivered/appended events.
let injection_id = event_id
.strip_prefix("inject-context:")
.unwrap_or(event_id.as_str());
if let Some(pending) = self.pending_injections.remove(injection_id) {
let delivered_at_ms = crate::cursor::tools::runtime::now_ms()
.min(i64::MAX as u64)
as i64;
self.handle.emit(&events::context_injection_delivered(
injection_id.to_owned(),
pending.delivery_batch_id.clone(),
delivered_at_ms,
))?;
if let Some(user_message) = pending.user_message {
self.handle
.emit(&events::user_message_appended(user_message))?;
}
}
}
@@ -509,6 +575,7 @@ impl ConversationOutput {
.map_err(|_| Error::Protocol("checkpoint worker stopped".into()))?
{
Ok(checkpoint) => {
context_tokens = checkpoint_context_tokens(&checkpoint);
compaction_checkpoint = Some(checkpoint);
state.barrier.complete(Ok(()));
}
@@ -652,7 +719,7 @@ impl ConversationOutput {
if self.superseded.is_cancelled() {
worker.abort();
self.abort_execs().await;
return Ok(());
return Ok(RunFinish::Transport(TransportFinish::Cancelled));
}
return match outcome {
RunOutcome::Completed => {
@@ -668,8 +735,7 @@ impl ConversationOutput {
for _ in 0..3 {
self.checkpoint.publish(&self.handle, &checkpoint).await?;
}
finish_success(&self.handle);
return Ok(());
return Ok(RunFinish::TurnCompleted);
}
let checkpoints = final_checkpoint.take().ok_or_else(|| {
Error::Protocol("Completed without final state".into())
@@ -685,18 +751,19 @@ impl ConversationOutput {
ttft_breakdown: None,
message: Some(pb::agent_server_message::Message::ConversationCheckpointUpdate(checkpoints.settled)),
})?;
finish_success(&self.handle);
Ok(())
Ok(RunFinish::TurnCompleted)
}
RunOutcome::Cancelled => {
worker.abort();
self.abort_execs().await;
finish_cancelled(&self.handle)
Ok(RunFinish::Transport(TransportFinish::Cancelled))
}
RunOutcome::Failed(failure) => {
worker.abort();
self.abort_execs().await;
finish_failed(&self.handle, &cursor_error(failure))
Ok(RunFinish::Transport(TransportFinish::Failed(cursor_error(
failure,
))))
}
};
}
@@ -1015,10 +1082,34 @@ pub(crate) fn finish_cancelled(handle: &TransportHandle) -> Result<()> {
Ok(())
}
fn checkpoint_context_tokens(checkpoint: &pb::ConversationStateStructure) -> Option<u64> {
checkpoint
.token_details
.as_ref()
.map(|details| u64::from(details.used_tokens))
}
#[cfg(test)]
mod tests {
use super::accept_tool_completion;
use crate::{run::CommandResult, Error};
use super::{accept_tool_completion, checkpoint_context_tokens};
use crate::{cursor::protocol::proto::agent::v1 as pb, run::CommandResult, Error};
#[test]
fn compacted_checkpoint_replaces_the_in_memory_context_usage() {
let compacted = pb::ConversationStateStructure {
token_details: Some(pb::ConversationTokenDetails {
used_tokens: 20_000,
..Default::default()
}),
..Default::default()
};
assert_eq!(checkpoint_context_tokens(&compacted), Some(20_000));
assert_eq!(
checkpoint_context_tokens(&pb::ConversationStateStructure::default()),
None
);
}
#[test]
fn closing_and_ended_runs_ignore_known_tool_completions() {
@@ -26,6 +26,8 @@ pub(crate) struct ConversationDependencies {
pub provider: Arc<dyn Provider>,
pub compiler: PromptCompiler,
pub web_cache: WebCache,
/// 本地 rules 服务的 md 存储目录;编译请求上下文时合并其中的规则。
pub local_rules_dir: Option<std::path::PathBuf>,
}
struct RegistryInner {
@@ -47,6 +49,7 @@ impl ConversationRegistry {
provider: Arc<dyn Provider>,
compiler: PromptCompiler,
web_cache: WebCache,
local_rules_dir: Option<std::path::PathBuf>,
) -> Self {
Self {
inner: Arc::new(RegistryInner {
@@ -58,6 +61,7 @@ impl ConversationRegistry {
provider,
compiler,
web_cache,
local_rules_dir,
},
}),
}
+300 -105
View File
@@ -11,25 +11,29 @@ use crate::{
protocol::proto::agent::v1 as pb,
services::{blob_sync::BlobSynchronizer, context_sync::RequestContextSynchronizer},
tools::{
codec,
codec, compat,
runtime::CursorToolRuntime,
tool_call_result::{tool_result_channel, ToolResultReceiver, ToolResultSender},
ClientToolEvent, ToolDispatcher,
},
transport::{OrderedInbox, TransportHandle},
},
run::{CommandResult, RunEngine, RunHandle},
run::{CommandResult, RunEngine, RunHandle, RunPhase},
};
use super::{
CompiledMessages, ConversationDependencies, ConversationOutput, ConversationOutputDependencies,
ConversationRegistry, MessageDelivery, TransportCommand,
ConversationRegistry, MessageDelivery, RunFinish, TransportCommand, TransportFinish,
};
pub struct ConversationRuntime;
const CONTINUATION_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
#[derive(Clone)]
struct RunGeneration {
id: u64,
request: pb::AgentRunRequest,
superseded: CancellationToken,
finished: CancellationToken,
run: Arc<parking_lot::Mutex<Option<RunHandle>>>,
@@ -41,6 +45,14 @@ struct RunGeneration {
struct FinishGeneration(CancellationToken);
struct TransportActorGuard(TransportHandle);
impl Drop for TransportActorGuard {
fn drop(&mut self) {
self.0.close_transport();
}
}
impl Drop for FinishGeneration {
fn drop(&mut self) {
self.0.cancel();
@@ -54,6 +66,7 @@ impl ConversationRuntime {
mut receiver: mpsc::Receiver<TransportCommand>,
) {
tokio::spawn(async move {
let _actor_guard = TransportActorGuard(handle.clone());
let dependencies = registry.dependencies().clone();
let blob_sync = BlobSynchronizer::new(
handle.request_id().into(),
@@ -65,19 +78,70 @@ impl ConversationRuntime {
let context_sync =
RequestContextSynchronizer::new(handle.clone(), dependencies.store.clone());
let mut current = None::<RunGeneration>;
let mut next_generation = 1_u64;
let mut pending_finish = None::<(u64, TransportFinish)>;
let mut draining = false;
let mut waiting_for_action = false;
loop {
let command = match receiver.recv().await {
Some(command) => command,
None => {
handle.mark_disconnected();
if let Some(generation) = current.as_ref() {
generation.superseded.cancel();
if let Some(run) = generation.run.lock().clone() {
run.cancel();
let command = if draining {
if !handle.admissions_drained() {
tokio::select! {
command = receiver.recv() => match command {
Some(command) => command,
None => {
finish_pending(&handle, &current, pending_finish.take());
break;
}
},
_ = handle.wait_admissions_drained() => continue,
}
} else {
handle.mark_draining();
match receiver.try_recv() {
Ok(command) => command,
Err(mpsc::error::TryRecvError::Empty)
| Err(mpsc::error::TryRecvError::Disconnected) => {
finish_pending(&handle, &current, pending_finish.take());
break;
}
}
super::finish_cancelled(&handle).ok();
break;
}
} else if waiting_for_action {
tokio::select! {
command = receiver.recv() => match command {
Some(command) => command,
None => {
handle.mark_disconnected();
super::finish_success(&handle);
break;
}
},
_ = tokio::time::sleep(CONTINUATION_IDLE_TIMEOUT) => {
let Some(generation) = current.as_ref() else {
super::finish_success(&handle);
break;
};
handle.begin_close();
pending_finish = Some((generation.id, TransportFinish::Success));
draining = true;
waiting_for_action = false;
continue;
}
}
} else {
match receiver.recv().await {
Some(command) => command,
None => {
handle.mark_disconnected();
if let Some(generation) = current.as_ref() {
generation.superseded.cancel();
if let Some(run) = generation.run.lock().clone() {
run.cancel();
}
}
super::finish_cancelled(&handle).ok();
break;
}
}
};
match command {
@@ -92,11 +156,41 @@ impl ConversationRuntime {
let _ = handle.emit(&codec::abort(id));
}
}
super::finish_cancelled(&handle).ok();
let turn_completed = waiting_for_action
|| current.as_ref().is_some_and(|generation| {
generation
.run
.lock()
.as_ref()
.is_none_or(|run| run.phase() != RunPhase::Running)
});
if turn_completed {
super::finish_success(&handle);
} else {
super::finish_cancelled(&handle).ok();
}
break;
}
TransportCommand::Close => {
break;
TransportCommand::RunFinished { generation, finish } => {
if !current
.as_ref()
.is_some_and(|current| current.id == generation)
{
continue;
}
match finish {
RunFinish::TurnCompleted => {
pending_finish = None;
draining = false;
waiting_for_action = true;
}
RunFinish::Transport(finish) => {
waiting_for_action = false;
handle.begin_close();
pending_finish = Some((generation, finish));
draining = true;
}
}
}
TransportCommand::Append { seqno, message } => {
for (_seqno, message) in inbox.push(seqno, *message) {
@@ -105,6 +199,12 @@ impl ConversationRuntime {
Some(pb::agent_client_message::Message::RunRequest(
request,
)) => {
waiting_for_action = false;
if draining {
handle.reopen();
draining = false;
pending_finish = None;
}
if let Some(conversation_id) =
request.conversation_id.as_deref()
{
@@ -117,60 +217,21 @@ impl ConversationRuntime {
"invalid Cursor conversation id"
);
let _ = super::finish_failed(&handle, &error);
let _ =
handle.command(TransportCommand::Close).await;
return;
}
}
let previous_finished =
if let Some(previous) = current.take() {
previous.superseded.cancel();
if let Some(run) = previous.run.lock().clone() {
run.cancel();
}
for id in previous
.tool_runtime
.interrupt_for_run_replacement()
.await
{
let _ = handle.emit(&codec::abort(id));
}
Some(previous.finished.clone())
} else {
None
};
let (results, result_receiver) = tool_result_channel();
let (runtime_actions, runtime_action_receiver) =
mpsc::unbounded_channel::<compile::RuntimeAction>();
let tool_runtime = tool_runtime_factory.next_run();
let tools = ToolDispatcher::with_results(
tool_runtime.clone(),
results.clone(),
dependencies.store.clone(),
dependencies.web_cache.clone(),
);
let generation = RunGeneration {
superseded: CancellationToken::new(),
finished: CancellationToken::new(),
run: Arc::new(parking_lot::Mutex::new(None)),
results,
runtime_actions,
tool_runtime,
tools,
};
current = Some(generation.clone());
spawn_run_request(
registry.clone(),
handle.clone(),
start_generation(
&registry,
&handle,
&dependencies,
&blob_sync,
&context_sync,
&tool_runtime_factory,
&mut current,
&mut next_generation,
request,
dependencies.clone(),
blob_sync.clone(),
context_sync.clone(),
generation,
previous_finished,
result_receiver,
runtime_action_receiver,
);
)
.await;
}
Some(pb::agent_client_message::Message::ExecClientMessage(
message,
@@ -262,17 +323,18 @@ impl ConversationRuntime {
.take_exec(throw.id)
.await
{
Some(pending) => generation.results.send_error(
crate::Error::Protocol(format!(
"Exec {} failed: {}",
pending.call.call_id, throw.error
)),
Some(pending) => generation.results.send(
compat::failure_with_message(
&pending.call,
format!(
"Exec {} failed: {}",
pending.call.call_id, throw.error
),
),
),
None => generation.results.send_error(
crate::Error::Protocol(format!(
"unknown ExecClientThrow id: {}",
throw.id
)),
None => tracing::warn!(
id = throw.id,
"ignoring failure for unknown tool execution"
),
}
}
@@ -330,27 +392,48 @@ impl ConversationRuntime {
// return an explicit Protocol Error rather than falling through silently.
Some(
pb::agent_client_message::Message::ConversationAction(
action,
conversation_action,
),
) => match action.action {
) => match conversation_action.action.clone() {
Some(
pb::conversation_action::Action::UserMessageAction(
action,
),
) => {
let Some(generation) = current.as_ref() else {
let delivered_to_active_run =
current.as_ref().is_some_and(|generation| {
generation.run.lock().as_ref().is_some_and(
|run| run.phase() == RunPhase::Running,
) && generation
.runtime_actions
.send(compile::RuntimeAction::UserMessage(
action.clone(),
))
.is_ok()
});
if delivered_to_active_run {
continue;
}
let Some(previous) = current.as_ref() else {
continue;
};
if generation
.runtime_actions
.send(compile::RuntimeAction::UserMessage(action))
.is_err()
{
generation.results.send_error(crate::Error::Protocol(
"UserMessageAction arrived without an active Run"
.into(),
));
}
let mut request = previous.request.clone();
request.action = Some(conversation_action);
request.conversation_state = None;
request.pre_fetched_blobs.clear();
waiting_for_action = false;
start_generation(
&registry,
&handle,
&dependencies,
&blob_sync,
&context_sync,
&tool_runtime_factory,
&mut current,
&mut next_generation,
request,
)
.await;
}
Some(pb::conversation_action::Action::CancelAction(_)) => {
if let Some(generation) = current.as_ref() {
@@ -427,6 +510,95 @@ impl ConversationRuntime {
}
}
#[allow(clippy::too_many_arguments)]
async fn start_generation(
registry: &ConversationRegistry,
handle: &TransportHandle,
dependencies: &ConversationDependencies,
blob_sync: &BlobSynchronizer,
context_sync: &RequestContextSynchronizer,
tool_runtime_factory: &CursorToolRuntime,
current: &mut Option<RunGeneration>,
next_generation: &mut u64,
request: pb::AgentRunRequest,
) {
let previous_finished = if let Some(previous) = current.take() {
previous.superseded.cancel();
if let Some(run) = previous.run.lock().clone() {
run.cancel();
}
for id in previous.tool_runtime.interrupt_for_run_replacement().await {
let _ = handle.emit(&codec::abort(id));
}
Some(previous.finished.clone())
} else {
None
};
let (results, result_receiver) = tool_result_channel();
let (runtime_actions, runtime_action_receiver) =
mpsc::unbounded_channel::<compile::RuntimeAction>();
let tool_runtime = tool_runtime_factory.next_run();
let tools = ToolDispatcher::with_results(
tool_runtime.clone(),
results.clone(),
dependencies.store.clone(),
dependencies.web_cache.clone(),
);
let generation = RunGeneration {
id: *next_generation,
request: request.clone(),
superseded: CancellationToken::new(),
finished: CancellationToken::new(),
run: Arc::new(parking_lot::Mutex::new(None)),
results,
runtime_actions,
tool_runtime,
tools,
};
*next_generation = next_generation.saturating_add(1);
*current = Some(generation.clone());
spawn_run_request(
registry.clone(),
handle.clone(),
request,
dependencies.clone(),
blob_sync.clone(),
context_sync.clone(),
generation,
previous_finished,
result_receiver,
runtime_action_receiver,
);
}
fn finish_pending(
handle: &TransportHandle,
current: &Option<RunGeneration>,
pending: Option<(u64, TransportFinish)>,
) {
let Some((generation, finish)) = pending else {
return;
};
if current
.as_ref()
.is_some_and(|current| current.id == generation)
{
finish_transport(handle, finish);
}
}
fn finish_transport(handle: &TransportHandle, finish: TransportFinish) {
match finish {
TransportFinish::Success => super::finish_success(handle),
TransportFinish::Failed(error) => {
let _ = super::finish_failed(handle, &error);
}
TransportFinish::Cancelled => {
let _ = super::finish_cancelled(handle);
}
}
}
#[allow(clippy::too_many_arguments)]
fn spawn_run_request(
registry: ConversationRegistry,
@@ -471,6 +643,7 @@ fn spawn_run_request(
checkpoint: &checkpoint,
blob_sync: &blob_sync,
context_sync: &context_sync,
local_rules_dir: dependencies.local_rules_dir.as_deref(),
},
) => prepared,
};
@@ -485,8 +658,12 @@ fn spawn_run_request(
%error,
"failed to prepare Cursor Run"
);
let _ = super::finish_failed(&handle, &error);
let _ = handle.command(TransportCommand::Close).await;
let _ = handle
.command(TransportCommand::RunFinished {
generation: generation.id,
finish: RunFinish::Transport(TransportFinish::Failed(error)),
})
.await;
return;
}
};
@@ -518,8 +695,12 @@ fn spawn_run_request(
{
CommandResult::Applied | CommandResult::Duplicate => {
if !generation.superseded.is_cancelled() {
super::finish_success(&handle);
let _ = handle.command(TransportCommand::Close).await;
let _ = handle
.command(TransportCommand::RunFinished {
generation: generation.id,
finish: RunFinish::Transport(TransportFinish::Success),
})
.await;
}
return;
}
@@ -543,8 +724,12 @@ fn spawn_run_request(
}
CommandResult::StaleTarget => {
if !generation.superseded.is_cancelled() {
super::finish_success(&handle);
let _ = handle.command(TransportCommand::Close).await;
let _ = handle
.command(TransportCommand::RunFinished {
generation: generation.id,
finish: RunFinish::Transport(TransportFinish::Success),
})
.await;
}
return;
}
@@ -603,16 +788,21 @@ fn spawn_run_request(
tool_runtime: generation.tool_runtime.clone(),
},
);
if let Err(error) = output.run().await {
if !generation.superseded.is_cancelled() {
tracing::error!(
request_id = handle.request_id(),
%error,
"Cursor session failed"
);
let _ = super::finish_failed(&handle, &error);
let finish = match output.run().await {
Ok(finish) => finish,
Err(error) => {
if generation.superseded.is_cancelled() {
RunFinish::Transport(TransportFinish::Cancelled)
} else {
tracing::error!(
request_id = handle.request_id(),
%error,
"Cursor session failed"
);
RunFinish::Transport(TransportFinish::Failed(error))
}
}
}
};
let _ = core_run.await;
registry.release(&conversation_id, &run_id).await;
if generation
@@ -624,7 +814,12 @@ fn spawn_run_request(
*generation.run.lock() = None;
}
if !generation.superseded.is_cancelled() {
let _ = handle.command(TransportCommand::Close).await;
let _ = handle
.command(TransportCommand::RunFinished {
generation: generation.id,
finish,
})
.await;
}
});
}

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