Compare commits

..
294 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
leookun 2ff74bc6a8 feat: webfetch 2026-08-30 11:54:32 +08:00
leookun 66ac94d5b1 feat: implement web cache for persisting and serving fetched content
- Introduced a new `WebCache` module to manage web content caching.
- Added functionality to store fetched content and serve it via a dedicated route.
- Integrated web cache into the search module for improved content retrieval.
- Implemented database migration management with detailed diagnostics for better error handling during startup.
- Updated the SQLite store to utilize the new migration system for enhanced database management.
2026-08-30 04:29:39 +08:00
leookun fd7998e43c feat: enhance desktop application startup diagnostics and logging
- Introduced a new `startup` module to capture and manage startup diagnostics.
- Implemented logging functionality with daily rotation and error reporting for application startup failures.
- Updated the main application run function to return an exit code based on startup success or failure.
- Added new dependencies for logging and diagnostics in `Cargo.toml`.
- Increased request timeout in the control service for improved stability.
- Expanded maximum search results in the federation module for better search capabilities.
2026-08-30 03:19:01 +08:00
leookun 44e2d8057a feat: initialize server structure and database schema
- Added initial server setup with Cargo.toml defining dependencies and project structure.
- Created build.rs for generating protobuf bindings and validating wire contracts.
- Established database schema with initial migration files for conversations, messages, and runs.
- Introduced tools and prompts for Cursor functionality, enhancing user interaction capabilities.
2026-08-30 01:33:39 +08:00
leookun d200b3791d new plan 2026-08-29 22:05:04 +08:00
leookun 279e6bb07c refactor: align project directory architecture 2026-08-29 20:45:51 +08:00
leookun 88f258f184 feat: enhance Makefile for Windows and non-Windows desktop builds
- Added conditional logic in the Makefile to support Tauri signing key generation for Windows environments.
- Updated the build-desktop target to ensure proper signing key handling across different operating systems.
2026-08-28 23:36:27 +08:00
leokun c983124288 Merge pull request #374 from zwenooo/codex/fix-cursor-reasoning-parameter
fix: align Cursor reasoning parameter metadata
2026-08-28 23:22:06 +08:00
leokun 6447fd998a Merge pull request #367 from jiah0231/fix/runtime-unsupported-tool-recovery
修复:运行中遇到已移除/未知工具时避免 Agent 中断Fix/runtime unsupported tool recovery
2026-08-28 23:20:32 +08:00
leookun 5cc2401ce2 feat: add first valid response timing and related metrics
- Introduced `first_valid_response_ms` and `ttfr_ms` to track the timing of the first valid response in LLM calls.
- Updated relevant interfaces and components to display and utilize the new metrics, including CallDetails, CallTable, and LatencyChart.
- Enhanced the database schema to accommodate the new timing fields.
- Implemented logic in the service layer to record the first valid response during model interactions.
2026-08-28 22:56:39 +08:00
leokun 5a0bc2e0e9 fix: persist each provider retry as its own llm_calls row
Retry attempts now finish the failed call, start a new call id, and record the next request so call history shows intermediate failures.
2026-08-28 21:09:45 +08:00
leokun 6a3570a95a refactor: implement retry mechanism for API calls in Anthropic, OpenAI Chat, and OpenAI Responses providers
- Replaced direct API call handling with a retry mechanism using `send_with_retry`.
- Improved error handling and response management for better reliability in network requests.
2026-08-28 20:37:52 +08:00
leokun 921bcfd247 feat: implement automatic call refresh on CallsPage
- Added a refreshCalls function to appStore to fetch updated call data.
- Integrated useEffect in CallsPage to periodically refresh calls every 2 seconds and on visibility change.
2026-08-28 19:46:37 +08:00
leokun e27b989002 Fix compaction projection and MCP image truncation 2026-08-28 18:54:27 +08:00
leokun e874d68b79 docs: update README and documentation to clarify model configuration and restart requirements
- Enhanced instructions for restarting Cursor after upgrades or initial model configuration to ensure proper functionality.
- Updated references from "Troubleshooting" to "Frequently Asked Questions" for better alignment with user needs.
- Improved clarity in the installation and usage steps across multiple language versions.
2026-08-28 16:12:06 +08:00
zwen befb18d9c3 fix: align Cursor reasoning parameter metadata 2026-08-28 15:24:12 +08:00
leokun d0033a768b Merge pull request #369 from leookun/fix/stream-lifecycle-reliability
fix: repair stream lifecycle reliability
2026-08-28 13:02:16 +08:00
leookun 59379f1f72 fix: repair stream lifecycle reliability 2026-08-28 13:00:39 +08:00
leookun 370f120b8a docs: update README to reflect recent changes and improve clarity 2026-08-28 12:50:01 +08:00
leookun 1ab86c1851 docs: update image references in README for consistency 2026-08-28 12:44:18 +08:00
leokun d015d1c33b docs: sync README from source repo 2026-08-28 12:36:30 +08:00
jiah0231 2c32dad271 test: cover AwaitShell emitted during active run 2026-08-28 12:21:45 +08:00
jiah0231 85241a0413 docs: describe AwaitShell as runtime unsupported tool 2026-08-28 12:21:34 +08:00
leokun 1fcb78ad95 Merge pull request #366 from joel01-dev/fix/stream-reliability-v2 2026-08-28 12:14:10 +08:00
jiah0231 edc28de86b 修复:兼容已移除工具,避免旧会话 Resume 中断
兼容 v0.1.5-beta.1 删除 AwaitShell 后的旧会话 Resume,并将未知/已移除工具降级为模型可见失败结果,避免整个 Agent Run 被 Protocol Error 直接终止。
2026-08-28 12:12:50 +08:00
joel01-dev b2610c562e fix: restore upstream openai_chat.rs, add only debug logging 2026-08-28 01:12:31 -03:00
joel01-dev 318545cebd fix: restore upstream dispatch/mod.rs, add only Bash alias 2026-08-28 01:07:41 -03:00
joel01-dev 2369d101ec docs: add PR description 2026-08-28 00:48:43 -03:00
joel01-dev 80604bdf97 fix: prevent silent SSE stream hangs, add Bash alias, debug logging
- Actor: use lifecycle::cancel() instead of handle.cancel() on channel
  close and Abort, ensuring end-stream frame is always emitted
- Lifecycle: use match instead of ? on encode_error_end_stream with
  fallback end-stream frame to prevent silent hangs
- Dispatch: add Bash tool alias for Shell
- Context/Blob sync: increase timeouts from 15s to 60s
- Providers: add detailed debug logging to openai_chat and router
2026-08-28 00:47:53 -03:00
leookun c4031d4074 chore(release): bump desktop to v0.1.5-beta.1 2026-08-28 10:40:26 +08:00
leookun b15e149b9d fix:add anthropic cache block 2026-08-28 10:33:00 +08:00
leookun 15f2280e07 Merge branch 'main' of github.com:leookun/cursor-byok 2026-08-28 10:18:18 +08:00
leookun 71ddf71ec1 feat(api): enhance statistics storage management with scope options
- Introduced a new type `StatisticsStorageScope` to specify the scope for clearing statistics.
- Updated the `clearStatisticsStorage` API method to accept a scope parameter, allowing for selective clearing of detailed records or all statistics.
- Modified the demo API to handle the new scope parameter appropriately.
- Updated the SettingsPage component to include a selection for clearing scope, enhancing user control over statistics management.
- Added new translations for the updated messages related to statistics clearing in both English and Chinese.
2026-08-28 10:18:11 +08:00
leokun cde4d55fd2 Merge pull request #361 from LyricalNanoha/fix/omit-empty-tools-array 2026-08-28 10:11:09 +08:00
leookun 217f984112 feat(docs): enhance documentation and UI consistency
- Updated the index.html to dynamically set the theme color based on user selection, improving user experience during theme switching.
- Modified proxy.ts to include 'icon.png' in the matcher for better asset handling.
- Added a new icon.png file for enhanced branding.
- Improved metadata in layout.tsx and page.tsx files for better clarity and consistency in documentation.
- Updated various documentation files to reflect the new branding of 'Cursor Byok' and ensure consistent terminology throughout.
- Removed outdated blog entries and added new content to better align with the current product direction.
2026-08-28 01:33:51 +08:00
leookun 2b54014cf9 feat(docs): update ESLint configuration to include additional directories
- Added '.open-next/**', '.wrangler/**', and 'cloudflare-env.d.ts' to the ESLint configuration for improved linting coverage of new files and directories.
2026-08-28 00:47:45 +08:00
leookun 6b65a4ae31 feat(docs): reorganize images in README for clarity and consistency
- Moved image assets to a more appropriate section in README.md for better visual flow.
- Removed duplicate image entries to streamline the documentation.
2026-08-28 00:43:40 +08:00
leookun 8ca62e933b feat(docs): update image assets in README for improved visuals
- Replaced existing images in README.md with updated versions for brand, dashboard, and model settings.
- Added new image files: en-brand-1.png, en-home-1.png, and en-model-1.png.
2026-08-28 00:42:52 +08:00
leookun c711690f90 feat(docs): update configuration and improve caching for documentation site
- Added Cloudflare and OpenNext configurations to .gitignore.
- Updated next.config.mjs to disable image optimization for better performance with CDN.
- Modified fetchJson function in github.ts to use 'force-cache' for improved data fetching efficiency.
- Updated multiple image assets for the documentation site.
2026-08-28 00:41:13 +08:00
leookun ed300cc92e feat(docs): add wrangler configuration for cursor-byok documentation site
- Created a new wrangler.jsonc file to define the configuration for the cursor-byok-docs project.
- Set compatibility date and enabled necessary compatibility flags for Node.js API and public fetch.
- Specified asset directory and service bindings for the documentation site.
2026-08-28 00:35:37 +08:00
leookun eca063e424 feat(docs): enhance documentation site with internationalization and layout updates
- Updated the demo API to support locale-based content rendering.
- Refactored the main layout to accommodate language-specific metadata and routing.
- Introduced new components for blog and documentation pages, ensuring proper language handling.
- Added styles for improved UI consistency across different sections.
- Removed deprecated layout files and streamlined the structure for better maintainability.
2026-08-28 00:35:31 +08:00
leokun 6c758f2b44 Merge branch 'main' of github.com:leookun/cursor-byok
# Conflicts:
#	server/src/cursor/tools/dispatch/mod.rs
2026-08-27 21:47:27 +08:00
leokun 9cb16e116b feat(cursor): normalize block_until_ms argument for shell tool calls
- Added a new function to normalize the `block_until_ms` argument for shell tool calls, ensuring it is an integer and within valid range.
- Updated the `start` function to utilize the normalization logic.
- Introduced tests to validate the behavior of the normalization function, including acceptance of integer-valued floats and rejection of invalid values.
2026-08-27 21:45:36 +08:00
leokun c5d578c5b1 feat(docs): add documentation site and demo features
- Introduced a new documentation site for Cursor BYOK using Next.js and Fumadocs.
- Added a product demo page with a corresponding Vite configuration.
- Implemented a demo API to simulate LLM calls and responses.
- Enhanced the Makefile to include new build and development commands for the documentation.
- Updated package.json scripts for building and running the documentation site.
- Created various components and layouts for the documentation structure, including blog and user documentation sections.
- Added styling for the new components and layouts to ensure a cohesive design.
- Included a README and other necessary files for local development and deployment.
2026-08-27 21:45:28 +08:00
leokun 661ce11207 Merge pull request #359 from jiah0231/fix/shell-block-until-ms
修复:兼容 Shell/AwaitShell 的整数值 block_until_ms
2026-08-27 19:57:52 +08:00
leokun 477750860f Merge pull request #363 from Weizhena/fix/ollama-reasoning
fix: accept reasoning field in OpenAI Chat streams( When using Ollama Cloud)
2026-08-27 19:56:57 +08:00
Jilin Chen 02a190489a fix: accept reasoning field in OpenAI Chat streams 2026-08-27 19:23:58 +08:00
leokun ee915ee760 fix: preempt root loop on context injection 2026-08-27 17:01:03 +08:00
leokun aa68205735 fix: extend provider request timeout 2026-08-27 15:54:08 +08:00
nanoha e4515dc4e0 fix: omit tools field from provider requests when empty
vLLM and some OpenAI-compatible APIs reject requests containing an
empty `tools: []` array with a 400 Bad Request. This affects the
model connectivity test which sends no tools. Instead of always
including the tools field, conditionally add it only when tools are
present.
2026-08-27 14:02:16 +08:00
leokun 14d6a501a0 chore: release v0.1.4 2026-08-27 11:30:54 +08:00
leokun 82f3ae8d59 feat(settings): add dock icon visibility setting to desktop settings
- Introduced a new setting `show_dock_icon` in the `DesktopSettings` interface.
- Updated `AppLifecycleSettingsCard` to manage dock icon visibility with a toggle switch.
- Enhanced the API to read and write the new dock icon visibility setting.
- Added corresponding translations for the new setting in both English and Chinese locales.
- Updated tests to ensure the default behavior of showing the dock icon and proper round-trip functionality for settings.
2026-08-27 11:24:04 +08:00
leokun 31dfa8d295 refactor: improve MCP parameter normalization and test clarity
- Changed `normalize_mcp_parameters` to avoid mutating the input parameters.
- Updated the test name to better reflect its purpose of preserving the cursor object union schema.
- Adjusted assertions in the test to compare the entire schema instead of individual fields.
2026-08-27 11:11:13 +08:00
jiah0231 7914f24d83 fix: preserve dynamic MCP argument schemas 2026-08-27 10:31:18 +08:00
jiah0231 b85cb37898 fix: normalize integer-valued shell timeouts 2026-08-27 10:30:42 +08:00
leookun 995a12df44 chore: update dependencies and improve chart components
- Updated `foreign-types` to version 0.5.0 and added new dependencies in `Cargo.lock`.
- Removed unused dependencies `chart.js` and `react-chartjs-2` from `package.json` and `package-lock.json`.
- Enhanced `CacheHitRateChart` to use `EChart` for rendering, improving performance and visual fidelity.
- Adjusted styles for `CacheHitRateChart` to ensure proper layout and responsiveness.
- Refactored API query construction in `api.ts` for better readability.
- Updated `HomeMetrics` to support refresh functionality for dynamic data updates.
2026-08-27 00:34:35 +08:00
leokun 7058d0193d Merge pull request #356 from GGboya/feat/provider-presets
feat(console): dual-protocol provider presets + fix sub-path model di…
2026-08-27 00:12:14 +08:00
leookun eff642fdcf Merge branch 'main' into pr/GGboya/356 2026-08-26 23:28:11 +08:00
leokun 2c95d7f836 fix: finalize draft releases by asset id 2026-08-26 22:44:27 +08:00
ggboysongyuhangandClaude Fable 5 75babb7334 feat(console): dual-protocol provider presets + fix sub-path model discovery
Console — provider presets inside the model editor:

- Preset chips for five coding-plan providers (Zhipu GLM / Kimi / DeepSeek /
  Volcengine Ark / MiniMax) rendered at the top of the add/edit model dialog
- All five providers expose both an Anthropic-protocol and an
  OpenAI-protocol endpoint; a click fills the endpoint matching the current
  model type, and switching the type automatically swaps to the same
  provider's other endpoint (no stale URL/protocol mismatches)
- Switching to a different provider clears the API key; switching protocols
  within the same provider keeps it
- Fills base URL (full-request-URL form for irregular paths), request
  protocol, claude-cli custom headers for Anthropic endpoints, token limits,
  remark, and the default model id + display name; known preset model ids
  merge into the model-name combobox

Server — model discovery for sub-path endpoints:

- model_discovery_url previously replaced the whole path with /v1/models,
  which strips the /api/anthropic, /coding, /api/paas/v4 style prefixes every
  coding-plan provider uses, so discovery always 404'd for them. It now
  appends to the existing path and normalizes endpoint/version suffixes
  (/chat/completions, /responses, /messages, /v1, /v3, /v4)
- Verified live against Zhipu (10 models) and Kimi (4 models) coding plans;
  existing tests updated to the append semantics plus new URL cases

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 22:43:24 +08:00
leokun f6be6c2b27 release: v0.1.3 2026-08-26 22:09:36 +08:00
leokun ded534fecf feat: update UI 2026-08-26 22:01:14 +08:00
leokun 5450fc76e2 fix: shell 2026-08-26 20:20:38 +08:00
leokun df053c3720 fix: harden concurrent persistence and task recovery 2026-08-26 16:46:41 +08:00
leookun 9deb42915c release: v0.1.2 2026-08-26 15:56:02 +08:00
leookun 1bc1c3d978 merge old config 2026-08-26 15:50:53 +08:00
leookun e1937233ec merge old config 2026-08-26 15:46:11 +08:00
leookun 34f97334dc test: update compaction fixtures for main 2026-08-26 01:51:00 +08:00
leookun ab7b5e8c00 merge: fix compaction input usage anchor 2026-08-26 01:49:15 +08:00
leookun 78ff002aad fix: anchor compaction to provider input usage 2026-08-26 01:49:12 +08:00
leookun 847e92c7ea refactor: update cursor request handling and improve parent request management
- Changed `run_id` to `request_id` in `CursorParent` struct for clarity.
- Enhanced the `prepare` function to handle parent requests asynchronously, ensuring proper error handling for active runs.
- Updated database interactions to include `cursor_request_id` for better tracking of requests.
- Added tests to verify the behavior of reused cursor request IDs and their mapping to distinct executions.
2026-08-26 01:46:30 +08:00
leokun ae31757635 Merge pull request #349 from linanwanttodo/main
feat: 支持 Linux 平台使用(CA 安装适配 + 跨平台静默启动),并修复 api_key 泄漏
2026-08-26 00:56:43 +08:00
leookun a773888852 fix: show window on manual launch 2026-08-26 00:40:56 +08:00
leookun bdf4b923d7 fix: preserve provider API key echo 2026-08-26 00:31:14 +08:00
Linanwanttodo d95273c49b feat: 支持 Linux 平台使用(CA 安装适配 + 静默启动),并修复 api_key 泄漏
CA 适配:
- 移除 CA 模块对 macOS/Windows 的硬性限制,Linux 走完整初始化流程
- 按发行版自动选择安装命令:Debian 系 update-ca-certificates,
  Fedora/RHEL/Arch 系 update-ca-trust extract;已安装检测比对信任锚文件
- 删除不可达的 CaState::Unsupported 分支及前端"请使用 macOS 或 Windows"提示

静默启动(参考 cc-switch 实现逻辑):
- 新增 DesktopSettings 持久化(service_settings 表)及 GET/PUT /api/settings/desktop
- 主窗口统一以 visible(false) 创建,启动时读取配置决定是否显示
- 应用设置页:开启"开机启动"后显示"静默启动"子开关

安全修复:
- ProviderEndpoint.api_key 增加 serde(skip_serializing),
  避免明文 API Key 随 JSON 响应泄漏(provider_console 测试恢复通过)

其他:
- Linux 补齐 open_terminal_with_command:按序探测 x-terminal-emulator /
  gnome-terminal / konsole 等并在新终端窗口执行命令
- "打开终端安装 CA"失败信息不再被前端静默吞掉
- 顺手修复上游遗留问题:desktop.rs 未使用导入、output.rs 测试模块位置、providers.rs 格式
2026-08-25 22:59:21 +08:00
leokun 081e1f50e2 feat: NormalizedProvider 2026-08-25 20:50:57 +08:00
leookun 5f87357681 fix: restore Windows shell parsing metadata 2026-08-25 13:31:14 +08:00
leokun 7dded0d81f Merge pull request #329 from LyricalNanoha/feat/shell-sandbox-permissions
feat: add required_permissions support for Shell sandbox policy
2026-08-24 23:53:45 +08:00
leokun 283f2296e7 Merge pull request #331 from baichuan335/feat/custom-context-option
feat: 增加自定义上下文选项功能
2026-08-24 23:41:42 +08:00
leookun b61059590f refactor: share token count utilities 2026-08-24 23:41:16 +08:00
baichuan335 5509ac45ca feat: add custom context option 2026-08-24 23:29:24 +08:00
leokun cfd1e740b2 chore: release v0.1.0-beta.10 2026-08-24 19:46:15 +08:00
leokun 65edad6a21 chore: release v0.1.0-beta.9 2026-08-24 19:13:50 +08:00
sunyueandCursor 1368a587ca feat: add required_permissions support for Shell sandbox policy
The Shell tool schema lacked a `required_permissions` parameter,
preventing models from requesting elevated sandbox permissions
(e.g. unrestricted network access). This adds:

- `required_permissions` parameter to the Shell tool schema in tools.json
- Sandboxing instructions in the Shell tool description so models know
  when and how to request permissions
- `shell_sandbox_policy()` in request.rs to map the parameter to the
  protobuf `SandboxPolicy.requested_sandbox_policy` field

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 18:59:25 +08:00
leokun 58daa91c90 chore: release v0.1.0-beta.8 2026-08-24 18:43:44 +08:00
leokun eb26b17ba0 fix: stabilize todo state and responses streams 2026-08-24 18:43:44 +08:00
leokun 95fb9be967 chore: release v0.1.0-beta.7 2026-08-24 18:21:49 +08:00
leokun 89631bdab0 fix: adjust secondary button height and enhance MultiCombobox input behavior 2026-08-24 18:06:49 +08:00
leokun fe17e15e75 chore: release v0.1.0-beta.6 2026-08-24 15:46:25 +08:00
leokun 24177fcb6e fix: restore service entrypoint and clean checks 2026-08-24 15:46:14 +08:00
leokun 7fa4953883 fix: harden provider, tool, and desktop behavior 2026-08-24 15:37:42 +08:00
leokun 4bb4ca5c8d chore: release v0.1.0-beta.5 2026-08-24 12:33:17 +08:00
leokun 4ddd3adb3f feat: normalize MCP tool names and enhance OpenAI response handling 2026-08-24 12:22:11 +08:00
leookun 1a0cf89fe1 fix: set OpenAI Responses image detail to auto 2026-08-24 12:00:49 +08:00
leookun 81a1afdae9 feat: add model connectivity tests and harden cursor heartbeats 2026-08-24 09:57:28 +08:00
leookun 6170778de9 feat: add update indicators and stabilize Cursor streams 2026-08-24 05:24:07 +08:00
leookun 0f23a9a9c3 feat: add configurable Cursor TAB routing 2026-08-24 04:35:28 +08:00
leookun 8a4fc5076a fix token usage average 2026-08-24 03:49:57 +08:00
leookun b5199de6f1 Merge branch 'refactor/0.1.0-beta' 2026-08-24 03:46:56 +08:00
leookun b74291af79 stabilize cursor request context and release workflow 2026-08-24 03:44:56 +08:00
leookun 2075b61763 release desktop beta 0.1.0-beta.1 2026-08-24 03:22:18 +08:00
leookun 03075952a6 feat(i18n): add i18n support 2026-08-24 03:08:29 +08:00
leokun 4c2efb05c2 Merge pull request #304 from leookun/refactor/0.1.0-beta
Refactor/0.1.0-beta
2026-08-24 02:54:36 +08:00
leookun 2abffcc835 merge main into refactor after Tauri rewrite 2026-08-24 02:53:35 +08:00
leookun 4053a7fb20 refactor: rebuild desktop app with Tauri 2026-08-24 02:49:00 +08:00
leokun 564f2bdcae Merge pull request #313 from leookun/codex/main-worktree
Codex/main worktree
2026-08-20 14:00:13 +08:00
leookun 77c8e31a09 update prompt 2026-08-20 13:59:31 +08:00
leookun eb68375306 update prompt 2026-08-20 12:56:49 +08:00
leokun e9d3845de0 Merge pull request #295 from Sxuan-Coder/feat/config-import-export
feat(config): 支持完整配置导入导出
2026-08-19 11:36:43 +08:00
leokun d4bfa8b938 Merge pull request #308 from Sxuan-Coder/fix/replay-dangling-tool-call-output
fix: 修复工具调用与结果之间穿插文本时,调用被误删导致 Responses API 400(#307)
2026-08-19 11:36:29 +08:00
leokun dead4477be Merge pull request #306 from ProtectCookies/main
fix(network): 修复长耗时请求被误报「Network disconnected」中断的问题
2026-08-19 11:36:02 +08:00
上玄 b7f8237f3b fix: keep tool call in provider sanitize when text interleaves call and result
trimDanglingAssistantToolCalls in the provider router held the same
consecutive-tool-message assumption as the replay projector: when a
model emits function_call items before explanation text within one
response (e.g. gpt-5.3-codex-spark), the sanitized sequence becomes
assistant[tool_call] -> assistant[text] -> tool[result] and responded
calls were stripped at the provider boundary even though the projector
already replayed them correctly. The orphan tool result then forced the
Responses adapter to synthesize a placeholder function_call with empty
arguments, degrading the arguments the model sees on in-loop requests.

Widen the response collection window to skip interleaved plain
assistant text messages and drop orphan tool results, mirroring the
projector fix.
2026-08-14 19:59:47 +08:00
上玄 5d04b5b08b fix: keep tool call replay when assistant text interleaves call and result
trimReplayDanglingAssistantToolCalls only collected tool results that
immediately followed the assistant tool-call message. Models such as
gpt-5.3-codex-spark may emit the function_call item before the
explanation text within one response, so history replay order becomes
assistant[tool_call] -> assistant[text] -> tool[result]. The call was
misjudged as dangling and stripped while the tool result survived,
producing a function_call_output without a matching function_call that
the Responses API rejects with 400.

- widen the response collection window to skip interleaved plain
  assistant text messages, and drop orphan tool results in the same pass
- synthesize a placeholder function_call (or drop the output when the
  tool name is unknown) in normalizeOpenAIResponsesInput so conversations
  already persisted with corrupted history can resume
2026-08-14 16:51:45 +08:00
杨超 a90c8d9560 fix(network): 修复proto打包报错问题 2026-08-14 14:13:43 +08:00
杨超 e9dc2e098d fix(network): 修复长耗时请求被误报「Network disconnected」中断的问题
客户端扩展在慢请求(如提交信息生成)开始约 10 秒后会探测
NetworkService/IsConnected,非 200 应答即判定断网并中止进行中的请求。
此前该探测落入通配路由返回 404,导致模型仍在正常返回时界面先报断连。

- host.go: 注册 IsConnected 精确路由,恒定以 200 + 空 IsConnectedResponse 应答
- client.go: newProtoMessage 补齐 aiserver.v1.IsConnectedResponse 类型,
  避免 mock 编码失败导致探测返回 502
- state_db.go: 新增强开 statsig gate 机制(cursorStateEnabledStatsigGates),
  强制启用 disable_network_change_monitor_local 从源头关闭该探测;
  disableCursorStatsigGates 泛化为 syncCursorStatsigGateOverrides
2026-08-14 12:19:47 +08:00
leokun a3ec2a0dfc release: 0.0.48 2026-08-12 20:32:19 +08:00
leokun 38472a3c63 Merge pull request #299 from leookun/fix/issue-298-per-user-ca
Fix shared root CA by generating one per installation
2026-08-12 20:11:43 +08:00
leokun 2b8d1c9e3d fix: generate a per-installation root CA 2026-08-12 20:04:29 +08:00
上玄 3fc04eeb4f feat(config): 支持完整配置导入导出 2026-08-12 09:34:02 +08:00
leokun 7a67e76617 Merge pull request #270 from zfscgy/feat/image-read
Feat/image read: 本地文件读取工具支持读取图片
2026-08-12 01:22:35 +08:00
leookun 08ce12aa55 Merge branch 'main' into codex/pr270-content-addressed-read-images
# Conflicts:
#	frontend/src/i18n/generated/catalog.json
2026-08-12 01:14:24 +08:00
leookun 8f8d28880d feat(forwarder): persist read images by content hash 2026-08-12 01:10:14 +08:00
leokun 988ba63d40 Merge pull request #291 from Sxuan-Coder/feat/optional-reasoning-effort
fix(model): 支持不设置 reasoning effort
2026-08-11 23:44:52 +08:00
上玄 edd59dc684 fix(model): 支持不设置推理强度 2026-08-11 21:18:10 +08:00
leokun da5fa34a4d 更新 README-CN.md 2026-08-10 23:19:34 +08:00
leokun 4bd2359282 Update README-CN.md 2026-08-10 23:14:14 +08:00
leokun 7838ffc6a2 Update README.md 2026-08-10 23:13:48 +08:00
leokun ee30775be1 Update Chinese translation link in README 2026-08-10 23:10:17 +08:00
leokun 80a5093aa9 Update README.md 2026-08-10 23:09:41 +08:00
leokun b9742b1667 Update README-CN.md 2026-08-10 23:09:04 +08:00
leokun 7d3e74be59 Update README.md 2026-08-10 23:08:36 +08:00
leokun 1285bf9d62 Update README-CN.md 2026-08-10 23:05:44 +08:00
leokun 7a724595eb Update README.md 2026-08-10 23:05:31 +08:00
leokun 00aec40e50 Update README.md 2026-08-10 23:03:50 +08:00
leokun c10a2d475d Merge pull request #285 from leookun/release/0.0.47
release: 0.0.47
2026-08-10 22:56:00 +08:00
leookun 4864d3675b release: 0.0.47 2026-08-10 22:55:16 +08:00
leokun 3f95318a49 Merge pull request #284 from leookun/fix/compress
Enhance checkpoint handling and error management in forwarder
2026-08-10 22:34:40 +08:00
leookun 9373e57ebf Enhance checkpoint handling and error management in forwarder
- Added flushing of assistant text during provider completion to ensure no output is lost on transport failure.
- Updated checkpoint blob synchronization tests to validate behavior under various conditions, including terminal and non-terminal states.
- Introduced new functions for managing checkpoint terminal actions, improving clarity and maintainability of the code.
- Implemented additional tests for imported blob handling and conversation state restoration, ensuring robustness in data integrity across operations.
2026-08-10 22:25:37 +08:00
leokun f1992b0cfe Merge pull request #281 from jiah0231/fix/openai-reasoning-summary
fix: request OpenAI Responses reasoning summaries
2026-08-10 10:24:45 +08:00
haoge0211 67a9c27931 fix: request OpenAI Responses reasoning summaries 2026-08-08 20:48:07 +08:00
leookun 3cf8bdbc3c docs: make English README the default
Keep the Chinese documentation available through a dedicated language link.
2026-08-08 16:22:12 +08:00
leokun 684953a80b Merge pull request #279 from leookun/fix/cli-model-name
refactor: update model details handling in CLI
2026-08-08 15:46:22 +08:00
leookun 297b56aed0 refactor: update model details handling in CLI
- Renamed test function to better reflect its purpose.
- Enhanced model details structure by adding DisplayName and DisplayNameShort fields in buildCLIModelDetails.
- Updated test cases to validate the new fields and ensure correct functionality.
2026-08-08 15:45:59 +08:00
leookun 2393df1cb8 Remove Chinese README file and update English README links for consistency 2026-08-08 01:00:24 +08:00
leookun 7d622dd039 Enhance README and UI components with improved user guidance and account identifier masking
- Updated README.md to provide clearer project information, features, and quick start instructions.
- Added a function to mask the user's account identifier in CursorAccountCard.vue for enhanced privacy.
- Adjusted localization files to remove outdated entries and improve clarity across multiple languages.
- Refactored MainLayout.vue to streamline author information handling and improve user experience.
2026-08-08 00:54:02 +08:00
leokun 1a7a20c519 Merge pull request #277 from leookun/feat/shell-tool-streaming
feat: add shell tool call delta message handling
2026-08-07 22:40:56 +08:00
leookun 9eb24bb4d4 feat: add shell tool call delta message handling
Implemented the buildShellToolCallDeltaMessage function to map client shell output to the delta format for Cursor's terminal bubble. This includes handling both stdout and stderr events. Updated the service to publish these messages when processing execution results. Additionally, added a test for enabling terminal output UI streaming in the bootstrap statsig configuration.
2026-08-07 22:39:58 +08:00
leokun 426bdd6592 Merge pull request #276 from widwei/fix/anthropic-thinking-carrier
fix(anthropic): pass back thinking block for assistant turns without reasoning
2026-08-07 17:43:00 +08:00
weiwei fa36dc2c60 fix(anthropic): pass back thinking block for assistant turns without reasoning
DeepSeek 类 thinking 模型在 adaptive thinking 下部分 tool-call 轮次不输出
thinking 块,重放时适配器不生成 thinking 块,上游 Anthropic 兼容 API 在
thinking 模式下要求每个 assistant 轮次回传 thinking 块,导致
"The content[].thinking in the thinking mode must be passed back to the API."
400 且毒化会话历史,后续所有请求持续失败。

引入 thinking carrier:缺 reasoning 的轮次复用请求内最近一个有
reasoning+signature 的 assistant 轮次兜底,无 carrier 时输出空 thinking 块;
thinking 关闭时行为不变。与 openai.go 已有空 reasoning_content 兜底对称。

Closes #268
2026-08-07 17:17:03 +08:00
leokun 4e649d676f Merge pull request #272 from leookun/release/0.0.46
Release/0.0.46
2026-08-07 02:41:46 +08:00
leookun 917a711b42 release: 0.0.46 2026-08-07 02:41:14 +08:00
leookun 270bcdb54e Merge branch 'main' into release/0.0.46 2026-08-07 02:28:54 +08:00
leokun c6d719d9b9 Merge pull request #271 from leookun/feat/shangtang
refactor: checkpoint ui
2026-08-07 02:21:54 +08:00
leookun a7f9494828 fix: checkpoint 2026-08-07 02:19:29 +08:00
leookun 225de8bb85 refactor: UI 2026-08-07 01:50:20 +08:00
leookun 5bd39ed317 docs: update PULL_REQUEST_TEMPLATE 2026-08-06 21:56:19 +08:00
leokun 26502ebec8 Merge pull request #269 from leookun/feat/idempotent-checkpoint-recovery
Feat/idempotent checkpoint recovery
2026-08-06 21:13:56 +08:00
leokun 2e265d415e feat(forwarder): make checkpoint recovery idempotent
Preserve interrupted provider output, keep checkpoint projections deterministic, and enforce proto snapshot synchronization.
2026-08-06 21:08:43 +08:00
郑非 85a43115c7 read image tests 2026-08-06 21:06:13 +08:00
郑非 b475166ba8 Support read image 2026-08-06 21:05:08 +08:00
leokun 0d1cedda5b Merge remote-tracking branch 'origin/hotfix/chat-checkpoint' 2026-08-06 16:34:10 +08:00
leokun c7fe198110 Merge branch 'main' of github.com:leookun/cursor-byok 2026-08-06 14:10:18 +08:00
leokun 8c4baf7ade Merge pull request #262 from k23223/feat/model-adapter-model-selection
feat(model-adapter): add remote model selection
2026-08-06 14:10:08 +08:00
leokun 4e0d2ee703 Merge branch 'main' of github.com:leookun/cursor-byok 2026-08-06 14:05:58 +08:00
leokun e1dacab268 Merge pull request #260 from leookun/hotfix/fork-chat 2026-08-06 12:12:46 +08:00
leookun da15109312 Merge branch 'hotfix/fork-chat' into release/0.0.46 2026-08-05 23:35:12 +08:00
xiaopuandCursor 320b9fb7b9 feat(model-adapter): add remote model selection
- Fetch and normalize OpenAI and Anthropic model lists through the proxy service.
- Add searchable multi-model selection with localized UI and persisted adapter mappings.
- Cover endpoint resolution, authentication, pagination, and response parsing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 15:23:51 +08:00
leokun c55c575a58 Merge pull request #255 from Phoeky/patch-1 2026-08-05 11:58:23 +08:00
leookun d3adfffbd8 Implement checkpoint blob handling in forwarder service
- Added support for checkpointing phases and blob management in the forwarder.
- Introduced new types and methods for handling checkpoint blobs, including queuing and publishing checkpoints.
- Enhanced the projector to build checkpoint projections with content-addressed blobs.
- Implemented tests to ensure proper checkpoint blob synchronization and handling of cancellation scenarios.
2026-08-05 02:09:45 +08:00
leookun 4e9335d82f Implement tests for ProjectLegacyCheckpoint to ensure proper handling of conversation state and message imports. This includes verifying that no dangling inline blobs are present and that the correct user and assistant messages are imported. 2026-08-05 01:46:47 +08:00
leokun 374ff9c217 Merge pull request #259 from leookun/release/0.0.45
Release/0.0.45
2026-08-05 01:26:22 +08:00
leookun 06ab0d8dae chore(release): update version to 0.0.45 and fix conversation disappearance issue
- Updated version number to 0.0.45 across all relevant files.
- Fixed an issue that could cause conversations to disappear.
2026-08-05 01:25:39 +08:00
leookun 2f47f02497 chore(cursor-proxy-debugger): enhance traffic capture for Fork Chat and update README
- Added support for capturing and decoding Fork Chat traffic, including `ForkBackgroundComposer`, `NotifyConversationClone`, and `UploadConversationBlobs`.
- Updated the README to reflect new features and usage instructions for Fork Chat traffic.
- Modified `.gitignore` to include `proto/extensions-cursor-app/`.
- Refactored `Taskfile.yml` to improve error messages related to Cursor extensions.
- Introduced new tests for decoding functionality in `cursor-proxy-debugger`.
2026-08-05 01:17:01 +08:00
leookun 058aaa532e remote: cursor source code from git 2026-08-05 00:34:30 +08:00
leookun 622a57fed7 Revert "Merge pull request #239 from DedSecer/fix/cursor-fork-context"
This reverts commit 697fa99245, reversing
changes made to 5742073be5.
2026-08-04 23:49:49 +08:00
leookun f5a7347f44 fix: gate WebView sandbox opt-out behind environment variable 2026-08-04 21:49:26 +08:00
Phoeky a9b406ec30 解决 VDI 环境(如 VMware Horizon)导致主窗口白屏。 2026-08-04 17:39:14 +08:00
leokun 639c452a00 Merge pull request #252 from leookun/release/0.0.44
chore(release): update version to 0.0.44 and enhance release notes
2026-08-03 23:18:11 +08:00
leookun 0c9a07e018 chore(release): update version to 0.0.44 and enhance release notes
- Updated version number to 0.0.44 across all relevant files.
- Added support for cursor-cli in release notes.
2026-08-03 23:17:44 +08:00
leokun 1adca7f600 Merge pull request #241 from warelik/fix/cli-local-mode-endpoints
fix(backend): cover cursor-agent CLI local-mode endpoint surface — startup 404s, git-workspace tool 404s, telemetry
2026-08-03 22:58:47 +08:00
leookun 5df3e8f512 feat(cursor): update model handling to use agentv1 responses and enhance CLI model details
- Refactored response handling in newProtoMessage to utilize agentv1 for GetUsableModelsResponse and GetDefaultModelForCliResponse.
- Added new tests for buildCLIModelDetails and encodeCLIModels to ensure correct model details and encoding.
- Updated buildUsableModelsPayload and buildDefaultModelForCliPayload to leverage buildCLIModelDetails for improved model data structure.
2026-08-03 22:56:17 +08:00
leokun a2e3a07773 Update README.md 2026-08-03 18:54:56 +08:00
leokun 6b7c077448 Update README.md 2026-08-03 18:54:04 +08:00
leokun c64911e8c6 Update README.md 2026-08-03 18:53:30 +08:00
leokun d1d56bd6eb 更新 README.md 2026-08-03 12:31:41 +08:00
leokun ee47f187f0 Merge pull request #249 from leookun/release/0.0.43
chore(release): update version to 0.0.43 and enhance release notes
2026-08-03 01:23:21 +08:00
leookun 726f082e6b chore(release): update version to 0.0.43 and enhance release notes
- Updated version number to 0.0.43 across all relevant files.
- Added new features and fixes to release notes, including:
  - Fix for command recognition issues.
  - Fix for summarize command.
  - Fix for MiniMax thinking restriction.
  - Support for Fork message and conversation linking.
  - Introduction of cursor debugger.
  - Added QQ and Telegram group information.
2026-08-03 01:22:56 +08:00
leookun 424235f11d Merge branch 'main' into fix/cli-local-mode-endpoints
# Conflicts:
#	internal/backend/server/middleware.go
2026-08-03 00:39:56 +08:00
leokun 58770aa206 Merge pull request #231 from octo-patch/octo/20260730-parameter-refresh-recvqgk2Qimxgy
fix(openai): recognize MiniMax in thinking disable
2026-08-03 00:35:29 +08:00
leokun eb0ad7b636 Merge pull request #248 from leookun/codex/cursor-proxy-debugger
feat: add Cursor protocol debugger
2026-08-03 00:23:40 +08:00
leokun 697fa99245 Merge pull request #239 from DedSecer/fix/cursor-fork-context
fix(forwarder): preserve Cursor fork context
2026-08-03 00:21:14 +08:00
leokun 1854a3ab9a feat(cursor): add independent Cursor protocol debugging proxy and build tasks
- Introduced a new task for running the Cursor protocol debugging proxy.
- Added a build task for the Cursor protocol debugging proxy.
- Updated documentation to include usage instructions for the debugging tool.
2026-08-03 00:19:18 +08:00
leokun 5742073be5 Merge pull request #242 from DedSecer/feat/cursor-local-history-transcripts
feat(cursor): support referencing local history from new chats
2026-08-02 23:27:31 +08:00
leookun 90d35085c0 Merge branch 'main' into feat/cursor-local-history-transcripts 2026-08-02 22:58:50 +08:00
leokun 7764eca0c3 Merge pull request #243 from aike0210/codex/feat-cursor-control-plane-account
feat(cursor): 修复本地模式下插件市场、Skills 和 MCP 无法加载的问题
2026-08-02 22:56:53 +08:00
leookun a5437e60fe feat(cursor): add contributor profile link and improve UI for Cursor account card
- Integrated a tooltip with a button to open the contributor's GitHub profile in the CursorAccountCard component.
- Updated the layout of the account card for better visual organization.
- Removed routing mode options from the configuration view and adjusted related translations in multiple languages.
- Cleaned up unused routing mode logic in the app state management.
2026-08-02 22:53:57 +08:00
leookun 674de0b041 Merge branch 'main' into codex/feat-cursor-control-plane-account 2026-08-02 22:28:56 +08:00
leookun d1ffd1a95c feat: delete test requirements 2026-08-02 21:39:47 +08:00
aike1202 9d316c0b3d feat(cursor): 支持独立控制面账号登录 2026-08-01 22:05:08 +08:00
DedSecer 834288839b test(cursor): make transcript adapter tests self-contained
Keep the transcript coverage independent from branch-specific projector helpers so it runs cleanly on the upstream main test layout.
2026-08-01 21:10:46 +08:00
DedSecer 68e63c7a72 feat(cursor): sync local conversation history to Cursor transcripts
Project sidecar context history into Cursor-compatible JSONL transcripts so previous conversations remain readable when referenced from new chats. Backfill existing sessions on startup and preserve Cursor-managed turn status entries during atomic updates.
2026-08-01 21:08:14 +08:00
warelik 2c650b3765 fix(backend): cover cursor-agent CLI local-mode endpoint surface
Desktop Cursor works through the local proxy because it drives the agent
over BidiAppend/RunSSE plus the already-mocked unary endpoints. The
cursor-agent CLI speaks the same agent protocol but calls additional
unary endpoints that had no local handlers, so every request fell into a
wildcard route and came back as HTTP 404, which the Connect client maps
to '[unimplemented] HTTP 404'.

Three independent breaks, one visible symptom:

1. Startup: ServerConfigService/GetServerConfig (only the AiService
   variant was mocked), DashboardService/GetTeamAdminSettingsOrEmptyIfNotInTeam
   and DashboardService/ListMarketplaces were missing, so the CLI aborted
   during session init.

2. Git workspaces: the CLI resolves the repo path-encryption key from
   indexingConfig.default{User,Team}PathEncryptionKey in GetServerConfig
   when no IDE-stored repo keys exist. The mock returned no indexingConfig,
   so repository identity init failed with 'No encryption key found'.

3. Tool execution: every fs tool executor (Ls/Grep/Glob/Shell) consults
   the ignore service, which calls getRepoBlockExcludeGlobs() ->
   DashboardService/GetTeamReposOrEmptyIfNotInTeam. The 404 propagated as
   the tool result error, so model answers arrived but every tool call
   returned '[unimplemented] HTTP 404'. Non-git workspaces skip the
   repo-block path, which is why tools only failed inside git repos.

Also close the remaining tolerated 404 noise so a CLI session produces
zero unimplemented responses: model listing (GetUsableModels,
GetDefaultModelForCli, GetDefaultModel), dashboard/plugin housekeeping
(GetGlobalCommands, GetEffectiveUserPlugins, RegisterMarketplaceAndPlugins,
GetCliDownloadUrl) and telemetry (AnalyticsService/SubmitLogs,
AnalyticsService/TrackEvents, OTLP /v1/traces).

Additional logging: PolicyMiddleware now includes the request path in the
per-request log line, which is what made this diagnosable from app.log.
2026-08-01 10:58:19 +03:00
DedSecerandCursor 14b286a466 fix(forwarder): preserve Cursor fork context
Store checkpoint turns as validated blobs and restore prefetched parent turns so forked conversations retain their replay history and rewind prefix.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 10:51:23 +08:00
octo-patch 828e0a9941 fix(openai): recognize MiniMax in thinking disable
Route the MiniMax OpenAI-compatible endpoint (global api.minimax.io and
China api.minimaxi.com hosts, plus the MiniMax-M3/MiniMax-M2.7 model ids)
to the thinking_type disable branch so that disabling thinking writes
thinking:{type:"disabled"} and drops reasoning_effort, matching the
existing MiMo/deepseek/glm behavior.
2026-07-30 13:25:22 +00:00
leokun 00f52166b2 Merge pull request #222 from DedSecer/fix/cursor-command-prompt-replay
fix(prompt): replay selected Cursor commands
2026-07-30 20:30:01 +08:00
leokun 3349b13a2b fix(prompt): persist cursor command replay context 2026-07-30 20:28:20 +08:00
DedSecerandCursor 4451a6df3b fix(forwarder): handle Cursor summarize actions
Map Cursor's protocol-level SummarizeAction to manual context compaction while keeping the legacy /compact directive disabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 19:32:42 +08:00
leokun 7b1c1d39af Update README.md 2026-07-28 22:33:49 +08:00
DedSecer 17f30a8852 fix(prompt): replay selected Cursor commands
Include client-resolved command content in model-visible user history so slash commands such as /init retain their actual instructions.
2026-07-28 10:46:07 +08:00
leookun 1f976b2a5e release: 0.0.42
- 修复grep或者read长时间阻塞问题 @liorxuan
2026-07-27 21:57:51 +08:00
leokun f3a7aece9d Merge pull request #221 from qqlzyhello/fix/bidi-append-seq-restart
同 request_id 下 append_seq 重启时不再误判 stale
2026-07-27 21:53:04 +08:00
lixingcheng 3a3be0c9e9 fix(forwarder): reset bidi append seq when Cursor reuses request_id
Cursor may reuse the same request_id for a later turn and restart
append_seqno from 1. Accept seq=1 after progress as a sequence restart
when idle so tool results are not discarded as stale forever.
2026-07-27 18:09:09 +08:00
leookun 9e05739969 docs: 拆分贡献指南为中英双语独立文件
CONTRIBUTING.md(中文)+ CONTRIBUTING_EN.md(英文),互相链接。
2026-07-27 01:02:21 +08:00
leookun 4bb0cbc1fc docs: 添加双语贡献指南和 PR 模板
Closes #161

- CONTRIBUTING.md:中英双语,涵盖开发环境、项目结构、代码规范、构建发布
- .github/PULL_REQUEST_TEMPLATE.md:双语 PR 描述模板与检查清单
2026-07-27 01:01:29 +08:00
leookun 0f6b0f9484 release: 0.0.41
- 修复内存泄漏问题,该可能导致内存异常占用
- 支持俄语增加翻译范围
- 修复qwen-3.8-max中断问题(mimo也应该属于同一类问题)
- 修复claude模型可能无法识别图片问题 @GGHansome
- 支持自定义Openai端点 @Sxuan-Coder
- WebSearch 接入百度搜索,DuckDuckGo 作为兜底 @杨超
- 修复一些兼容性问题 @kael-odin
- 修复window上一些表现问题 @philau2512

🔔 如何让AI自动拉模型配置? (以下为提示词,把地址和密钥换为你的)
我的模型配置在 ~/.cursor-local-assistant-v2/config.yaml,我的API地址是:https://xxx 密钥是xxx,帮我拉所有模型配置进去,不要影响已有模型,根据models标准接口拉取。
2026-07-27 00:55:02 +08:00
leookun e401303f64 fix: 修复502导致的内存泄漏问题 2026-07-27 00:15:23 +08:00
leokun 799dbda7e0 Merge pull request #178 from kael-odin/fix/mimo-thinking-disable
fix(openai): recognize MiMo in thinking disable
2026-07-16 10:39:50 +08:00
leokun f3cbf1e6c3 Merge pull request #179 from kael-odin/fix/anthropic-thinking-disable
fix(anthropic): apply thinking config on override path, symmetric with openai
2026-07-16 10:39:19 +08:00
leokun 2380a0d692 Merge pull request #187 from ProtectCookies/main
feat: WebSearch 接入百度搜索,DuckDuckGo 作为兜底
2026-07-16 10:36:01 +08:00
leokun 1da1faa4d4 Merge pull request #186 from GGHansome/main
尝试修复使用claude模型无法识别图片问题
2026-07-16 10:35:43 +08:00
leokun ce05b0bf7a Merge pull request #205 from philau2512/main
fix(ui): support window resizing and fully localize remaining Chinese texts to EN/JA
2026-07-16 10:35:14 +08:00
philau2512 44a47992d9 feat(build): add PowerShell and batch build scripts for convenient compilation 2026-07-15 17:53:45 +07:00
philau2512 319bd3d7d8 fix(i18n): extract new Chinese keys and implement dynamic localization for footer/updates/tray menu 2026-07-15 17:53:42 +07:00
philau2512 12c7973a06 fix(window): enable maximize button state to support native window resizing on Windows 2026-07-15 17:53:38 +07:00
leokun f93b18740d Merge pull request #189 from leookun/release/0.0.40
release: 0.0.40
2026-07-10 11:52:27 +08:00
leokun c6ee26b12d release: 0.0.40 2026-07-10 11:50:02 +08:00
leokun 7c1c6a134b Merge pull request #188 from leookun/feat/gpt-5.6
feat: gpt-5.6 support
2026-07-10 11:42:10 +08:00
leokun e0aa270328 feat: gpt-5.6 support 2026-07-10 11:38:45 +08:00
杨超 556ea21f38 feat: WebSearch 接入百度搜索,DuckDuckGo 作为兜底
百度搜索失败或无结果时自动回退到 DuckDuckGo,不引入 Bing。
2026-07-10 10:36:59 +08:00
GGHansome 315aba49c2 fix: claude model can not access image 2026-07-10 03:17:02 +08:00
leokun 6ab537ad4e Merge pull request #180 from kael-odin/docs/extra-params-tooltip-wording
docs(model-adapter): fix extra-params tooltip wording (merge→override)
2026-07-09 20:26:49 +08:00
kael-odin 6d7a4e32fe docs(model-adapter): fix extra-params tooltip wording (merge→override)
ModelAdapterModal.vue described OpenAI extra params as "合并到" (merge into)
the request body, but the backend (request_override.go:applyExtraParams)
writes body[name] = value — same-name fields are overridden, not merged.
This contradicted ModelEditor.vue's tooltip ("覆盖到...同名字段以这里为准")
and risked users expecting array fields like `tools` to be appended,
which would silently replace the built-in function tools.

Align the modal tooltip with the editor view and the actual override
semantics.
2026-07-08 09:30:37 +08:00
kael-odin 50ab63de3d fix(anthropic): apply thinking config on override path, symmetric with openai
AnthropicAdapter.Stream built thinking config (buildAnthropicThinkingConfig)
and wrote it into body only inside the `if len(body)==0` normal-construction
block. The RequestBodyOverride branch skipped it entirely — a disabled effort
on the override path left whatever thinking config the override body carried,
violating user intent and diverging from openai.go, where
applyOpenAIThinkingDisable runs unconditionally after both branches.

Also, on the normal path, disabled only wrote thinking:{type:disabled} but
left a stale output_config (set by a prior adaptive turn or by
AnthropicExtraParams) in place — an explicitly disabled request could still
carry output_config.effort=high, a contradictory payload.

Fix:
- Extract applyAnthropicThinkingConfig(body, req), called unconditionally
  after the override/normal block, mirroring openai.go:1860.
- disabled: force thinking:{type:"disabled"}, delete output_config, set
  thinking_disabled_provider_param=thinking.type knob.
- adaptive (AnthropicThinkingEffort non-empty): write
  thinking:{type:adaptive,display:summarized} + output_config.
- empty effort: no-op.
- buildAnthropicThinkingConfig retained for stable-message-count / message
  normalization signals inside the normal block.

Tests: anthropic_thinking_disable_test.go covers all three branches, alias
normalization (off→disabled), disabled overriding existing adaptive config,
and the override-path symmetry scenario.
2026-07-08 09:12:03 +08:00
kael-odin 802daa8fcd fix(openai): recognize MiMo in thinking disable
openAIThinkingDisableKind only matched dashscope/qwen/aliyun,
deepseek/bigmodel/z.ai/zhipu, and gpt-5.1+/gpt-6. Xiaomi MiMo
(api.xiaomimimo.com, model mimo-*) fell through to the default
branch, so when a user set reasoning=disabled the adapter wrote
no disable field and MiMo kept thinking enabled (extra latency +
reasoning token cost contrary to user intent).

Add xiaomimimo/mimo to the thinking_type case — MiMo uses the same
thinking:{type:"disabled"} shape as deepseek/glm, confirmed in
MiMo docs.

- openai.go: +2 conditions in openAIThinkingDisableKind
- openai_thinking_disable_test.go: cover MiMo (base/model/ultraspeed),
  regress deepseek/glm/z.ai/qwen/gpt-5.1/gpt-6/unknown, and verify
  applyOpenAIThinkingDisable writes thinking:{type:disabled} +
  deletes reasoning_effort for MiMo
2026-07-07 18:13:21 +08:00
leokun eb2c236699 Merge pull request #168 from Sxuan-Coder/main
feat(openai): 支持"自定义路径"endpoint 以兼容非标准 OpenAI 兼容服务
2026-07-06 10:38:28 +08:00
上玄 86a655fb5d refactor: remove endpoint tests and enhance URL handling
- Deleted outdated test files for OpenAI endpoint functionality.
- Improved the OpenAIEndpointURL function to better handle custom paths and version segments.
- Introduced a new helper function, stripEndpointVersionPrefix, to streamline endpoint processing.
2026-07-04 19:03:14 +08:00
上玄 c50e7d0606 feat: add support for custom OpenAI endpoint path
- Introduced a new endpoint option for OpenAI integrations, allowing users to specify a custom path.
- Updated relevant components and validation logic to accommodate the new endpoint.
- Enhanced documentation and error messages to reflect the addition of the custom endpoint option.
2026-07-04 18:51:41 +08:00
leokun 4ec7b4289f v0.0.39 2026-07-01 15:31:24 +08:00
leokun 6bebe37bfc Merge branches 'main' and 'main' of github.com:leookun/cursor-byok 2026-07-01 14:25:30 +08:00
leokun 90c3c35774 Merge pull request #160 from lixiangwuxian/main
feat: add support for multiple paths when using workspace mode
2026-06-30 20:08:51 +08:00
lixiangwuxian 64a0e09605 feat: add support for multiple paths when using workspace mode 2026-06-30 16:58:50 +08:00
leokun 47d336ac81 update readme 2026-06-30 10:49:26 +08:00
leokun 268e9fbde0 Add MIT License file 2026-06-30 10:46:26 +08:00
leokun 7d89b0af2a Remove obsolete build tasks from BUILD.MD 2026-06-30 10:43:29 +08:00
leokun c083be5ec2 v0.3.8 2026-06-30 10:38:52 +08:00
631 changed files with 191603 additions and 37 deletions
View File
@@ -0,0 +1,82 @@
---
name: cursor-prefix-stability
description: Implement and review Cursor BYOK conversation projection with append-only provider history and prefix-cache stability. Use when changing runtime prompts, request context, canonical messages, checkpoint hydration, compaction, message identity, or provider history serialization under server.
---
# Cursor prefix stability
Preserve the longest valid provider prefix across turns. Treat provider-visible history as an append-only log unless an explicit compaction operation replaces it.
## Architecture
Keep the invariant in the provider-independent conversation layers:
```text
server/
├── prompt/cursor/*/runtime.md Per-turn runtime content
├── src/cursor/request/ Request context and runtime compilation
├── src/cursor/projection/ Canonical ↔ Cursor checkpoint projection
├── src/cursor/checkpoint/ Stable roots, turns, and hydration
├── src/run/ Provider-independent model history
└── src/provider/ Provider-specific serialization only
```
Do not solve prefix instability independently in each provider adapter. Produce one stable canonical history before dispatching to OpenAI Responses, OpenAI Chat, Anthropic, or another provider.
## Required invariants
- When no compaction occurs, the complete provider-visible history from turn N must be an exact structural prefix of turn N+1. Never edit, remove, merge, reorder, renormalize, or regenerate an earlier message.
- Keep `PromptSpec.instructions` and the stable tool prefix byte-stable when their inputs have not changed. Deterministic ordering is required; do not use unordered iteration in provider-visible output.
- Separate conversation/request context from the per-turn runtime message. The runtime message contains current-turn material such as the user query, selected context, open files, action context, mode reminders, and timestamp.
- Project rules, skills, subagents, environment/Git context, and MCP metadata as a stable `request-context:*` message:
- append it on the first applicable turn;
- do not append it again when its compiled content is identical to the latest projected request context;
- when its content changes, append a new request-context message immediately before the current runtime message;
- never represent a context change by rewriting the system prompt, replacing an earlier context message, or mutating a checkpoint root.
- Give every appended context update a unique event identity. Compare the latest context by content, not only by identifier, so `A → B → A` appends the final `A` again while retries of the same event remain idempotent.
- Preserve `request-context:*` wire identity through checkpoint encoding and hydration. Deduplication must still work after process restart or conversation resume.
- Automatic compaction is an explicit prefix reset. Compact obsolete history, retain exactly the latest request-context message, then place the summary and current initial messages in deterministic order. Manual compaction may reproject current context on the next user turn.
- Background completions and injected runtime events must not manufacture duplicate request context unless they actually start a user turn whose context changed.
## Change workflow
Before editing, trace the whole path that applies:
```text
AgentRunRequest
→ request context hydration/compilation
→ CanonicalMessage identity and persistence
→ checkpoint encode/decode
→ projected ModelRequest history
→ provider serialization
```
Determine which data is conversation-level and which is turn-level. If a proposed change moves or rewrites an earlier provider-visible value, redesign it as a new append-only event unless the operation is explicitly compaction.
Use TDD for changes in this path. Start with a failing behavioral test, then implement the smallest provider-independent change.
## Verification
Cover the affected behavior with structural assertions, not token-count estimates alone:
- Two turns with identical request context: the first request history is an exact prefix of the second, the system instructions are identical, and only one `request-context:*` message exists.
- Changed context: one new context message appears at the tail before the new runtime query; all earlier messages remain unchanged.
- Context reversion `A → B → A`: three distinct context events are retained in order.
- Retry of one runtime event: no duplicate or conflicting context message is persisted.
- Checkpoint round-trip: request-context identity and content survive encode/hydrate.
- Automatic compaction: only the latest context is retained outside the summary.
- Runtime templates render without embedding conversation-level rules or MCP metadata in every user query.
Run focused tests first, then the relevant server suites:
```bash
cargo test --lib
cargo test --test runtime_modes
cargo test --test prefix_stability
cargo test --test checkpoint_recovery
cargo test --test compaction
cargo clippy --lib -- -D warnings
cargo fmt --all -- --check
```
Do not repair unrelated dirty-worktree failures while validating. Report them separately.
+84
View File
@@ -0,0 +1,84 @@
---
name: database-schema
description: Implement and review Cursor BYOK SQLite schema changes. Use when adding or changing tables, columns, indexes, constraints, foreign keys, SQLx migrations, persistence mappings, or database fixtures under server.
---
# Cursor BYOK Database Schema
Treat a schema change as an end-to-end persistence change, not as an isolated SQL edit. Keep the database, Rust store, API contracts, fixtures, and tests aligned.
## Architecture
Use the existing layers and keep responsibilities in their current directories:
```text
server/
├── migrations/ Ordered SQLite/SQLx migrations
├── src/store/ Queries, bindings, row decoding, transactions
├── src/ Domain and API types that consume persisted data
└── tests/ Migration, store, API, and integration coverage
```
Before editing, inspect the complete table definition, every query that reads or writes it, its Rust types, API projections, and relevant fixtures. Search by the table name and affected field names; do not infer the persistence path from one file.
## Migration rules
- `server/src/store/sqlite.rs` runs embedded SQLx migrations from `server/migrations`.
- Never modify, rename, reorder, or delete a migration that may already have been applied. SQLx records its checksum; changing an applied file causes startup failure with `migration ... was previously applied but has been modified`.
- Add the next numbered forward migration, using a descriptive filename such as `0003_add_request_protocol.sql`.
- A fresh database must reach the current schema by applying all migrations in order. Do not duplicate a new column or table in both the initial migration and a later migration.
- Only squash or rewrite migration history when the user explicitly asks for a full database reset and accepts that existing databases will no longer start. Do not infer that permission from a development-only workflow.
- Do not add compatibility views, triggers, shadow fields, or fallback reads. Migrate once, then make the application consume the new schema directly.
- Keep one coherent schema change together. Split unrelated changes into separate migrations.
## SQLite design
- Choose nullability from domain meaning. Use `NULL` for genuinely unknown historical data; use a default only when it is correct for every existing row.
- Store booleans as constrained integers, for example `INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1))`.
- Add `CHECK`, `UNIQUE`, and foreign-key constraints when they express real invariants. Choose `ON DELETE` behavior deliberately.
- Add an index only for a demonstrated lookup, join, ordering, or uniqueness requirement. Match its leading columns to actual query shapes.
- Use a transaction for changes that must update multiple tables atomically.
- SQLite supports only limited `ALTER TABLE`. For an unsupported constraint, type, or destructive column change, create the replacement table with the final schema, copy and transform data, replace the old table, and recreate required indexes and foreign keys in one migration.
- Preserve timestamps, identifiers, and existing semantic values during table rebuilds. Do not silently manufacture domain data.
## Application changes
Trace every changed field through the full path that applies:
1. Migration SQL and constraints.
2. Rust domain/request/response structs.
3. SQL column lists, placeholders, `.bind(...)` order, row decoding, and update statements.
4. Transactions and repository/store methods.
5. API serialization and frontend TypeScript types when the field is exposed.
6. UI creation, editing, listing, and details when requested by the product behavior.
7. Test fixtures, literal struct initializers, snapshots, and mock rows.
List SQL columns explicitly. Keep selected-column order, row decoding, insert columns, and bind order visibly aligned. Avoid `SELECT *` because schema additions can silently invalidate positional decoding assumptions.
When a value records the effective behavior of a call, persist the value actually consumed at execution time rather than merely the model or provider default. Keep absent, defaulted, and explicitly supplied values distinguishable when that distinction matters.
## Verification
Add focused coverage proportional to the change:
- A fresh database applies every migration.
- A database at the previous migration upgrades successfully and preserves existing rows.
- Store create/read/update paths round-trip the new fields.
- Defaults, nullability, uniqueness, checks, and foreign keys behave as designed.
- Multi-table writes roll back atomically on failure.
- API and frontend types expose the same semantics when applicable.
Run the narrow tests first, then the repository checks affected by the change. At minimum for server schema work, run:
```bash
cargo fmt --all -- --check
cargo test --workspace
```
If frontend contracts changed, also run from `apps/desktop`:
```bash
npm run check
```
Do not repair unrelated dirty-worktree changes while validating. Report any pre-existing failure separately from failures caused by the schema change.
+24
View File
@@ -0,0 +1,24 @@
---
name: floating-ui
description: Implement or review floating desktop UI such as dropdowns, popovers, tooltips, menus, comboboxes, and nested overlays under apps/desktop.
---
# Floating UI
Use `@floating-ui/dom` for every interactive element positioned relative to a trigger. Do not calculate coordinates manually or rely on CSS absolute offsets for dropdowns, popovers, menus, combobox lists, or tooltips.
## Required structure
- Render overlays through `createPortal(..., document.body)` so layout and stacking contexts do not clip them.
- Position with `computePosition` inside `autoUpdate`; clean up the function returned by `autoUpdate` when the overlay closes or unmounts.
- Start from the closest existing control in `apps/desktop/src/components/ui`. Match its `placement`, `offset`, `flip`, `shift`, and `size` middleware unless the interaction requires a deliberate difference.
- Store computed `left`, `top`, width, and available height in React state. Apply the state to the portal root; do not mutate element styles directly.
- Use `size` when reference width or viewport height constrains the overlay. Lists that can grow must use `components/virtual/VirtualList.tsx`; keep fixed headers and footers outside the virtual viewport.
## Interaction invariants
- Keep the trigger's open/focus border visible while focus is inside a portaled overlay.
- Close on outside pointer interaction and Escape, then return focus to the trigger.
- Outside-click checks must include both trigger and overlay. A nested portaled overlay must stop its pointer event from reaching the parent's outside-click listener, so interacting with a child menu never closes its parent.
- Expose trigger state with `aria-expanded`, `aria-controls`, and the appropriate `aria-haspopup`; give the overlay the matching menu, listbox, dialog, or tooltip semantics.
- Verify placement near all viewport edges, nested-overlay clicks, keyboard dismissal, virtual scrolling, and fixed footer behavior.
+90
View File
@@ -0,0 +1,90 @@
---
name: frontend
description: Implement and review the Cursor BYOK React/Tauri desktop frontend. Use for changes under apps/desktop involving routing, page layout, window chrome, settings UI, scrolling, virtualization, charts, themes, or frontend component architecture.
---
# Cursor BYOK Frontend
Build on the components and theme system already present in `apps/desktop`. Preserve the Tauri HTTP boundary; frontend management features call `/__byok-api__/api` and never add IPC business APIs.
## Desktop HTTP communication
- Treat the Rust management server as the single HTTP origin for the desktop UI. The main WebView and browser-opened detail pages must use `http://127.0.0.1:<dynamic-port>/__byok-api__/`; do not load the main UI from `tauri://localhost` or expose a second frontend origin.
- Make every frontend management request relative to `/__byok-api__/api`. Do not discover, inject, persist, or pass an `apiOrigin`, and do not construct management URLs from a fixed port.
- Keep the `/__byok-api__/` namespace reserved for this boundary:
- `/__byok-api__/api/*` is handled locally by the Rust management API.
- Other `/__byok-api__/*` paths are frontend documents, assets, modules, and development resources.
- In production, serve the frontend embedded by Tauri's `frontendDist` through the Rust server. Reuse Tauri's asset resolver rather than bundling or copying a second set of frontend resources.
- In development, let the Rust server reverse-proxy non-API `/__byok-api__/*` requests to Vite. Preserve the request path and query string. Do not configure Vite to proxy management API requests back to Rust.
- Configure Vite's base path as `/__byok-api__/` so generated assets, module imports, and development client URLs stay under the reserved namespace.
- Bind the development Vite server to an explicit loopback address compatible with the Rust proxy target; do not rely on `localhost` resolving to the same IP family.
- Open external detail pages with their normal loopback HTTP URL and hash route. Because the page and API are same-origin, do not add Tauri URL workarounds, API-origin query parameters, or CORS-dependent browser flows.
- Use Tauri commands only for native desktop capabilities such as opening Terminal, tray integration, or operating-system actions. Do not create IPC mirrors for HTTP business APIs.
## Component boundaries
- Keep Provider, Model, Call, Usage, and persisted settings state outside presentation components.
- Let components receive business snapshots and dispatch actions. Local React state is allowed for transient UI behavior such as focus, open state, drag state, and input drafts.
- Split pages, layouts, charts, forms, and reusable controls into focused components. Do not collect an entire feature set in `App.tsx`.
- Keep `App.tsx` as the route composition root.
## Page layout
- Build every full-height page with `components/layout/PageLayout.tsx`; do not hand-roll page shells with grid rows.
- Put page-level commands and controls such as create, import, export, filtering, sorting, and view options in the shared page action region through `PageActions`. Keep only operations that target one concrete list item, such as edit and delete, inside that item's row. Do not add duplicate action bars or panel-header controls inside page content.
- Never render a title region above a data table or flat data list. Use the page title for context, keep column labels in the table header, place page-level controls in `PageActions`, and wrap the table in a titleless `Card`. A hierarchy label that identifies parent data in a grouped parent-child view is not a table title.
- Keep the main scroll viewport full-height and `position: relative`; place the page title and action region absolutely over it.
- Reserve the absolute title/action region with `padding-top` on the scroll content, never by shortening or offsetting the scroll viewport itself.
- Keep the menu Card fixed and reserve it with the content area's `margin-left`; do not place Header, menu, and content in a flow grid.
- Do not use child or section padding to create external spacing. Use flex/grid gaps between siblings and margins or fixed offsets at container boundaries; the scroll-content top padding is reserved only for its absolute title/action overlay.
- Use a vertical flex layout with these invariants:
- Header: `flex: 0 0 auto`.
- Footer: `flex: 0 0 auto`.
- Content: `flex: 1 1 auto`, `min-height: 0`, and `overflow: hidden`.
- Keep Header and Footer outside the scroll viewport. They must never grow or shrink with content.
- Apply macOS, Windows, and Linux window-corner safe-area variables only to edge chrome that can enter native rounded corners.
- Keep macOS traffic-light space and the top fixed drag strip free of interactive controls.
- Avoid decorative brand text and explanatory copy when it does not help the user perform an action or interpret state.
## Typography
- Write component styles in SCSS and source every `font-size` from `src/styles/_typography.scss`; never hardcode a font size in SCSS, React styles, or chart options.
- Use `base` for normal UI text and ordinary titles, including title bars, panels, cards, and section headings. Do not increase a font merely because its element is `h1` or `h2`.
- Use `xs` only for secondary information. Reserve `lg` for an explicitly oversized display title; do not use it for routine headings.
- Audit all typography-token usages after changing the scale, not only raw numeric font sizes.
## Scrolling and virtualization
- Use `components/layout/VirtualPage.tsx` for vertically scrolling page content.
- Use `components/virtual/VirtualList.tsx` for data collections and menus that can grow.
- Treat `ScrollArea` as the low-level primitive owned by the virtual scrolling implementation. Do not import `ScrollArea` directly in pages or layouts.
- Use the global `scroll-shadow-top` and `scroll-shadow-bottom` masks for scroll-edge shadows. When a scroll content inset reserves an absolute overlay, source the top shadow distance from the same CSS variable as its `padding-top`.
- Model non-list pages as a short sequence of measurable top-level virtual sections.
- Do not use document scrolling, native page `overflow: auto`, or nested vertical scroll containers.
- Ensure every flex/grid ancestor of a virtual viewport has `min-height: 0` and the viewport has an explicit bounded height.
## Routing and pages
- Use `HashRouter` for Tauri compatibility.
- Keep `/` as the statistics dashboard and expose every primary page through the persistent menu in the shared `AppLayout`.
- Use top-level routes for primary pages: `/`, `/providers`, `/models`, `/calls`, and `/settings`.
- Do not create separate Home and Settings shells or add a Home-switching control.
- Keep route content independent of the window Header and Footer.
## Charts
- Use ECharts through `components/charts/EChart.tsx`.
- Register only required ECharts Core charts, components, and renderers.
- Keep chart components declarative: accept domain data and construct an option without fetching or persisting data.
- Let `ResizeObserver` resize the chart with its layout container and dispose the instance on unmount.
## Validation
Run from `apps/desktop`:
```bash
npm run check
npm run tauri:build -- --debug --no-bundle
```
Verify that Header and Footer remain fixed, only the virtual content viewport scrolls, settings navigation remains reachable at minimum window size, and macOS controls stay outside rounded-corner and traffic-light unsafe regions.
@@ -0,0 +1,4 @@
interface:
display_name: "Cursor BYOK Frontend"
short_description: "Build consistent React desktop layouts and virtualized pages"
default_prompt: "Use $frontend to implement or review the Cursor BYOK React desktop interface."
+48
View File
@@ -0,0 +1,48 @@
---
name: i18n
description: Implement and review Cursor BYOK desktop localization, including t() usage, locale catalogs, system-language selection, translation completeness, and language settings under apps/desktop.
---
# Desktop i18n
Keep localization organized as:
```text
apps/desktop/
├── plugins/static-i18n-plugin.ts
└── src/i18n/
├── I18nRoot.tsx
├── runtime.ts
├── store.ts
├── generated/catalog.json
└── locales/
├── zh-CN.json
└── en-US.json
```
## Author messages
- Use the global `t()` function with a static Simplified Chinese string literal: `t("保存")`.
- Do not import or locally declare `t`.
- Keep calls inside render paths when the result must update after a language change. Do not translate module-level UI constants.
- Use named placeholders with a static object: `t("第 {page} 页", { page })`.
- Preserve every placeholder name exactly in all translations.
- Wrap all user-visible labels, descriptions, tooltips, empty states, validation messages, and accessibility labels. Do not wrap protocol tokens, model IDs, URLs, or product names that should remain unchanged.
## Locale behavior
- `system` is the default preference.
- Resolve supported operating-system languages to their locale and fall back to `en-US` for every unsupported language.
- Persist only explicit locale selections. Removing the preference restores system-language behavior.
- Apply changes immediately and update the document `lang` attribute.
## Update catalogs
From `apps/desktop`:
1. Run `npm run i18n:scan` after adding or changing `t()` sources.
2. Translate every empty entry in `src/i18n/locales/en-US.json`.
3. Never edit `src/i18n/generated/catalog.json` or `zh-CN.json` manually; scanning owns them.
4. Run `npm run check`. A normal build must fail on a missing translation or mismatched placeholder.
When adding another locale, add it to the plugin's supported locales, runtime locale type and messages, locale resolver, settings options, and provide a complete locale JSON file.
+77
View File
@@ -0,0 +1,77 @@
---
name: release
description: Prepare, authorize, publish, troubleshoot, and verify Cursor BYOK desktop GitHub Releases. Use for version bumps, release tags, GitHub Actions release runs, updater manifests, signing, or release-readiness checks.
---
# Desktop release
Release through `.github/workflows/release.yml`. Preserve both updater formats: Tauri uses `latest.json`; legacy `v0.0.49` clients use `update.json`.
## Publication authority
- Only the repository author, GitHub user `leookun`, may authorize a live release.
- Before any live mutation, require an explicit release instruction from the author in the current task and verify `gh api user --jq .login` returns `leookun`.
- Treat all of these as publication actions: pushing a `v*` tag, rerunning the release workflow, and publishing or editing a GitHub Release. Pushing a release commit to `main` only prepares the release and must never trigger publication by itself.
- Without that authorization, restrict work to inspection, local edits, validation, and a release-ready commit or branch. Do not infer publication permission from requests such as “prepare”, “check”, or “ready to release”.
- Never print, commit, or upload `.tauri/cursor-byok.key` anywhere except the repository's `TAURI_SIGNING_PRIVATE_KEY` Actions Secret when the author explicitly requests that secret configuration.
- Never delete, replace, or move an existing tag or published Release without separate explicit authorization.
## Version and GitHub Release policy
- Do not use GitHub prereleases. Keep `prerelease: false` for every release and publish the completed release as Latest.
- Use `vMAJOR.MINOR.PATCH` for a stable tag, for example `v0.1.0`.
- Use standard SemVer `vMAJOR.MINOR.PATCH-beta.N` for a test tag, for example `v0.1.0-beta.1`. A beta is still a normal GitHub Release, not a GitHub prerelease. Make its title or body visibly say Beta.
- This normal-Release rule is required because both installed update clients resolve assets through GitHub's `/releases/latest/download/` path, which excludes GitHub prereleases.
- Windows beta builds must use the NSIS bundle. WiX/MSI rejects nonnumeric prerelease identifiers such as `beta.1`; do not weaken the SemVer tag to accommodate MSI.
- Release only from a `v*` tag whose commit is contained in `origin/main`. The tag must equal `v<version>` from the desktop manifests.
- Keep ordinary `main` pushes and manual workflow dispatch disabled as release triggers. The author pushes the matching tag only after the release commit is present on `origin/main`.
- Never republish an already published version. Select a new version instead.
## Release sources
Keep the desktop version identical in the manifests and their locks:
```text
cursor-byok/
├── Cargo.lock
├── apps/desktop/
│ ├── package.json
│ ├── package-lock.json
│ └── src-tauri/
│ ├── Cargo.toml
│ └── tauri.conf.json
├── scripts/cursor-proto/proto/
│ ├── agent_v1.proto
│ └── aiserver_v1.proto
└── .github/workflows/release.yml
```
The two listed Proto files are required build inputs and must be committed. Keep the other locally extracted Proto files ignored unless the build starts depending on them.
## Prepare and validate
1. Inspect `git status`, fetch `origin/main`, and preserve unrelated user changes. Confirm the release commit is based on the current remote head.
2. Choose stable or beta numbering explicitly. Update the desktop version in both manifests and lockfiles; do not change the independent `cursor-server` version merely to release the desktop app.
3. Confirm the updater public key in `tauri.conf.json` matches `.tauri/cursor-byok.key.pub` without exposing the private key.
4. Confirm `TAURI_SIGNING_PRIVATE_KEY` exists in GitHub Actions. `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` must be absent when the local key has no password.
5. Confirm neither the intended tag nor Release already exists.
6. From `apps/desktop`, run:
```bash
npm run check
npm run tauri:build -- --debug --no-bundle
```
7. Validate the workflow YAML and inspect the staged diff. Ensure `.tauri/`, unrelated local files, and unrelated user changes are not staged.
8. Use the `tauri-action@v1` input `uploadUpdaterJson: true`; `includeUpdaterJson` is not a valid v1 input.
## Publish and verify
After the author explicitly authorizes publication:
1. Commit only the reviewed release set and push it to `main`. Confirm the release commit is present in `origin/main`; this push must not start the release workflow.
2. Create the matching tag on that commit, for example `v0.1.0-beta.1`, and push only that tag. This tag push is the publication trigger.
3. Follow the triggered `Release desktop app` run through completion. Report the run URL and stop on failure; diagnose locally before asking the author to authorize another live attempt.
4. Verify `v<version>` exists, is published rather than draft, has `prerelease: false`, and is the repository's Latest release.
5. Verify the Release contains signed Tauri updater artifacts plus `latest.json`, and the legacy platform archives plus `update.json`.
6. For a beta, report clearly that it is a test version even though GitHub represents it as a normal Latest Release.
@@ -0,0 +1,4 @@
interface:
display_name: "Desktop Release"
short_description: "Prepare, authorize, publish, and verify desktop releases"
default_prompt: "Use $release to prepare and verify a Cursor BYOK desktop release."
+13
View File
@@ -0,0 +1,13 @@
.git
.DS_Store
**/.DS_Store
**/node_modules
**/target
**/dist
server/*.db
server/*.db-shm
server/*.db-wal
docs
logs
*.tar
*.zip
@@ -0,0 +1,77 @@
import { createHash } from "node:crypto";
import { stat, readFile, writeFile } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
const assetSpecs = [
["macos-arm64", ".tar.gz"],
["macos-amd64", ".tar.gz"],
["windows-amd64", ".zip"],
["linux-amd64", ".tar.gz"],
];
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;
}
async function sha256(path) {
const content = await readFile(path);
return createHash("sha256").update(content).digest("hex");
}
async function main() {
const options = readOptions(process.argv.slice(2));
const version = required(options, "version").replace(/^v/, "");
const repository = required(options, "repository");
const assetsDir = resolve(required(options, "assets-dir"));
const output = resolve(required(options, "output"));
const releaseNotes = options.get("notes")?.trim() || `Cursor BYOK v${version}`;
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`invalid semantic version: ${version}`);
}
if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) {
throw new Error(`invalid GitHub repository: ${repository}`);
}
const platforms = {};
for (const [platform, suffix] of assetSpecs) {
const filename = `cursor-byok-${version}-${platform}${suffix}`;
const path = join(assetsDir, filename);
const info = await stat(path);
if (!info.isFile()) throw new Error(`release asset is not a file: ${path}`);
platforms[platform] = {
url: `https://github.com/${repository}/releases/download/v${version}/${basename(path)}`,
size: info.size,
checksum: `sha256:${await sha256(path)}`,
};
}
const manifest = {
version,
release_date: new Date().toISOString(),
release_notes: releaseNotes,
platforms,
mandatory: false,
};
await writeFile(output, `${JSON.stringify(manifest, null, 2)}\n`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
@@ -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/);
});
+101
View File
@@ -0,0 +1,101 @@
import { readFile, writeFile } from "node:fs/promises";
import { 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 normalizeTauriUpdate(manifest, release, repository, version) {
if (manifest.version !== version) {
throw new Error(
`updater manifest version ${manifest.version ?? "is missing"}; expected ${version}`,
);
}
if (release.tag_name !== `v${version}`) {
throw new Error(
`GitHub release tag ${release.tag_name ?? "is missing"}; expected v${version}`,
);
}
if (!manifest.platforms || typeof manifest.platforms !== "object") {
throw new Error("updater manifest has no platforms");
}
const assetsBySourceUrl = new Map();
const publicAssetUrls = new Set();
for (const asset of release.assets ?? []) {
if (!asset?.id || !asset?.name) continue;
const publicUrl = `https://github.com/${repository}/releases/download/v${version}/${encodeURIComponent(asset.name)}`;
assetsBySourceUrl.set(
`https://api.github.com/repos/${repository}/releases/assets/${asset.id}`,
publicUrl,
);
if (asset.browser_download_url) {
assetsBySourceUrl.set(asset.browser_download_url, publicUrl);
}
publicAssetUrls.add(publicUrl);
}
for (const [platform, entry] of Object.entries(manifest.platforms)) {
if (!entry?.signature || !entry?.url) {
throw new Error(`updater platform ${platform} is missing its URL or signature`);
}
const publicUrl = assetsBySourceUrl.get(entry.url) ?? entry.url;
if (!publicAssetUrls.has(publicUrl)) {
throw new Error(`updater platform ${platform} references an unknown release asset`);
}
entry.url = publicUrl;
}
return manifest;
}
async function main() {
const options = readOptions(process.argv.slice(2));
const manifestPath = resolve(required(options, "manifest"));
const releasePath = resolve(required(options, "release"));
const repository = required(options, "repository");
const version = required(options, "version").replace(/^v/, "");
if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) {
throw new Error(`invalid GitHub repository: ${repository}`);
}
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`invalid semantic version: ${version}`);
}
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
const release = JSON.parse(await readFile(releasePath, "utf8"));
const normalized = normalizeTauriUpdate(
manifest,
release,
repository,
version,
);
await writeFile(manifestPath, `${JSON.stringify(normalized, 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;
});
}
+283
View File
@@ -0,0 +1,283 @@
name: Release desktop app
on:
push:
tags:
- "v*"
permissions:
contents: write
concurrency:
group: desktop-release
cancel-in-progress: false
jobs:
prepare:
name: Prepare release
runs-on: ubuntu-latest
outputs:
should_publish: ${{ steps.release.outputs.should_publish }}
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Verify release author
env:
REPOSITORY_OWNER: ${{ github.repository_owner }}
shell: bash
run: |
if [[ "${GITHUB_ACTOR}" != "${REPOSITORY_OWNER}" ]]; then
echo "Only ${REPOSITORY_OWNER} may publish a release" >&2
exit 1
fi
- name: Verify updater signing key
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
shell: bash
run: |
if [[ -z "${TAURI_SIGNING_PRIVATE_KEY}" ]]; then
echo "TAURI_SIGNING_PRIVATE_KEY is not configured" >&2
exit 1
fi
- name: Read and verify app version
id: version
shell: bash
run: |
version=$(node -p "require('./apps/desktop/src-tauri/tauri.conf.json').version")
package_version=$(node -p "require('./apps/desktop/package.json').version")
cargo_version=$(sed -n '/^version = / { s/version = "\([^"]*\)"/\1/p; q; }' apps/desktop/src-tauri/Cargo.toml)
test "${version}" = "${package_version}"
test "${version}" = "${cargo_version}"
test "${GITHUB_REF_TYPE}" = "tag"
test "${GITHUB_REF_NAME}" = "v${version}"
git fetch origin main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then
echo "Release tag must point to a commit contained in origin/main" >&2
exit 1
fi
echo "version=${version}" >> "${GITHUB_OUTPUT}"
- name: Check whether this version is already published
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
shell: bash
run: |
state=$(gh release view "v${VERSION}" --json isDraft --jq 'if .isDraft then "draft" else "published" end' 2>/dev/null || true)
if [[ "${state}" = "published" ]]; then
echo "Version ${VERSION} is already published; nothing to do."
echo "should_publish=false" >> "${GITHUB_OUTPUT}"
else
echo "should_publish=true" >> "${GITHUB_OUTPUT}"
fi
publish:
name: Publish (${{ matrix.platform }})
needs: prepare
if: needs.prepare.outputs.should_publish == 'true'
strategy:
fail-fast: false
matrix:
include:
- platform: linux-x86_64
os: ubuntu-22.04
args: ""
target: ""
- platform: windows-x86_64
os: windows-latest
args: "--bundles nsis"
target: ""
- platform: macos-aarch64
os: macos-15
args: "--target aarch64-apple-darwin"
target: aarch64-apple-darwin
- platform: macos-x86_64
os: macos-15-intel
args: "--target x86_64-apple-darwin"
target: x86_64-apple-darwin
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install Linux system dependencies
if: matrix.platform == 'linux-x86_64'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- uses: dtolnay/rust-toolchain@stable
- name: Install Rust target
if: matrix.target != ''
run: rustup target add ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: apps/desktop/package-lock.json
- name: Install frontend dependencies
working-directory: apps/desktop
run: npm ci
- uses: tauri-apps/tauri-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ contains(matrix.platform, 'macos') && '-' || '' }}
with:
projectPath: apps/desktop
tagName: v__VERSION__
releaseName: Cursor BYOK v__VERSION__
releaseBody: ${{ contains(needs.prepare.outputs.version, '-') && 'Beta release. Download the installer for your platform from the assets below.' || 'Download the installer for your platform from the assets below.' }}
releaseDraft: true
prerelease: false
uploadUpdaterJson: true
args: ${{ matrix.args }}
- name: Package legacy Linux updater asset
if: matrix.platform == 'linux-x86_64'
shell: bash
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
mkdir -p legacy-update
tar -czf "legacy-update/cursor-byok-${VERSION}-linux-amd64.tar.gz" -C target/release cursor-byok-desktop
- 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
$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')
shell: bash
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
mkdir -p legacy-update
archive=$(find "target/${{ matrix.target }}/release/bundle/macos" -maxdepth 1 -name '*.app.tar.gz' -print -quit)
test -n "${archive}"
legacy_platform=macos-arm64
if [[ "${{ matrix.platform }}" = "macos-x86_64" ]]; then
legacy_platform=macos-amd64
fi
cp "${archive}" "legacy-update/cursor-byok-${VERSION}-${legacy_platform}.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: legacy-update-${{ matrix.platform }}
path: legacy-update/*
if-no-files-found: error
finalize:
name: Publish GitHub Release
needs: [prepare, publish]
if: needs.prepare.outputs.should_publish == 'true' && needs.publish.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
pattern: legacy-update-*
path: legacy-update
merge-multiple: true
- name: Generate legacy update manifest
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
node .github/scripts/generate-legacy-update.mjs \
--version "${VERSION}" \
--repository "${GITHUB_REPOSITORY}" \
--assets-dir legacy-update \
--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 }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
mkdir -p tauri-update
release_id=$(
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq ".[] | select(.tag_name == \"v${VERSION}\") | .id"
)
test -n "${release_id}"
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > tauri-update/release.json
asset_id=$(node -p 'require("./tauri-update/release.json").assets.find(({ name }) => name === "latest.json")?.id ?? ""')
test -n "${asset_id}"
gh api \
-H "Accept: application/octet-stream" \
"repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" \
> tauri-update/latest.json
node .github/scripts/normalize-tauri-update.mjs \
--manifest tauri-update/latest.json \
--release tauri-update/release.json \
--repository "${GITHUB_REPOSITORY}" \
--version "${VERSION}"
- name: Upload normalized Tauri updater manifest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: gh release upload "v${VERSION}" tauri-update/latest.json --clobber
- name: Upload legacy updater assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: gh release upload "v${VERSION}" legacy-update/* --clobber
- name: Publish the completed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: gh release edit "v${VERSION}" --draft=false --latest
+35
View File
@@ -0,0 +1,35 @@
claude-server.tar
.DS_Store
dist
.task
/local-docs/
/bin/
logs/
cursor-server.db
cursor-server.db-shm
cursor-server.db-wal
gen/
logs.zip
frontend/bindings
dist
node_modules
*.tsbuildinfo
cursor-server.tar
/target/
/server/target/
/apps/desktop/src-tauri/gen/
/.tauri/
/server/*.db
/server/*.db-shm
/server/*.db-wal
server-node/cursor.tar
server-go/cursor.tar
server-go/log/
.cursor-local-assistant
.cursor-local-assistant-v2
.cursor-app-formatted/
proto/extensions-cursor-app/
cursor-tab-server/cursor-tab-server-linux-amd64.tar
/scripts/cursor-proto/proto/*
!/scripts/cursor-proto/proto/agent_v1.proto
!/scripts/cursor-proto/proto/aiserver_v1.proto
+25
View File
@@ -0,0 +1,25 @@
# AGENTS.md
- **The directory structure is the architecture.** Simple, clear directory and module naming >= module dependency relationships > concrete implementation details; communicate with the user using directory trees.
- Do not preserve backward compatibility. Remove obsolete paths instead of adding compatibility layers, fallbacks, or migrations.
- Choose the simplest implementation that fully meets the current requirements. Avoid speculative abstractions, configuration, and indirection.
- Grow the system in layers. Start from the smallest version that works end to end, and add each new capability on top of a product that already works. Never trade a working product for unfinished complexity.
- Keep components modular and concerns clearly separated.
- Prefer established, well-maintained libraries when they reduce overall complexity or improve reliability. Do not reimplement common functionality without a clear reason.
- Lean on the dependencies already in the project before writing your own implementation or adding packages. Do not assume a library lacks a capability without checking its documentation and types.
- Make architectural decisions for the long term. Do not accept a stopgap that only works for now and is meant to be replaced later.
## Communication
- Lead with the conclusion. Keep responses direct and omit filler, repeated context, generic explanations, and narration of obvious steps.
- Match the level requested by the user: discuss architecture as architecture, behavior as behavior, and code details only when they materially support the answer.
- For architecture, refactoring, and module-boundary discussions, communicate primarily with annotated directory trees and ASCII architecture/data-flow diagrams instead of long prose.
- In directory trees, annotate every relevant directory and file with its single responsibility. When discussing code size, include line counts in the comments:
- use measured line counts for existing code;
- use clearly marked approximate targets such as `≈300 lines` for proposed code;
- state whether tests, generated code, and blank lines are excluded.
- Show the complete main execution path, including inputs, ownership boundaries, runtime loops, persistence, outputs, and extension points. Make the direction of data and control flow explicit.
- Clearly separate the current structure from the proposed structure. Explicitly list modules that move, merge, split, or are deleted.
- Use the user's vocabulary consistently. Do not introduce new domain terms when existing plain-language terms are sufficient; when an implementation name must be mentioned, distinguish it from the architecture concept.
- For stateful or concurrent behavior, show timing, ownership, state boundaries, and before/during/after behavior explicitly. Distinguish continuation within the same lifecycle from creation of a new lifecycle.
- Prefer compact trees, flow diagrams, state matrices, and mappings when they communicate the structure more clearly than paragraphs.
- Tie recommendations to the actual repository structure and code. Do not present a speculative target architecture as if it already exists.
Generated
+9201
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[workspace]
members = [
"server",
"apps/desktop/src-tauri",
"crates/semble-core",
"support/benchmarks/semble",
]
resolver = "2"
# Keep local Rust builds compact. Full dependency debug info and incremental
# object caches dominate target/ in this workspace, while line tables for our
# own crates are enough for normal backtraces and source-level debugging.
[profile.dev]
debug = 1
incremental = false
[profile.dev.package."*"]
debug = 0
[profile.test]
debug = 1
incremental = false
[profile.test.package."*"]
debug = 0
+51
View File
@@ -0,0 +1,51 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS web
WORKDIR /src/apps/desktop
COPY apps/desktop/package.json apps/desktop/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY apps/desktop/index.html apps/desktop/tsconfig.json apps/desktop/tsconfig.node.json apps/desktop/vite.config.ts ./
COPY apps/desktop/plugins/ plugins/
COPY apps/desktop/public/ public/
COPY apps/desktop/src/ src/
RUN npm run build
FROM rust:1-bookworm AS server
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
COPY server/ server/
COPY apps/desktop/src-tauri/Cargo.toml apps/desktop/src-tauri/Cargo.toml
COPY apps/desktop/src-tauri/build.rs apps/desktop/src-tauri/build.rs
COPY apps/desktop/src-tauri/src/ apps/desktop/src-tauri/src/
COPY apps/desktop/src-tauri/capabilities/ apps/desktop/src-tauri/capabilities/
COPY apps/desktop/src-tauri/icons/ apps/desktop/src-tauri/icons/
COPY apps/desktop/src-tauri/tauri.conf.json apps/desktop/src-tauri/tauri.conf.json
COPY protocols/cursor/ protocols/cursor/
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/src/target \
cargo build --release --locked --package cursor-server --bin cursor-server && \
cp target/release/cursor-server /tmp/cursor-server
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install --yes --no-install-recommends ca-certificates curl && \
rm -rf /var/lib/apt/lists/* && \
useradd --system --uid 10001 --home-dir /nonexistent --shell /usr/sbin/nologin cursor-byok && \
mkdir -p /app/console /data && \
chown cursor-byok:cursor-byok /data
COPY --from=server /tmp/cursor-server /usr/local/bin/cursor-server
COPY --from=web /src/apps/desktop/dist/ /app/console/
ENV CURSOR_LISTEN_ADDR=0.0.0.0:3000 \
CURSOR_DATABASE_URL=sqlite:///data/cursor-server.db \
CURSOR_CONSOLE_DIR=/app/console \
RUST_LOG=cursor_server=info
USER cursor-byok
EXPOSE 3000
VOLUME ["/data"]
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
CMD curl --fail --silent http://127.0.0.1:3000/__byok-api__/healthz || exit 1
ENTRYPOINT ["/usr/local/bin/cursor-server"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 leookun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+50
View File
@@ -0,0 +1,50 @@
LOCAL_TAURI_SIGNING_KEY := $(CURDIR)/.tauri/cursor-byok.local.key
.PHONY: check dev-web dev-docs dev-server dev-desktop build-web build-docs build-server build-desktop build-docker
check:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --all-targets
npm --prefix apps/desktop run check
npm --prefix apps/docs run check
dev-web:
npm --prefix apps/desktop run dev:web
dev-docs:
npm --prefix apps/docs run dev
dev-server:
CURSOR_CONSOLE_DIR=apps/desktop/dist cargo run --package cursor-server --bin cursor-server
dev-desktop:
npm --prefix apps/desktop run tauri:dev
build-web:
npm --prefix apps/desktop run build
build-docs:
npm --prefix apps/docs run build
build-server:
cargo build --release --package cursor-server --bin cursor-server
ifeq ($(OS),Windows_NT)
$(LOCAL_TAURI_SIGNING_KEY):
@powershell -NoProfile -Command "New-Item -ItemType Directory -Force -Path '$(dir $@)' | Out-Null; & '$(CURDIR)/apps/desktop/node_modules/.bin/tauri.cmd' signer generate --ci --write-keys '$@'"
build-desktop: $(LOCAL_TAURI_SIGNING_KEY)
@node -e "const { spawnSync } = require('node:child_process'); const result = spawnSync(process.execPath, ['node_modules/@tauri-apps/cli/tauri.js', 'build', '--bundles', 'nsis'], { cwd: 'apps/desktop', stdio: 'inherit', env: { ...process.env, TAURI_SIGNING_PRIVATE_KEY: process.argv[1], TAURI_SIGNING_PRIVATE_KEY_PASSWORD: '' } }); process.exit(result.status ?? 1)" "$(LOCAL_TAURI_SIGNING_KEY)"
else
$(LOCAL_TAURI_SIGNING_KEY):
@install -d -m 700 "$(dir $@)"
@apps/desktop/node_modules/.bin/tauri signer generate --ci --write-keys "$@" >/dev/null
@chmod 600 "$@" "$@.pub"
build-desktop: $(LOCAL_TAURI_SIGNING_KEY)
TAURI_SIGNING_PRIVATE_KEY="$(LOCAL_TAURI_SIGNING_KEY)" TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" npm --prefix apps/desktop run tauri:build
endif
build-docker:
docker build --tag cursor-byok:local .
+236
View File
@@ -0,0 +1,236 @@
<div align="center">
# cursor-byok
cursor-byok 是一个运行在本机的 Cursor 模型网关,帮助你在 Cursor 中使用自己配置的模型服务。
[English README](./README.md) · [使用指南](https://docs.leokun.cn) · [下载](https://github.com/leookun/cursor-byok/releases/latest) · [提交问题](https://github.com/leookun/cursor-byok/issues)
[![Release](https://img.shields.io/github/v/release/leookun/cursor-byok?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/leookun/cursor-byok/total?style=flat-square)](https://github.com/leookun/cursor-byok/releases)
[![License](https://img.shields.io/github/license/leookun/cursor-byok?style=flat-square)](./LICENSE)
[![Platforms](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
</div>
![将 cursor-byok 连接到多种模型 API](./images/en-brand-1.png)
![cursor-byok 控制面板](./images/en-home-1.png)
## 项目简介
cursor-byok 是一个开源的本地模型网关。它在你的设备上运行服务,接收 Cursor 发出的 Agent 请求,将请求转发到你配置的模型服务,并尽可能保留 Cursor Agent 的工具调用、Skills、MCP 和多轮对话能力。
你可以连接兼容 OpenAI 或 Anthropic 协议的服务,自定义服务地址、模型 ID、API Key 和请求参数,也可以使用 Cursor 平台默认选项之外的模型通道。
> [!IMPORTANT]
> cursor-byok 免费且开源,但你连接的模型服务商可能会按用量收费。本项目是独立项目,与 Cursor 或其开发者没有关联,也未获得其认可。
## 主要功能
- **自定义模型通道**:配置自己的 API 地址、凭据和模型 ID。
- **多种 API 协议**:支持 OpenAI Responses API、OpenAI Chat Completions API 和 Anthropic Messages API 兼容服务。
- **模型管理**:添加、复制、编辑、排序模型配置,并批量测试连接。
- **连接性能测试**:查看首字延迟、生成速度、总耗时和原始服务商响应。
- **Agent 工作流**:继续使用工具调用、Skills、MCP 和多轮对话。
- **会话指标**:查看 Token 用量、缓存命中率、对话轮次和估算价值。
- **TAB 补全服务**:在公益服务、官方直连和自定义服务之间选择连接方式。
- **跨平台运行**:支持 macOS、Windows 和 Linux。
## 快速开始
1. 从 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) 下载适合你操作系统的最新版本。
2. 启动 cursor-byok,打开 **Cursor 配置**,按提示初始化本地 CA(证书颁发机构)。
3. 在模型设置中添加模型,填写服务地址、API Key 和模型名称,然后保存并运行 **测试**。
4. 确认测试通过后,保持 cursor-byok 运行。
5. **首次升级 Cursor 或首次配置模型后,完全退出并重新启动 Cursor,然后新开一个对话**。在模型列表中选择已配置的模型,开始使用 Agent。
完整的安装步骤、配置说明和常见问题,请参阅[中文使用指南](https://docs.leokun.cn/zh/docs)。
> [!TIP]
> 首次升级 Cursor 或首次完成配置后,必须完全退出并重新启动 Cursor,再新开一个对话。配置前已经打开的对话不会加载新连接;使用自定义模型时,请在模型列表中手动选择该模型,不要选择 **Auto**。
## 模型配置
每个模型配置都是独立的上游通道,可以单独设置服务商、协议、凭据和生成参数。
![模型设置页面](./images/en-model-1.png)
### 类型与协议选择
| 模型系列 | 模型类型 | 请求协议 |
| --- | --- | --- |
| Claude 系列 | **Anthropic** | Messages API |
| GPT / OpenAI 系列 | **OpenAI** | **Responses API** |
| 其他模型 | **OpenAI** | **Chat Completions API** |
GPT 系列建议使用 **Responses API**。如果使用 Chat Completions,可能无法保留提示词缓存,导致速度变慢和费用增加。
### 常用字段
- **模型类型**:选择 OpenAI 或 Anthropic,决定上游接口格式。
- **请求协议**:OpenAI 类型需要继续选择 Responses API 或 Chat Completions API。
- **服务器地址**:可以填写服务商基础地址,让应用按协议追加标准端点,也可以填写完整请求 URL 并原样使用。
- **API Key**:填写上游服务要求的访问密钥。密钥保存在本机,用于发送模型请求。
- **模型名称**:填写服务商接口接受的模型标识,也可以使用 **获取模型** 读取服务商返回的模型列表。
- **显示名称**:Cursor 模型列表中显示的名称,不会改变发送给上游的模型标识。
- **备注**:显示在 Cursor 的模型说明中。
还可以根据模型能力设置上下文窗口 Token、最大输出 Token、推理或思考强度、自定义 Headers,以及 OpenAI 或 Anthropic 的额外参数。自定义 Headers 和额外参数必须是 JSON 对象,只应填写服务商明确支持的字段。
保存配置后运行 **测试**,确认地址、协议、API Key、模型标识和流式响应都正常,再在 Cursor 中使用该模型。
## TAB 补全服务
Cursor 的 Tab 补全由独立的 TAB 服务处理,不经过模型通道。你可以在 **系统设置 → TAB 设置** 中选择以下模式:
- **使用公益服务(默认)**:使用项目作者部署的公共服务,无需额外配置。
- **直连**:直接连接当前 Cursor 账号对应的官方 TAB 服务,适合账号拥有官方额度的情况。
- **自定义**:自行部署 [`cursor-tab-server`](https://github.com/leookun/cursor-byok/tree/archive/v0.0.49/cursor-tab-server),然后填写 TAB 服务地址。
修改 TAB 设置后,建议重启 Cursor 并新开一个对话,确保新的连接方式生效。
## 与官方账号并存
新版设计支持 cursor-byok 与 Cursor 官方服务并存:
- 直接在 Cursor 中登录自己的账号。如果之前使用旧版生成的 fake 账户,请先退出该账户,再登录自己的账号。
- 账号拥有官方额度时,官方模型和本地模型可以随时切换混用。
- **Auto 只使用官方模型**,不会自动使用你配置的本地模型。账号没有官方额度时,请手动选择自己配置的模型。
- 插件、代码库索引等 Cursor 功能可以继续使用。
## 数据流转
```text
Cursor 客户端
│
│ Agent 请求与工具结果
▼
cursor-byok 本地服务
│
│ OpenAI / Anthropic 兼容请求
▼
你配置的模型 API
```
API Key、模型配置和应用设置保存在本机。模型请求仍会发送到你选择的上游服务商,请根据对应服务商的隐私政策和计费规则使用。
## 项目结构
```text
cursor-byok/
├── apps/
│ ├── desktop/
│ │ ├── src/
│ │ │ ├── features/ # 首页、模型、调用记录与设置
│ │ │ ├── shell/ # 窗口框架、页面布局与广告外壳
│ │ │ ├── shared/ # UI、虚拟列表、状态、API 与平台能力
│ │ │ ├── i18n/ # 本地化运行时与语言目录
│ │ │ └── styles/ # 全局主题与排版令牌
│ │ └── src-tauri/ # Tauri 桌面生命周期
│ └── docs/ # Next.js 与 Fumadocs 中文/英文文档站
├── server/
│ ├── src/
│ │ ├── cursor/ # Cursor 协议适配
│ │ ├── run/ # 通用 Agent Runtime 与内部 Port
│ │ ├── provider/ # 模型供应商适配
│ │ ├── model/ # 聚合领域模型
│ │ ├── store/ # SQLite Repository
│ │ ├── control/ # 管理面 API
│ │ ├── harness/ # Cursor 本地集成
│ │ └── search/ # Web 与 Semble 搜索接入
│ ├── prompt/
│ └── migrations/
├── crates/
│ └── semble-core/ # 本地代码索引与搜索核心库
├── protocols/
│ └── cursor/ # Cursor 协议定义的唯一来源
├── support/ # 仓库辅助设施
│ ├── cursor-protocol-extractor/ # Cursor 协议提取与 Go 代码生成工具
│ ├── cursor-capture/ # Cursor 协议抓取和调试工具
│ └── benchmarks/ # 代码搜索和索引基准测试
├── images/ # README 展示图片
├── Cargo.toml # Rust 工作区配置
└── Makefile # 常用开发、检查和构建命令
```
## 本地开发
### 环境要求
- Rust 工具链和 Cargo
- Node.js 与 npm
- Tauri 2 的系统构建依赖
- Docker(仅在构建 Docker 镜像时需要)
### 安装依赖
```bash
npm --prefix apps/desktop install
npm --prefix apps/docs install
```
### 启动开发环境
启动桌面前端:
```bash
make dev-web
```
启动桌面应用:
```bash
make dev-desktop
```
启动文档站:
```bash
make dev-docs
```
文档站默认地址为 <http://localhost:3000>。
### 检查与构建
运行完整检查:
```bash
make check
```
分别构建各部分:
```bash
make build-web # 构建桌面前端
make build-server # 构建 Rust 本地服务
make build-docs # 构建文档站
make build-desktop # 构建 Tauri 桌面安装包
make build-docker # 构建 Docker 镜像
```
文档内容位于 `apps/docs/content/docs` 和 `apps/docs/content/blog`。修改文档侧边栏时同步更新 `apps/docs/content/docs/meta.json`。
## 路线图
项目将继续改进模型兼容性、Agent 工具、本地运行稳定性和自托管体验,并探索支持更多 IDE、聊天和 Agent 工作流。
计划与进展请参阅[发布路线图](https://github.com/leookun/cursor-byok/discussions/32)。
## 社区与反馈
- [中文使用指南](https://docs.leokun.cn/zh/docs)
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
- [Telegram 社区](https://t.me/cursor_byok)
- QQ 群:`1095916242`、`1094411438`、`1095918002`、`1094419321`
提交问题时,请附上操作系统、cursor-byok 版本、模型类型、请求协议、已脱敏的服务地址、错误信息和复现步骤。请勿公开 API Key 或其他凭据。
## 参与贡献
欢迎提交 Issue 和 Pull Request。提交代码前请先阅读项目中的开发说明,并运行 `make check` 确认格式、测试和前端构建检查通过。
## 许可证
本项目采用 [MIT License](./LICENSE) 开源。
+100 -37
View File
@@ -1,48 +1,111 @@
## 为什么做这个项目
<div align="center">
公司喜欢把 Agent 服务与模型绑定在一起,让用户只能在指定模型、指定订阅和指定计费方式下使用工具。
# cursor-byok
cursor-byok is a local implementation of Cursor's backend.
<br>
<br>
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
我希望打破这种绑定关系:模型应该可以自由选择。开发者应该能够把自己的模型 API 接入到任何 IDE、Chat、Agent 或开发工具中,也可以自托管整套服务,避免被单一平台锁定。
[User Guide](https://docs.leokun.cn) · [Download](https://github.com/leookun/cursor-byok/releases/latest) · [Report an Issue](https://github.com/leookun/cursor-byok/issues) · [中文版本说明](./README-CN.md)
这个项目的目标,是让模型选择权重新回到用户手里。
## 路线图
[正式版路线图](https://github.com/leookun/cursor-byok/discussions/32)
注:正式版发布后的不久,本代码库将会全面开源,并迎来更多有趣的工具,local-first是我们的终极目标
## 后续
后续会继续扩展更多工具和使用场景,包括但不限于:
- 支持更多 IDE 接入
- 支持更多 Chat 类应用
- 支持更多 Agent 工具和工作流
- 提供更完善的自托管部署方式
- 持续优化不同模型 API 的兼容性
- 降低接入成本,让已有模型额度可以被更充分地利用
最终希望做到:让你的模型 API 可以自由接入到你想使用的任何工具中。
## 截图
[![Release](https://img.shields.io/github/v/release/leookun/cursor-byok?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/leookun/cursor-byok/total?style=flat-square)](https://github.com/leookun/cursor-byok/releases)
[![License](https://img.shields.io/github/license/leookun/cursor-byok?style=flat-square)](./LICENSE)
[![Platforms](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
<img width="820" alt="image" src="https://github.com/user-attachments/assets/2e1710b0-cdbd-4576-bd24-1614df016219" />
</div>
<img width="820" alt="image" src="https://github.com/user-attachments/assets/00885453-6a91-4052-aadf-f686daeec881" />
![Connect cursor-byok to a wide range of model APIs](./images/en-brand-1.png)
<img width="820" alt="image" src="https://github.com/user-attachments/assets/a607be84-a738-4e33-9750-13352e74001c" />
![cursor-byok dashboard](./images/en-home-1.png)
## About
cursor-byok is an open-source local model gateway for Cursor. It runs a service on your machine that connects Cursor to the model APIs you configure, routes model requests through your own providers, and preserves Cursor Agent capabilities such as tool calling, Skills, and MCP.
You can connect OpenAI- and Anthropic-compatible services, customize endpoints, model IDs, API keys, and request parameters, and use model channels beyond the options built into the platform.
> [!IMPORTANT]
> cursor-byok is free and open source, but the model APIs you connect may charge for usage. This is an independent project and is not affiliated with or endorsed by Cursor or its developers.
## Features
- **Bring your own model channels:** Configure your own API endpoint, credentials, and model IDs.
- **Multiple API protocols:** Use OpenAI- and Anthropic-compatible APIs or a custom endpoint.
- **Model management:** Add, duplicate, edit, reorder, and batch-test multiple model configurations.
- **Connection benchmarks:** Measure time to first token, generation speed, and inspect raw provider responses.
- **Agent workflows:** Keep tool calling, Skills, MCP, and multi-turn conversations available.
- **Session metrics:** Track token usage, cache hit rate, conversation turns, and estimated value.
- **Cross-platform:** Run on macOS, Windows, and Linux.
## Quick Start
1. Download the latest build for your platform from [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest).
2. Launch cursor-byok, open **Model Settings**, and enter the endpoint, API key, and model ID.
3. Test the model configuration. Once it passes, return to the dashboard and start the service.
4. Test the model configuration. Once it passes, return to the dashboard and start the service.
5. After upgrading Cursor or configuring a model for the first time, quit Cursor completely and restart it, then start a new conversation and select the configured model.
For complete installation steps, system configuration, and Frequently Asked Questions, see the [User Guide](https://docs.leokun.cn).
## Model Management
Model configurations support both OpenAI and Anthropic API protocols. Each model channel can independently define its context window, maximum output tokens, reasoning effort, custom headers, and additional request parameters.
![cursor-byok model settings](./images/en-model-1.png)
## How It Works
```text
Cursor client
│
│ Agent requests and tool results
▼
cursor-byok local service
│
│ OpenAI- / Anthropic-compatible requests
▼
Your model API
```
cursor-byok handles protocol adaptation, model request forwarding, tool-call coordination, and conversation state on your machine. API keys and application settings are stored locally; requests are still sent to the model provider you configure.
## Why This Project
Many Agent products bundle their tool capabilities with a fixed set of models, subscriptions, and billing options, leaving users limited to the channels offered by the platform.
cursor-byok is built to return model choice to the user. Developers can make full use of the APIs and credits they already have, choose the models and providers that fit their needs, and self-host related services when required.
## Roadmap
The project will continue to improve model compatibility, Agent tooling, local runtime stability, and the self-hosting experience while exploring support for more IDE, chat, and Agent workflows.
See the [release roadmap](https://github.com/leookun/cursor-byok/discussions/32) for plans and progress.
## Community and Support
- [User Guide](https://docs.leokun.cn)
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
- [Telegram community](https://t.me/cursor_byok)
- QQ groups: `1095916242`, `1094411438`, `1095918002`, `1094419321`
## Star History
<a href="https://www.star-history.com/?repos=leookun%2Fcursor-byok&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&legend=top-left" />
</picture>
</a>
## Development and Contributing
Issues and pull requests are welcome. See the [Contributing Guide](./CONTRIBUTING_EN.md) for prerequisites, build commands, project structure, and contribution guidelines.
## Contributors
<a href="https://github.com/leookun/cursor-byok/graphs/contributors">
<img src="https://contrib.rocks/image?repo=leookun/cursor-byok" />
</a>
## License
This project is open source under the [MIT License](./LICENSE).
+9
View File
@@ -0,0 +1,9 @@
<!-- Provenance for the Rust reimplementation and its compatibility fixtures. -->
# Semble upstream
- Repository: https://github.com/MinishLab/semble
- Baseline commit: `921849164e2632dd4f0e1c1370f82cfe15ed6d6c`
- License: MIT
- Model: `minishlab/potion-code-16M-v2`
The Rust implementation preserves the externally useful indexing and search behavior while remaining independent of Python at runtime.
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
background: var(--boot-bg, #141414);
}
</style>
<script>
// 在应用样式加载前按主题参数铺底色,避免切换 poster 时闪出错误颜色
(function () {
var light = new URLSearchParams(location.search).get("theme") === "default-light";
document.documentElement.style.setProperty("--boot-bg", light ? "#f5f5f5" : "#141414");
var meta = document.createElement("meta");
meta.name = "theme-color";
meta.content = light ? "#f5f5f5" : "#141414";
document.head.appendChild(meta);
})();
</script>
<title>Cursor BYOK 产品演示</title>
</head>
<body>
<div id="root"></div>
<script src="/src/demo/index.tsx" type="module"></script>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#141414" />
<style>
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
background-color: var(--vscode-editor-background, var(--startup-background, #141414));
}
</style>
<script>
(() => {
try {
const theme = localStorage.getItem("cursor-byok.theme") === "default-light"
? "default-light"
: "default-dark";
const light = theme === "default-light";
const background = light ? "#fcfcfc" : "#141414";
document.documentElement.dataset.theme = theme;
document.documentElement.style.setProperty("--startup-background", background);
document.documentElement.style.colorScheme = light ? "light" : "dark";
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", background);
} catch {
// The inline CSS already provides the dark-theme fallback.
}
})();
</script>
<title>Cursor BYOK</title>
</head>
<body>
<noscript>Cursor BYOK 需要 JavaScript 才能运行。</noscript>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>
+3490
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.6",
"description": "Cursor BYOK desktop management application",
"type": "module",
"scripts": {
"start": "vite",
"dev": "vite",
"typecheck": "tsc --noEmit",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
"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",
"dev:web": "concurrently --kill-others --success first --names server,web \"cross-env CURSOR_CONSOLE_PROXY=http://127.0.0.1:1420 cargo run --manifest-path ../../server/Cargo.toml --bin cursor-server\" \"wait-on http-get://127.0.0.1:3000/__byok-api__/healthz && vite\"",
"serve": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0",
"@iconify/json": "^2.2.519",
"@iconify/react": "^6.0.2",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"echarts": "^6.1.0",
"keepalive-for-react": "^5.0.11",
"keepalive-for-react-router": "^5.0.7",
"monaco-editor": "0.56.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"sortablejs": "^1.15.7",
"zrender": "^6.1.0"
},
"devDependencies": {
"@babel/parser": "^7.29.8",
"@babel/traverse": "^7.29.8",
"@tauri-apps/cli": "^2.11.4",
"@types/babel__traverse": "^7.28.0",
"@types/node": "^26.1.2",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-react": "^6.0.5",
"code-inspector-plugin": "^1.6.6",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"sass": "^1.102.0",
"typescript": "^7.0.2",
"vite": "^8.2.0",
"wait-on": "^9.0.1"
},
"overrides": {
"dompurify": "3.4.14"
}
}
+467
View File
@@ -0,0 +1,467 @@
import { createHash } from "node:crypto"
import fs from "node:fs"
import path from "node:path"
import { parse as parseJavaScript, type ParserPlugin } from "@babel/parser"
import traverseModule from "@babel/traverse"
import { normalizePath, type Plugin } from "vite"
// @ts-expect-error - CJS/ESM 模块互操作。
const traverse = traverseModule.default ?? traverseModule
const SOURCE_LOCALE = "zh-CN"
const SUPPORTED_LOCALES = ["zh-CN", "en-US"]
const CHINESE_SOURCE_PATTERN = /[\u3400-\u9fff]/u
const PLACEHOLDER_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g
const AUTO_IMPORT_NAME = "__staticI18nT"
const AUTO_IMPORT = `import { t as ${AUTO_IMPORT_NAME} } from "/src/i18n/runtime";\n`
const BABEL_PLUGINS = [
"jsx",
"typescript",
["classProperties", { decoratorsBeforeExport: false }],
["classPrivateProperties", { decoratorsBeforeExport: false }],
"classPrivateMethods",
"topLevelAwait",
"importAttributes"
] as unknown as ParserPlugin[]
interface SourceRef {
file: string
line: number
column: number
}
interface MessageRecord {
id: string
source: string
kind: MessageKind
placeholders: string[]
ref: SourceRef
}
interface MergedEntry {
source: string
kind: MessageKind
placeholders: string[]
refs: SourceRef[]
}
interface CallReplacement {
start: number
end: number
}
interface BabelArgument {
type: string
value?: string
properties?: BabelObjectProperty[]
}
interface BabelObjectProperty {
type: string
computed?: boolean
key?: { type: string; name?: string; value?: string }
}
interface BabelCallNode {
callee: { type: string; name?: string; start?: number | null; end?: number | null }
arguments: BabelArgument[]
loc?: { start: { line: number; column: number } | null } | null
}
interface BabelCallPath {
node: BabelCallNode
scope: { getBinding(name: string): unknown }
}
enum MessageKind {
Text = "text",
Template = "template",
}
enum BabelNodeType {
Identifier = "Identifier",
StringLiteral = "StringLiteral",
ObjectExpression = "ObjectExpression",
ObjectProperty = "ObjectProperty",
}
function hashMessageID(message: string): string {
return createHash("sha256").update(message).digest("hex").slice(0, 16)
}
function stripQuery(id: string): string {
return id.split("?")[0]
}
function isSourceFile(id: string): boolean {
return /\.(?:js|jsx|ts|tsx)$/.test(stripQuery(id))
}
function isExcludedFile(sourceRoot: string, id: string): boolean {
const cleanID = normalizePath(stripQuery(id))
if (cleanID.includes("/node_modules/")) return true
const relativePath = normalizePath(path.relative(sourceRoot, cleanID))
if (relativePath.startsWith("..")) return true
return relativePath.startsWith("i18n/")
}
function readJSONFile(filePath: string): Record<string, string> {
if (!fs.existsSync(filePath)) return {}
const raw = fs.readFileSync(filePath, "utf8").trim()
return raw ? JSON.parse(raw) : {}
}
function writeJSONFile(filePath: string, payload: unknown): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`)
}
function buildRef(
filePath: string,
sourceRoot: string,
loc: { line: number; column: number } | null
): SourceRef {
return {
file: normalizePath(path.relative(sourceRoot, filePath)),
line: loc?.line ?? 1,
column: loc?.column ?? 1
}
}
function sourceLocation(
filePath: string,
sourceRoot: string,
loc: { line: number; column: number } | null
): string {
const ref = buildRef(filePath, sourceRoot, loc)
return `${ref.file}:${ref.line}:${ref.column}`
}
function placeholderNames(message: string): string[] {
return Array.from(message.matchAll(PLACEHOLDER_PATTERN), (match) => match[1])
}
function assertUniquePlaceholders(
placeholders: string[],
location: string,
message: string
): void {
const duplicate = placeholders.find(
(name, index) => placeholders.indexOf(name) !== index
)
if (!duplicate) return
throw new Error(
`[static-i18n] ${location} 重复使用占位符 {${duplicate}}:${JSON.stringify(message)}`
)
}
function objectParameterNames(argument: BabelArgument, location: string): string[] {
if (argument.type !== BabelNodeType.ObjectExpression) {
throw new Error(`[static-i18n] ${location} 的 t params 必须是静态对象字面量`)
}
const names: string[] = []
for (const property of argument.properties ?? []) {
if (property.type !== BabelNodeType.ObjectProperty || property.computed) {
throw new Error(
`[static-i18n] ${location} 的 t params 不允许展开、计算属性或方法`
)
}
const key = property.key
const name =
key?.type === BabelNodeType.Identifier
? key.name
: key?.type === BabelNodeType.StringLiteral
? key.value
: undefined
if (!name) {
throw new Error(`[static-i18n] ${location} 的 t params key 必须是静态名称`)
}
if (names.includes(name)) {
throw new Error(`[static-i18n] ${location} 的 t params 重复提供 ${name}`)
}
names.push(name)
}
return names
}
function validateSourceCall(
filePath: string,
sourceRoot: string,
source: string,
args: BabelArgument[],
loc: { line: number; column: number } | null
): string[] {
const location = sourceLocation(filePath, sourceRoot, loc)
if (!CHINESE_SOURCE_PATTERN.test(source)) {
throw new Error(
`[static-i18n] ${location} 的 t source 必须包含中文:${JSON.stringify(source)}`
)
}
const placeholders = placeholderNames(source)
assertUniquePlaceholders(placeholders, location, source)
if (!placeholders.length) {
if (args.length !== 1) {
throw new Error(`[static-i18n] ${location} 的无参数消息不能传入 params`)
}
return placeholders
}
if (args.length !== 2) {
throw new Error(
`[static-i18n] ${location} 必须为占位符传入一个具名 params 对象`
)
}
const parameters = objectParameterNames(args[1], location)
const missing = placeholders.filter((name) => !parameters.includes(name))
const extra = parameters.filter((name) => !placeholders.includes(name))
if (missing.length || extra.length) {
throw new Error(
`[static-i18n] ${location} 的 params 与占位符不一致` +
`${missing.length ? `,缺少:${missing.join(", ")}` : ""}` +
`${extra.length ? `,多余:${extra.join(", ")}` : ""}`
)
}
return placeholders
}
function buildMessageRecord(
filePath: string,
sourceRoot: string,
source: string,
placeholders: string[],
loc: { line: number; column: number } | null
): MessageRecord {
return {
id: hashMessageID(source),
source,
kind: placeholders.length ? MessageKind.Template : MessageKind.Text,
placeholders,
ref: buildRef(filePath, sourceRoot, loc)
}
}
function mergeMessageRecords(records: MessageRecord[]) {
const entries = new Map<string, MergedEntry>()
for (const record of records) {
const current = entries.get(record.id)
if (!current) {
entries.set(record.id, {
source: record.source,
kind: record.kind,
placeholders: record.placeholders,
refs: [record.ref]
})
continue
}
if (current.source !== record.source) {
throw new Error(
`[static-i18n] Message id collision for ${record.id}: ${current.source} <> ${record.source}`
)
}
current.refs.push(record.ref)
}
const sortedEntries: Record<string, MergedEntry> = {}
for (const id of Array.from(entries.keys()).sort()) {
const entry = entries.get(id)!
sortedEntries[id] = {
...entry,
refs: entry.refs.sort(
(a, b) =>
a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column
)
}
}
return { entries: sortedEntries }
}
function mergeLocaleMessages(
existingMessages: Record<string, string>,
catalogEntries: Record<string, MergedEntry>,
locale: string
): Record<string, string> {
return Object.fromEntries(
Object.entries(catalogEntries).map(([id, entry]) => [
id,
locale === SOURCE_LOCALE ? entry.source : existingMessages[id] ?? ""
])
)
}
function validateTranslations(
sourceRoot: string,
entries: Record<string, MergedEntry>,
requireComplete: boolean
): void {
for (const locale of SUPPORTED_LOCALES) {
if (locale === SOURCE_LOCALE) continue
const localePath = path.join(sourceRoot, "i18n/locales", `${locale}.json`)
const messages = readJSONFile(localePath)
for (const [id, entry] of Object.entries(entries)) {
const translation = messages[id]
if (!translation) {
if (requireComplete) {
throw new Error(
`[static-i18n] ${localePath}:${id} 缺少译文:${entry.source}`
)
}
continue
}
const actual = placeholderNames(translation)
assertUniquePlaceholders(actual, `${localePath}:${id}`, translation)
const missing = entry.placeholders.filter((name) => !actual.includes(name))
const extra = actual.filter((name) => !entry.placeholders.includes(name))
if (!missing.length && !extra.length) continue
throw new Error(
`[static-i18n] ${localePath}:${id} 的译文占位符与 source 不一致` +
`${missing.length ? `,缺少:${missing.join(", ")}` : ""}` +
`${extra.length ? `,多余:${extra.join(", ")}` : ""}`
)
}
}
}
function walkSourceFiles(dirPath: string, visitor: (filePath: string) => void): void {
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
const nextPath = path.join(dirPath, entry.name)
if (entry.isDirectory()) {
if (entry.name !== "node_modules") walkSourceFiles(nextPath, visitor)
} else {
visitor(nextPath)
}
}
}
function parseProgram(code: string, filename: string) {
try {
return parseJavaScript(code, {
sourceType: "module",
sourceFilename: filename,
plugins: [...BABEL_PLUGINS]
})
} catch (error) {
throw new Error(
`[static-i18n] Failed to parse ${filename}: ${(error as Error).message}`
)
}
}
function analyzeTCalls(
code: string,
filePath: string,
sourceRoot: string
): { records: MessageRecord[]; replacements: CallReplacement[] } {
const records: MessageRecord[] = []
const replacements: CallReplacement[] = []
const ast = parseProgram(code, filePath)
traverse(ast, {
CallExpression(callPath: BabelCallPath) {
const node = callPath.node
if (node.callee.type !== BabelNodeType.Identifier || node.callee.name !== "t") return
let loc: { line: number; column: number } | null = null
if (node.loc?.start) {
loc = { line: node.loc.start.line, column: node.loc.start.column + 1 }
}
const location = sourceLocation(filePath, sourceRoot, loc)
if (callPath.scope.getBinding("t")) {
throw new Error(
`[static-i18n] ${location} 的 t 是保留全局函数,请删除本地声明或 import`
)
}
if (!node.arguments.length) {
throw new Error(`[static-i18n] ${location} 的 t 调用缺少中文 source`)
}
const firstArg = node.arguments[0]
if (firstArg.type !== BabelNodeType.StringLiteral || firstArg.value === undefined) {
throw new Error(`[static-i18n] ${location} 的 t source 必须是静态字符串字面量`)
}
const placeholders = validateSourceCall(
filePath,
sourceRoot,
firstArg.value,
node.arguments,
loc
)
records.push(
buildMessageRecord(filePath, sourceRoot, firstArg.value, placeholders, loc)
)
const { start, end } = node.callee
if (start == null || end == null) {
throw new Error(`[static-i18n] ${location} 无法定位 t 调用`)
}
replacements.push({ start, end })
}
})
return { records, replacements }
}
function collectCatalog(sourceRoot: string) {
const records: MessageRecord[] = []
walkSourceFiles(sourceRoot, (filePath) => {
const cleanPath = normalizePath(filePath)
if (!isSourceFile(cleanPath) || isExcludedFile(sourceRoot, cleanPath)) return
records.push(
...analyzeTCalls(fs.readFileSync(cleanPath, "utf8"), cleanPath, sourceRoot).records
)
})
return mergeMessageRecords(records)
}
function syncCatalogFiles(
sourceRoot: string,
catalog: ReturnType<typeof collectCatalog>
): void {
writeJSONFile(path.join(sourceRoot, "i18n/generated/catalog.json"), catalog)
for (const locale of SUPPORTED_LOCALES) {
const localePath = path.join(sourceRoot, "i18n/locales", `${locale}.json`)
writeJSONFile(
localePath,
mergeLocaleMessages(readJSONFile(localePath), catalog.entries, locale)
)
}
}
function injectRuntimeImport(code: string, replacements: CallReplacement[]): string {
let transformed = code
for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
transformed =
transformed.slice(0, replacement.start) +
AUTO_IMPORT_NAME +
transformed.slice(replacement.end)
}
return AUTO_IMPORT + transformed
}
export function staticI18nPlugin(): Plugin {
let sourceRoot = path.join(process.cwd(), "src")
const shouldScan =
process.argv.includes("--scan") || process.env.STATIC_I18N_SCAN === "true"
return {
name: "cursor-byok-static-i18n",
enforce: "pre",
configResolved(config) {
sourceRoot = path.join(config.root, "src")
},
buildStart() {
const catalog = collectCatalog(sourceRoot)
if (shouldScan) syncCatalogFiles(sourceRoot, catalog)
validateTranslations(sourceRoot, catalog.entries, !shouldScan)
},
transform(code, id) {
if (!isSourceFile(id) || isExcludedFile(sourceRoot, id)) return null
const analysis = analyzeTCalls(code, normalizePath(stripQuery(id)), sourceRoot)
if (!analysis.replacements.length) return null
return { code: injectRuntimeImport(code, analysis.replacements), map: null }
}
}
}
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "cursor-byok-desktop"
version = "0.1.6"
edition = "2021"
publish = false
[lib]
name = "cursor_byok_desktop"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
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"
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-single-instance = "2"
tauri-plugin-clipboard-manager = "2"
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"] }
+9
View File
@@ -0,0 +1,9 @@
fn main() {
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")
}
@@ -0,0 +1,28 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Cursor BYOK 主窗口的最小权限",
"remote": {
"urls": [
"http://127.0.0.1:*",
"http://localhost:*"
]
},
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-is-maximized",
"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",
"updater:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -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"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-open-terminal-with-command"
description = "Enables the open_terminal_with_command command without any pre-configured scope."
commands.allow = ["open_terminal_with_command"]
[[permission]]
identifier = "deny-open-terminal-with-command"
description = "Denies the open_terminal_with_command command without any pre-configured scope."
commands.deny = ["open_terminal_with_command"]
+309
View File
@@ -0,0 +1,309 @@
use std::{
process::{Command, ExitCode},
sync::{
atomic::{AtomicBool, Ordering},
Mutex,
},
time::Duration,
};
use axum::{
extract::{Extension, Json},
http::StatusCode,
routing::post,
Router,
};
use tauri::{
async_runtime::JoinHandle, webview::Color, AppHandle, Manager, RunEvent, WebviewUrl,
WebviewWindow, WebviewWindowBuilder,
};
use tauri_plugin_opener::OpenerExt;
use tokio_util::sync::CancellationToken;
#[cfg(dev)]
use cursor_server::config::ConsoleSource;
use cursor_server::{App, Config, Result};
#[cfg(not(dev))]
use crate::frontend;
use crate::startup::{self, StartupDiagnostics};
use crate::tray;
pub(crate) const MAIN_WINDOW_LABEL: &str = "main";
const AUTOSTART_ARG: &str = "--autostart";
struct DesktopRuntime {
shutdown: CancellationToken,
server: Mutex<Option<JoinHandle<Result<()>>>>,
exiting: AtomicBool,
}
#[tauri::command]
fn open_terminal_with_command(command: String) -> tauri::Result<()> {
#[cfg(target_os = "macos")]
{
let _ = command;
Command::new("open").args(["-a", "Terminal"]).status()?;
Ok(())
}
#[cfg(target_os = "windows")]
{
Command::new("cmd")
.args(["/C", "start", "cmd", "/K", &command])
.spawn()?;
Ok(())
}
#[cfg(target_os = "linux")]
{
const TERMINALS: &[(&str, &[&str])] = &[
("x-terminal-emulator", &["-e"]),
("gnome-terminal", &["--"]),
("konsole", &["-e"]),
("xfce4-terminal", &["--execute"]),
("alacritty", &["-e"]),
("kitty", &[]),
];
let script = format!("{command}; exec bash");
for (terminal, separator) in TERMINALS {
let mut process = Command::new(terminal);
process.args(*separator);
process.arg("bash").arg("-c").arg(&script);
match process.spawn() {
Ok(_) => return Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
}
}
Err(tauri::Error::from(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no supported terminal emulator found",
)))
}
}
#[derive(serde::Deserialize)]
struct OpenExternalUrlRequest {
url: String,
}
fn open_external_url(app: &AppHandle, url: &str) -> std::result::Result<(), String> {
let parsed = url::Url::parse(url).map_err(|error| format!("invalid URL: {error}"))?;
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
return Err("only absolute HTTP and HTTPS URLs are allowed".into());
}
app.opener()
.open_url(parsed.as_str(), None::<&str>)
.map_err(|error| format!("failed to open URL: {error}"))
}
async fn open_external_url_handler(
Extension(app): Extension<AppHandle>,
Json(request): Json<OpenExternalUrlRequest>,
) -> std::result::Result<StatusCode, (StatusCode, String)> {
open_external_url(&app, &request.url)
.map(|_| StatusCode::NO_CONTENT)
.map_err(|error| (StatusCode::BAD_REQUEST, error))
}
fn desktop_api_router(app: AppHandle) -> Router {
Router::new()
.route(
"/__byok-api__/api/desktop/open-external-url",
post(open_external_url_handler),
)
.layer(Extension(app))
}
fn create_main_window(
app: &AppHandle,
address: std::net::SocketAddr,
) -> tauri::Result<WebviewWindow> {
let url = format!("http://{address}/__byok-api__/")
.parse()
.expect("local frontend URL");
let builder = WebviewWindowBuilder::new(app, MAIN_WINDOW_LABEL, WebviewUrl::External(url))
.title("Cursor BYOK")
.inner_size(820.0, 558.0)
.min_inner_size(820.0, 558.0)
.center()
.background_color(Color(20, 20, 20, 255))
.decorations(cfg!(target_os = "macos"))
.shadow(true)
.resizable(true)
.visible(false);
#[cfg(target_os = "macos")]
let builder = builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
builder.build()
}
pub fn run() -> ExitCode {
let diagnostics = match StartupDiagnostics::initialize() {
Ok(diagnostics) => diagnostics,
Err(error) => {
startup::report_logging_failure(error.as_ref());
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,
architecture = std::env::consts::ARCH,
log_directory = %diagnostics.log_directory().display(),
"desktop starting"
);
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,
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);
}
}))
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.setup(move |app| {
app.handle().plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec![AUTOSTART_ARG]),
))?;
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;
config.console = Some(ConsoleSource::Proxy(
"http://127.0.0.1:1420"
.parse()
.expect("Vite development URL"),
));
config
};
let server = tauri::async_runtime::block_on(App::new(config))?
.merge_router(desktop_api_router(app.handle().clone()));
#[cfg(not(dev))]
let server = server.merge_router(frontend::router(app.handle().clone()));
let listener = tauri::async_runtime::block_on(server.bind())?;
let address = listener.local_addr()?;
tauri::async_runtime::block_on(server.harness().cleanup_stale_settings())?;
let desktop_settings =
tauri::async_runtime::block_on(server.store().desktop_settings())
.unwrap_or_default();
#[cfg(target_os = "macos")]
app.handle()
.set_dock_visibility(desktop_settings.show_dock_icon)?;
let shutdown = CancellationToken::new();
let server_shutdown = shutdown.clone();
let app_handle = app.handle().clone();
let task = tauri::async_runtime::spawn(async move {
let result = server.serve_on(listener, server_shutdown).await;
if let Err(error) = &result {
tracing::error!(%error, "desktop server stopped unexpectedly");
app_handle.exit(1);
}
result
});
app.manage(DesktopRuntime {
shutdown,
server: Mutex::new(Some(task)),
exiting: AtomicBool::new(false),
});
let window = create_main_window(app.handle(), address)?;
if desktop_settings.silent_start && started_by_autostart {
tracing::info!("silent autostart enabled; keeping the main window hidden");
} else {
window.show()?;
window.set_focus()?;
}
tray::create(app)?;
crate::update::signal_ready_if_requested()?;
Ok(())
})
.build(tauri::generate_context!());
let app = match app {
Ok(app) => app,
Err(error) => {
diagnostics.report_fatal(&error);
return ExitCode::FAILURE;
}
};
app.run(|app, event| match event {
RunEvent::WindowEvent {
label,
event: tauri::WindowEvent::CloseRequested { api, .. },
..
} if label == MAIN_WINDOW_LABEL => {
let runtime = app.state::<DesktopRuntime>();
if !runtime.exiting.load(Ordering::Acquire) {
api.prevent_close();
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
let _ = window.hide();
}
}
}
RunEvent::ExitRequested { api, .. } => {
let runtime = app.state::<DesktopRuntime>();
if !runtime.exiting.swap(true, Ordering::AcqRel) {
api.prevent_exit();
runtime.shutdown.cancel();
let server = runtime.server.lock().expect("server lock poisoned").take();
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Some(server) = server {
match tokio::time::timeout(Duration::from_secs(11), server).await {
Ok(Ok(Ok(()))) => {}
Ok(Ok(Err(error))) => {
tracing::error!(%error, "desktop server shutdown failed")
}
Ok(Err(error)) => tracing::error!(%error, "desktop server task failed"),
Err(_) => tracing::warn!("desktop server shutdown timed out"),
}
}
app.exit(0);
});
}
}
#[cfg(target_os = "macos")]
RunEvent::Reopen {
has_visible_windows: false,
..
} => tray::show_main_window(app),
_ => {}
});
ExitCode::SUCCESS
}
+43
View File
@@ -0,0 +1,43 @@
//! Serves Tauri's embedded frontend assets through the local HTTP server.
use axum::{
body::Body,
extract::State,
http::{header, HeaderValue, Response, StatusCode, Uri},
routing::get,
Router,
};
use tauri::AppHandle;
pub(crate) fn router(app: AppHandle) -> Router {
Router::new()
.route("/__byok-api__/", get(asset))
.route("/__byok-api__/{*path}", get(asset))
.with_state(app)
}
async fn asset(State(app): State<AppHandle>, uri: Uri) -> Response<Body> {
let path = uri
.path()
.strip_prefix("/__byok-api__/")
.filter(|path| !path.is_empty())
.unwrap_or("index.html");
let Some(asset) = app.asset_resolver().get(path.to_string()) else {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.expect("static not-found response");
};
let mut response = Response::new(Body::from(asset.bytes));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_str(&asset.mime_type)
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
);
if let Some(csp) = asset.csp_header.and_then(|value| value.parse().ok()) {
response
.headers_mut()
.insert(header::CONTENT_SECURITY_POLICY, csp);
}
response
}
+14
View File
@@ -0,0 +1,14 @@
mod desktop;
#[cfg(not(dev))]
mod frontend;
mod resource_limits;
mod startup;
mod tray;
mod update;
pub fn run() -> std::process::ExitCode {
if let Some(exit_code) = update::run_replacement_if_requested() {
return exit_code;
}
desktop::run()
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() -> std::process::ExitCode {
cursor_byok_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,
})
}
+139
View File
@@ -0,0 +1,139 @@
//! Captures startup diagnostics before the database and desktop runtime initialize.
use std::{error::Error, path::PathBuf};
use rfd::{MessageButtons, MessageDialog, MessageLevel};
use tracing_appender::{
non_blocking::WorkerGuard,
rolling::{RollingFileAppender, Rotation},
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
const LOG_DIRECTORY_NAME: &str = "logs";
const LOG_FILE_PREFIX: &str = "cursor-byok";
const LOG_FILE_SUFFIX: &str = "log";
const RETAINED_LOG_FILES: usize = 15;
type BoxError = Box<dyn Error + Send + Sync>;
pub(crate) struct StartupDiagnostics {
log_directory: PathBuf,
_writer_guard: WorkerGuard,
}
impl StartupDiagnostics {
pub(crate) fn initialize() -> Result<Self, BoxError> {
let log_directory = cursor_server::config::managed_data_dir()?.join(LOG_DIRECTORY_NAME);
std::fs::create_dir_all(&log_directory)?;
let file_appender = RollingFileAppender::builder()
.rotation(Rotation::DAILY)
.filename_prefix(LOG_FILE_PREFIX)
.filename_suffix(LOG_FILE_SUFFIX)
.max_log_files(RETAINED_LOG_FILES)
.build(&log_directory)?;
let (file_writer, writer_guard) = tracing_appender::non_blocking(file_appender);
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "cursor_byok_desktop=info,cursor_server=info".into());
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer())
.with(
tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(file_writer),
)
.try_init()?;
Ok(Self {
log_directory,
_writer_guard: writer_guard,
})
}
pub(crate) fn log_directory(&self) -> &std::path::Path {
&self.log_directory
}
pub(crate) fn report_fatal(&self, error: &(dyn Error + 'static)) {
let details = error_chain(error);
tracing::error!(
error = %details,
log_directory = %self.log_directory.display(),
"desktop failed to start"
);
show_fatal_dialog(&details, Some(&self.log_directory));
}
}
pub(crate) fn report_logging_failure(error: &(dyn Error + 'static)) {
let details = error_chain(error);
eprintln!("Cursor BYOK failed to initialize logging: {details}");
show_fatal_dialog(&details, None);
}
fn show_fatal_dialog(details: &str, log_directory: Option<&std::path::Path>) {
let log_guidance = match log_directory {
Some(directory) => format!(
"日志目录 / Log directory:\n{}\n\n请将最新的日志文件发送给开发者。\nPlease send the latest log file to the developer.",
directory.display()
),
None => "日志系统也未能启动,因此没有生成日志文件。\nLogging also failed to initialize, so no log file was created."
.to_owned(),
};
let description = format!(
"Cursor BYOK 无法启动 / failed to start.\n\n错误 / Error:\n{details}\n\n{log_guidance}"
);
let _ = MessageDialog::new()
.set_level(MessageLevel::Error)
.set_title("Cursor BYOK 启动失败 / Startup Error")
.set_description(description)
.set_buttons(MessageButtons::Ok)
.show();
}
fn error_chain(error: &(dyn Error + 'static)) -> String {
let mut details = error.to_string();
let mut source = error.source();
while let Some(cause) = source {
details.push_str("\nCaused by: ");
details.push_str(&cause.to_string());
source = cause.source();
}
details
}
#[cfg(test)]
mod tests {
use super::*;
use std::fmt;
#[derive(Debug)]
struct OuterError(std::io::Error);
impl fmt::Display for OuterError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("database initialization failed")
}
}
impl Error for OuterError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.0)
}
}
#[test]
fn fatal_report_includes_the_complete_error_chain() {
let error = OuterError(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"database file is read-only",
));
assert_eq!(
error_chain(&error),
"database initialization failed\nCaused by: database file is read-only"
);
}
}
+55
View File
@@ -0,0 +1,55 @@
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::TrayIconBuilder,
App, AppHandle, Manager,
};
#[cfg(target_os = "windows")]
use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent};
use crate::desktop::MAIN_WINDOW_LABEL;
const OPEN_MENU_ID: &str = "tray-open";
const QUIT_MENU_ID: &str = "tray-quit";
pub fn create(app: &mut App) -> tauri::Result<()> {
let open = MenuItem::with_id(app, OPEN_MENU_ID, "打开 Cursor BYOK", true, None::<&str>)?;
let separator = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, QUIT_MENU_ID, "退出", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&open, &separator, &quit])?;
TrayIconBuilder::with_id("main")
.icon(tauri::include_image!("./icons/32x32.png"))
.tooltip("Cursor BYOK")
.menu(&menu)
.show_menu_on_left_click(cfg!(target_os = "macos"))
.on_menu_event(|app, event| match event.id().as_ref() {
OPEN_MENU_ID => show_main_window(app),
QUIT_MENU_ID => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
#[cfg(target_os = "windows")]
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main_window(tray.app_handle());
}
#[cfg(not(target_os = "windows"))]
let _ = (tray, event);
})
.build(app)?;
Ok(())
}
pub fn show_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
+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());
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cursor BYOK",
"version": "0.1.6",
"identifier": "dev.cursorbyok.desktop",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [],
"security": {
"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"
]
}
},
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"icon": [
"icons/linux-32x32.png",
"icons/linux-128x128.png",
"icons/linux-256x256.png",
"icons/linux-512x512.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDc1MEY2RjQzRjA0MjhCMDUKUldRRmkwTHdRMjhQZGJJam9zT1NwcW83L1V4ckJvbkQzQ0FYVFZ6MGRpa1N2NVFkUHk1dW14c1AK",
"endpoints": [
"https://github.com/leookun/cursor-byok/releases/latest/download/latest.json"
],
"windows": {
"installMode": "passive"
}
}
}
}
@@ -0,0 +1,5 @@
{
"bundle": {
"targets": ["app"]
}
}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useRef } from "react";
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import { MessageProvider } from "./shared/ui/MessageProvider";
import { useMessage } from "./shared/ui/message";
import { AppFrame } from "./shell/AppFrame";
import { AppLayout } from "./shell/AppLayout";
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";
export function App() {
return (
<>
<HashRouter>
<Routes>
<Route path="calls/:callId" element={<CallDetailsPage />} />
<Route element={<AppFrame />}>
<Route element={<AppLayout />}>
<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 />} />
</Route>
</Routes>
</HashRouter>
<AppMessages />
</>
);
}
function AppMessages() {
const { error } = useAppStore();
const previousError = useRef<string | null>(null);
const showMessage = useMessage();
useEffect(() => {
if (error && error !== previousError.current) showMessage(error);
previousError.current = error;
}, [error, showMessage]);
useEffect(() => {
void updateStore.check().then((version) => {
if (!version) return;
showMessage(t("发现新版本 {version},可在设置中安装", { version }), { duration: 6_000 });
}).catch(() => {
// Startup checks are best-effort; manual checks in Settings report errors.
});
}, [showMessage]);
return <MessageProvider />;
}
+293
View File
@@ -0,0 +1,293 @@
import type {
CallDetail,
CursorHarnessStatus,
LlmCall,
Model,
Overview,
OverviewTokenUsageBucket,
ProxySettings,
StatisticsStorage,
TabSettings,
} from "../shared/api";
const API_ROOT = "/__byok-api__/api";
const FIXED_NOW = Date.UTC(2026, 7, 27, 8, 0, 0);
const models: Model[] = [
createModel({ hash: "mock-claude-sonnet", order: 1, name: "Claude Sonnet 4", type: "anthropic", url: "https://api.anthropic.com", modelId: "claude-sonnet-4-20250514" }),
createModel({ hash: "mock-claude-opus", order: 2, name: "Claude Opus 4", type: "anthropic", url: "https://api.anthropic.com", modelId: "claude-opus-4-20250514" }),
createModel({ hash: "mock-gpt", order: 3, name: "GPT-5.2", type: "openai", url: "https://api.openai.com", modelId: "gpt-5.2" }),
createModel({ hash: "mock-o3", order: 4, name: "o3", type: "openai", url: "https://api.openai.com", modelId: "o3" }),
createModel({ hash: "mock-deepseek-v3", order: 5, name: "DeepSeek V3.2", type: "openai", url: "https://api.deepseek.com", modelId: "deepseek-chat", endpoint: "/v1/chat/completions" }),
createModel({ hash: "mock-deepseek-r1", order: 6, name: "DeepSeek R1", type: "openai", url: "https://api.deepseek.com", modelId: "deepseek-reasoner", endpoint: "/v1/chat/completions" }),
createModel({ hash: "mock-gemini-pro", order: 7, name: "Gemini 2.5 Pro", type: "openai", url: "https://generativelanguage.googleapis.com", modelId: "gemini-2.5-pro", endpoint: "/v1beta/openai/chat/completions" }),
createModel({ hash: "mock-gemini-flash", order: 8, name: "Gemini 2.5 Flash", type: "openai", url: "https://generativelanguage.googleapis.com", modelId: "gemini-2.5-flash", endpoint: "/v1beta/openai/chat/completions" }),
createModel({ hash: "mock-qwen-max", order: 9, name: "Qwen3 Max", type: "openai", url: "https://dashscope.aliyuncs.com/compatible-mode", modelId: "qwen3-max", endpoint: "/v1/chat/completions" }),
createModel({ hash: "mock-qwen-plus", order: 10, name: "Qwen Plus", type: "openai", url: "https://dashscope.aliyuncs.com/compatible-mode", modelId: "qwen-plus", endpoint: "/v1/chat/completions" }),
createModel({ hash: "mock-kimi-k2", order: 11, name: "Kimi K2", type: "openai", url: "https://api.moonshot.cn", modelId: "kimi-k2-0711-preview", endpoint: "/v1/chat/completions" }),
createModel({ hash: "mock-kimi-128k", order: 12, name: "Moonshot V1 128K", type: "openai", url: "https://api.moonshot.cn", modelId: "moonshot-v1-128k", endpoint: "/v1/chat/completions" }),
createModel({ hash: "mock-glm-45", order: 13, name: "GLM-4.5", type: "openai", url: "https://open.bigmodel.cn", modelId: "glm-4.5", endpoint: "/api/paas/v4/chat/completions" }),
createModel({ hash: "mock-glm-air", order: 14, name: "GLM-4.5-Air", type: "openai", url: "https://open.bigmodel.cn", modelId: "glm-4.5-air", endpoint: "/api/paas/v4/chat/completions" }),
createModel({ hash: "mock-mistral-large", order: 15, name: "Mistral Large", type: "openai", url: "https://api.mistral.ai", modelId: "mistral-large-latest", endpoint: "/v1/chat/completions" }),
createModel({ hash: "mock-mistral-small", order: 16, name: "Mistral Small", type: "openai", url: "https://api.mistral.ai", modelId: "mistral-small-latest", endpoint: "/v1/chat/completions" }),
];
const calls: LlmCall[] = Array.from({ length: 24 }, (_, index) => {
const model = models[index % models.length];
const failed = index === 7 || index === 19;
return {
call_kind: "provider_llm",
route: "local_byok",
call_id: `mock-call-${String(index + 1).padStart(3, "0")}`,
run_id: `mock-run-${Math.floor(index / 3) + 1}`,
conversation_id: `mock-conversation-${Math.floor(index / 4) + 1}`,
provider_call_index: index + 1,
model_hash: model.model_hash,
provider_type: model.type,
provider_url: model.base_url,
request_type: model.type === "anthropic" ? "messages" : "responses",
request_url: model.type === "anthropic" ? `${model.base_url}/v1/messages` : `${model.base_url}${model.openai_endpoint}`,
model_id: model.model_id,
display_name: model.display_name,
reasoning_effort: index % 2 === 0 ? "high" : null,
fast: index % 3 === 0,
status: failed ? "failed" : "completed",
finish_reason: failed ? null : "stop",
created_at_ms: FIXED_NOW - index * 3 * 60_000,
ttfb_ms: 210 + index * 13,
ttfr_ms: 290 + index * 15,
ttft_ms: 370 + index * 17,
duration_ms: failed ? 812 : 1_420 + index * 71,
input_tokens: 4_800 + index * 337,
output_tokens: failed ? 0 : 820 + index * 43,
total_tokens: failed ? 4_800 + index * 337 : 5_620 + index * 380,
cache_read_tokens: 3_100 + index * 251,
cache_write_tokens: 320 + index * 19,
reasoning_tokens: index % 2 === 0 ? 420 + index * 11 : null,
message_count: 14 + (index % 8),
tool_count: 3 + (index % 5),
http_status: failed ? 429 : 200,
error_kind: failed ? "provider_rate_limit" : null,
error_message: failed ? "Mock provider rate limit" : null,
detailed: true,
};
});
const harnessStatus: CursorHarnessStatus = {
platform: "macos",
ca: "ready",
configured_models: models.length,
enabled_models: models.length,
integration: "enabled",
proxy_url: "http://127.0.0.1:54321",
ca_install_command: null,
};
let detailed = true;
let portSettings = { proxy_port: 0, service_port: 0 };
let proxySettings: ProxySettings = {
mode: "system",
address: "",
auth_enabled: false,
username: "",
has_password: false,
};
let tabSettings: TabSettings = { mode: "public", address: "" };
let storage: StatisticsStorage = { bytes: 26_004_480, call_count: calls.length, trace_count: calls.length };
export function installDemoApi() {
const nativeFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
const requestUrl = input instanceof Request ? input.url : input instanceof URL ? input.href : input;
const url = new URL(requestUrl, window.location.href);
if (!url.pathname.startsWith(API_ROOT)) return nativeFetch(input, init);
const path = url.pathname.slice(API_ROOT.length) || "/";
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
const body = await readBody(input, init);
if (path === "/ads") return json({ slots: [] });
if (path === "/models" && method === "GET") return json(models);
if (path === "/models" && method === "POST") return json(models);
if (path === "/models/order") return json(models);
if (path === "/models/discover") return json({ models: models.map((model) => model.model_id) });
if (path === "/models/import-v0049" && method === "GET") {
return json({ source: "demo", total: 0, new_models: 0, existing_models: 0, models: [] });
}
if (path === "/models/import-v0049") return json({ imported: 0, skipped: 0, total: 0 });
if (/^\/models\/[^/]+\/test\/[^/]+$/.test(path) && method === "POST") {
return json({ duration_ms: 1_284, first_valid_response_ms: 418, output_tokens: 42, tokens_per_second: 38.6, tokens_estimated: false, output: "Mock connectivity test passed." });
}
if (/^\/models\/[^/]+\/test\/[^/]+$/.test(path) || /^\/models\/[^/]+$/.test(path)) {
return method === "DELETE" ? empty() : json(models[0]);
}
if (path === "/overview") return json(createOverview(url.searchParams));
if (path === "/llm-calls") return json(calls);
if (path.startsWith("/llm-calls/")) return json(createCallDetail(path.slice("/llm-calls/".length)));
if (path === "/harness/cursor/status") return json(harnessStatus);
if (path === "/harness/cursor/ca/initialize" || path === "/harness/cursor/enabled") return json(harnessStatus);
if (path === "/settings/observability" && method === "GET") return json({ detailed });
if (path === "/settings/observability") {
detailed = Boolean((body as { detailed?: unknown } | null)?.detailed);
return json({ detailed });
}
if (path === "/settings/ports" && method === "GET") return json(portSettings);
if (path === "/settings/ports") {
portSettings = body as typeof portSettings;
return json(portSettings);
}
if (path === "/settings/storage/statistics" && method === "GET") return json(storage);
if (path === "/settings/storage/statistics") {
const scope = (body as { scope?: string } | null)?.scope ?? "details";
storage = scope === "all"
? { bytes: 0, call_count: 0, trace_count: 0 }
: { ...storage, bytes: 0 };
return json(storage);
}
if (path === "/settings/proxy" && method === "GET") return json(proxySettings);
if (path === "/settings/proxy") {
const next = body as Partial<ProxySettings>;
proxySettings = { ...proxySettings, ...next, has_password: Boolean(next.has_password) };
return json(proxySettings);
}
if (path === "/settings/tab" && method === "GET") return json(tabSettings);
if (path === "/settings/tab") {
tabSettings = body as TabSettings;
return json(tabSettings);
}
if (path === "/settings/desktop" && method === "GET") return json({ silent_start: false, show_dock_icon: true });
if (path === "/settings/desktop") return json(body);
if (path === "/desktop/open-external-url") {
const target = (body as { url?: string } | null)?.url;
if (target) {
const next = new URL(target, window.location.href);
if (next.origin === window.location.origin && next.hash) window.location.hash = next.hash;
}
return empty();
}
if (path.endsWith("/dismissals")) return empty();
return json({ message: `Unhandled demo endpoint: ${method} ${path}` }, 404);
};
}
function createModel({ hash, order, name, type, url, modelId, endpoint = "/v1/responses" }: {
hash: string;
order: number;
name: string;
type: Model["type"];
url: string;
modelId: string;
endpoint?: string;
}): Model {
return {
model_hash: hash,
sort_order: order,
display_name: name,
group_name: null,
type,
base_url: url,
use_full_url: false,
api_key: "demo-key",
tooltip_data: `${name} Mock 通道`,
model_id: modelId,
reasoning_effort: type === "openai" ? "high" : null,
openai_endpoint: type === "openai" ? endpoint : "",
openai_extra_params_enabled: false,
openai_extra_params: {},
custom_headers_enabled: false,
custom_headers: {},
anthropic_extra_params_enabled: false,
anthropic_extra_params: {},
context_window_tokens: 200_000,
max_completion_tokens: type === "openai" ? 32_000 : null,
anthropic_max_tokens: type === "anthropic" ? 32_000 : null,
anthropic_thinking_effort: type === "anthropic" ? "high" : null,
thinking_budget_tokens: null,
created_at_ms: FIXED_NOW - order * 86_400_000,
updated_at_ms: FIXED_NOW,
};
}
function createOverview(params: URLSearchParams): Overview {
const start = Number(params.get("start_ms"));
const end = Number(params.get("end_ms"));
const duration = Number.isFinite(start) && Number.isFinite(end) && end > start ? end - start : 365 * 86_400_000;
const granularity = duration <= 2 * 60 * 60_000 ? "minute" : duration <= 2 * 86_400_000 ? "hour" : "day";
const step = granularity === "minute" ? 60_000 : granularity === "hour" ? 3_600_000 : 86_400_000;
const count = granularity === "minute" ? Math.min(60, Math.max(10, Math.ceil(duration / step))) : granularity === "hour" ? Math.min(24, Math.max(8, Math.ceil(duration / step))) : Math.min(365, Math.max(7, Math.ceil(duration / step)));
const series = createSeries(count, step, Number.isFinite(end) && end > 0 ? end : FIXED_NOW);
const totals = series.reduce((sum, bucket) => ({
input: sum.input + bucket.input_tokens,
cacheRead: sum.cacheRead + bucket.cache_read_tokens,
cacheWrite: sum.cacheWrite + bucket.cache_write_tokens,
output: sum.output + bucket.output_tokens,
}), { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 });
const llmCalls = Math.max(12, Math.round(count * 5.4));
return {
metrics: {
llm_calls: llmCalls,
successful_calls: llmCalls - Math.max(1, Math.floor(llmCalls * 0.008)),
failed_calls: Math.max(1, Math.floor(llmCalls * 0.008)),
token_usage: totals.input + totals.cacheRead + totals.cacheWrite + totals.output,
prompt_tokens: totals.input + totals.cacheRead + totals.cacheWrite,
input_tokens: totals.input,
cache_read_tokens: totals.cacheRead,
cache_write_tokens: totals.cacheWrite,
output_tokens: totals.output,
},
token_usage_granularity: granularity,
token_usage_series: series,
};
}
function createSeries(count: number, step: number, end: number): OverviewTokenUsageBucket[] {
return Array.from({ length: count }, (_, index) => {
const wave = 0.72 + ((index * 17) % 31) / 50;
return {
bucket_start_ms: end - (count - index) * step,
// input : cache_read = 1 : 99,使默认口径缓存命中率恰为 99%
input_tokens: Math.round(800 * wave),
cache_read_tokens: Math.round(79_200 * wave),
cache_write_tokens: Math.round(7_500 * wave),
output_tokens: Math.round(12_500 * wave),
};
});
}
function createCallDetail(id: string): CallDetail {
const call = calls.find((item) => item.call_id === decodeURIComponent(id)) ?? calls[0];
return {
call,
request: {
headers: { authorization: "Bearer sk-demo-••••", "content-type": "application/json" },
body: { model: call.model_id, stream: true, messages: [{ role: "user", content: "Mock Agent request" }] },
byte_count: 8_426,
},
response_chunks: [
{ seq: 1, received_offset_ms: 418, data: "event: response.created", byte_count: 128 },
{ seq: 2, received_offset_ms: 512, data: "event: response.output_text.delta", byte_count: 256 },
],
cursor_trace: null,
};
}
async function readBody(input: RequestInfo | URL, init?: RequestInit): Promise<unknown> {
const raw = init?.body ?? (input instanceof Request ? await input.clone().text() : null);
if (typeof raw !== "string" || raw.length === 0) return null;
try { return JSON.parse(raw) as unknown; }
catch { return raw; }
}
function json(value: unknown, status = 200) {
return new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json" },
});
}
function empty() {
return new Response(null, { status: 204 });
}
+13
View File
@@ -0,0 +1,13 @@
import { installDemoApi } from "./api";
installDemoApi();
const params = new URLSearchParams(window.location.search);
const locale = params.get("locale") === "en-US" ? "en-US" : "zh-CN";
const theme = params.get("theme") === "default-light" ? "default-light" : "default-dark";
document.documentElement.dataset.platform = "macos";
localStorage.setItem("cursor-byok.locale", locale);
localStorage.setItem("cursor-byok.theme", theme);
void import("../index");
@@ -0,0 +1,38 @@
@use "../../styles/typography" as type;
.root {
section { min-width: 0; }
h4 { margin: 12px 0 6px; color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; font-weight: 500; }
}
.details {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
padding: 1px;
margin: 0;
overflow: hidden;
background: var(--vscode-sideBar-border);
border-radius: 6px;
> div {
min-width: 0;
display: grid;
grid-template-columns: 132px minmax(0, 1fr);
gap: 10px;
padding: 8px 10px;
background: var(--vscode-editorWidget-background);
}
dt { color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; }
dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
code { white-space: pre-wrap; font-family: var(--oa-code-font); font-size: type.$font-size-xs; }
}
.meta{
margin-bottom: 10px;
}
.meta,
.empty {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
@@ -0,0 +1,82 @@
import type { CallDetail } from "../../shared/api";
import { JsonEditor } from "../../shared/ui/JsonEditor";
import { Tabs, type TabItem } from "../../shared/ui/Tabs";
import styles from "./CallDetails.module.scss";
const show = (value: string | number | null) => value ?? "-";
const timing = (value: number | null) => value == null ? "-" : `${value} ms`;
export function CallDetails({ detail }: { detail: CallDetail }) {
const { call, request, response_chunks: chunks, cursor_trace: cursorTrace } = detail;
const responseBody = chunks.map((chunk) => chunk.data).join("");
const responseBytes = chunks.reduce((total, chunk) => total + chunk.byte_count, 0);
const fields: Array<[string, string | number]> = [
["Call ID", call.call_id],
[t("调用类型"), call.call_kind === "cursor_official" ? t("Cursor 官方") : "LLM"],
[t("路由"), call.route === "cursor_official" ? t("Cursor 官方") : "BYOK"],
["Run ID", call.run_id],
["Conversation ID", call.conversation_id],
[t("上游调用序号"), call.provider_call_index],
["Model Hash", show(call.model_hash)],
[t("上游类型"), call.provider_type],
[t("上游地址"), call.provider_url],
[t("最终请求类型"), call.request_type],
[t("最终请求地址"), call.request_url],
["Model ID", call.model_id],
[t("显示名称"), call.display_name],
[t("思考强度"), show(call.reasoning_effort)],
["Fast", call.fast == null ? "-" : call.fast ? t("是") : t("否")],
[t("状态"), call.status],
["Finish Reason", show(call.finish_reason)],
["HTTP Status", show(call.http_status)],
["Created At", `${call.created_at_ms} · ${new Date(call.created_at_ms).toLocaleString()}`],
[t("耗时"), timing(call.duration_ms)],
["TTFB", timing(call.ttfb_ms)],
["TTFR", timing(call.ttfr_ms)],
["TTFT", timing(call.ttft_ms)],
["Input Token", show(call.input_tokens)],
["Output Token", show(call.output_tokens)],
["Total Token", show(call.total_tokens)],
["Cache Read Token", show(call.cache_read_tokens)],
["Cache Write Token", show(call.cache_write_tokens)],
["Reasoning Token", show(call.reasoning_tokens)],
[t("消息数"), call.message_count],
[t("工具数"), call.tool_count],
[t("详细记录"), call.detailed ? t("是") : t("否")],
["Error Kind", show(call.error_kind)],
["Error Message", show(call.error_message)],
];
const tabs: TabItem[] = [
{ value: "call", label: t("调用信息"), content: <section>
<dl className={styles.details}>{fields.map(([label, value]) => <div key={label}><dt>{label}</dt><dd><code>{value}</code></dd></div>)}</dl>
</section> },
{ value: "request", label: t("请求"), content: <section>
{request ? <>
<div className={styles.meta}>{t("字节数")}:{request.byte_count}</div>
<h4>{t("请求头")}</h4><JsonEditor ariaLabel={t("请求头")} value={JSON.stringify(request.headers)} readOnly />
<h4>{t("请求体")}</h4><JsonEditor ariaLabel={t("请求体")} value={JSON.stringify(request.body)} readOnly detail />
</> : <div className={styles.empty}>{t("未记录请求内容,请开启详细记录后重试。")}</div>}
</section> },
{ value: "response", label: t("响应流"), content: <section>
{chunks.length > 0 ? <>
<div className={styles.meta}>{t("分块数")}:{chunks.length} · {t("字节数")}:{responseBytes}</div>
<JsonEditor ariaLabel={t("响应流")} value={responseBody} readOnly detail />
</> : <div className={styles.empty}>{t("未记录响应内容,请开启详细记录后重试。")}</div>}
</section> },
];
if (cursorTrace) tabs.push({ value: "cursor-trace", label: t("Cursor 追踪"), content: <section>
<div className={styles.meta}>
Request ID:{cursorTrace.trace.request_id} · {t("工件数")}:{cursorTrace.artifacts.length}
</div>
<JsonEditor
ariaLabel={t("Cursor 追踪")}
value={JSON.stringify({ trace: cursorTrace.trace, artifacts: cursorTrace.artifacts })}
readOnly
detail
/>
</section> });
return <div className={styles.root}><Tabs items={tabs} /></div>;
}
@@ -0,0 +1,27 @@
@use "../../styles/typography" as type;
.root {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--vscode-editor-background);
}
.scroller {
width: 100%;
height: 100%;
}
.content {
width: calc(100% - 36px);
max-width: var(--app-page-max-width);
padding: 28px 0 40px;
margin-right: auto;
margin-left: auto;
h1 {
margin: 0 0 20px;
font-size: type.$font-size-lg;
font-weight: 630;
}
}
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { api, type CallDetail } from "../../shared/api";
import { CallDetails } from "./CallDetails";
import { TitledCard } from "../../shared/ui/TitledCard";
import styles from "./CallDetailsPage.module.scss";
import { ScrollableContent } from "../../shared/virtual/ScrollableContent";
export function CallDetailsPage() {
const { callId = "" } = useParams();
const [detail, setDetail] = useState<CallDetail | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setDetail(null);
setError(null);
void api.call(callId).then((value) => {
if (!cancelled) setDetail(value);
}).catch((cause) => {
if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause));
});
return () => {
cancelled = true;
};
}, [callId]);
const content = error
? <TitledCard title={t("无法加载调用详情")}><div style={{ padding: 16 }}>{error}</div></TitledCard>
: detail
? <CallDetails detail={detail} />
: <TitledCard title={t("调用详情")}><div style={{ padding: 16 }}>{t("正在加载调用详情…")}</div></TitledCard>;
return <main className={styles.root}>
<ScrollableContent className={styles.scroller} contentClassName={styles.content}>
<h1>{detail?.call.display_name ?? t("调用详情")}</h1>
{content}
</ScrollableContent>
</main>;
}
@@ -0,0 +1,12 @@
@use "../../styles/typography" as type;
.status {
padding: 3px 7px;
color: var(--vscode-descriptionForeground);
background: var(--vscode-input-background);
border-radius: 99px;
font-size: type.$font-size-xs;
}
.completed { color: #56b870; }
.failed { color: var(--vscode-errorForeground, #f48771); }
@@ -0,0 +1,199 @@
import type { LlmCall } from "../../shared/api";
import controls from "../../shared/ui/Controls.module.scss";
import { DataTable, type DataTableColumn } from "../../shared/ui/DataTable";
import { Icon } from "../../shared/ui/Icon";
import { TooltipTrigger } from "../../shared/ui/TooltipTrigger";
import { eyeIcon } from "../../shared/ui/icons";
import styles from "./CallTable.module.scss";
const value = (input: string | number | null) => input ?? "-";
const milliseconds = (input: number | null) => input == null ? "-" : `${input} ms`;
export function CallTable({ calls, onDetails }: { calls: LlmCall[]; onDetails: (call: LlmCall) => void }) {
const columns: DataTableColumn<LlmCall>[] = [
{
key: "status",
header: t("状态"),
render: (call) => (
<span
className={[styles.status, styles[call.status]]
.filter(Boolean)
.join(" ")}
>
{call.status}
</span>
),
},
{
key: "display_name",
header: t("显示名称"),
render: (call) => call.display_name,
title: (call) => call.display_name,
},
{
key: "created_at",
header: t("时间"),
render: (call) => new Date(call.created_at_ms).toLocaleString(),
},
{
key: "model_id",
header: t("模型名称"),
render: (call) => call.model_id,
title: (call) => call.model_id,
},
{
key: "reasoning_effort",
header: t("思考强度"),
render: (call) => value(call.reasoning_effort),
},
{
key: "fast",
header: "Fast",
render: (call) => call.fast == null ? "-" : call.fast ? t("是") : t("否"),
},
{
key: "call_kind",
header: t("调用类型"),
render: (call) =>
call.call_kind === "cursor_official" ? t("Cursor 官方") : "LLM",
},
{
key: "route",
header: t("路由"),
render: (call) =>
call.route === "cursor_official" ? t("Cursor 官方") : "BYOK",
},
// { key: "model_hash", header: "Model Hash", render: (call) => value(call.model_hash), title: (call) => call.model_hash ?? undefined },
// { key: "provider_type", header: t("供应商类型"), render: (call) => call.provider_type },
// { key: "provider_url", header: t("供应商地址"), render: (call) => call.provider_url, title: (call) => call.provider_url },
// { key: "request_type", header: t("最终请求类型"), render: (call) => call.request_type },
// { key: "request_url", header: t("最终请求地址"), render: (call) => call.request_url, title: (call) => call.request_url },
{
key: "finish_reason",
header: "Finish Reason",
render: (call) => value(call.finish_reason),
},
{ key: "http", header: "HTTP", render: (call) => value(call.http_status) },
{
key: "duration",
header: t("耗时"),
render: (call) => milliseconds(call.duration_ms),
},
{
key: "ttfb",
header: "TTFB",
render: (call) => milliseconds(call.ttfb_ms),
},
{
key: "ttfr",
header: "TTFR",
render: (call) => milliseconds(call.ttfr_ms),
},
{
key: "ttft",
header: "TTFT",
render: (call) => milliseconds(call.ttft_ms),
},
{
key: "input_tokens",
header: "Input Token",
render: (call) => value(call.input_tokens),
},
{
key: "output_tokens",
header: "Output Token",
render: (call) => value(call.output_tokens),
},
{
key: "total_tokens",
header: "Total Token",
render: (call) => value(call.total_tokens),
},
{
key: "cache_read",
header: "Cache Read",
render: (call) => value(call.cache_read_tokens),
},
{
key: "cache_write",
header: "Cache Write",
render: (call) => value(call.cache_write_tokens),
},
{
key: "reasoning_tokens",
header: "Reasoning Token",
render: (call) => value(call.reasoning_tokens),
},
{
key: "message_count",
header: t("消息数"),
render: (call) => call.message_count,
},
{
key: "tool_count",
header: t("工具数"),
render: (call) => call.tool_count,
},
{
key: "detailed",
header: t("详细记录"),
render: (call) => (call.detailed ? t("是") : t("否")),
},
{
key: "error_kind",
header: "Error Kind",
render: (call) => value(call.error_kind),
title: (call) => call.error_kind ?? undefined,
},
{
key: "error_message",
header: "Error Message",
render: (call) => value(call.error_message),
title: (call) => call.error_message ?? undefined,
},
{
key: "call_id",
header: "Call ID",
render: (call) => call.call_id,
title: (call) => call.call_id,
},
{
key: "run_id",
header: "Run ID",
render: (call) => call.run_id,
title: (call) => call.run_id,
},
{
key: "conversation_id",
header: "Conversation ID",
render: (call) => call.conversation_id,
title: (call) => call.conversation_id,
},
{
key: "call_index",
header: "Call Index",
render: (call) => call.provider_call_index,
},
{
key: "actions",
header: t("操作"),
sticky: "right",
render: (call) => (
<TooltipTrigger label={t("查看详情")}>
<button
className={controls.iconButton}
aria-label={t("查看详情")}
onClick={() => onDetails(call)}
>
<Icon icon={eyeIcon} size="1.1em" />
</button>
</TooltipTrigger>
),
},
];
return <DataTable rows={calls} columns={columns} rowKey={(call) => call.call_id} minWidth={4180} />;
}
@@ -0,0 +1,11 @@
.pageContent {
height: 100%;
}
.page {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
display: flex;
}
@@ -0,0 +1,34 @@
import { useEffect } from "react";
import { CallTable } from "./CallTable";
import { PageContent } from "../../shell/layout/PageContent";
import { appStore, useAppStore } from "../../shared/store/appStore";
import styles from "./CallsPage.module.scss";
const CALL_REFRESH_INTERVAL_MS = 2_000;
export function CallsPage() {
const { calls } = useAppStore();
useEffect(() => {
let disposed = false;
const refreshCalls = () => {
if (!disposed && document.visibilityState === "visible") {
void appStore.refreshCalls();
}
};
refreshCalls();
const interval = window.setInterval(refreshCalls, CALL_REFRESH_INTERVAL_MS);
window.addEventListener("focus", refreshCalls);
document.addEventListener("visibilitychange", refreshCalls);
return () => {
disposed = true;
window.clearInterval(interval);
window.removeEventListener("focus", refreshCalls);
document.removeEventListener("visibilitychange", refreshCalls);
};
}, []);
const content = <div className={styles.page}><CallTable calls={calls} onDetails={(call) => void appStore.openCallDetails(call.call_id)} /></div>;
return <PageContent fixed title={t("调用")} contentClassName={styles.pageContent} sections={[{ key: "calls", estimatedHeight: 720, content }]} />;
}
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useState } from "react";
import { api, configuredPluginModels, type Overview } from "../../shared/api";
import { ContributionCalendarChart } from "./charts/ContributionCalendarChart";
import { DailyTokenUsageChart } from "./charts/DailyTokenUsageChart";
import { HomeMetrics } from "./metrics/HomeMetrics";
import { PageContent } from "../../shell/layout/PageContent";
import type { VirtualPageSection } from "../../shell/layout/VirtualPage";
import { OverviewTimeRangeFilter, type OverviewRangePreset } from "./overview/OverviewTimeRangeFilter";
import { PageActions } from "../../shell/PageActions";
import { appStore, useAppStore } from "../../shared/store/appStore";
import { formatTimeInput, parseTimeInput } from "../../shared/utils/parseTimeInput";
import { claudeIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
type TimeRange = { startMs: number; endMs: number };
function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new Date()): TimeRange {
const endMs = now.getTime();
if (preset === "today") {
const start = new Date(now);
start.setHours(0, 0, 0, 0);
return { startMs: start.getTime(), endMs };
}
if (preset === "month") {
const start = new Date(now);
start.setMonth(start.getMonth() - 1);
return { startMs: start.getTime(), endMs };
}
const duration = preset === "ten-minutes" ? 10 * 60_000 : preset === "hour" ? 60 * 60_000 : 7 * 24 * 60 * 60_000;
return { startMs: now.getTime() - duration, endMs };
}
export function HomePage() {
const { overview, busy, models, plugins } = useAppStore();
const [preset, setPreset] = useState<OverviewRangePreset>("month");
const [customRange, setCustomRange] = useState<TimeRange | null>(null);
const [customOpen, setCustomOpen] = useState(false);
const [customStart, setCustomStart] = useState("");
const [customEnd, setCustomEnd] = useState("");
const [selectedModels, setSelectedModels] = useState<string[]>([]);
const [appliedModels, setAppliedModels] = useState<string[]>([]);
const [rangeOverview, setRangeOverview] = useState<Overview | null>(null);
const [rangeBusy, setRangeBusy] = useState(false);
const [refreshVersion, setRefreshVersion] = useState(0);
const selectedRange = preset === "custom" ? customRange : presetRange(preset);
useEffect(() => {
if (!selectedRange) return;
let active = true;
setRangeBusy(true);
void api.overview({
...selectedRange,
modelHashes: appliedModels,
}).then((next) => {
if (active) setRangeOverview(next);
}).finally(() => {
if (active) setRangeBusy(false);
});
return () => { active = false; };
}, [preset, customRange, overview, refreshVersion, appliedModels]);
const filteredOverview = rangeOverview ?? overview;
const dailyTokenUsage = filteredOverview.token_usage_series.map((bucket) => ({
bucketStartMs: bucket.bucket_start_ms,
inputTokens: bucket.input_tokens,
cacheReadTokens: bucket.cache_read_tokens,
cacheWriteTokens: bucket.cache_write_tokens,
outputTokens: bucket.output_tokens,
}));
const contribution = overview.token_usage_series.map((bucket) => ({
date: new Date(bucket.bucket_start_ms).toISOString().slice(0, 10),
tokens: bucket.input_tokens + bucket.cache_read_tokens + bucket.cache_write_tokens + bucket.output_tokens,
}));
const metrics = {
llmCalls: filteredOverview.metrics.llm_calls,
successfulCalls: filteredOverview.metrics.successful_calls,
failedCalls: filteredOverview.metrics.failed_calls,
tokenUsage: filteredOverview.metrics.token_usage,
promptTokens: filteredOverview.metrics.prompt_tokens,
cacheReadTokens: filteredOverview.metrics.cache_read_tokens,
cacheWriteTokens: filteredOverview.metrics.cache_write_tokens,
};
const openCustom = (open: boolean) => {
if (open && !customStart && !customEnd) {
const now = new Date();
setCustomEnd(formatTimeInput(now));
setCustomStart(formatTimeInput(new Date(now.getTime() - 60 * 60_000)));
}
setCustomOpen(open);
};
const applyCustom = () => {
const startMs = parseTimeInput(customStart);
const endMs = parseTimeInput(customEnd);
if (startMs === null || endMs === null || startMs >= endMs) return;
setCustomRange({ startMs, endMs });
setAppliedModels(selectedModels);
setPreset("custom");
setCustomOpen(false);
};
const refresh = async () => {
await appStore.refresh();
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),
})),
...configuredPluginModels(plugins).map((model) => ({
value: model.id,
label: model.displayName,
icon: flatColorOrganizationIcon,
})),
];
const sections: VirtualPageSection[] = [
{
key: "daily-token-usage",
estimatedHeight: 240,
content: <DailyTokenUsageChart
data={dailyTokenUsage}
granularity={filteredOverview.token_usage_granularity}
/>,
},
{
key: "metrics",
estimatedHeight: 130,
content: <HomeMetrics data={metrics} refreshVersion={refreshVersion} />,
},
{
key: "activity",
estimatedHeight: 106,
content: <ContributionCalendarChart data={contribution} />,
},
];
return <>
<PageActions><OverviewTimeRangeFilter
value={preset}
customOpen={customOpen}
customStart={customStart}
customEnd={customEnd}
modelOptions={modelOptions}
selectedModels={selectedModels}
busy={busy || rangeBusy}
onSelect={(value) => { setPreset(value); setCustomOpen(false); }}
onCustomOpenChange={openCustom}
onCustomStartChange={setCustomStart}
onCustomEndChange={setCustomEnd}
onSelectedModelsChange={setSelectedModels}
onCustomApply={applyCustom}
onRefresh={() => void refresh()}
/></PageActions>
<PageContent title={t("概览")} sections={sections} />
</>;
}
@@ -0,0 +1,36 @@
@use "../../../styles/typography" as type;
.root {
width: 100%;
}
.scroller {
width: 100%;
overflow: hidden;
}
.canvas {
width: 100%;
flex: 0 0 auto;
}
.axis {
position: relative;
width: 100%;
height: 18px;
margin-top: 10px;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-2xs;
span {
position: absolute;
top: 0;
width: 28px;
white-space: nowrap;
}
}
.tooltipContent {
display: flex;
flex-direction: column;
}
@@ -0,0 +1,275 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { init, Rect, type ElementEvent } from "zrender";
import type { Locale } from "../../../i18n/runtime";
import { useI18n } from "../../../i18n/store";
import { Tooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
import styles from "./ContributionCalendarChart.module.scss";
export type ContributionDay = {
date: string;
tokens: number;
};
type ContributionCalendarChartProps = {
data: ContributionDay[];
};
type CalendarCell = ContributionDay & {
column: number;
row: number;
level: number;
};
type CellExtra = CalendarCell & {
kind: "calendar-cell";
x: number;
y: number;
width: number;
height: number;
};
type TooltipState = {
date: string;
tokens: number;
anchor: TooltipAnchor;
};
type AxisLabel = {
key: string;
text: string;
left: number;
};
const levelColors = [
"rgba(139, 148, 158, 0.20)",
"#9be9a8",
"#40c463",
"#30a14e",
"#216e39",
];
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const CALENDAR_CONFIG = {
cellAspectRatio: 0.9,
cellGap: 3,
resizeTransitionMs: 180,
rowCount: 7,
axisLabelGap: 8,
axisLabelWidth: 28,
} as const;
function parseDate(date: string) {
return new Date(`${date}T00:00:00Z`);
}
function mondayIndex(date: Date) {
return (date.getUTCDay() + 6) % 7;
}
function cellOffset(index: number, cellSize: number) {
return index * (cellSize + CALENDAR_CONFIG.cellGap);
}
function isCellExtra(value: unknown): value is CellExtra {
return typeof value === "object" && value !== null && (value as CellExtra).kind === "calendar-cell";
}
function buildCalendarLayout(data: ContributionDay[], locale: Locale) {
if (data.length === 0) return null;
const monthFormatter = new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" });
const maximum = Math.max(1, ...data.map(({ tokens }) => tokens));
const firstDate = parseDate(data[0].date);
const calendarStart = new Date(firstDate.getTime() - mondayIndex(firstDate) * DAY_IN_MS);
const cells: CalendarCell[] = data.map((day) => {
const date = parseDate(day.date);
const daysFromStart = Math.round((date.getTime() - calendarStart.getTime()) / DAY_IN_MS);
const level = day.tokens === 0 ? 0 : Math.max(1, Math.ceil((day.tokens / maximum) * 4));
return { ...day, column: Math.floor(daysFromStart / 7), row: mondayIndex(date), level };
});
const columnCount = cells.at(-1)!.column + 1;
const monthTicks = cells.reduce<Array<{ key: string; text: string; column: number }>>((ticks, cell) => {
const date = parseDate(cell.date);
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}`;
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: monthFormatter.format(date), column: cell.column });
return ticks;
}, []);
return { cells, columnCount, monthTicks };
}
export function ContributionCalendarChart({ data }: ContributionCalendarChartProps) {
const { locale } = useI18n();
const scrollerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const layoutRef = useRef<ReturnType<typeof buildCalendarLayout>>(null);
const scheduleDrawRef = useRef<() => void>(() => undefined);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]);
const layout = useMemo(() => buildCalendarLayout(data, locale), [data, locale]);
const tokenFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]);
layoutRef.current = layout;
useLayoutEffect(() => {
const scroller = scrollerRef.current;
const node = canvasRef.current;
if (!scroller || !node) return;
const chart = init(node, {
renderer: "canvas",
width: 1,
height: 1,
useDirtyRect: true,
});
const handleMouseOver = (event: ElementEvent) => {
const extra = event.target?.extra;
if (!isCellExtra(extra)) return;
const anchor: TooltipAnchor = {
contextElement: node,
getBoundingClientRect: () => {
const bounds = node.getBoundingClientRect();
return new DOMRect(bounds.left + extra.x, bounds.top + extra.y, extra.width, extra.height);
},
};
setTooltip({ date: extra.date, tokens: extra.tokens, anchor });
};
const handleMouseOut = (event: ElementEvent) => {
if (isCellExtra(event.target?.extra)) setTooltip(null);
};
chart.on("mouseover", handleMouseOver);
chart.on("mouseout", handleMouseOut);
const cellRects = new Map<string, Rect>();
let drawFrame = 0;
let lastAvailableWidth = -1;
let lastCanvasHeight = -1;
let lastLayout: typeof layout = null;
const draw = () => {
const currentLayout = layoutRef.current;
if (!currentLayout) return;
const availableWidth = Math.floor(scroller.getBoundingClientRect().width);
if (availableWidth <= 0 || (availableWidth === lastAvailableWidth && currentLayout === lastLayout)) return;
const gapsWidth = (currentLayout.columnCount - 1) * CALENDAR_CONFIG.cellGap;
const cellWidth = Math.max(0, (availableWidth - gapsWidth) / currentLayout.columnCount);
const cellHeight = cellWidth / CALENDAR_CONFIG.cellAspectRatio;
const width = availableWidth;
const height = CALENDAR_CONFIG.rowCount * cellHeight
+ (CALENDAR_CONFIG.rowCount - 1) * CALENDAR_CONFIG.cellGap;
let lastLabelEnd = -Infinity;
const nextAxisLabels = currentLayout.monthTicks.flatMap((tick) => {
const left = Math.min(
cellOffset(tick.column, cellWidth),
availableWidth - CALENDAR_CONFIG.axisLabelWidth,
);
if (left < lastLabelEnd + CALENDAR_CONFIG.axisLabelGap) return [];
lastLabelEnd = left + CALENDAR_CONFIG.axisLabelWidth;
return [{ ...tick, left }];
});
setAxisLabels(nextAxisLabels);
if (availableWidth !== lastAvailableWidth || height !== lastCanvasHeight) {
node.style.width = "100%";
node.style.height = `${height}px`;
chart.resize({ width, height });
lastAvailableWidth = availableWidth;
lastCanvasHeight = height;
}
lastLayout = currentLayout;
const currentDates = new Set(currentLayout.cells.map((cell) => cell.date));
for (const [date, rect] of cellRects) {
if (currentDates.has(date)) continue;
chart.remove(rect);
cellRects.delete(date);
}
for (const cell of currentLayout.cells) {
const x = cellOffset(cell.column, cellWidth);
const y = cellOffset(cell.row, cellHeight);
const shape = {
x,
y,
width: cellWidth,
height: cellHeight,
r: Math.min(3, Math.min(cellWidth, cellHeight) / 4),
};
const extra = {
...cell,
kind: "calendar-cell" as const,
x,
y,
width: cellWidth,
height: cellHeight,
} satisfies CellExtra;
const current = cellRects.get(cell.date);
if (current) {
current.extra = extra;
current.stopAnimation();
current.animateTo(
{ shape, style: { fill: levelColors[cell.level] } },
{ duration: CALENDAR_CONFIG.resizeTransitionMs, easing: "cubicOut" },
);
continue;
}
const rect = new Rect({
shape,
style: {
fill: levelColors[cell.level],
stroke: "rgba(139, 148, 158, 0.10)",
lineWidth: 1,
},
cursor: "default",
extra,
});
cellRects.set(cell.date, rect);
chart.add(rect);
}
setTooltip(null);
};
const scheduleDraw = () => {
window.cancelAnimationFrame(drawFrame);
drawFrame = window.requestAnimationFrame(draw);
};
scheduleDrawRef.current = scheduleDraw;
const observer = new ResizeObserver(scheduleDraw);
observer.observe(scroller);
scheduleDraw();
return () => {
observer.disconnect();
window.cancelAnimationFrame(drawFrame);
scheduleDrawRef.current = () => undefined;
chart.dispose();
};
}, [layout !== null]);
useLayoutEffect(() => {
scheduleDrawRef.current();
}, [layout]);
if (!layout) return null;
return (
<section className={styles.root} aria-label={t("过去一年的 Token 用量")}>
<div ref={scrollerRef} className={styles.scroller}>
<div
ref={canvasRef}
className={styles.canvas}
role="img"
aria-label={t("过去一年的 Token 用量日历")}
/>
<div className={styles.axis} aria-hidden="true">
{axisLabels.map((label) => <span key={label.key} style={{ left: label.left }}>{label.text}</span>)}
</div>
</div>
<Tooltip anchor={tooltip?.anchor ?? null}>
<div className={styles.tooltipContent}>
<strong>{tooltip?.date}</strong>
<span>{t("Token 用量:{tokens}", { tokens: tokenFormatter.format(tooltip?.tokens ?? 0) })}</span>
</div>
</Tooltip>
</section>
);
}
@@ -0,0 +1,7 @@
@use "../../../styles/typography" as type;
.root {
--daily-token-tooltip-font-size: #{type.$font-size-2xs};
width: 100%;
height: 200px;
}
@@ -0,0 +1,204 @@
import type { EChartsCoreOption } from "echarts/core";
import { useMemo, useState } from "react";
import type { TokenUsageGranularity } from "../../../shared/api";
import { formatCompactInteger } from "../../../shared/utils/numberFormat";
import { EChart } from "./EChart";
import styles from "./DailyTokenUsageChart.module.scss";
export type DailyTokenUsage = {
bucketStartMs: number;
inputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
outputTokens: number;
};
type TooltipItem = {
dataIndex: number;
};
const seriesColors = {
input: "#0091ff",
cacheRead: "#40c463",
cacheWrite: "#3A62BA",
output: "#E7B40B",
} as const;
const levelLineColor = "#E7B40B";
const emptyBarColor = "rgba(139, 148, 158, 0.20)";
const EMPTY_BAR_RATIO = 1;
const DATA_HEIGHT_RATIO = 1;
const seriesFocus = {
emphasis: { focus: "series" },
blur: { itemStyle: { opacity: 0.2 } },
} as const;
function colorMark(color: string): string {
return `<span style="display:inline-block;width:8px;height:8px;margin-right:6px;border-radius:50%;background-color:${color};vertical-align:middle"></span>`;
}
function formatDay(value: Date) {
return `${value.getUTCMonth() + 1}/${value.getUTCDate()}`;
}
function pad(value: number) {
return String(value).padStart(2, "0");
}
function formatAxisLabel(bucketStartMs: number, granularity: TokenUsageGranularity) {
const value = new Date(bucketStartMs);
if (granularity === "minute") return `${pad(value.getHours())}:${pad(value.getMinutes())}`;
if (granularity === "hour") return `${pad(value.getHours())}:00`;
const day = value.getUTCDay();
if (day === 6) return t("周六");
if (day === 0) return t("周日");
return formatDay(value);
}
function formatTooltipTime(bucketStartMs: number, granularity: TokenUsageGranularity) {
const value = new Date(bucketStartMs);
if (granularity === "day") return formatDay(value);
const time = `${pad(value.getHours())}:${pad(value.getMinutes())}`;
return `${value.getMonth() + 1}/${value.getDate()} ${time}`;
}
function totalTokens(day: DailyTokenUsage) {
return day.inputTokens + day.cacheReadTokens + day.cacheWriteTokens + day.outputTokens;
}
export function DailyTokenUsageChart({
data,
granularity,
}: {
data: DailyTokenUsage[];
granularity: TokenUsageGranularity;
}) {
const [hovered, setHovered] = useState(false);
const maximumTotal = data.reduce((maximum, day) => Math.max(maximum, totalTokens(day)), 0);
const axisMaximum = Math.max(1, maximumTotal / DATA_HEIGHT_RATIO);
const emptyBarHeight = axisMaximum * EMPTY_BAR_RATIO;
const nonZeroTotals = data.map(totalTokens).filter((total) => total !== 0);
const averageLevel = nonZeroTotals.length === 0
? 0
: nonZeroTotals.reduce((sum, total) => sum + total, 0) / nonZeroTotals.length;
const option = useMemo<EChartsCoreOption>(() => ({
animationDuration: 450,
animationEasing: "cubicOut",
grid: { top: 8, right: 0, bottom: 0, left: 0 },
tooltip: {
trigger: "axis",
confine: true,
backgroundColor: "var(--vscode-editorHoverWidget-background)",
borderColor: "var(--vscode-editorHoverWidget-border)",
textStyle: { color: "var(--vscode-foreground)", fontFamily: "PingFang-Medium" },
extraCssText: "border-radius: 8px; box-shadow: 0 12px 32px rgb(0 0 0 / 30%); font-size: var(--daily-token-tooltip-font-size); line-height: 1.5;",
axisPointer: {
type: "shadow",
shadowStyle: { color: "rgba(139, 148, 158, 0.14)" },
},
formatter: (params: unknown) => {
const first = (params as TooltipItem[])[0];
const day = data[first.dataIndex];
return [
formatTooltipTime(day.bucketStartMs, granularity),
`${t("总请求")}:${formatCompactInteger(totalTokens(day))}`,
`${colorMark(seriesColors.input)}${t("输入(非缓存)")}:${formatCompactInteger(day.inputTokens)}`,
`${colorMark(seriesColors.cacheRead)}${t("缓存输入")}:${formatCompactInteger(day.cacheReadTokens)}`,
`${colorMark(seriesColors.cacheWrite)}${t("缓存写入")}:${formatCompactInteger(day.cacheWriteTokens)}`,
`${colorMark(seriesColors.output)}${t("模型输出")}:${formatCompactInteger(day.outputTokens)}`,
].join("<br/>");
},
},
xAxis: {
type: "category",
data: data.map(({ bucketStartMs }) => bucketStartMs),
axisTick: { show: false },
axisLine: { lineStyle: { color: "rgba(139, 148, 158, 0.32)" } },
axisLabel: {
interval: "auto",
hideOverlap: true,
formatter: (_value: string, index: number) => formatAxisLabel(data[index].bucketStartMs, granularity),
color: "#8c8c8c",
fontFamily: "HFKos",
margin: 14,
},
},
yAxis: {
type: "value",
show: false,
min: 0,
max: axisMaximum,
},
series: [
{
name: t("无用量"),
type: "bar",
stack: "empty-placeholder",
data: data.map((day) => totalTokens(day) === 0 ? emptyBarHeight : 0),
barMaxWidth: 18,
silent: true,
z: 0,
itemStyle: { color: emptyBarColor, borderRadius: [2, 2, 0, 0] },
emphasis: { disabled: true },
},
{
name: t("输入(非缓存)"),
type: "bar",
stack: "tokens",
data: data.map(({ inputTokens }) => inputTokens),
barMaxWidth: 18,
itemStyle: { color: seriesColors.input },
...seriesFocus,
},
{
name: t("缓存输入"),
type: "bar",
stack: "tokens",
data: data.map(({ cacheReadTokens }) => cacheReadTokens),
barMaxWidth: 18,
itemStyle: { color: seriesColors.cacheRead },
...seriesFocus,
},
{
name: t("缓存写入"),
type: "bar",
stack: "tokens",
data: data.map(({ cacheWriteTokens }) => cacheWriteTokens),
barMaxWidth: 18,
itemStyle: { color: seriesColors.cacheWrite },
...seriesFocus,
},
{
name: t("模型输出"),
type: "bar",
stack: "tokens",
data: data.map(({ outputTokens }) => outputTokens),
barMaxWidth: 18,
barGap: "-100%",
itemStyle: { color: seriesColors.output, borderRadius: [2, 2, 0, 0] },
markLine: {
silent: true,
symbol: "none",
lineStyle: { color: levelLineColor, opacity: hovered ? 1 : 0, type: "dashed", width: 2 },
label: {
show: hovered,
position: "insideStartTop",
formatter: t("平均"),
color: levelLineColor,
distance: 8,
},
data: [{ yAxis: averageLevel }],
},
...seriesFocus,
},
],
}), [averageLevel, axisMaximum, data, emptyBarHeight, granularity, hovered]);
return <EChart
option={option}
className={styles.root}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
/>;
}
@@ -0,0 +1,4 @@
.root {
width: 100%;
height: 260px;
}
@@ -0,0 +1,38 @@
import { BarChart, GaugeChart, LineChart } from "echarts/charts";
import { GridComponent, LegendComponent, MarkLineComponent, TooltipComponent } from "echarts/components";
import { getInstanceByDom, init, use, type EChartsCoreOption } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { useEffect, useRef, type MouseEventHandler } from "react";
import styles from "./EChart.module.scss";
use([BarChart, GaugeChart, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, CanvasRenderer]);
type EChartProps = {
option: EChartsCoreOption;
className?: string;
onMouseEnter?: MouseEventHandler<HTMLDivElement>;
onMouseLeave?: MouseEventHandler<HTMLDivElement>;
};
export function EChart({ option, className = styles.root, onMouseEnter, onMouseLeave }: EChartProps) {
const element = useRef<HTMLDivElement>(null);
useEffect(() => {
const node = element.current;
if (!node) return;
const chart = init(node, undefined, { renderer: "canvas" });
const observer = new ResizeObserver(() => chart.resize());
observer.observe(node);
return () => {
observer.disconnect();
chart.dispose();
};
}, []);
useEffect(() => {
const node = element.current;
if (node) getInstanceByDom(node)?.setOption(option, { notMerge: true });
}, [option]);
return <div ref={element} className={className} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />;
}
@@ -0,0 +1,22 @@
import type { EChartsCoreOption } from "echarts/core";
import type { LlmCall } from "../../../shared/api";
import { EChart } from "./EChart";
export function LatencyChart({ calls }: { calls: LlmCall[] }) {
const points = calls.slice(0, 20).reverse();
const option: EChartsCoreOption = {
animationDuration: 280,
color: ["#d79a62", "#ad84cf", "#72a8d8"],
grid: { top: 22, right: 18, bottom: 30, left: 48 },
legend: { top: 0, right: 8, textStyle: { color: "#999" } },
tooltip: { trigger: "axis" },
xAxis: { type: "category", data: points.map((call) => new Date(call.created_at_ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })), axisLabel: { color: "#888" }, axisLine: { lineStyle: { color: "#5555" } } },
yAxis: { type: "value", axisLabel: { color: "#888", formatter: "{value} ms" }, splitLine: { lineStyle: { color: "#8882" } } },
series: [
{ name: "TTFR", type: "line", smooth: true, symbol: "none", data: points.map((call) => call.ttfr_ms ?? 0) },
{ name: "TTFT", type: "line", smooth: true, symbol: "none", data: points.map((call) => call.ttft_ms ?? 0) },
{ name: t("总耗时"), type: "line", smooth: true, symbol: "none", data: points.map((call) => call.duration_ms ?? 0) },
],
};
return <EChart option={option} />;
}
@@ -0,0 +1,21 @@
import type { EChartsCoreOption } from "echarts/core";
import type { LlmCall } from "../../../shared/api";
import { EChart } from "./EChart";
export function TokenTrendChart({ calls }: { calls: LlmCall[] }) {
const points = calls.slice(0, 20).reverse();
const option: EChartsCoreOption = {
animationDuration: 280,
color: ["#6f91f4", "#62c6a3"],
grid: { top: 22, right: 18, bottom: 30, left: 48 },
legend: { top: 0, right: 8, textStyle: { color: "#999" } },
tooltip: { trigger: "axis" },
xAxis: { type: "category", data: points.map((call) => new Date(call.created_at_ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })), axisLabel: { color: "#888" }, axisLine: { lineStyle: { color: "#5555" } } },
yAxis: { type: "value", axisLabel: { color: "#888" }, splitLine: { lineStyle: { color: "#8882" } } },
series: [
{ name: t("输入 Token"), type: "bar", stack: "tokens", data: points.map((call) => call.input_tokens ?? 0), barMaxWidth: 22 },
{ name: t("输出 Token"), type: "bar", stack: "tokens", data: points.map((call) => call.output_tokens ?? 0), barMaxWidth: 22 },
],
};
return <EChart option={option} />;
}
@@ -0,0 +1,34 @@
@use "../../../styles/typography" as type;
.root {
position: relative;
width: 200px;
max-width: 100%;
height: 82px;
overflow: hidden;
align-self: center;
flex: 0 0 auto;
}
.chart {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 140px;
pointer-events: none;
}
.label {
position: absolute;
right: 0;
bottom: 10px;
left: 0;
display: flex;
justify-content: center;
color: var(--vscode-foreground);
font-family: var(--oa-number-font);
font-size: type.$font-size-xl;
line-height: 1;
pointer-events: none;
}
@@ -0,0 +1,62 @@
import type { EChartsCoreOption } from "echarts/core";
import { useEffect, useMemo, useState } from "react";
import { EChart } from "../charts/EChart";
import styles from "./CacheHitRateChart.module.scss";
const valueColor = "#40c463";
const trackColor = "rgba(139, 148, 158, 0.20)";
export function CacheHitRateChart({ rate, animationKey = 0 }: { rate: number; animationKey?: number }) {
const finiteRate = Number.isFinite(rate) ? rate : 0;
const percentage = Math.max(0, Math.min(100, finiteRate * 100));
const [displayedPercentage, setDisplayedPercentage] = useState(0);
const label = Number.isFinite(rate) ? `${percentage.toFixed(2)}%` : "--";
useEffect(() => {
setDisplayedPercentage(0);
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => setDisplayedPercentage(percentage));
});
return () => cancelAnimationFrame(frame);
}, [animationKey, percentage]);
const option = useMemo<EChartsCoreOption>(() => ({
animationDuration: 0,
animationDurationUpdate: displayedPercentage > 0 ? 1_000 : 0,
animationEasing: "cubicOut",
animationEasingUpdate: "cubicOut",
series: [{
type: "gauge",
min: 0,
max: 100,
startAngle: 180,
endAngle: 0,
center: ["50%", "50%"],
radius: "90%",
silent: true,
pointer: { show: false },
progress: {
show: true,
roundCap: true,
width: 11,
itemStyle: { color: displayedPercentage > 0 ? valueColor : "transparent" },
},
axisLine: {
roundCap: true,
lineStyle: { width: 11, color: [[1, trackColor]] },
},
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
anchor: { show: false },
title: { show: false },
detail: { show: false },
data: [{ value: displayedPercentage }],
}],
}), [displayedPercentage]);
return <div className={styles.root} role="img" aria-label={t("缓存命中率 {rate}", { rate: label })}>
<EChart className={styles.chart} option={option} />
<div className={styles.label}>{label}</div>
</div>;
}
@@ -0,0 +1,86 @@
@use "../../../styles/typography" as type;
.scroller {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.root {
width: 100%;
height: 130px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
overflow: hidden;
border: 1px solid var(--vscode-sideBar-border);
border-radius: var(--oa-overlay-radius);
font-family: var(--oa-metrics-font);
}
.metric {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 16px;
& + & {
border-left: 1px solid var(--vscode-sideBar-border);
}
}
.label {
display: flex;
align-items: center;
gap: 5px;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
line-height: 1;
}
.info {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: inherit;
background: transparent;
border: 0;
transition: color 150ms ease;
opacity: 0.5;
&:hover,
&:focus-visible {
color: var(--vscode-foreground);
}
}
.body {
min-width: 0;
}
.value {
overflow: hidden;
color: var(--vscode-foreground);
font-family: var(--oa-number-font);
font-size: type.$font-size-xxl;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.secondary {
margin-top: 12px;
overflow: hidden;
color: var(--vscode-descriptionForeground);
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.tooltipText {
white-space: pre-wrap;
}
@@ -0,0 +1,179 @@
import { useState } from "react";
import { formatCompactInteger, formatInteger } from "../../../shared/utils/numberFormat";
import { Icon } from "../../../shared/ui/Icon";
import { Tooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
import { informationOutlineIcon } from "../../../shared/ui/icons";
import { CacheHitRateChart } from "./CacheHitRateChart";
import styles from "./HomeMetrics.module.scss";
export type HomeMetricsData = {
llmCalls: number;
successfulCalls: number;
failedCalls: number;
tokenUsage: number;
promptTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
};
const TOKEN_PRICE_PER_MILLION = {
input: 5,
output: 25,
cacheRead: 0.5,
cacheWrite: 6.25,
} as const;
function formatMetricValue(value: number) {
const full = formatInteger(value);
const compact = formatCompactInteger(value);
return full === compact ? full : `${full} (${compact})`;
}
function formatRate(value: number | null) {
return value === null ? t("暂无数据") : `${(Math.max(0, Math.min(1, value)) * 100).toFixed(2)}%`;
}
function calculateRate(numerator: number, denominator: number) {
return denominator > 0 ? numerator / denominator : null;
}
function priceTokens(tokens: number, pricePerMillion: number) {
return (tokens / 1_000_000) * pricePerMillion;
}
function formatUSD(value: number) {
return `$${value.toFixed(2)}`;
}
function elementAnchor(element: HTMLElement): TooltipAnchor {
return {
contextElement: element,
getBoundingClientRect: () => element.getBoundingClientRect(),
};
}
function InfoTooltip({ content }: { content: string }) {
const [anchor, setAnchor] = useState<TooltipAnchor | null>(null);
return <>
<button
type="button"
className={styles.info}
aria-label={t("查看说明")}
onMouseEnter={(event) => setAnchor(elementAnchor(event.currentTarget))}
onMouseLeave={() => setAnchor(null)}
onFocus={(event) => setAnchor(elementAnchor(event.currentTarget))}
onBlur={() => setAnchor(null)}
><Icon icon={informationOutlineIcon} size="1.1em" /></button>
<Tooltip anchor={anchor}>
<div className={styles.tooltipText}>{content}</div>
</Tooltip>
</>;
}
export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsData; refreshVersion?: number }) {
const inputTokens = Math.max(0, data.promptTokens - data.cacheReadTokens - data.cacheWriteTokens);
const outputTokens = Math.max(0, data.tokenUsage - data.promptTokens);
const defaultCacheHitRate = calculateRate(data.cacheReadTokens, data.cacheReadTokens + inputTokens);
const cacheReuseRate = calculateRate(
data.cacheReadTokens,
data.cacheReadTokens + data.cacheWriteTokens + inputTokens,
);
const successfulCallRate = calculateRate(data.successfulCalls, data.llmCalls);
const costs = {
input: priceTokens(inputTokens, TOKEN_PRICE_PER_MILLION.input),
output: priceTokens(outputTokens, TOKEN_PRICE_PER_MILLION.output),
cacheRead: priceTokens(data.cacheReadTokens, TOKEN_PRICE_PER_MILLION.cacheRead),
cacheWrite: priceTokens(data.cacheWriteTokens, TOKEN_PRICE_PER_MILLION.cacheWrite),
};
const totalCost = costs.input + costs.output + costs.cacheRead + costs.cacheWrite;
const cacheCost = costs.cacheRead + costs.cacheWrite;
const cacheTooltip = [
t("当前:{rate}", { rate: formatRate(defaultCacheHitRate) }),
t("公式:缓存读取 /(缓存读取 + 非缓存输入)"),
t("默认 {defaultRate} / 计入创建 {reuseRate}", {
defaultRate: formatRate(defaultCacheHitRate),
reuseRate: formatRate(cacheReuseRate),
}),
].join("\n");
const callsTooltip = [
t("按历史 LLM 调用记录汇总,进行中的调用不计入。"),
"",
t("总调用:{count}", { count: formatMetricValue(data.llmCalls) }),
t("成功调用:{count}", { count: formatMetricValue(data.successfulCalls) }),
t("异常调用:{count}", { count: formatMetricValue(data.failedCalls) }),
t("成功占比:{rate}", { rate: formatRate(successfulCallRate) }),
].join("\n");
const tokensTooltip = [
t("总请求 Token 包含提示词和模型输出。"),
"",
t("总请求:{tokens}", { tokens: formatMetricValue(data.tokenUsage) }),
t("提示词:{tokens}", { tokens: formatMetricValue(data.promptTokens) }),
t("输出推算:{tokens}", { tokens: formatMetricValue(outputTokens) }),
t("非缓存输入:{tokens}", { tokens: formatMetricValue(inputTokens) }),
t("缓存读取:{tokens}", { tokens: formatMetricValue(data.cacheReadTokens) }),
t("缓存写入:{tokens}", { tokens: formatMetricValue(data.cacheWriteTokens) }),
"",
t("缓存读写已计入提示词侧统计。"),
].join("\n");
const costTooltip = [
t("按 Claude Opus 4.7 价格估算。"),
t("缓存统计策略:默认口径({rate})", { rate: formatRate(defaultCacheHitRate) }),
"",
t("普通输入:{tokens} × ${price}/1M = {cost}", {
tokens: formatMetricValue(inputTokens),
price: TOKEN_PRICE_PER_MILLION.input,
cost: formatUSD(costs.input),
}),
t("模型输出:{tokens} × ${price}/1M = {cost}", {
tokens: formatMetricValue(outputTokens),
price: TOKEN_PRICE_PER_MILLION.output,
cost: formatUSD(costs.output),
}),
t("缓存读取:{tokens} × ${price}/1M = {cost}", {
tokens: formatMetricValue(data.cacheReadTokens),
price: TOKEN_PRICE_PER_MILLION.cacheRead,
cost: formatUSD(costs.cacheRead),
}),
t("缓存写入:{tokens} × ${price}/1M = {cost}", {
tokens: formatMetricValue(data.cacheWriteTokens),
price: TOKEN_PRICE_PER_MILLION.cacheWrite,
cost: formatUSD(costs.cacheWrite),
}),
"",
t("合计:{cost}", { cost: formatUSD(totalCost) }),
].join("\n");
return <div className={styles.scroller}>
<section className={styles.root} aria-label={t("调用统计")}>
<article className={styles.metric}>
<div className={styles.label}>{t("缓存命中率")}<InfoTooltip content={cacheTooltip} /></div>
<CacheHitRateChart rate={defaultCacheHitRate ?? 0} animationKey={refreshVersion} />
</article>
<article className={styles.metric}>
<div className={styles.label}>{t("LLM 调用")}<InfoTooltip content={callsTooltip} /></div>
<div className={styles.body}>
<div className={styles.value} title={formatInteger(data.llmCalls)}>{formatCompactInteger(data.llmCalls)}</div>
<div className={styles.secondary}>{t("成功 {successful} / 异常 {failed}", {
successful: formatCompactInteger(data.successfulCalls),
failed: formatCompactInteger(data.failedCalls),
})}</div>
</div>
</article>
<article className={styles.metric}>
<div className={styles.label}>{t("Token 消耗")}<InfoTooltip content={tokensTooltip} /></div>
<div className={styles.body}>
<div className={styles.value} title={formatInteger(data.tokenUsage)}>{formatCompactInteger(data.tokenUsage)}</div>
<div className={styles.secondary}>{t("提示词 {tokens}", { tokens: formatCompactInteger(data.promptTokens) })}</div>
</div>
</article>
<article className={styles.metric}>
<div className={styles.label}>{t("价值估算")}<InfoTooltip content={costTooltip} /></div>
<div className={styles.body}>
<div className={styles.value} title={formatUSD(totalCost)}>{formatUSD(totalCost)}</div>
<div className={styles.secondary}>{t("缓存读写 {cost}", { cost: formatUSD(cacheCost) })}</div>
</div>
</article>
</section>
</div>;
}
@@ -0,0 +1,78 @@
@use "../../../styles/typography" as type;
.root,
.presets {
display: flex;
align-items: center;
}
.filterRow { width: 100%; }
.root {
gap: 3px;
}
.presets {
overflow: hidden;
// border: 1px solid var(--vscode-sideBar-border);
border-radius: 5px;
button {
height: 25px;
padding: 0 8px;
color: var(--vscode-descriptionForeground);
background: transparent;
border: 0;
border-right: 1px solid var(--vscode-sideBar-border);
font-size: type.$font-size-xs;
white-space: nowrap;
&:last-child { border-right: 0; }
&:hover { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); }
&[aria-pressed="true"] { color: var(--vscode-list-activeSelectionForeground); background: var(--vscode-list-activeSelectionBackground); }
}
}
.popover {
position: fixed;
z-index: 14000;
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
color: var(--vscode-foreground);
background: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-dropdown-border);
border-radius: var(--oa-overlay-radius);
box-shadow: var(--oa-dropdown-shadow);
overflow: visible;
label {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
align-items: center;
gap: 8px;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
input {
min-width: 0;
height: 32px;
padding: 0 8px;
color: var(--vscode-input-foreground);
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
border-radius: 5px;
outline: none;
font: inherit;
&:focus { border-color: var(--vscode-focusBorder); }
}
}
.popoverActions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
@@ -0,0 +1,115 @@
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { parseTimeInput } from "../../../shared/utils/parseTimeInput";
import controls from "../../../shared/ui/Controls.module.scss";
import { Icon } from "../../../shared/ui/Icon";
import { MultiSelect, type MultiSelectOption } from "../../../shared/ui/MultiSelect";
import { TooltipTrigger } from "../../../shared/ui/TooltipTrigger";
import { refreshIcon } from "../../../shared/ui/icons";
import styles from "./OverviewTimeRangeFilter.module.scss";
export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom";
export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: {
value: OverviewRangePreset;
customOpen: boolean;
customStart: string;
customEnd: string;
modelOptions: MultiSelectOption[];
selectedModels: string[];
busy: boolean;
onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void;
onCustomOpenChange: (open: boolean) => void;
onCustomStartChange: (value: string) => void;
onCustomEndChange: (value: string) => void;
onSelectedModelsChange: (value: string[]) => void;
onCustomApply: () => void;
onRefresh: () => void;
}) {
const presets: Array<{ value: Exclude<OverviewRangePreset, "custom">; label: string }> = [
{ value: "hour", label: t("近1小时") },
{ value: "today", label: t("近1自然日") },
{ value: "ten-minutes", label: t("近10分钟") },
{ value: "week", label: t("近一周") },
{ value: "month", label: t("近一个月") },
];
const customButton = useRef<HTMLButtonElement>(null);
const popover = useRef<HTMLDivElement>(null);
const popoverId = useId();
const [position, setPosition] = useState({ left: 0, top: 0, width: 300, maxHeight: 480 });
useLayoutEffect(() => {
if (!customOpen || !customButton.current || !popover.current) return;
return autoUpdate(customButton.current, popover.current, () => void computePosition(customButton.current!, popover.current!, {
placement: "bottom-end",
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 }), size({
padding: 10,
apply: ({ availableHeight }) => setPosition((current) => ({
...current,
maxHeight: Math.max(240, availableHeight),
})),
})],
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
}, [customOpen]);
useEffect(() => {
if (!customOpen) return;
const closeOutside = (event: PointerEvent) => {
const target = event.target as Node;
if (!customButton.current?.contains(target) && !popover.current?.contains(target)) onCustomOpenChange(false);
};
document.addEventListener("pointerdown", closeOutside);
return () => document.removeEventListener("pointerdown", closeOutside);
}, [customOpen, onCustomOpenChange]);
const parsedStart = parseTimeInput(customStart);
const parsedEnd = parseTimeInput(customEnd);
const customValid = parsedStart !== null && parsedEnd !== null && parsedStart < parsedEnd;
return <div className={styles.root} aria-label={t("概览时间范围")}>
<div className={styles.presets}>
{presets.map((preset) => <button
key={preset.value}
type="button"
aria-pressed={value === preset.value}
onClick={() => onSelect(preset.value)}
>{preset.label}</button>)}
<button
ref={customButton}
type="button"
aria-haspopup="dialog"
aria-controls={customOpen ? popoverId : undefined}
aria-expanded={customOpen}
aria-pressed={value === "custom"}
onClick={() => onCustomOpenChange(!customOpen)}
>{t("自定义")}</button>
</div>
<TooltipTrigger label={t("刷新")}><button className={controls.iconButton} aria-label={t("刷新")} disabled={busy} onClick={onRefresh}>
<Icon className={busy ? controls.spin : ""} icon={refreshIcon} size="1.1em" />
</button></TooltipTrigger>
{customOpen && createPortal(<div
id={popoverId}
ref={popover}
className={styles.popover}
role="dialog"
aria-label={t("自定义概览筛选")}
style={position}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
onCustomOpenChange(false);
customButton.current?.focus();
}
}}
>
<label><span>{t("开始时间")}</span><input type="text" placeholder={t("如:2026-08-23 09:00、1小时前")} value={customStart} onChange={(event) => onCustomStartChange(event.target.value)} /></label>
<label><span>{t("结束时间")}</span><input type="text" placeholder={t("如:现在、2026-08-23 18:00")} value={customEnd} onChange={(event) => onCustomEndChange(event.target.value)} /></label>
<div className={styles.filterRow}><MultiSelect label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
<div className={styles.popoverActions}>
<button type="button" className={controls.secondary} onClick={() => onCustomOpenChange(false)}>{t("取消")}</button>
<button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button>
</div>
</div>, document.body)}
</div>;
}
@@ -0,0 +1,43 @@
import { createContext, useContext, type ReactNode } from "react";
import { useAppStore } from "../../shared/store/appStore";
import controls from "../../shared/ui/Controls.module.scss";
import styles from "./CursorSettings.module.scss";
const CaReady = createContext(false);
const ModelsReady = createContext(false);
export function CursorCaProvider({ children }: { children: ReactNode }) {
const { cursorHarness } = useAppStore();
return <CaReady.Provider value={cursorHarness?.ca === "ready"}>{children}</CaReady.Provider>;
}
export function CursorCaGate({ busy, waitingForRefresh, onInitialize, onRefresh, children }: { busy: boolean; waitingForRefresh: boolean; onInitialize: () => void; onRefresh: () => void; children: ReactNode }) {
const ready = useContext(CaReady);
const { cursorHarness } = useAppStore();
if (ready) return children;
const installedLocally = cursorHarness?.ca === "untrusted";
return <div className={styles.gate}>
<strong>{installedLocally ? t("需要在系统中信任本地 CA") : t("需要先初始化本地 CA")}</strong>
<span>{installedLocally ? t("请在终端中粘贴授权命令并输入密码,完成后点击下方按钮") : t("CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。")}</span>
<button className={controls.primary} disabled={busy} onClick={waitingForRefresh ? onRefresh : onInitialize}>{busy ? t("刷新中…") : waitingForRefresh ? t("我已初始化,刷新") : installedLocally ? t("打开终端安装 CA") : t("初始化 CA")}</button>
</div>;
}
export function CursorModelProvider({ children }: { children: ReactNode }) {
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 }) {
const ready = useContext(ModelsReady);
if (ready) return children;
return <div className={styles.gate}>
<strong>{t("还没有可供 Cursor 使用的模型")}</strong>
<span>{t("Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。")}</span>
<div className={styles.gateActions}>
<button className={controls.primary} disabled={busy} onClick={onAdd}>{t("添加模型")}</button>
<button className={controls.secondary} disabled={busy} onClick={onImport}>{previewingImport ? t("读取中…") : t("导入旧版配置")}</button>
</div>
</div>;
}
@@ -0,0 +1,312 @@
import type { IconifyIcon } from "@iconify/react/offline";
import { useEffect, useRef, useState, type ReactNode } from "react";
import Sortable from "sortablejs";
import type { Model, PluginModelDescriptor } from "../../shared/api";
import { Card } from "../../shared/ui/Card";
import { Icon } from "../../shared/ui/Icon";
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";
export type CursorModelGrouping = "flat" | "provider" | "type";
export type CursorModelGroup = {
key: string;
label: string;
icon: IconifyIcon;
models: Model[];
};
type CursorModelCardsProps = {
models: Model[];
pluginModels: PluginModelDescriptor[];
grouping: CursorModelGrouping;
disabled: boolean;
testingModelHashes: Set<string>;
testResults: Map<string, CursorModelTestState>;
onTest: (model: Model) => void;
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" | "pluginModels" | "onTestPluginModel" | "onPluginSettings"> & {
sortable: boolean;
};
export function cursorModelGroups(models: Model[], grouping: Exclude<CursorModelGrouping, "flat">): CursorModelGroup[] {
const groups = new Map<string, CursorModelGroup>();
for (const model of models) {
const descriptor = grouping === "provider" ? providerGroup(model) : typeGroup(model);
const group = groups.get(descriptor.key);
if (group) {
group.models.push(model);
} else {
groups.set(descriptor.key, { ...descriptor, models: [model] });
}
}
return [...groups.values()];
}
export function CursorModelCards(props: CursorModelCardsProps) {
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}>
{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>;
}
function ModelGrid({
models,
sortable: sortableEnabled,
disabled,
testingModelHashes,
testResults,
onTest,
onEdit,
onDuplicate,
onDelete,
onReorder,
}: ModelGridProps) {
const grid = useRef<HTMLDivElement>(null);
const sortable = useRef<Sortable | null>(null);
const currentModels = useRef(models);
const reorder = useRef(onReorder);
currentModels.current = models;
reorder.current = onReorder;
useEffect(() => {
if (!sortableEnabled || !grid.current) return;
sortable.current = Sortable.create(grid.current, {
animation: 160,
dataIdAttr: "data-model-hash",
draggable: `.${styles.modelCard}`,
handle: `.${styles.sortHandle}`,
ghostClass: styles.sortGhost,
chosenClass: styles.sortChosen,
dragClass: styles.sortDragging,
forceFallback: true,
fallbackOnBody: true,
fallbackTolerance: 3,
onEnd: (event) => {
const oldIndex = event.oldDraggableIndex ?? event.oldIndex;
const newIndex = event.newDraggableIndex ?? event.newIndex;
if (typeof oldIndex !== "number"
|| typeof newIndex !== "number"
|| oldIndex === newIndex) {
sortable.current?.sort(currentModels.current.map((model) => model.model_hash), false);
return;
}
const reordered = currentModels.current.slice();
const [moved] = reordered.splice(oldIndex, 1);
if (!moved || newIndex < 0 || newIndex > reordered.length) {
sortable.current?.sort(currentModels.current.map((model) => model.model_hash), false);
return;
}
reordered.splice(newIndex, 0, moved);
reorder.current(reordered.map((model) => model.model_hash));
},
});
return () => {
sortable.current?.destroy();
sortable.current = null;
};
}, [sortableEnabled]);
useEffect(() => {
sortable.current?.option("disabled", disabled);
sortable.current?.sort(models.map((model) => model.model_hash), false);
}, [disabled, models]);
return <div ref={grid} className={styles.modelGrid}>
{models.map((model) => {
const result = testResults.get(model.model_hash);
const testing = testingModelHashes.has(model.model_hash);
return <Card className={styles.modelCard} data-model-hash={model.model_hash} key={model.model_hash}>
{sortableEnabled && <button type="button" className={styles.sortHandle} disabled={disabled} aria-label={t("拖动排序")} title={t("拖动排序")} onClick={(event) => event.stopPropagation()}>
<Icon icon={dragIcon} size="1.25em" />
</button>}
<div className={styles.modelCardContent}>
<div className={styles.modelCardTop}>
<div className={styles.modelCardName}>
<span className={styles.modelCardNameText}>{model.display_name}</span>
<span className={styles.modelCardModelId}>{model.model_id}</span>
</div>
<span className={styles.modelTypeBadge}>
<Icon icon={model.type === "anthropic" ? claudeIcon : openAiIcon} />
{model.type === "anthropic" ? "Anthropic" : "OpenAI"}
</span>
</div>
<div className={styles.modelCardTest}>
<CursorModelTestResult state={result} testing={testing} />
</div>
<div className={styles.modelCardActions}>
<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>;
})}
</div>;
}
function providerGroup(model: Model) {
const key = providerDomain(model.base_url);
const label = model.group_name?.trim() || key;
return { key, label, icon: flatColorOrganizationIcon };
}
function providerDomain(baseUrl: string) {
const value = baseUrl.trim();
try {
return new URL(value).hostname.toLowerCase() || value;
} catch {
try {
return new URL(`https://${value}`).hostname.toLowerCase() || value;
} catch {
return value;
}
}
}
function typeGroup(model: Model) {
if (model.type === "anthropic") return { key: "anthropic", label: "Anthropic", icon: claudeIcon };
if (model.openai_endpoint === "/v1/chat/completions") return { key: "openai-chat", label: "OpenAI Chat", icon: openAiIcon };
return { key: "openai-responses", label: "OpenAI Responses", icon: openAiIcon };
}
@@ -0,0 +1,214 @@
import type { ModelInput, ModelType } from "../../shared/api";
import { defaultCustomHeadersText } from "../../shared/utils/modelDefaults";
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../shared/utils/modelPresets";
import { Button } from "../../shared/ui/Button";
import { Checkbox } from "../../shared/ui/Checkbox";
import { FormField, SecretTextInput, TextInput } from "../../shared/ui/FormControls";
import { JsonEditor } from "../../shared/ui/JsonEditor";
import { Combobox, Select } from "../../shared/ui/Select";
import { Switch } from "../../shared/ui/Switch";
import { claudeIcon, openAiIcon } from "../../shared/ui/icons";
import { CursorPresetChips } from "./CursorPresetChips";
import styles from "./CursorSettings.module.scss";
export type CursorModelDraft = {
providerId: string;
model: ModelInput;
openAIExtraParamsText: string;
customHeadersText: string;
anthropicExtraParamsText: string;
};
export const emptyCursorModelDraft = (): CursorModelDraft => ({
providerId: "builtin/openai",
model: {
sort_order: 0,
display_name: "",
group_name: null,
type: "openai",
base_url: "",
use_full_url: false,
api_key: "",
tooltip_data: "",
model_id: "",
reasoning_effort: null,
openai_endpoint: "/v1/responses",
openai_extra_params_enabled: false,
openai_extra_params: {},
custom_headers_enabled: false,
custom_headers: {},
anthropic_extra_params_enabled: false,
anthropic_extra_params: {},
context_window_tokens: null,
max_completion_tokens: null,
anthropic_max_tokens: null,
anthropic_thinking_effort: "xhigh",
thinking_budget_tokens: null,
},
openAIExtraParamsText: "{}",
customHeadersText: defaultCustomHeadersText,
anthropicExtraParamsText: "{}",
});
export function CursorModelEditor({ draft, modelOptions, discovering, onChange, onDiscover }: {
draft: CursorModelDraft;
modelOptions: string[];
discovering: boolean;
onChange: (draft: CursorModelDraft) => void;
onDiscover: () => void;
}) {
const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } });
const setType = (type: ModelType) => {
// 切换协议类型时,若当前地址命中某预设的另一协议端点,自动换到该预设对应协议的端点,
// 避免出现「类型是 Anthropic、URL 却是 OpenAI chat/completions」的错配
const other: ModelType = type === "anthropic" ? "openai" : "anthropic";
const preset = modelPresets.find((candidate) => trimTrailingSlash(presetEndpoint(candidate, other).baseUrl) === trimTrailingSlash(draft.model.base_url.trim()));
const endpoint = preset ? presetEndpoint(preset, type) : null;
onChange({
...draft,
providerId: `builtin/${type}`,
model: {
...draft.model,
type,
...(endpoint ? {
base_url: endpoint.baseUrl,
use_full_url: endpoint.useFullUrl,
custom_headers_enabled: endpoint.customHeaders !== null,
custom_headers: endpoint.customHeaders ? { ...endpoint.customHeaders } : {},
} : {}),
openai_endpoint: type === "openai" ? (endpoint?.openaiEndpoint || draft.model.openai_endpoint || "/v1/responses") : "",
anthropic_thinking_effort: type === "anthropic" ? draft.model.anthropic_thinking_effort || "xhigh" : null,
},
customHeadersText: endpoint?.customHeaders ? JSON.stringify(endpoint.customHeaders, null, 2) : draft.customHeadersText,
});
};
const numberValue = (value: string) => value === "" ? null : Math.trunc(Number(value));
const canDiscover = Boolean(draft.model.base_url.trim() && draft.model.api_key.trim());
// 选中预设后,把该服务商已知的模型 id 并入下拉,方便直接选(仍可用「获取模型」发现)
const presetModelOptions = modelPresets
.filter((preset) => trimTrailingSlash(presetEndpoint(preset, draft.model.type).baseUrl) === trimTrailingSlash(draft.model.base_url.trim()))
.flatMap((preset) => preset.models.map((item) => item.model_id));
const combinedOptions = [...new Set([...modelOptions, ...presetModelOptions])];
const applyPreset = (preset: ModelPreset) => {
const endpoint = presetEndpoint(preset, draft.model.type);
const first = preset.models[0];
// 切到别家服务商时清空 API Key(不同家的 Key 不能串用);同一家内切换协议则保留
const currentBase = trimTrailingSlash(draft.model.base_url.trim());
const sameProvider = [preset.endpoints.anthropic, preset.endpoints.openai]
.some((candidate) => trimTrailingSlash(candidate.baseUrl) === currentBase);
onChange({
...draft,
model: {
...draft.model,
base_url: endpoint.baseUrl,
use_full_url: endpoint.useFullUrl,
openai_endpoint: draft.model.type === "openai" ? endpoint.openaiEndpoint : draft.model.openai_endpoint,
custom_headers_enabled: endpoint.customHeaders !== null,
custom_headers: endpoint.customHeaders ? { ...endpoint.customHeaders } : {},
api_key: sameProvider ? draft.model.api_key : "",
model_id: first?.model_id ?? draft.model.model_id,
display_name: first?.display_name ?? draft.model.display_name,
tooltip_data: !draft.model.tooltip_data.trim() || draft.model.tooltip_data === t("备注") ? preset.name : draft.model.tooltip_data,
context_window_tokens: first?.context_window_tokens ?? draft.model.context_window_tokens,
...(draft.model.type === "openai"
? { max_completion_tokens: first?.max_output_tokens ?? draft.model.max_completion_tokens }
: { anthropic_max_tokens: first?.max_output_tokens ?? draft.model.anthropic_max_tokens }),
},
customHeadersText: endpoint.customHeaders ? JSON.stringify(endpoint.customHeaders, null, 2) : draft.customHeadersText,
});
};
const requestUrlPlaceholder = draft.model.use_full_url
? draft.model.type === "anthropic"
? "https://api.anthropic.com/v1/messages"
: draft.model.openai_endpoint === "/v1/chat/completions"
? "https://api.openai.com/v1/chat/completions"
: "https://api.openai.com/v1/responses"
: draft.model.type === "anthropic"
? "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.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" },
]} onChange={(openai_endpoint) => setModel({ openai_endpoint })} /></FormField>}
<div className={styles.urlField}>
<FormField label={draft.model.use_full_url ? t("完整请求 URL") : t("服务器地址")} hint={draft.model.use_full_url ? t("系统会原样使用此地址,不追加或修改请求路径。") : t("系统会根据请求协议自动追加标准端点路径。")}> <TextInput placeholder={requestUrlPlaceholder} value={draft.model.base_url} onChange={(event) => setModel({ base_url: event.target.value })} /></FormField>
<Checkbox checked={draft.model.use_full_url} label={t("使用完整请求地址")} onChange={(use_full_url) => setModel({ use_full_url })} />
</div>
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}> <SecretTextInput placeholder="sk-xxxxxx" autoComplete="off" value={draft.model.api_key} onChange={(event) => setModel({ api_key: event.target.value })} /></FormField>
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}> <Combobox value={draft.model.model_id} options={combinedOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</Button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /></FormField>
<FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <TextInput placeholder={t("例如:主力模型")} value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label={t("备注")} hint={t("显示在 Cursor 模型说明中。")}> <TextInput placeholder={t("请输入模型备注")} value={draft.model.tooltip_data} onChange={(event) => setModel({ tooltip_data: event.target.value })} /></FormField>
<FormField label={t("上下文窗口 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: numberValue(event.target.value) })} /></FormField>
{draft.model.type === "openai" ? <>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.max_completion_tokens ?? ""} onChange={(event) => setModel({ max_completion_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("推理强度")}> <Select ariaLabel={t("推理强度")} value={draft.model.reasoning_effort ?? ""} options={effortOptions(true)} onChange={(value) => setModel({ reasoning_effort: value || null })} /></FormField>
</> : <>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.anthropic_max_tokens ?? ""} onChange={(event) => setModel({ anthropic_max_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("思考强度")}> <Select ariaLabel={t("思考强度")} value={draft.model.anthropic_thinking_effort ?? "xhigh"} options={effortOptions(false)} onChange={(anthropic_thinking_effort) => setModel({ anthropic_thinking_effort })} /></FormField>
<FormField label={t("思考预算 Token")} hint={t("留空时使用 adaptive thinking。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用 adaptive thinking")} value={draft.model.thinking_budget_tokens ?? ""} onChange={(event) => setModel({ thinking_budget_tokens: numberValue(event.target.value) })} /></FormField>
</>}
<ToggleJsonField
label={t("自定义 Headers")}
enabled={draft.model.custom_headers_enabled}
text={draft.customHeadersText}
onEnabledChange={(custom_headers_enabled) => setModel({ custom_headers_enabled })}
onTextChange={(customHeadersText) => onChange({ ...draft, customHeadersText })}
/>
{draft.model.type === "openai" ? <ToggleJsonField
label={t("OpenAI 额外参数")}
enabled={draft.model.openai_extra_params_enabled}
text={draft.openAIExtraParamsText}
onEnabledChange={(openai_extra_params_enabled) => setModel({ openai_extra_params_enabled })}
onTextChange={(openAIExtraParamsText) => onChange({ ...draft, openAIExtraParamsText })}
/> : <ToggleJsonField
label={t("Anthropic 额外参数")}
enabled={draft.model.anthropic_extra_params_enabled}
text={draft.anthropicExtraParamsText}
onEnabledChange={(anthropic_extra_params_enabled) => setModel({ anthropic_extra_params_enabled })}
onTextChange={(anthropicExtraParamsText) => onChange({ ...draft, anthropicExtraParamsText })}
/>}
</div>
</div>;
}
function ToggleJsonField({ label, enabled, text, onEnabledChange, onTextChange }: {
label: string;
enabled: boolean;
text: string;
onEnabledChange: (enabled: boolean) => void;
onTextChange: (text: string) => void;
}) {
return <div className={`${styles.fullWidth} ${styles.jsonOption}`}>
<label><span>{label}</span><Switch label={label} checked={enabled} onChange={onEnabledChange} /></label>
{enabled && <JsonEditor ariaLabel={label} value={text} onChange={onTextChange} />}
</div>;
}
function effortOptions(optional: boolean) {
return [
...(optional ? [{ value: "", label: t("不设置") }] : []),
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "Extra High" },
{ value: "max", label: "Max" },
];
}
@@ -0,0 +1,77 @@
@use "../../styles/typography" as type;
.root {
min-width: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 7px 9px;
border: 1px solid transparent;
border-radius: 6px;
font-size: type.$font-size-xs;
}
.idle {
color: var(--vscode-descriptionForeground);
background: color-mix(in srgb, var(--vscode-foreground) 6%, transparent);
border-color: color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
}
.testing {
color: var(--vscode-textLink-foreground);
background: color-mix(in srgb, var(--vscode-textLink-foreground) 10%, transparent);
border-color: color-mix(in srgb, var(--vscode-textLink-foreground) 24%, transparent);
}
.success {
color: var(--vscode-testing-iconPassed, #73c991);
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 11%, transparent);
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 28%, transparent);
}
.error {
color: var(--vscode-errorForeground, #f48771);
background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 10%, transparent);
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;
text-overflow: ellipsis;
white-space: nowrap;
}
.details {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
color: currentColor;
background: transparent;
border: 0;
font: inherit;
cursor: help;
white-space: nowrap;
&:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
}
}
@@ -0,0 +1,64 @@
import type { ModelConnectivityResult } from "../../shared/api";
import { Icon } from "../../shared/ui/Icon";
import { TooltipTrigger } from "../../shared/ui/TooltipTrigger";
import { informationOutlineIcon } from "../../shared/ui/icons";
import styles from "./CursorModelTestResult.module.scss";
export type CursorModelTestState =
| { status: "success"; result: ModelConnectivityResult }
| { status: "error"; error: string }
| { status: "cancelled" };
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
? t("速度:{speed} tokens/s", { speed: formatSpeed(state.result.tokens_per_second) })
: t("错误:{error}", { error: state.error });
const detail = success
? t("速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}", {
speed: formatSpeed(state.result.tokens_per_second),
firstText: state.result.first_valid_response_ms ?? "--",
duration: state.result.duration_ms,
tokens: state.result.output_tokens,
estimated: state.result.tokens_estimated ? t("(估算)") : "",
output: state.result.output || "--",
})
: 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>
</div>;
}
function formatSpeed(value: number) {
return Number.isFinite(value) ? value.toFixed(1) : "0.0";
}
@@ -0,0 +1,47 @@
.wrap {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.label {
font-size: 12px;
color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.7));
}
.chips {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border: 1px solid var(--vscode-editorWidget-border, rgba(255, 255, 255, 0.16));
border-radius: 999px;
background: transparent;
color: inherit;
font-size: 12px;
cursor: pointer;
&:hover {
border-color: var(--vscode-focusBorder, #49b0ff);
}
&.active {
border-color: var(--vscode-focusBorder, #49b0ff);
color: var(--vscode-focusBorder, #49b0ff);
}
}
.icon {
width: 14px;
height: 14px;
border-radius: 3px;
object-fit: contain;
}
@@ -0,0 +1,25 @@
import type { ModelType } from "../../shared/api";
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../shared/utils/modelPresets";
import styles from "./CursorPresetChips.module.scss";
/** 常用服务商预设:点击按当前协议类型自动填充对应端点与默认模型 */
export function CursorPresetChips({ type, baseUrl, onPick }: { type: ModelType; baseUrl: string; onPick: (preset: ModelPreset) => void }) {
return <div className={styles.wrap}>
<span className={styles.label}>{t("常用预设")}</span>
<div className={styles.chips}>
{modelPresets.map((preset) => {
const active = trimTrailingSlash(baseUrl) === trimTrailingSlash(presetEndpoint(preset, type).baseUrl);
return <button
type="button"
key={preset.key}
className={active ? `${styles.chip} ${styles.active}` : styles.chip}
title={preset.keyHint}
onClick={() => onPick(preset)}
>
<img className={styles.icon} src={preset.icon} alt="" />
{preset.name}
</button>;
})}
</div>
</div>;
}
@@ -0,0 +1,340 @@
@use "../../styles/typography" as type;
.page {
display: grid;
gap: 16px;
}
.groupActions {
display: flex;
align-items: center;
overflow: hidden;
border-radius: 5px;
button {
height: 25px;
padding: 0 8px;
color: var(--vscode-descriptionForeground);
background: transparent;
border: 0;
border-right: 1px solid var(--vscode-sideBar-border);
font-size: type.$font-size-xs;
white-space: nowrap;
&:last-child { border-right: 0; }
&:hover:not(:disabled) { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); }
&[aria-pressed="true"] { color: var(--vscode-list-activeSelectionForeground); background: var(--vscode-list-activeSelectionBackground); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
}
.gateActions {
display: flex;
align-items: center;
gap: 8px;
}
.gate {
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: 440px;
font-size: type.$font-size-xs;
}
button {
margin-top: 6px;
}
}
.modelGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 12px;
}
.modelGroups {
display: flex;
flex-direction: column;
gap: 20px;
padding-top: 10px;
}
.groupCard {
padding: 4px 12px 8px;
}
.groupHeader {
display: flex;
align-items: center;
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;
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;
padding: 16px;
}
.modelCardContent {
min-height: 120px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 12px;
}
.modelCardTop {
min-width: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.modelCardName {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
.modelCardNameText,
.modelCardModelId {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modelCardNameText {
color: var(--vscode-foreground);
font-size: type.$font-size-base;
}
.modelCardModelId {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-2xs;
}
}
.modelTypeBadge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 7px;
color: var(--vscode-foreground);
border: 1px solid var(--vscode-sideBar-border);
border-radius: 999px;
font-size: type.$font-size-2xs;
}
.modelCardTest {
min-height: 31px;
display: flex;
align-items: center;
}
.modelCardActions {
display: flex;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 8px;
// 空间不足时按钮收缩显示省略号,而不是换行。
> button {
min-width: 0;
flex: 0 1 auto;
}
}
.deleteButton:hover {
color: var(--vscode-errorForeground, #f48771);
}
.sortHandle {
position: absolute;
top: 8px;
left: 8px;
z-index: 1;
width: 30px;
height: 30px;
display: grid;
place-items: center;
padding: 0;
color: transparent;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
opacity: 0;
cursor: grab;
touch-action: none;
transition: opacity 160ms ease, color 160ms ease, border-color 160ms ease, background-color 160ms ease;
&:focus-visible,
.modelCard:hover & {
color: var(--vscode-foreground);
background: var(--vscode-list-hoverBackground);
border-color: var(--vscode-sideBar-border);
opacity: 1;
}
&:active {
cursor: grabbing;
}
&:disabled {
cursor: not-allowed;
opacity: 0.3;
}
}
.sortGhost {
opacity: 0.4;
}
.sortChosen {
border-color: var(--vscode-focusBorder);
}
.sortDragging {
cursor: grabbing;
}
.editor {
display: flex;
flex-direction: column;
gap: 14px;
}
.command {
margin: 0;
padding: 10px;
overflow: auto;
color: var(--vscode-textLink-foreground);
background: var(--vscode-textCodeBlock-background);
border-radius: 4px;
white-space: pre-wrap;
user-select: text;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.fullWidth {
width: 100%;
grid-column: 1 / -1;
}
.discoverButton {
min-height: 34px;
height: 34px;
flex: 0 0 auto;
}
.urlField {
min-width: 0;
display: flex;
flex-direction: column;
gap: 7px;
}
.switches {
display: flex;
flex-direction: column;
gap: 9px;
label {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.jsonOption {
display: flex;
flex-direction: column;
gap: 8px;
> label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
@media (max-width: 720px) {
.grid {
grid-template-columns: 1fr;
}
.fullWidth {
grid-column: auto;
}
}
@@ -0,0 +1,380 @@
import { useCallback, useEffect, useRef, useState } from "react";
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 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";
import { TooltipTrigger } from "../../shared/ui/TooltipTrigger";
import { addIcon } from "../../shared/ui/icons";
import { useMessage } from "../../shared/ui/message";
import { PageActions } from "../../shell/PageActions";
import { appStore, useAppStore } from "../../shared/store/appStore";
export function CursorSettingsPage() {
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);
const [modelOptions, setModelOptions] = useState<string[]>([]);
const [discovering, setDiscovering] = useState(false);
const [caCommand, setCaCommand] = useState<string | null>(null);
const [waitingForCaRefresh, setWaitingForCaRefresh] = useState(false);
const [deleting, setDeleting] = useState<Model | null>(null);
const [testingModelHashes, setTestingModelHashes] = useState<Set<string>>(() => new Set());
const [modelTestResults, setModelTestResults] = useState<Map<string, CursorModelTestState>>(() => new Map());
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;
const canGroupByType = typeGroups.length > 1;
useEffect(() => {
if ((grouping === "provider" && !canGroupByProvider) || (grouping === "type" && !canGroupByType)) {
setGrouping("flat");
}
}, [canGroupByProvider, canGroupByType, grouping]);
useEffect(() => {
if (caCommand) void api.copyCursorText(caCommand);
}, [caCommand]);
const initializeCa = async () => {
const status = await appStore.initializeCursorCa();
if (status?.ca === "untrusted" && status.ca_install_command) setCaCommand(status.ca_install_command);
};
const openNew = () => {
const next = emptyCursorModelDraft();
next.model.sort_order = models.length + 1;
setEditing(null);
setModelOptions([]);
setDraft(next);
};
const openEdit = (model: Model) => {
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),
anthropicExtraParamsText: JSON.stringify(model.anthropic_extra_params, null, 2),
});
};
const discover = async () => {
if (!draft) return;
setDiscovering(true);
try {
const custom_headers = parseHeaders(draft.customHeadersText);
const result = await api.discoverModels({
type: draft.model.type,
base_url: draft.model.base_url.trim(),
api_key: draft.model.api_key.trim(),
custom_headers_enabled: draft.model.custom_headers_enabled,
custom_headers,
});
setModelOptions([...new Set(result.models)]);
} catch (cause) {
message(errorText(cause));
} finally {
setDiscovering(false);
}
};
const persist = async (): Promise<Model | null> => {
if (!draft) return null;
const input = draftInput(draft);
if (editing) return appStore.updateCursorModel(editing.model_hash, input);
return (await appStore.createModels([input]))?.[0] ?? null;
};
const save = async () => {
try {
if (await persist()) {
setDraft(null);
setEditing(null);
}
} catch (cause) {
message(errorText(cause));
}
};
const cancelModelTest = async (modelHash: string) => {
const active = activeModelTests.current.get(modelHash);
if (!active || active.cancelling) return;
active.cancelling = true;
try {
await api.cancelModelTest(modelHash, active.testId);
active.controller.abort();
} catch (cause) {
active.cancelling = false;
message(t("取消测试失败:{error}", { error: errorText(cause) }), { duration: 5000 });
}
};
const cancelAllModelTests = async () => {
await Promise.all([...activeModelTests.current.keys()].map((modelHash) => cancelModelTest(modelHash)));
};
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";
}
const active = { testId: crypto.randomUUID(), controller: new AbortController(), cancelling: false };
activeModelTests.current.set(model.model_hash, active);
setTestingModelHashes((current) => new Set(current).add(model.model_hash));
try {
const result = await api.testModel(model.model_hash, active.testId, active.controller.signal);
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "success", result }));
if (notify) message(t("模型 {model} 连通性测试成功({duration} ms)", { model: model.display_name, duration: result.duration_ms }));
return "success";
} catch (cause) {
if (active.cancelling || active.controller.signal.aborted) {
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "cancelled" }));
return "cancelled";
}
const error = errorText(cause);
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "error", error }));
if (notify) message(t("连通性测试失败:{error}", { error }), { duration: 5000 });
return "failure";
} finally {
if (activeModelTests.current.get(model.model_hash) === active) activeModelTests.current.delete(model.model_hash);
setTestingModelHashes((current) => {
const next = new Set(current);
next.delete(model.model_hash);
return next;
});
}
};
const saveAndTest = async () => {
setSavingAndTesting(true);
let saved: Model | null = null;
try {
saved = await persist();
} catch (cause) {
message(errorText(cause));
} finally {
setSavingAndTesting(false);
}
if (!saved) return;
setEditing(saved);
await testModel(saved);
await appStore.refresh();
};
const testAllModels = async () => {
if (!testTargets.length || batchTesting) return;
setBatchTesting(true);
try {
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: testTargets.length })
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
{ duration: failed === 0 && cancelled === 0 ? 2400 : 5000 });
} finally {
setBatchTesting(false);
}
};
const duplicateModel = async (model: Model) => {
const names = new Set(models.map((item) => item.display_name));
const baseName = t("{name} 副本", { name: model.display_name });
let displayName = baseName;
let suffix = 2;
while (names.has(displayName)) {
displayName = `${baseName} ${suffix}`;
suffix += 1;
}
const created = await appStore.createModels([{
...modelInput(model),
sort_order: models.length + 1,
display_name: displayName,
}]);
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("排序失败"));
}
}, [message]);
const list = <CursorModelCards
models={models}
pluginModels={pluginModels}
grouping={grouping}
disabled={cursorBusy}
testingModelHashes={testingModelHashes}
testResults={modelTestResults}
onTest={(model) => void testModel(model)}
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 () => {
await appStore.refresh();
if (appStore.getSnapshot().cursorHarness?.ca !== "ready") setWaitingForCaRefresh(false);
};
const openCaTerminal = () => {
if (caCommand) void api.openCursorCaInstallTerminal(caCommand).catch((cause) => message(errorText(cause)));
setCaCommand(null);
setWaitingForCaRefresh(true);
};
const content = <CursorCaProvider><CursorCaGate busy={cursorBusy} waitingForRefresh={waitingForCaRefresh} onInitialize={() => void initializeCa()} onRefresh={() => void refreshCa()}>
<div className={styles.page}>
<LegacyModelImport>{({ busy: importingLegacyModels, previewing, open }) =>
<CursorModelProvider><CursorModelGate busy={cursorBusy || importingLegacyModels} previewingImport={previewing} onAdd={openNew} onImport={open}>{list}</CursorModelGate></CursorModelProvider>
}</LegacyModelImport>
</div>
</CursorCaGate></CursorCaProvider>;
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 + pluginSectionHeight)
: Math.max(380, activeGroups.reduce((height, group) => height + 60 + group.models.length * 56, 0) + Math.max(0, activeGroups.length - 1) * 20 + pluginSectionHeight);
return <>
{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>}
{canGroupByType && <button type="button" aria-pressed={grouping === "type"} onClick={() => setGrouping("type")}>{t("按类型")}</button>}
<button type="button" disabled={cursorBusy || (!batchTesting && testingModelHashes.size > 0)} onClick={() => void (batchTesting ? cancelAllModelTests() : testAllModels())}>{batchTesting ? t("取消全部测试") : t("一键测试")}</button>
</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="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()} />
</>}
</Modal>
<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>
</>;
}
function modelInput(model: Model): ModelInput {
const { model_hash: _hash, created_at_ms: _created, updated_at_ms: _updated, ...input } = model;
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,
display_name: draft.model.display_name.trim(),
base_url: draft.model.base_url.trim(),
api_key: draft.model.api_key.trim(),
tooltip_data: draft.model.tooltip_data.trim(),
model_id: draft.model.model_id.trim(),
openai_extra_params: parseObject(draft.openAIExtraParamsText, t("OpenAI 额外参数")),
custom_headers: parseHeaders(draft.customHeadersText),
anthropic_extra_params: parseObject(draft.anthropicExtraParamsText, t("Anthropic 额外参数")),
};
if (!model.display_name || !model.base_url || !model.api_key || !model.tooltip_data || !model.model_id) throw new Error(t("服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空"));
for (const [label, value] of [[t("上下文窗口 Token"), model.context_window_tokens], [t("最大输出 Token"), model.type === "openai" ? model.max_completion_tokens : model.anthropic_max_tokens], [t("思考预算 Token"), model.thinking_budget_tokens]] as const) {
if (value !== null && (!Number.isSafeInteger(value) || value <= 0)) throw new Error(t("{label} 必须是大于 0 的整数", { label }));
}
return model;
}
function parseHeaders(text: string): Record<string, string> {
const parsed = parseObject(text, t("自定义 Headers"));
if (Object.values(parsed).some((value) => typeof value !== "string")) throw new Error(t("自定义 Headers 的值必须都是字符串"));
return parsed as Record<string, string>;
}
function parseObject(text: string, label: string): Record<string, unknown> {
let parsed: unknown;
try { parsed = JSON.parse(text || "{}"); } catch { throw new Error(t("{label} 必须是有效 JSON", { label })); }
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new Error(t("{label} 必须是 JSON 对象", { label }));
return parsed as Record<string, unknown>;
}
function errorText(cause: unknown) {
return cause instanceof Error ? cause.message : String(cause);
}
@@ -0,0 +1,92 @@
@use "../../styles/typography" as type;
.content {
display: grid;
gap: 14px;
}
.source {
display: grid;
gap: 6px;
code {
overflow-wrap: anywhere;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.counts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
> div {
display: grid;
gap: 3px;
padding: 10px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
border-radius: 5px;
}
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.models {
display: grid;
gap: 6px;
}
.model {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 9px 10px;
background: var(--vscode-list-inactiveSelectionBackground);
border-radius: 5px;
> div {
display: grid;
min-width: 0;
gap: 3px;
}
small {
overflow: hidden;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
text-overflow: ellipsis;
white-space: nowrap;
}
> span {
flex: 0 0 auto;
font-size: type.$font-size-xs;
}
}
.new {
color: var(--vscode-testing-iconPassed, #73c991);
}
.existing,
.hint {
color: var(--vscode-descriptionForeground);
}
.hint {
font-size: type.$font-size-xs;
}
@media (max-width: 680px) {
.model {
align-items: stretch;
flex-direction: column;
}
}
@@ -0,0 +1,92 @@
import { useState, type ReactNode } from "react";
import { api, type LegacyModelImportPreview } from "../../shared/api";
import { appStore } from "../../shared/store/appStore";
import { ConfirmDialog } from "../../shared/ui/ConfirmDialog";
import { useMessage } from "../../shared/ui/message";
import styles from "./LegacyModelImport.module.scss";
type LegacyModelImportControl = {
busy: boolean;
previewing: boolean;
open: () => void;
};
export function LegacyModelImport({ children }: { children: (control: LegacyModelImportControl) => ReactNode }) {
const message = useMessage();
const [preview, setPreview] = useState<LegacyModelImportPreview | null>(null);
const [previewing, setPreviewing] = useState(false);
const [importing, setImporting] = useState(false);
const open = async () => {
try {
setPreviewing(true);
setPreview(await api.previewV0049Models());
} catch (cause) {
message(errorText(cause));
} finally {
setPreviewing(false);
}
};
const confirm = async () => {
try {
setImporting(true);
const result = await appStore.importV0049Models();
if (!result) {
const error = appStore.getSnapshot().error;
if (error) message(error);
return;
}
setPreview(null);
message(result.imported > 0
? t("导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型", { imported: result.imported, skipped: result.skipped })
: t("配置中的 {count} 个模型均已存在,无需重复导入", { count: result.skipped }));
} catch (cause) {
message(errorText(cause));
} finally {
setImporting(false);
}
};
return <>
{children({ busy: previewing || importing, previewing, open: () => void open() })}
<ConfirmDialog
open={preview !== null}
title={t("确认导入旧版模型配置")}
busy={importing}
wide
cancelLabel={t("取消")}
confirmLabel={t("确认导入")}
onCancel={() => setPreview(null)}
onConfirm={() => void confirm()}
>
{preview && <div className={styles.content}>
<div className={styles.source}>
<strong>{t("配置文件")}</strong>
<code>{preview.source}</code>
</div>
<div className={styles.counts}>
<div><strong>{preview.total}</strong><small>{t("配置模型")}</small></div>
<div><strong>{preview.new_models}</strong><small>{t("将新增")}</small></div>
<div><strong>{preview.existing_models}</strong><small>{t("已存在")}</small></div>
</div>
<div className={styles.models}>
{preview.models.map((model) => <div className={styles.model} key={model.model_hash}>
<div>
<strong>{model.display_name}</strong>
<small>{model.model_id} · {model.type === "openai" ? "OpenAI" : "Anthropic"}</small>
</div>
<span className={model.existing ? styles.existing : styles.new}>
{model.existing ? t("已存在,跳过") : t("新增")}
</span>
</div>)}
</div>
<small className={styles.hint}>{t("重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。")}</small>
</div>}
</ConfirmDialog>
</>;
}
function errorText(cause: unknown) {
return cause instanceof Error ? cause.message : String(cause);
}
@@ -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);
}
@@ -0,0 +1,31 @@
@use "../../styles/typography" as type;
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 16px;
& + & {
border-top: 1px solid var(--vscode-widget-border);
}
> div {
display: grid;
gap: 5px;
}
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.updateDot {
width: 7px;
height: 7px;
flex: 0 0 7px;
background: #ef4444;
border-radius: 50%;
}
@@ -0,0 +1,166 @@
import { useEffect, useState } from "react";
import {
currentAppVersion,
hasDockVisibilitySetting,
hasNativeAppLifecycle,
readAutostart,
readDesktopSettings,
writeAutostart,
writeDockIconVisibility,
writeSilentStart,
} from "../../shared/native/appLifecycle";
import { updateStore, useUpdateStore } from "../../shared/store/updateStore";
import { Button } from "../../shared/ui/Button";
import { Switch } from "../../shared/ui/Switch";
import { TitledCard } from "../../shared/ui/TitledCard";
import { useMessage } from "../../shared/ui/message";
import styles from "./AppLifecycleSettingsCard.module.scss";
export function AppLifecycleSettingsCard() {
const message = useMessage();
const native = hasNativeAppLifecycle();
const dockVisibilitySetting = hasDockVisibilitySetting();
const { availableVersion, checking, installing } = useUpdateStore();
const [version, setVersion] = useState("…");
const [autostart, setAutostart] = useState(false);
const [loadingAutostart, setLoadingAutostart] = useState(native);
const [silentStart, setSilentStart] = useState(false);
const [dockIconVisible, setDockIconVisible] = useState(true);
const [loadingDesktopSettings, setLoadingDesktopSettings] = useState(native);
useEffect(() => {
let disposed = false;
void currentAppVersion().then((next) => { if (!disposed) setVersion(next); });
if (native) {
void readAutostart()
.then((enabled) => { if (!disposed) setAutostart(enabled); })
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)))
.finally(() => { if (!disposed) setLoadingAutostart(false); });
void readDesktopSettings()
.then((settings) => {
if (disposed) return;
setSilentStart(settings.silent_start);
setDockIconVisible(settings.show_dock_icon);
})
.catch(() => {})
.finally(() => {
if (disposed) return;
setLoadingDesktopSettings(false);
});
}
return () => { disposed = true; };
}, [message, native]);
const toggleAutostart = async (enabled: boolean) => {
try {
setLoadingAutostart(true);
await writeAutostart(enabled);
setAutostart(await readAutostart());
message(enabled ? t("已开启开机启动") : t("已关闭开机启动"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setLoadingAutostart(false);
}
};
const toggleSilentStart = async (enabled: boolean) => {
try {
setLoadingDesktopSettings(true);
await writeSilentStart(enabled);
setSilentStart(enabled);
message(enabled ? t("已开启静默启动") : t("已关闭静默启动"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setLoadingDesktopSettings(false);
}
};
const toggleDockIcon = async (visible: boolean) => {
try {
setLoadingDesktopSettings(true);
await writeDockIconVisibility(visible);
setDockIconVisible(visible);
message(visible ? t("已显示 Dock 栏图标") : t("已隐藏 Dock 栏图标"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setLoadingDesktopSettings(false);
}
};
const checkUpdate = async () => {
try {
const nextVersion = await updateStore.check();
message(nextVersion ? t("发现新版本 {version}", { version: nextVersion }) : t("当前已是最新版本"));
} catch (cause) {
const error = cause instanceof Error ? cause.message : String(cause);
message(t("检查更新失败:{error}", { error }));
}
};
const updateNow = async () => {
try {
await updateStore.install();
} catch (cause) {
const error = cause instanceof Error ? cause.message : String(cause);
message(t("安装更新失败:{error}", { error }));
}
};
return <TitledCard title={t("应用设置")}>
<div className={styles.row}>
<div>
<strong>{t("开机启动")}</strong>
<small>{t("登录系统后自动启动 Cursor BYOK。")}</small>
</div>
<Switch
checked={autostart}
disabled={!native || loadingAutostart}
label={t("开机启动")}
onChange={(enabled) => void toggleAutostart(enabled)}
/>
</div>
{autostart && <div className={styles.row}>
<div>
<strong>{t("静默启动")}</strong>
<small>{t("开机启动时不显示主窗口,仅保留系统托盘图标。")}</small>
</div>
<Switch
checked={silentStart}
disabled={!native || loadingDesktopSettings}
label={t("静默启动")}
onChange={(enabled) => void toggleSilentStart(enabled)}
/>
</div>}
{dockVisibilitySetting && <div className={styles.row}>
<div>
<strong>{t("在 Dock 栏显示")}</strong>
<small>{t("关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。")}</small>
</div>
<Switch
checked={dockIconVisible}
disabled={loadingDesktopSettings}
label={t("在 Dock 栏显示")}
onChange={(visible) => void toggleDockIcon(visible)}
/>
</div>}
<div className={styles.row}>
<div>
<strong>{t("软件更新")}</strong>
<small>{availableVersion
? t("版本 {version} 可以安装", { version: availableVersion })
: t("当前版本 {version}", { version })}</small>
</div>
{availableVersion
? <Button size="small" variant="primary" disabled={installing} onClick={() => void updateNow()}>
{installing ? t("安装中…") : t("下载并安装")}
<span className={styles.updateDot} aria-hidden="true" />
</Button>
: <Button size="small" disabled={!native || checking} onClick={() => void checkUpdate()}>
{checking ? t("检查中…") : t("检查更新")}
</Button>}
</div>
</TitledCard>;
}
@@ -0,0 +1,53 @@
@use "../../styles/typography" as type;
.content,
.customFields {
display: grid;
gap: 14px;
}
.content {
padding: 16px;
}
.row {
min-width: 0;
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.control {
width: min(420px, 62%);
}
.value {
min-width: 0;
max-width: 70%;
color: var(--vscode-descriptionForeground);
overflow-wrap: anywhere;
text-align: right;
}
.headerAction {
padding: 4px;
color: var(--vscode-textLink-foreground);
background: transparent;
border: 0;
font-size: type.$font-size-xs;
&:hover { color: var(--vscode-textLink-activeForeground); }
&:disabled { opacity: .5; }
}
.actionGroup {
display: flex;
align-items: center;
gap: 6px;
}
@media (max-width: 680px) {
.control { width: 68%; }
}

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