Compare commits

...
151 Commits
Author SHA1 Message Date
leookun 9637efd795 chore(release): bump desktop to 0.1.7 2026-09-05 01:40:32 +08:00
leookun d8190fc07e fix(desktop): cache ads across refresh failures 2026-09-05 01:35:30 +08:00
leookun f1ab8c7221 fix(desktop): terminate Cursor before proxy setup 2026-09-05 01:23:34 +08:00
leokun 03b25be9d8 fix(desktop): speed up ad marquee text 2026-09-05 00:36:53 +08:00
leokun 7484bb9e07 fix(desktop): improve ad marquee and hide windows consoles 2026-09-05 00:36:11 +08:00
leokun 20fdea8fb3 fix(codex-auth): add reset card actions and tests 2026-09-05 00:12:26 +08:00
leokun c379c315e6 feat(desktop): add compact modal size 2026-09-05 00:12:20 +08:00
leokun 2cb15b541b feat(desktop): add plugin resource actions 2026-09-05 00:12:14 +08:00
leokun 8fbbcd5ae9 feat(desktop): add promotion marquee and proxy fix 2026-09-04 23:07:41 +08:00
leokun 900364229e Merge branch 'main' of github.com:leookun/cursor-byok 2026-09-04 21:58:29 +08:00
leokun 4700d3abc0 feat(desktop): add tooltip provider and model management 2026-09-04 21:58:24 +08:00
leokun 50ac77cdf3 Merge pull request #422 from kevin9327/fix/proxy-settings-unreadable-row
fix(store): keep an unreadable proxy row from bricking every request
2026-09-04 19:39:26 +08:00
kevin9327andClaude Opus 5 0a8934b46d fix(store): keep an unreadable proxy row from bricking every request
`8942287` renamed `ProxyMode::System` to `ProxyMode::Default`, changing the
persisted wire value from `"system"` to `"default"`. No migration rewrites
the existing `service_settings` row and `ProxyMode` has no alias, so any
install that ever saved proxy settings on an earlier build now holds a row
this build cannot deserialize:

    unknown variant `system`, expected `default` or `custom`

`proxy_settings_secret` turned that into a hard error, and it is the first
statement of every outbound client factory, so on upgrade the failure hits
model calls, plugin installs, search, and the Cursor upstream proxy alike.

It is also unrecoverable from the UI. `proxy_settings` reads the same row,
so the settings page cannot render the proxy card, and `set_proxy_settings`
reads the existing row before it writes, so the user cannot overwrite the
row that broke them. Only editing SQLite by hand clears it.

Read the row through a fallback that logs and returns the default instead.
The affected rows are exactly the ones whose mode meant "no outbound proxy",
which is what the default already is, so nothing is silently changed for
them; a genuinely corrupt row costs the user a re-entered address instead of
a dead install.

Deliberately not `#[serde(alias = "system")]`: `8942287` added a test
asserting that value no longer parses, and this keeps that true while making
the persisted row survivable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:50:51 +09:00
leokun 37f9c4c4c8 Merge pull request #401 from masudranaxpert/main
feat(antigravity): add Google Antigravity auth plugin with auto-rotation, model catalog & multi-turn support
2026-09-04 17:21:39 +08:00
leokun 6ad0d74379 Merge main and host authorization-code OAuth in core 2026-09-04 17:16:29 +08:00
leokun 2f3bffd15a fix(ci): satisfy Rust 1.98 clippy checks 2026-09-04 16:35:37 +08:00
leokun edcdd77cad style(desktop): refine ads menu background 2026-09-04 16:18:42 +08:00
leokun 242861436d fix(desktop): show discovered model options 2026-09-04 16:17:56 +08:00
leokun 3aae326914 ci: check frontend and desktop platforms 2026-09-04 16:13:53 +08:00
leokun 082b6f05d8 Merge pull request #396 from kevin9327/ci/run-make-check-on-pull-requests 2026-09-04 16:12:52 +08:00
leokun 0f564c0e7c fix(tools): preserve accurate truncation notices 2026-09-04 16:12:31 +08:00
leokun 3039eaff21 Merge pull request #392 from kevin9327/fix/tool-result-truncation-budget 2026-09-04 16:08:43 +08:00
leokun aa47152f1f fix(provider): preserve content-filtered tool call rejection 2026-09-04 16:08:26 +08:00
leokun 56b67aff88 Merge pull request #391 from kevin9327/fix/openai-chat-stop-with-tool-calls 2026-09-04 16:07:15 +08:00
leokun 296c9a947f Merge pull request #408 from kevin9327/chore/restore-make-check-green-2 2026-09-04 16:06:56 +08:00
leokun a000ac8921 Merge pull request #393 from kevin9327/fix/usage-accumulation-drops-known-counts 2026-09-04 16:06:56 +08:00
leokun 02db171f2c Merge pull request #390 from kevin9327/fix/local-rules-test-blob-ack 2026-09-04 16:06:56 +08:00
leokun cd09c60657 Merge remote-tracking branch 'origin/main' 2026-09-04 15:46:50 +08:00
leokun 52c52abc66 Merge pull request #397 from kevin9327/fix/duplicate-tool-call-id-hang
fix(conversation): scope completed tool call ids to their round
2026-09-04 15:46:17 +08:00
leokun 82f6add55d Merge pull request #412 from ProtectCookies/main 2026-09-04 15:45:51 +08:00
leokun 17342167ac feat: update desktop settings and server compatibility 2026-09-04 15:33:55 +08:00
leokun 8942287a27 refactor(proxy): change proxy mode from "system" to "default" across the application
- Updated the default proxy mode in `api.ts`, `ProxySettingsCard.tsx`, and `SettingsPage.tsx` to "default".
- Adjusted related translations in `catalog.json`, `en-US.json`, and `zh-CN.json`.
- Modified the `ProxyMode` enum in `settings.rs` to reflect the change from "system" to "default".
- Enhanced proxy handling in the server code to support the new default mode.
2026-09-03 16:32:02 +08:00
leokun 924b5e5926 feat(cursor): cli 接入本地模型路由与配置
- feat(服务配置): 本地响应配置并禁用 HTTP/2 传输
- feat(模型目录): 提供默认模型接口及本地路由凭据
- fix(待办状态): 基于检查点合并增量待办并保留未变更项
- fix(模型参数): 忽略未知参数以兼容新版 Cursor 请求
- test(本地路由): 覆盖模型元数据路由与凭据行为
2026-09-03 16:01:25 +08:00
ProtectCookies a511fb983d Merge branch 'main' of https://github.com/ProtectCookies/cursor-byok 2026-09-03 11:47:10 +08:00
ProtectCookies 49a38ca857 feat(overview): 近4小时/近24小时移入自定义弹层,近4小时支持选择图表粒度
- 主时间栏移除近4小时/近24小时,改为自定义弹层顶部的快捷范围
- 近4小时点击后弹出粒度菜单:1分钟/15分钟/30分钟/1小时
- overview API 新增 bucket_ms 参数,支持客户端指定分桶粒度
- 指定粒度时桶数上限放宽至 1440,未指定时维持原自动逻辑
2026-09-03 11:45:32 +08:00
ProtectCookies e0f9bd6780 fix(desktop): 页面操作栏不再被当作标题栏拖动
.actionRegion 由 drag 改为 no-drag,修复顶部时间条、刷新按钮可拖动窗口、双击触发最大化的问题。
2026-09-03 11:45:17 +08:00
leokun 543f618fee Merge pull request #411 from ProtectCookies/main
feat: Git Commit 信息 BYOK 生成(默认直连,可选本地模型)
2026-09-03 10:44:12 +08:00
ProtectCookies 42811a27f5 feat: 新增 Commit 设置及本地提交信息生成
- 新增 CommitSettingsCard 组件,支持选择生成模型与编辑提示词
- 新增 /settings/commit GET/PUT 接口及 CommitSettings 持久化
- 实现 WriteGitCommitMessage RPC 本地生成,空 model_id 时直连转发
- 新增 NetworkService/IsConnected 探针响应,防止流式生成被中断
- 添加 commit prompt 模板及 proto 消息定义
- 补充 zh-CN / en-US 国际化词条
2026-09-03 10:31:41 +08:00
kevin9327 e4b5e132d6 chore: restore a green make check on main
- output.rs: the read_lints test helper (#398) builds ToolCall without
  the argument_error field added in d83e14a, so the lib-test target
  does not compile
- connect_wire.rs: cursor::router takes NetworkClients since 2dad593
- runtime.rs: clippy::nonminimal_bool from 5cdf642
- update/mod.rs: clippy::needless_return in the Windows arm, ddaa61c
2026-09-03 07:55:51 +09:00
kevin9327andClaude Opus 4.8 a42f84cfe7 fix(conversation): scope completed tool call ids to their round
A provider that reuses a tool call id across two rounds of one run
wedges the run permanently. `ToolDispatcher::start_batch` skips any call
whose id is in `ToolBatchState::completed`, so the second call is never
dispatched and never produces a `ToolCompletion`, while
`tool_round::execute` blocks waiting for `calls.len()` results with no
timeout on that path. The client sees the tool call appear and then
nothing: no completion, no further output, no end-stream frame.

The `completed` set is built once per run and never cleared, so it is
run-scoped. A tool call id is only unique within a round, which the
schema already states as `UNIQUE (round_id, call_id)`; the sibling
runtime completed-map is likewise already cleared per round at
output.rs:615.

Clear `completed` when `ExecuteToolRound` begins a new round, tracked
independently of `active_round` so it does not depend on the order in
which ToolRoundStarted and ExecuteToolRound are observed. Replaying a
round still skips the calls that round already committed.

The practical trigger is openai_chat.rs:196, which synthesizes
`call-{index}` from a per-stream index when a provider omits tool call
ids, so `call-0` recurs on every model call. That file is left alone
here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 07:35:10 +09:00
leokun 8fdcdd7f84 Merge pull request #395 from kevin9327/chore/restore-make-check-green
chore: restore a green `make check` on main
2026-09-03 00:32:31 +08:00
leokun e1d6bd9726 Merge pull request #398 from kevin9327/fix/readlints-drops-extra-paths
fix(tools): report the ReadLints paths that were never checked
2026-09-03 00:31:54 +08:00
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
masudranaxpert d7578bcc15 feat(antigravity): add Google Antigravity auth plugin with auto-rotation, model catalog and multi-turn support 2026-09-01 18:23:55 +06: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
kevin9327andClaude Opus 4.8 1269110615 fix(tools): report the ReadLints paths that were never checked
ReadLints accepts an array of paths, but codec::request encodes only
paths[0] into the DiagnosticsArgs exec, and the exec protocol cannot
carry more than one path. Nothing downstream mentions the drop: for
{"paths": ["a.ts", "b.ts", "c.ts"]} the model is handed
"No diagnostics found in a.ts" with is_error false, so it concludes
b.ts and c.ts are clean when neither was ever opened. The existing
truncation notice cannot catch this, since it compares total_diagnostics
against a per-file diagnostics count.

Name the unread paths in the model-facing result, using the bracketed
notice convention already in this file and the ToolCall that output()
is already given (as task() and render::read() already use it).
Single-path calls are unchanged.

This does not close the capability gap. A real fix fans out one
DiagnosticsArgs exec per path and aggregates the results into the
repeated FileDiagnostics that ReadLintsToolSuccess already defines,
which needs multi-exec reservation and a completion barrier because
take_exec drops the pending entry on the first result. That is a
separate change; this one only stops the silent misinformation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 20:13:22 +09:00
kevin9327andClaude Opus 4.8 86c3899016 ci: run the Makefile check gates on pull requests
`release.yml` only runs on `v*` tags, so nothing verifies a commit
before it lands on `main`. `main` is currently red on three of the five
gates `make check` defines, which is the drift this is meant to catch.

Adds a single job running the three Rust gates verbatim as the Makefile
spells them:

    cargo fmt --all -- --check
    cargo clippy --workspace --all-targets -- -D warnings
    cargo test --workspace --all-targets

Conventions follow release.yml so the two workflows stay consistent:
ubuntu-22.04, the same apt packages (the workspace includes
apps/desktop/src-tauri, so even `cargo check` needs webkit2gtk),
dtolnay/rust-toolchain@stable and Swatinem/rust-cache@v2 with the same
`. -> target` workspace key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 20:02:14 +09:00
kevin9327andClaude Opus 4.8 38a4c3f516 chore: restore a green make check on main
`make check` currently fails on main before any change is made: one
`cargo fmt --all -- --check` diff and four `cargo clippy --workspace
--all-targets -- -D warnings` errors. All five are pre-existing and
none of them change behaviour.

- `server/tests/knowledge_rules.rs:125` — rustfmt wants the long
  `assert!` split across lines. Applied `cargo fmt --all` verbatim.
- `server/src/plugin/data.rs:206,215` — `path` is only read under
  `#[cfg(unix)]`, so every other target sees an unused binding. Added
  a `#[cfg(not(unix))] { let _ = path; }` arm, matching the
  `let _ = error;` idiom already used at line 189 of the same file.
  Windows behaviour is unchanged: these helpers stay no-ops there.
- `server/src/provider/openai_responses.rs:158` — `collapsible_match`.
  Applied clippy's own suggestion (move `thinking_open` into a match
  guard). The match ends in `_ => {}`, so a failed guard falls through
  to a no-op exactly as the inner `if` did.
- `server/src/store/models.rs:327` — `items_after_test_module`. Moved
  `optional_u64` and `to_i64` above `mod tests`; the bodies are
  untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 19:59:28 +09:00
kevin9327 ab4d3adee5 fix(model): stop token accumulation from erasing counts a later call omits
`Usage::add_assign` folds every field through

    fn sum(left: Option<u64>, right: Option<u64>) -> Option<u64> {
        left?.checked_add(right?)
    }

so `sum(Some(900), None)` is `None`, not `Some(900)`. Adding a record that does
not report a field therefore does not leave the running total alone - it wipes
it.

Both accumulators are per-turn and run over every provider call in the turn:
`run/engine.rs::accumulate_usage` (the compaction call plus each model cycle)
and `cursor/conversation/output.rs::turn_usage`, which is what `turn_ended`
reports to Cursor.

Providers report these fields inconsistently *across calls of one turn*, which
is all it takes. `openai_usage` reads `cache_read_tokens` from
`prompt_tokens_details`, an object most OpenAI-compatible gateways omit until
the prompt cache warms: call 1 (cold) yields `None`, call 2 yields
`Some(1024)`, and the turn reports `None`. The same happens to
`reasoning_tokens` when only some cycles reason, and to `total_tokens` on
gateways that omit it from the streaming usage chunk. Any tool-using turn makes
more than one call, so this is the normal case rather than an edge case.

Treat an unreported count as zero and keep the field unknown only when neither
side reported it. `checked_add` also becomes `saturating_add`: with the new
rule, silently turning an overflow into `None` would be the same erasure by
another route, and token counts never approach `u64::MAX` anyway.
`context_input_tokens` is untouched.

Before (with `left?.checked_add(right?)` restored):
  cargo test -p cursor-server --lib model::observability
  -> 2 failed: assertion `left == right` failed: left: None, right: Some(900)
After:
  cargo test -p cursor-server --lib model::observability -> 3 passed
2026-08-31 19:39:02 +09:00
kevin9327 24c41d0347 fix(tools): keep result truncation inside its byte budget and terminating
`truncate_text` looks for a fixed point where the kept prefix length equals the
byte count printed in its own truncation notice. Two things go wrong when the
limit is small, and both are reachable because two call sites pass a *remaining*
budget rather than a constant:

* `gate_grep_content` -> `truncate_text("Grep", .., budget.content_bytes)`
* `gate_mcp` -> `truncate_text("MCP text", .., remaining_text)`

1. The loop can spin forever. `notice.len()` grows with the decimal digit count
   of `shown`, so `kept.len()` alternates between two values across a power-of-
   ten boundary and never equals `shown`. With `tool_name = "Grep"` this happens
   at `limit` 78 and 170; with `"MCP text"` at 82 and 174. The conversation task
   then spins at 100% CPU and the turn never completes. `truncate_middle` in
   `model/tool_result_replay.rs` already guards against exactly this.

2. When the notice itself does not fit, `available` saturates to 0 and the
   function returns the ~68 byte notice alone, i.e. *more* than `limit`.
   `gate_grep_content` then evaluates `budget.content_bytes -= <68 bytes>` on a
   budget of at most 68, which panics with "attempt to subtract with overflow"
   in debug/test builds and wraps in release, silently disabling the 32 KiB
   content cap for the rest of the result.

Both are ordinary Grep results away: 16 matches of ~2 KiB leave a two-digit
remainder of the 32 KiB budget, and the next match then hits the small-limit
path.

Fix: return a plain prefix when the notice cannot fit, and stop as soon as the
kept length repeats a previous value. The reported byte count is then off by one
at most in that rare oscillating case, and the result is guaranteed to be at
most `limit` bytes. Every constant-limit call site is unaffected: their notices
always fit and their limits do not oscillate. `budget.content_bytes` now uses
`saturating_sub`, matching every other subtraction in this file.

Verified against the unfixed function:
  cargo test -p cursor-server --lib gate::tests::truncate_text_never_exceeds_its_limit
  -> FAILED: limit 1 produced 67 bytes
  cargo test -p cursor-server --lib gate::tests::grep_content_gate_survives
  -> FAILED: panicked at gate.rs:261: attempt to subtract with overflow
  cargo test -p cursor-server --lib gate::tests::truncate_text_terminates
  -> never returns (killed after 45s)
  cargo test -p cursor-server --lib gate::tests::grep_content_gate_terminates
  -> never returns (killed after 60s)
After: cargo test -p cursor-server --lib tools::tool_call_result::gate -> 4 passed
2026-08-31 19:35:39 +09:00
kevin9327 b6fc7326e7 fix(provider): keep OpenAI Chat tool calls that finish with reason "stop"
`map_finish` matched `"stop" | "content_filter"` before the `has_tools`
fallback, so the fallback only ever applied to finish reasons the adapter did
not recognise. When an OpenAI-compatible server streams tool calls and then
reports `finish_reason: "stop"` the adapter yielded `Done(Stop)` even though
`ToolCallStart` / `ToolCallEnd` had already been emitted.

`run/model_cycle.rs` rejects that combination:

    let has_tool_calls = !calls.is_empty();
    if matches!(finish_reason, FinishReason::ToolUse) != has_tool_calls {
        return Err(failure(RunFailure::Protocol(
            "finish reason and tool calls disagree".into()), ...));
    }

so the whole turn fails with a protocol error and the tool never runs. Servers
that report `"stop"` alongside `tool_calls` are common in BYOK setups
(llama.cpp, Ollama's OpenAI shim, several proxies), which makes those models
unusable for anything agentic.

Move the `has_tools` arm ahead of `"stop" | "content_filter"` so an observed
tool call outranks the label the provider attached to the stop. This matches
the two sibling adapters (`anthropic.rs` checks `_ if saw_tool` before every
reason except `Length`, `openai_responses.rs` derives the reason from
`saw_tool` alone) and this adapter's own `[DONE]` fallback, which already
infers `ToolUse` from `!tools.is_empty()`. `"length"` still wins so a
truncated response is still reported as truncated, and behaviour with no tool
calls is byte-for-byte unchanged.

Before (with the arm restored):
  cargo test -p cursor-server --lib provider::openai_chat
  -> observed_tool_calls_outrank_a_stop_finish_reason FAILED
     assertion `left == right` failed: left: Stop, right: ToolUse
After:
  cargo test -p cursor-server --lib provider::openai_chat -> 3 passed
2026-08-31 19:26:38 +09:00
kevin9327 e87abace8b fix(tests): acknowledge conversation Blob writes in the local rules test
`local_markdown_rules_land_in_the_request_context_message` never finishes:
`cargo test --workspace` fails on `main` with

    panicked at server\tests\local_rules_context.rs:64:14:
    run finishes within timeout: Elapsed(())

The Run publishes the conversation checkpoint by asking the client to write
Blobs, and it does not continue until every `KvServerMessage` is answered
with a `SetBlobResult`. The test drained the output stream without replying,
so the Run stalled after the first frame, the provider was never invoked, and
none of the assertions the test exists for were ever reached.

Answer the Blob writes the way every other transport test already does
(`error_lifecycle.rs`, `conversation_delivery.rs`, `interrupt.rs`). With the
acknowledgement in place the Run reaches `EndStream` in ~0.3s and the original
assertions run and pass, so `merge_local_rules` is now genuinely covered:
exactly one `request-context:` message is projected and it carries
`<user_rule>Always answer in haiku.</user_rule>`.

No production code changes.

Before: `cargo test -p cursor-server --test local_rules_context`
        -> FAILED (0 passed; 1 failed) after a 5s timeout
After:  `cargo test -p cursor-server --test local_rules_context`
        -> ok (1 passed; 0 failed) in 0.28s
2026-08-31 19:20:30 +09: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
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
556 changed files with 41302 additions and 73651 deletions
+86
View File
@@ -0,0 +1,86 @@
---
name: model-selection
description: Implement and review desktop selection of configured models through the unified grouped ModelSelect component. Use when adding or changing model dropdowns, model filters, Commit model settings, single-model selection, multi-model selection, provider grouping, or configured-model option construction under apps/desktop.
---
# Model Selection
Use one presentation component for every UI that selects from already configured models:
```text
apps/desktop/src/
├── shared/ui/
│ ├── ModelSelect.tsx # Single/multiple selection behavior and grouped floating menu
│ └── ModelSelect.module.scss # Trigger, provider headers, checkbox rows, and footer
├── shared/utils/
│ └── modelProvider.ts # Built-in model provider display name
└── features/
├── home/ # Builds overview multi-select options
└── settings/ # Builds Commit single-select options and persists selection
server/src/
├── cursor/services/commit_message.rs # Validates built-in/plugin IDs and creates Commit invocation
├── provider/router.rs # Routes stable IDs to built-in or plugin providers
└── plugin/registry.rs # Resolves plugin model IDs and executes plugin streams
```
## Component boundary
- Use `ModelSelect` when choosing one or more existing configured models.
- Do not implement another model dropdown, reuse generic `Select`, or add a compatibility wrapper for configured-model selection.
- Keep `ModelSelect` presentation-only. Pages own model/plugin state, option construction, filtering, and persistence.
- Keep editable model-ID entry in `Combobox`; creating or editing a model identifier is input, not configured-model selection.
- Delete replaced model-selection components, exports, styles, helpers, and compatibility paths once references are gone.
## Option contract
Construct every `ModelSelectOption` with:
- `value`: stable persisted/request identifier. Built-in models use `model_hash`; plugin models use plugin model `id`.
- `label`: user-facing model display name.
- `group`: supplier display name.
- `icon`/`iconSrc`: model/provider icon when available.
For built-in models, derive `group` with `modelProviderName(model)`. It uses the configured `group_name` first and the API hostname otherwise. For plugin models, use the localized provider display name.
Preserve source order within each supplier. The first occurrence of a supplier determines group order.
## Modes
```text
Single owner value: string
└── <ModelSelect mode="single"> ── choose one ── close ── persist
Multiple owner value: string[]
└── <ModelSelect mode="multiple"> ── toggle many ── remain open ── apply/filter
```
- Both modes render classic checkbox controls in option rows.
- Single mode allows exactly one checked option and closes immediately after selection.
- Multiple mode supports toggling, clearing, selecting all, and selecting none.
- In multiple mode, every supplier header has a checkbox: unchecked means none selected, checked means all selected, and indeterminate means some selected. Toggling it selects or clears that supplier.
- Indent child model rows relative to their supplier header so hierarchy remains visible.
- Commit always uses single mode and includes both configured built-in and configured plugin models. Its `直连` option has value `""`, belongs to the `Cursor` group, and is the first option.
- Commit settings follow the settings-card edit-state pattern: read mode shows the persisted model, Edit creates a local draft, selection only changes that draft, Cancel restores the persisted value, and Save persists once before returning to read mode.
- Persist the stable plugin model `id` unchanged. Commit generation validates that identifier through `PluginRegistry`, then lets `ProviderRouter` dispatch it; do not query the built-in model table for plugin IDs.
- Overview filtering uses multiple mode.
## Floating-menu invariants
Also apply the project `floating-ui` and `frontend` skills:
- Render the menu through a body portal and position it with `@floating-ui/dom`.
- Keep supplier headers and checkbox options inside the virtualized list; keep multi-select bulk actions outside it.
- Close on Escape and outside pointer interaction, then restore trigger focus.
- Preserve `aria-expanded`, `aria-controls`, `aria-haspopup`, listbox semantics, and multi-select semantics.
- Keep the trigger's open/focus border visible while the portaled menu owns focus.
## Review checklist
- Search the repository for old model-selection components and zero-reference model option helpers; delete them instead of retaining fallbacks.
- Confirm all configured-model selectors import `shared/ui/ModelSelect`.
- Confirm no feature implements checkbox selection, supplier grouping, portal positioning, or bulk actions independently.
- Confirm single/multiple value types cannot be mixed.
- Confirm Commit has `直连` first and cannot select multiple values.
- Confirm supplier labels are based on supplier identity, not request protocol type.
- Follow the user's validation instruction; when automated tests are not requested, report manual checks without running test or build commands.
@@ -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/);
});
+88
View File
@@ -0,0 +1,88 @@
name: CI
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
rust:
name: Rust (Linux)
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
# The workspace includes apps/desktop/src-tauri, so even `cargo check`
# needs the same system libraries the release workflow installs.
- name: Install Linux system dependencies
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
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- name: Check formatting
run: cargo fmt --all -- --check
- name: Run Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Run tests
run: cargo test --workspace --all-targets
frontend:
name: Frontend
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: apps/desktop/package-lock.json
- name: Install dependencies
working-directory: apps/desktop
run: npm ci
- name: Check types and production build
working-directory: apps/desktop
run: npm run check
desktop-rust:
name: Desktop Rust (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os:
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- name: Run platform Clippy
run: cargo clippy --package cursor-byok-desktop --all-targets -- -D warnings
+31 -4
View File
@@ -158,14 +158,27 @@ jobs:
mkdir -p legacy-update
tar -czf "legacy-update/cursor-byok-${VERSION}-linux-amd64.tar.gz" -C target/release cursor-byok-desktop
- name: Package legacy Windows updater asset
- name: Package and sign legacy Windows updater asset
if: matrix.platform == 'windows-x86_64'
shell: pwsh
env:
VERSION: ${{ needs.prepare.outputs.version }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
New-Item -ItemType Directory -Force legacy-update | Out-Null
Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip"
$asset = "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip"
Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath $asset
Push-Location apps/desktop
npm exec tauri signer sign -- "../../$asset"
Pop-Location
$entries = @(tar -tf $asset)
if ($entries.Count -ne 1 -or [System.IO.Path]::GetFileName($entries[0]) -ne 'cursor-byok-desktop.exe') {
throw "Windows updater archive must contain only cursor-byok-desktop.exe"
}
if (!(Test-Path "$asset.sig")) {
throw "Windows updater archive signature was not generated"
}
- name: Package legacy macOS updater asset
if: contains(matrix.platform, 'macos')
@@ -206,13 +219,27 @@ jobs:
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
node scripts/release/generate-legacy-update.mjs \
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 }}
@@ -231,7 +258,7 @@ jobs:
-H "Accept: application/octet-stream" \
"repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" \
> tauri-update/latest.json
node scripts/release/normalize-tauri-update.mjs \
node .github/scripts/normalize-tauri-update.mjs \
--manifest tauri-update/latest.json \
--release tauri-update/release.json \
--repository "${GITHUB_REPOSITORY}" \
+16
View File
@@ -7,3 +7,19 @@
- 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
+141 -1
View File
@@ -97,6 +97,28 @@ dependencies = [
"x11rb",
]
[[package]]
name = "ashpd"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
dependencies = [
"async-fs",
"async-net",
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.5",
"raw-window-handle",
"serde",
"serde_repr",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
[[package]]
name = "asn1-rs"
version = "0.7.2"
@@ -186,6 +208,17 @@ dependencies = [
"slab",
]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
@@ -215,6 +248,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-net"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
dependencies = [
"async-io",
"blocking",
"futures-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
@@ -1128,10 +1172,12 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "cursor-byok-desktop"
version = "0.1.4"
version = "0.1.7"
dependencies = [
"axum",
"cursor-server",
"libc",
"rfd",
"serde",
"serde_json",
"tauri",
@@ -1142,11 +1188,15 @@ dependencies = [
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tempfile",
"tokio",
"tokio-util",
"tracing",
"tracing-appender",
"tracing-subscriber",
"url",
"windows-sys 0.61.2",
"zip",
]
[[package]]
@@ -1202,6 +1252,7 @@ dependencies = [
"uuid",
"windows-sys 0.61.2",
"x509-parser",
"zip",
]
[[package]]
@@ -1520,6 +1571,15 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "dlib"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
"libloading",
]
[[package]]
name = "dlopen2"
version = "0.8.2"
@@ -1874,6 +1934,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
@@ -4555,6 +4616,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "portable-atomic"
version = "1.15.0"
@@ -5196,6 +5263,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
dependencies = [
"ashpd",
"block2",
"dispatch2",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -6289,6 +6380,12 @@ dependencies = [
"serde_json",
]
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]]
name = "syn"
version = "1.0.109"
@@ -7262,6 +7359,19 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-appender"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror 2.0.20",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
@@ -7636,6 +7746,12 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.3.0"
@@ -7852,6 +7968,7 @@ dependencies = [
"cc",
"downcast-rs",
"rustix",
"scoped-tls",
"smallvec",
"wayland-sys",
]
@@ -7910,6 +8027,8 @@ version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"dlib",
"log",
"pkg-config",
]
@@ -8966,16 +9085,36 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap 2.14.0",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
@@ -9028,6 +9167,7 @@ dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow 1.0.4",
"zcheapstr",
"zvariant_derive",
+1 -1
View File
@@ -3,7 +3,7 @@ members = [
"server",
"apps/desktop/src-tauri",
"crates/semble-core",
"benchmarks/semble",
"support/benchmarks/semble",
]
resolver = "2"
+1 -1
View File
@@ -20,7 +20,7 @@ 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 scripts/cursor-proto/proto/ scripts/cursor-proto/proto/
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 \
+8
View File
@@ -23,6 +23,13 @@ build-web:
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
@@ -30,6 +37,7 @@ $(LOCAL_TAURI_SIGNING_KEY):
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 .
+223
View File
@@ -0,0 +1,223 @@
<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 桌面生命周期
├── 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
```
### 启动开发环境
启动桌面前端:
```bash
make dev-web
```
启动桌面应用:
```bash
make dev-desktop
```
### 检查与构建
运行完整检查:
```bash
make check
```
分别构建各部分:
```bash
make build-web # 构建桌面前端
make build-server # 构建 Rust 本地服务
make build-desktop # 构建 Tauri 桌面安装包
make build-docker # 构建 Docker 镜像
```
## 路线图
项目将继续改进模型兼容性、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) 开源。
+6 -5
View File
@@ -17,9 +17,9 @@ cursor-byok is a local implementation of Cursor's backend.
</div>
![Connect cursor-byok to a wide range of model APIs](./images/en-brand.png)
![Connect cursor-byok to a wide range of model APIs](./images/en-brand-1.png)
![cursor-byok dashboard](./images/en-home.png)
![cursor-byok dashboard](./images/en-home-1.png)
## About
@@ -45,15 +45,16 @@ You can connect OpenAI- and Anthropic-compatible services, customize endpoints,
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. Open Cursor, select the configured model, and start using Agent.
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 troubleshooting, see the [User Guide](https://docs.leokun.cn).
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.png)
![cursor-byok model settings](./images/en-model-1.png)
## How It Works
+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>
+12 -2
View File
@@ -1,12 +1,12 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.4",
"version": "0.1.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-byok-desktop",
"version": "0.1.4",
"version": "0.1.7",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0",
@@ -23,6 +23,7 @@
"keepalive-for-react-router": "^5.0.7",
"monaco-editor": "0.56.0",
"react": "^19.2.8",
"react-css-marquee": "^1.0.1",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"sortablejs": "^1.15.7",
@@ -2978,6 +2979,15 @@
"node": ">=0.10.0"
}
},
"node_modules/react-css-marquee": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/react-css-marquee/-/react-css-marquee-1.0.1.tgz",
"integrity": "sha512-jPHydXILF3oIP2gOvabfbEY3PUdbkHtDVA4mqYQiYvEdLWk9oqNJkUwDBxHK1RtltvQY3zkyuxTaGnw+G8x48w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19"
}
},
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.4",
"version": "0.1.7",
"description": "Cursor BYOK desktop management application",
"type": "module",
"scripts": {
@@ -8,8 +8,9 @@
"dev": "vite",
"typecheck": "tsc --noEmit",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
"i18n:scan": "STATIC_I18N_SCAN=true vite build",
"i18n:scan": "cross-env STATIC_I18N_SCAN=true vite build",
"build": "vite build",
"build:demo": "npm run typecheck && npm run typecheck:node && vite build --config vite.demo.config.ts",
"check": "npm run typecheck && npm run typecheck:node && npm run build",
"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",
@@ -33,6 +34,7 @@
"keepalive-for-react-router": "^5.0.7",
"monaco-editor": "0.56.0",
"react": "^19.2.8",
"react-css-marquee": "^1.0.1",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"sortablejs": "^1.15.7",
+9 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cursor-byok-desktop"
version = "0.1.4"
version = "0.1.7"
edition = "2021"
publish = false
@@ -14,6 +14,8 @@ 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"] }
@@ -23,8 +25,14 @@ tauri-plugin-opener = "2"
tauri-plugin-autostart = "2"
tauri-plugin-process = "2"
tauri-plugin-updater = "2"
tempfile = "3"
tokio = { version = "1", features = ["time"] }
tokio-util = "0.7"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
zip = { version = "4", default-features = false, features = ["deflate"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading"] }
+5 -1
View File
@@ -1,5 +1,9 @@
fn main() {
let manifest = tauri_build::AppManifest::new().commands(&["open_terminal_with_command"]);
let manifest = tauri_build::AppManifest::new().commands(&[
"open_terminal_with_command",
"check_portable_update",
"install_portable_update",
]);
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest))
.expect("failed to build Tauri application")
}
@@ -18,6 +18,8 @@
"core:window:allow-close",
"core:app:allow-set-dock-visibility",
"allow-open-terminal-with-command",
"allow-check-portable-update",
"allow-install-portable-update",
"clipboard-manager:allow-write-text",
"autostart:default",
"process:allow-restart",
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-check-portable-update"
description = "Enables the check_portable_update command without any pre-configured scope."
commands.allow = ["check_portable_update"]
[[permission]]
identifier = "deny-check-portable-update"
description = "Denies the check_portable_update command without any pre-configured scope."
commands.deny = ["check_portable_update"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-install-portable-update"
description = "Enables the install_portable_update command without any pre-configured scope."
commands.allow = ["install_portable_update"]
[[permission]]
identifier = "deny-install-portable-update"
description = "Denies the install_portable_update command without any pre-configured scope."
commands.deny = ["install_portable_update"]
+57 -15
View File
@@ -1,5 +1,5 @@
use std::{
process::Command,
process::{Command, ExitCode},
sync::{
atomic::{AtomicBool, Ordering},
Mutex,
@@ -19,7 +19,6 @@ use tauri::{
};
use tauri_plugin_opener::OpenerExt;
use tokio_util::sync::CancellationToken;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[cfg(dev)]
use cursor_server::config::ConsoleSource;
@@ -27,6 +26,7 @@ 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";
@@ -141,19 +141,47 @@ fn create_main_window(
builder.build()
}
pub fn run() {
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);
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "cursor_server=info".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let app = tauri::Builder::default()
.invoke_handler(tauri::generate_handler![open_terminal_with_command])
.invoke_handler(tauri::generate_handler![
open_terminal_with_command,
crate::update::check_portable_update,
crate::update::install_portable_update,
])
.plugin(tauri_plugin_single_instance::init(|app, args, _| {
if !args.iter().any(|arg| arg == AUTOSTART_ARG) {
tray::show_main_window(app);
@@ -168,7 +196,12 @@ pub fn run() {
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec![AUTOSTART_ARG]),
))?;
let config = Config::desktop()?;
let config = {
let mut config = Config::desktop()?;
// 插件的 minAppVersion 按桌面应用版本判定,而不是内嵌 server 库的版本。
config.app_version = env!("CARGO_PKG_VERSION").into();
config
};
#[cfg(dev)]
let config = {
let mut config = config;
@@ -216,10 +249,17 @@ pub fn run() {
window.set_focus()?;
}
tray::create(app)?;
crate::update::signal_ready_if_requested()?;
Ok(())
})
.build(tauri::generate_context!())
.expect("failed to build Cursor BYOK desktop app");
.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 {
@@ -264,4 +304,6 @@ pub fn run() {
} => tray::show_main_window(app),
_ => {}
});
ExitCode::SUCCESS
}
+9 -1
View File
@@ -1,6 +1,14 @@
mod desktop;
#[cfg(not(dev))]
mod frontend;
mod resource_limits;
mod startup;
mod tray;
mod update;
pub use desktop::run;
pub fn run() -> std::process::ExitCode {
if let Some(exit_code) = update::run_replacement_if_requested() {
return exit_code;
}
desktop::run()
}
+2 -2
View File
@@ -1,5 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
cursor_byok_desktop::run();
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,
effective: effective.rlim_cur,
hard: effective.rlim_max,
})
}
+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"
);
}
}
+282
View File
@@ -0,0 +1,282 @@
use std::process::ExitCode;
#[cfg(target_os = "windows")]
use std::{
fs::{self, OpenOptions},
io::Write,
path::PathBuf,
process::Command,
};
#[cfg(any(target_os = "windows", test))]
use std::{
io::{Cursor, Read},
path::Path,
};
use serde::Serialize;
use tauri::AppHandle;
#[cfg(target_os = "windows")]
use tauri_plugin_updater::UpdaterExt;
#[cfg(target_os = "windows")]
mod replacement;
#[cfg(target_os = "windows")]
const PORTABLE_UPDATE_ENDPOINT: &str =
"https://github.com/leookun/cursor-byok/releases/latest/download/portable-latest.json";
#[cfg(any(target_os = "windows", test))]
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?;
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
}
#[cfg(target_os = "windows")]
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)
}
#[cfg(any(target_os = "windows", test))]
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)
}
#[cfg(target_os = "windows")]
fn stage_payload(target: &Path, payload: &[u8]) -> std::io::Result<PathBuf> {
let directory = tempfile::Builder::new()
.prefix("cursor-byok-portable-update-")
.tempdir()?;
let name = target
.file_name()
.ok_or_else(|| std::io::Error::other("application executable has no file name"))?;
let path = directory.path().join(name);
fs::write(&path, payload)?;
let _ = directory.keep();
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn update_zip(name: &str, payload: &[u8]) -> Vec<u8> {
let mut bytes = Cursor::new(Vec::new());
{
let mut archive = zip::ZipWriter::new(&mut bytes);
archive
.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
archive.write_all(payload).unwrap();
archive.finish().unwrap();
}
bytes.into_inner()
}
#[test]
fn extracts_the_single_expected_windows_executable() {
let bytes = update_zip(WINDOWS_PAYLOAD_NAME, b"MZpayload");
assert_eq!(extract_windows_payload(&bytes).unwrap(), b"MZpayload");
}
#[test]
fn rejects_unexpected_or_non_executable_payloads() {
let wrong_name = update_zip("other.exe", b"MZpayload");
assert!(extract_windows_payload(&wrong_name).is_err());
let wrong_content = update_zip(WINDOWS_PAYLOAD_NAME, b"not an executable");
assert!(extract_windows_payload(&wrong_content).is_err());
}
}
@@ -0,0 +1,296 @@
use std::{
ffi::{OsStr, OsString},
fs, io,
path::{Path, PathBuf},
process::{Child, Command, ExitCode},
thread,
time::{Duration, Instant},
};
const APPLY_ARG: &str = "--apply-portable-update";
const TARGET_ARG: &str = "--update-target";
const PID_ARG: &str = "--update-wait-pid";
const HANDSHAKE_ARG: &str = "--update-handshake";
pub(super) const READY_ARG: &str = "--portable-update-ready";
const PROCESS_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
const READY_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
pub(super) struct ReplacementRequest {
target: PathBuf,
pid: u32,
handshake: PathBuf,
}
pub(super) fn request_from_args() -> Result<Option<ReplacementRequest>, String> {
let args = std::env::args_os().collect::<Vec<_>>();
if !args.iter().any(|arg| arg == APPLY_ARG) {
return Ok(None);
}
let target = PathBuf::from(
argument_value(&args, TARGET_ARG).ok_or_else(|| format!("{TARGET_ARG} is required"))?,
);
let pid = argument_value(&args, PID_ARG)
.ok_or_else(|| format!("{PID_ARG} is required"))?
.to_string_lossy()
.parse::<u32>()
.map_err(|error| format!("invalid {PID_ARG}: {error}"))?;
let handshake = PathBuf::from(
argument_value(&args, HANDSHAKE_ARG)
.ok_or_else(|| format!("{HANDSHAKE_ARG} is required"))?,
);
validate_target(&target).map_err(|error| error.to_string())?;
Ok(Some(ReplacementRequest {
target,
pid,
handshake,
}))
}
pub(super) fn ready_marker_from_args() -> Option<PathBuf> {
let args = std::env::args_os().collect::<Vec<_>>();
argument_value(&args, READY_ARG).map(PathBuf::from)
}
fn argument_value<'a>(args: &'a [OsString], name: &str) -> Option<&'a OsStr> {
args.iter()
.position(|arg| arg == name)
.and_then(|index| args.get(index + 1))
.map(OsString::as_os_str)
}
fn validate_target(target: &Path) -> io::Result<()> {
if !target.is_absolute() || !target.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"update target must be an existing absolute file",
));
}
let source_name = std::env::current_exe()?
.file_name()
.map(OsStr::to_os_string)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "updater has no file name"))?;
if target.file_name() != Some(source_name.as_os_str()) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"update target file name does not match the updater",
));
}
Ok(())
}
pub(super) fn run(request: ReplacementRequest) -> ExitCode {
match run_inner(request) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("portable update replacement failed: {error}");
ExitCode::FAILURE
}
}
}
fn run_inner(request: ReplacementRequest) -> io::Result<()> {
wait_for_process(request.pid, PROCESS_WAIT_TIMEOUT, &request.handshake)?;
remove_file_if_exists(&request.handshake)?;
let source = std::env::current_exe()?;
let backup = backup_path(&request.target);
let ready = source.with_extension("ready");
remove_file_if_exists(&ready)?;
if let Err(error) = install_staged(&source, &request.target, &backup) {
relaunch(&request.target);
return Err(error);
}
let mut child = match Command::new(&request.target)
.arg(READY_ARG)
.arg(&ready)
.spawn()
{
Ok(child) => child,
Err(error) => {
restore_backup(&request.target, &backup)?;
relaunch(&request.target);
return Err(error);
}
};
match wait_until_ready(&mut child, &ready, READY_WAIT_TIMEOUT) {
Ok(()) => {
let _ = fs::remove_file(&backup);
let _ = fs::remove_file(&ready);
Ok(())
}
Err(error) => {
let _ = child.kill();
let _ = child.wait();
restore_backup(&request.target, &backup)?;
relaunch(&request.target);
Err(error)
}
}
}
fn relaunch(target: &Path) {
if target.is_file() {
let _ = Command::new(target).spawn();
}
}
fn remove_file_if_exists(path: &Path) -> io::Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
fn backup_path(target: &Path) -> PathBuf {
path_with_suffix(target, ".old")
}
fn pending_path(target: &Path) -> PathBuf {
path_with_suffix(target, ".new")
}
fn path_with_suffix(target: &Path, suffix: &str) -> PathBuf {
let mut name = target.as_os_str().to_os_string();
name.push(suffix);
PathBuf::from(name)
}
fn install_staged(source: &Path, target: &Path, backup: &Path) -> io::Result<()> {
let pending = pending_path(target);
remove_file_if_exists(&pending)?;
fs::copy(source, &pending)?;
let result = activate_pending(&pending, target, backup);
if result.is_err() {
let _ = fs::remove_file(&pending);
}
result
}
fn activate_pending(pending: &Path, target: &Path, backup: &Path) -> io::Result<()> {
remove_file_if_exists(backup)?;
fs::rename(target, backup)?;
if let Err(error) = fs::rename(pending, target) {
if let Err(restore_error) = restore_backup(target, backup) {
return Err(io::Error::other(format!(
"failed to install update ({error}) and restore the original executable ({restore_error})"
)));
}
return Err(error);
}
Ok(())
}
fn restore_backup(target: &Path, backup: &Path) -> io::Result<()> {
remove_file_if_exists(target)?;
fs::rename(backup, target)
}
fn wait_until_ready(child: &mut Child, marker: &Path, timeout: Duration) -> io::Result<()> {
let deadline = Instant::now() + timeout;
loop {
if marker.is_file() {
return Ok(());
}
if let Some(status) = child.try_wait()? {
return Err(io::Error::other(format!(
"updated application exited before startup completed: {status}"
)));
}
if Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"updated application did not report a successful startup",
));
}
thread::sleep(Duration::from_millis(100));
}
}
#[cfg(windows)]
fn wait_for_process(pid: u32, timeout: Duration, handshake: &Path) -> io::Result<()> {
use windows_sys::Win32::{
Foundation::{
CloseHandle, GetLastError, ERROR_INVALID_PARAMETER, WAIT_OBJECT_0, WAIT_TIMEOUT,
},
System::Threading::{OpenProcess, WaitForSingleObject},
};
const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000;
let handle = unsafe { OpenProcess(SYNCHRONIZE_ACCESS, 0, pid) };
if handle.is_null() {
let error = unsafe { GetLastError() };
return if error == ERROR_INVALID_PARAMETER {
fs::write(handshake, b"started")
} else {
Err(io::Error::from_raw_os_error(error as i32))
};
}
if let Err(error) = fs::write(handshake, b"started") {
unsafe { CloseHandle(handle) };
return Err(error);
}
let milliseconds = timeout.as_millis().min(u32::MAX as u128) as u32;
let result = unsafe { WaitForSingleObject(handle, milliseconds) };
unsafe { CloseHandle(handle) };
match result {
WAIT_OBJECT_0 => Ok(()),
WAIT_TIMEOUT => Err(io::Error::new(
io::ErrorKind::TimedOut,
"running application did not exit before the update timeout",
)),
_ => Err(io::Error::last_os_error()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn staged_file_can_be_restored() {
let directory = tempfile::tempdir().unwrap();
let source = directory.path().join("source.exe");
let target = directory.path().join("target.exe");
let backup = backup_path(&target);
fs::write(&source, b"new").unwrap();
fs::write(&target, b"old").unwrap();
install_staged(&source, &target, &backup).unwrap();
assert_eq!(fs::read(&target).unwrap(), b"new");
assert_eq!(fs::read(&backup).unwrap(), b"old");
restore_backup(&target, &backup).unwrap();
assert_eq!(fs::read(&target).unwrap(), b"old");
assert!(!backup.exists());
}
#[test]
fn missing_pending_file_restores_original_after_backup() {
let directory = tempfile::tempdir().unwrap();
let target = directory.path().join("target.exe");
let pending = pending_path(&target);
let backup = backup_path(&target);
fs::write(&target, b"old").unwrap();
assert!(activate_pending(&pending, &target, &backup).is_err());
assert_eq!(fs::read(&target).unwrap(), b"old");
assert!(!backup.exists());
}
#[test]
fn missing_source_preserves_original_file() {
let directory = tempfile::tempdir().unwrap();
let source = directory.path().join("missing.exe");
let target = directory.path().join("target.exe");
let backup = backup_path(&target);
fs::write(&target, b"old").unwrap();
assert!(install_staged(&source, &target, &backup).is_err());
assert_eq!(fs::read(&target).unwrap(), b"old");
assert!(!backup.exists());
assert!(!pending_path(&target).exists());
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cursor BYOK",
"version": "0.1.4",
"version": "0.1.7",
"identifier": "dev.cursorbyok.desktop",
"build": {
"beforeDevCommand": "npm run dev",
@@ -12,7 +12,7 @@
"app": {
"windows": [],
"security": {
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*",
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data: https:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*",
"dangerousDisableAssetCspModification": [
"style-src"
]
+49 -19
View File
@@ -1,20 +1,24 @@
import { useEffect, useRef } from "react";
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import { MessageProvider } from "./components/ui/MessageProvider";
import { useMessage } from "./components/ui/message";
import { AppFrame } from "./layouts/AppFrame";
import { AppLayout } from "./layouts/AppLayout";
import { CallsPage } from "./pages/CallsPage";
import { CallDetailsPage } from "./pages/CallDetailsPage";
import { CursorSettingsPage } from "./pages/CursorSettingsPage";
import { HomePage } from "./pages/HomePage";
import { SettingsPage } from "./pages/SettingsPage";
import { useAppStore } from "./store/appStore";
import { updateStore } from "./store/updateStore";
import { TooltipProvider } from "./shared/ui/Tooltip";
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";
const AUTO_UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1_000;
export function App() {
return (
<>
<TooltipProvider>
<HashRouter>
<Routes>
<Route path="calls/:callId" element={<CallDetailsPage />} />
@@ -23,6 +27,7 @@ export function App() {
<Route index element={<HomePage />} />
<Route path="calls" element={<CallsPage />} />
<Route path="harness/cursor" element={<CursorSettingsPage />} />
<Route path="plugins" element={<PluginManagementPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
@@ -30,13 +35,14 @@ export function App() {
</Routes>
</HashRouter>
<AppMessages />
</>
</TooltipProvider>
);
}
function AppMessages() {
const { error } = useAppStore();
const previousError = useRef<string | null>(null);
const lastAutomaticUpdateCheckAt = useRef(0);
const showMessage = useMessage();
useEffect(() => {
@@ -45,12 +51,36 @@ function AppMessages() {
}, [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.
});
let disposed = false;
const checkAutomatically = () => {
const now = Date.now();
if (now - lastAutomaticUpdateCheckAt.current < AUTO_UPDATE_CHECK_INTERVAL_MS) return;
lastAutomaticUpdateCheckAt.current = now;
const previousVersion = updateStore.getSnapshot().availableVersion;
void updateStore.check().then((version) => {
if (disposed || !version || version === previousVersion) return;
showMessage(t("发现新版本 {version},可在设置中安装", { version }), { duration: 6_000 });
}).catch(() => {
if (!disposed) lastAutomaticUpdateCheckAt.current = 0;
// Automatic checks are best-effort; manual checks in Settings report errors.
});
};
const checkWhenVisible = () => {
if (document.visibilityState === "visible") checkAutomatically();
};
checkAutomatically();
window.addEventListener("focus", checkAutomatically);
window.addEventListener("online", checkAutomatically);
document.addEventListener("visibilitychange", checkWhenVisible);
const timer = window.setInterval(checkAutomatically, AUTO_UPDATE_CHECK_INTERVAL_MS);
return () => {
disposed = true;
window.removeEventListener("focus", checkAutomatically);
window.removeEventListener("online", checkAutomatically);
document.removeEventListener("visibilitychange", checkWhenVisible);
window.clearInterval(timer);
};
}, [showMessage]);
return <MessageProvider />;
@@ -1,186 +0,0 @@
import type { IconifyIcon } from "@iconify/react/offline";
import { useEffect, useRef } from "react";
import Sortable from "sortablejs";
import type { Model } from "../../api";
import { Button } from "../ui/Button";
import { Card } from "../ui/Card";
import { Icon } from "../ui/Icon";
import { claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../ui/icons";
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[];
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;
onReorder: (modelHashes: string[]) => void;
};
type ModelGridProps = Omit<CursorModelCardsProps, "grouping"> & {
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) {
if (props.grouping === "flat") return <div style={{ paddingTop: "10px" }}>
<ModelGrid {...props} sortable />
</div>;
return <div className={styles.modelGroups}>
{cursorModelGroups(props.models, props.grouping).map((group) => <section className={styles.modelGroup} key={group.key}>
<div className={styles.modelGroupHeader}>
<Icon icon={group.icon} size="1.1em" />
<span>{group.label}</span>
</div>
<ModelGrid {...props} models={group.models} sortable={false} />
</section>)}
</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}>
<Button size="small" disabled={disabled && !testing} onClick={() => onTest(model)}>{testing ? t("取消测试") : t("测试")}</Button>
<Button size="small" disabled={disabled} onClick={() => onEdit(model)}>{t("编辑")}</Button>
<Button size="small" disabled={disabled} onClick={() => onDuplicate(model)}>{t("复制")}</Button>
<Button size="small" className={styles.deleteButton} disabled={disabled} onClick={() => onDelete(model)}>{t("删除")}</Button>
</div>
</div>
</Card>;
})}
</div>;
}
function providerGroup(model: Model) {
const label = providerDomain(model.base_url);
return { key: label, 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 };
}
@@ -1,40 +0,0 @@
import type { ModelConnectivityResult } from "../../api";
import { Icon } from "../ui/Icon";
import { TooltipTrigger } from "../ui/TooltipTrigger";
import { informationOutlineIcon } from "../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 }: { state?: CursorModelTestState; testing?: boolean }) {
if (testing) return <div className={`${styles.root} ${styles.testing}`}><span className={styles.summary}>{t("测试中…")}</span></div>;
if (!state) return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("未测试")}</span></div>;
if (state.status === "cancelled") return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("测试已取消")}</span></div>;
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_text_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 });
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";
}
@@ -1,127 +0,0 @@
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 { Icon, type IconProps } from "./Icon";
import { checkIcon, chevronDownIcon, windowCloseIcon } from "./icons";
import { VirtualList } from "../virtual/VirtualList";
import styles from "./MultiSelect.module.scss";
export type MultiSelectOption = {
value: string;
label: string;
icon?: IconProps["icon"];
};
export function MultiSelect({ label, value, options, onChange }: {
label: string;
value: string[];
options: MultiSelectOption[];
onChange: (value: string[]) => void;
}) {
const root = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const menu = useRef<HTMLDivElement>(null);
const firstOption = useRef<HTMLButtonElement>(null);
const menuId = useId();
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({ left: 0, top: 0, width: 220, maxHeight: 280 });
useLayoutEffect(() => {
if (!open || !root.current || !menu.current) return;
return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, {
placement: "bottom-start",
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 }), size({
padding: 10,
apply: ({ rects, availableHeight }) => setPosition((current) => ({
...current,
width: rects.reference.width,
maxHeight: Math.max(120, availableHeight),
})),
})],
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
}, [open]);
useEffect(() => {
if (!open) return;
firstOption.current?.focus();
const closeOutside = (event: PointerEvent) => {
const target = event.target as Node;
if (!root.current?.contains(target) && !menu.current?.contains(target)) setOpen(false);
};
document.addEventListener("pointerdown", closeOutside);
return () => document.removeEventListener("pointerdown", closeOutside);
}, [open]);
const selected = new Set(value);
const summary = value.length === 0 ? t("全部") : t("已选 {count} 项", { count: value.length });
const toggle = (option: string) => onChange(selected.has(option)
? value.filter((item) => item !== option)
: [...value, option]);
const close = () => { setOpen(false); trigger.current?.focus(); };
const openMenu = () => {
const width = root.current?.getBoundingClientRect().width;
if (width) setPosition((current) => ({ ...current, width }));
setOpen(true);
};
return <>
<div ref={root} className={styles.target} data-open={open || undefined}>
<button
ref={trigger}
type="button"
className={styles.trigger}
aria-label={t("筛选项:{label},{summary}", { label, summary })}
aria-haspopup="listbox"
aria-controls={open ? menuId : undefined}
aria-expanded={open}
onClick={() => { if (open) close(); else openMenu(); }}
onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); openMenu(); } }}
>
<span>{label}</span><span className={styles.summary} aria-hidden="true">{summary}</span><Icon icon={chevronDownIcon} size="1.1em" />
</button>
{value.length > 0 && <button type="button" className={styles.clear} aria-label={t("清除{label}筛选", { label })} onClick={() => onChange([])}>
<Icon icon={windowCloseIcon} size="1.1em" />
</button>}
</div>
{open && createPortal(<div
id={menuId}
ref={menu}
className={styles.menu}
aria-label={label}
style={position}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); close(); } }}
>
<div
className={styles.listFrame}
role="listbox"
aria-label={label}
aria-multiselectable="true"
style={{ height: Math.min(options.length * 32, Math.max(32, position.maxHeight - 45)) }}
><VirtualList
items={options}
itemKey="value"
className={styles.list}
style={{ height: "100%" }}
estimatedItemHeight={32}
overscan={4}
empty={<div className={styles.empty}>{t("暂无选项")}</div>}
>{(option, index) => <button
ref={index === 0 ? firstOption : undefined}
type="button"
role="option"
aria-label={option.label}
aria-selected={selected.has(option.value)}
onClick={() => toggle(option.value)}
>
<span className={styles.option}>{option.icon && <Icon icon={option.icon} />}<span>{option.label}</span></span>
<span className={styles.check} aria-hidden="true">{selected.has(option.value) && <Icon icon={checkIcon} size="1.1em" />}</span>
</button>}</VirtualList></div>
{options.length > 0 && <div className={styles.footer}>
<button type="button" onClick={() => onChange(options.map((option) => option.value))}>{t("全选")}</button>
<button type="button" onClick={() => onChange([])}>{t("全不选")}</button>
</div>}
</div>, document.body)}
</>;
}
-14
View File
@@ -1,14 +0,0 @@
import styles from "./Switch.module.scss";
export function Switch({ checked, disabled, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }) {
return <button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
className={styles.root}
data-checked={checked || undefined}
onClick={() => onChange(!checked)}
><span /></button>;
}
@@ -1,16 +0,0 @@
@use "../../styles/typography" as type;
.root {
position: fixed;
z-index: 15000;
max-width: 420px;
padding: 8px 10px;
color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground));
background: var(--vscode-editorHoverWidget-background);
border: 1px solid var(--vscode-editorHoverWidget-border);
border-radius: var(--oa-overlay-radius);
box-shadow: var(--oa-tooltip-shadow);
font-size: type.$font-size-xs;
line-height: 1.5;
overflow-wrap: anywhere;
}
@@ -1,44 +0,0 @@
import { autoUpdate, computePosition, flip, offset, shift, type VirtualElement } from "@floating-ui/dom";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import styles from "./Tooltip.module.scss";
export type TooltipAnchor = VirtualElement;
export function Tooltip({ id, anchor, children }: { id?: string; anchor: TooltipAnchor | null; children: ReactNode }) {
const tooltipRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
useLayoutEffect(() => {
const tooltip = tooltipRef.current;
if (!anchor || !tooltip) {
setPosition(null);
return;
}
const updatePosition = () => {
void computePosition(anchor, tooltip, {
placement: "top",
middleware: [offset(8), flip({ padding: 12 }), shift({ padding: 12 })],
}).then(({ x, y }) => setPosition({ left: x, top: y }));
};
return autoUpdate(anchor, tooltip, updatePosition);
}, [anchor]);
if (!anchor) return null;
// Tooltip overlays always live under body, outside the trigger's layout and stacking context.
return createPortal(
<div
ref={tooltipRef}
id={id}
className={styles.root}
role="tooltip"
style={{ left: position?.left ?? 0, top: position?.top ?? 0, visibility: position ? "visible" : "hidden" }}
>
{children}
</div>,
document.body,
);
}
+304
View File
@@ -0,0 +1,304 @@
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,
};
});
let harnessStatus: CursorHarnessStatus = {
platform: "macos",
ca: "ready",
configured_models: models.length,
enabled_models: models.length,
integration: "enabled",
settings_applied: true,
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: "default",
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 === "/promotions") 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") return json(harnessStatus);
if (path === "/harness/cursor/enabled") {
const enabled = Boolean((body as { enabled?: unknown } | null)?.enabled);
harnessStatus = {
...harnessStatus,
integration: enabled ? "enabled" : "disabled",
settings_applied: enabled,
proxy_url: enabled ? "http://127.0.0.1:54321" : null,
};
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");
@@ -1,4 +1,4 @@
@use "../styles/typography" as type;
@use "../../styles/typography" as type;
.root {
section { min-width: 0; }
@@ -1,6 +1,6 @@
import type { CallDetail } from "../api";
import { JsonEditor } from "./ui/JsonEditor";
import { Tabs, type TabItem } from "./ui/Tabs";
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 ?? "-";
@@ -32,6 +32,7 @@ export function CallDetails({ detail }: { detail: CallDetail }) {
["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)],
@@ -1,4 +1,4 @@
@use "../styles/typography" as type;
@use "../../styles/typography" as type;
.root {
width: 100%;
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { api, type CallDetail } from "../api";
import { CallDetails } from "../components/CallDetails";
import { TitledCard } from "../components/ui/TitledCard";
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 "../components/virtual/ScrollableContent";
import { ScrollableContent } from "../../shared/virtual/ScrollableContent";
export function CallDetailsPage() {
const { callId = "" } = useParams();
@@ -1,4 +1,4 @@
@use "../styles/typography" as type;
@use "../../styles/typography" as type;
.status {
padding: 3px 7px;
@@ -1,9 +1,9 @@
import type { LlmCall } from "../api";
import controls from "./ui/Controls.module.scss";
import { DataTable, type DataTableColumn } from "./ui/DataTable";
import { Icon } from "./ui/Icon";
import { TooltipTrigger } from "./ui/TooltipTrigger";
import { eyeIcon } from "./ui/icons";
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 ?? "-";
@@ -87,6 +87,11 @@ export function CallTable({ calls, onDetails }: { calls: LlmCall[]; onDetails: (
header: "TTFB",
render: (call) => milliseconds(call.ttfb_ms),
},
{
key: "ttfr",
header: "TTFR",
render: (call) => milliseconds(call.ttfr_ms),
},
{
key: "ttft",
header: "TTFT",
@@ -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 }]} />;
}
@@ -1,18 +1,39 @@
import { useEffect, useState } from "react";
import { api, type Overview } from "../api";
import { ContributionCalendarChart } from "../components/charts/ContributionCalendarChart";
import { DailyTokenUsageChart } from "../components/charts/DailyTokenUsageChart";
import { HomeMetrics } from "../components/metrics/HomeMetrics";
import { PageContent } from "../components/layout/PageContent";
import type { VirtualPageSection } from "../components/layout/VirtualPage";
import { OverviewTimeRangeFilter, type OverviewRangePreset } from "../components/overview/OverviewTimeRangeFilter";
import { PageActions } from "../layouts/PageActions";
import { appStore, useAppStore } from "../store/appStore";
import { formatTimeInput, parseTimeInput } from "../utils/parseTimeInput";
import { claudeIcon, openAiIcon } from "../components/ui/icons";
import { api, pluginText, 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, type QuickPreset } from "./overview/OverviewTimeRangeFilter";
import { PageActions } from "../../shell/PageActions";
import { appStore, useAppStore } from "../../shared/store/appStore";
import { formatTimeInput, parseTimeInput } from "../../shared/utils/parseTimeInput";
import { modelProviderName } from "../../shared/utils/modelProvider";
import { claudeIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
import { useI18n } from "../../i18n/store";
type TimeRange = { startMs: number; endMs: number };
const CALENDAR_DAYS = 365;
const DAY_MS = 24 * 60 * 60_000;
function contributionCalendarData(overview: Overview, endMs: number) {
const tokensByDate = new Map<string, number>();
for (const bucket of overview.token_usage_series) {
const date = new Date(bucket.bucket_start_ms).toISOString().slice(0, 10);
const tokens = bucket.input_tokens + bucket.cache_read_tokens + bucket.cache_write_tokens + bucket.output_tokens;
tokensByDate.set(date, (tokensByDate.get(date) ?? 0) + tokens);
}
const lastDay = new Date(Math.max(0, endMs - 1));
lastDay.setUTCHours(0, 0, 0, 0);
const firstDayMs = lastDay.getTime() - (CALENDAR_DAYS - 1) * DAY_MS;
return Array.from({ length: CALENDAR_DAYS }, (_, offset) => {
const date = new Date(firstDayMs + offset * DAY_MS).toISOString().slice(0, 10);
return { date, tokens: tokensByDate.get(date) ?? 0 };
});
}
function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new Date()): TimeRange {
const endMs = now.getTime();
if (preset === "today") {
@@ -25,13 +46,17 @@ function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new D
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;
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 } = useAppStore();
const { overview, busy, models, plugins } = useAppStore();
const { locale } = useI18n();
const [preset, setPreset] = useState<OverviewRangePreset>("month");
const [quick, setQuick] = useState<QuickPreset | null>(null);
const [customRange, setCustomRange] = useState<TimeRange | null>(null);
const [customOpen, setCustomOpen] = useState(false);
const [customStart, setCustomStart] = useState("");
@@ -66,10 +91,7 @@ export function HomePage() {
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 contribution = contributionCalendarData(filteredOverview, selectedRange?.endMs ?? Date.now());
const metrics = {
llmCalls: filteredOverview.metrics.llm_calls,
successfulCalls: filteredOverview.metrics.successful_calls,
@@ -93,19 +115,48 @@ export function HomePage() {
if (startMs === null || endMs === null || startMs >= endMs) return;
setCustomRange({ startMs, endMs });
setAppliedModels(selectedModels);
setQuick(null);
setPreset("custom");
setCustomOpen(false);
};
const selectQuick = (durationMs: number) => {
const end = new Date();
const start = new Date(end.getTime() - durationMs);
setCustomStart(formatTimeInput(start));
setCustomEnd(formatTimeInput(end));
setCustomRange({ startMs: start.getTime(), endMs: end.getTime() });
setAppliedModels(selectedModels);
setQuick(durationMs === 4 * 60 * 60_000 ? "four-hours" : "twenty-four-hours");
setPreset("custom");
setCustomOpen(false);
};
const selectPreset = (value: Exclude<OverviewRangePreset, "custom">) => {
setPreset(value);
setQuick(null);
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),
}));
const modelOptions = [
...models.map((model) => ({
value: model.model_hash,
label: model.display_name,
group: modelProviderName(model),
icon: iconFor(model.type),
})),
...plugins.flatMap((plugin) => plugin.providers.flatMap((provider) =>
provider.configured ? provider.models.filter((model) => model.enabled).map((model) => ({
value: model.id,
label: model.displayName,
group: pluginText(provider.displayName, locale) || model.pluginName,
iconSrc: model.icon || undefined,
icon: model.icon ? undefined : flatColorOrganizationIcon,
})) : [],
)),
];
const sections: VirtualPageSection[] = [
{
key: "daily-token-usage",
@@ -131,13 +182,15 @@ export function HomePage() {
return <>
<PageActions><OverviewTimeRangeFilter
value={preset}
quick={quick}
customOpen={customOpen}
customStart={customStart}
customEnd={customEnd}
modelOptions={modelOptions}
selectedModels={selectedModels}
busy={busy || rangeBusy}
onSelect={(value) => { setPreset(value); setCustomOpen(false); }}
onSelect={selectPreset}
onQuickSelect={selectQuick}
onCustomOpenChange={openCustom}
onCustomStartChange={setCustomStart}
onCustomEndChange={setCustomEnd}
@@ -1,4 +1,4 @@
@use "../../styles/typography" as type;
@use "../../../styles/typography" as type;
.root {
width: 100%;
@@ -1,8 +1,8 @@
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 "../ui/Tooltip";
import type { Locale } from "../../../i18n/runtime";
import { useI18n } from "../../../i18n/store";
import { useTooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
import styles from "./ContributionCalendarChart.module.scss";
export type ContributionDay = {
@@ -28,12 +28,6 @@ type CellExtra = CalendarCell & {
height: number;
};
type TooltipState = {
date: string;
tokens: number;
anchor: TooltipAnchor;
};
type AxisLabel = {
key: string;
text: string;
@@ -101,7 +95,7 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
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 { show: showTooltip, hide: hideTooltip } = useTooltip();
const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]);
const layout = useMemo(() => buildCalendarLayout(data, locale), [data, locale]);
const tokenFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]);
@@ -129,10 +123,13 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
return new DOMRect(bounds.left + extra.x, bounds.top + extra.y, extra.width, extra.height);
},
};
setTooltip({ date: extra.date, tokens: extra.tokens, anchor });
showTooltip(anchor, undefined, <div className={styles.tooltipContent}>
<strong>{extra.date}</strong>
<span>{t("Token 用量:{tokens}", { tokens: tokenFormatter.format(extra.tokens) })}</span>
</div>);
};
const handleMouseOut = (event: ElementEvent) => {
if (isCellExtra(event.target?.extra)) setTooltip(null);
if (isCellExtra(event.target?.extra)) hideTooltip();
};
chart.on("mouseover", handleMouseOver);
@@ -226,7 +223,7 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
cellRects.set(cell.date, rect);
chart.add(rect);
}
setTooltip(null);
hideTooltip();
};
const scheduleDraw = () => {
window.cancelAnimationFrame(drawFrame);
@@ -264,12 +261,6 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
{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>
);
}
@@ -1,4 +1,4 @@
@use "../../styles/typography" as type;
@use "../../../styles/typography" as type;
.root {
--daily-token-tooltip-font-size: #{type.$font-size-2xs};
@@ -1,7 +1,7 @@
import type { EChartsCoreOption } from "echarts/core";
import { useMemo, useState } from "react";
import type { TokenUsageGranularity } from "../../api";
import { formatCompactInteger } from "../../utils/numberFormat";
import type { TokenUsageGranularity } from "../../../shared/api";
import { formatCompactInteger } from "../../../shared/utils/numberFormat";
import { EChart } from "./EChart";
import styles from "./DailyTokenUsageChart.module.scss";
@@ -1,18 +1,19 @@
import type { EChartsCoreOption } from "echarts/core";
import type { LlmCall } from "../../api";
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"],
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) },
],
@@ -1,5 +1,5 @@
import type { EChartsCoreOption } from "echarts/core";
import type { LlmCall } from "../../api";
import type { LlmCall } from "../../../shared/api";
import { EChart } from "./EChart";
export function TokenTrendChart({ calls }: { calls: LlmCall[] }) {
@@ -1,4 +1,4 @@
@use "../../styles/typography" as type;
@use "../../../styles/typography" as type;
.root {
position: relative;
@@ -1,4 +1,4 @@
@use "../../styles/typography" as type;
@use "../../../styles/typography" as type;
.scroller {
width: 100%;
@@ -1,8 +1,7 @@
import { useState } from "react";
import { formatCompactInteger, formatInteger } from "../../utils/numberFormat";
import { Icon } from "../ui/Icon";
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
import { informationOutlineIcon } from "../ui/icons";
import { formatCompactInteger, formatInteger } from "../../../shared/utils/numberFormat";
import { Icon } from "../../../shared/ui/Icon";
import { useTooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
import { informationOutlineIcon } from "../../../shared/ui/icons";
import { CacheHitRateChart } from "./CacheHitRateChart";
import styles from "./HomeMetrics.module.scss";
@@ -53,22 +52,17 @@ function elementAnchor(element: HTMLElement): TooltipAnchor {
}
function InfoTooltip({ content }: { content: string }) {
const [anchor, setAnchor] = useState<TooltipAnchor | null>(null);
const { show, hide } = useTooltip();
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>
</>;
return <button
type="button"
className={styles.info}
aria-label={t("查看说明")}
onMouseEnter={(event) => show(elementAnchor(event.currentTarget), undefined, <div className={styles.tooltipText}>{content}</div>)}
onMouseLeave={hide}
onFocus={(event) => show(elementAnchor(event.currentTarget), undefined, <div className={styles.tooltipText}>{content}</div>)}
onBlur={hide}
><Icon icon={informationOutlineIcon} size="1.1em" /></button>;
}
export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsData; refreshVersion?: number }) {
@@ -1,4 +1,4 @@
@use "../../styles/typography" as type;
@use "../../../styles/typography" as type;
.root,
.presets {
@@ -76,3 +76,22 @@
justify-content: flex-end;
gap: 6px;
}
.quickPresets {
display: flex;
gap: 6px;
button {
height: 26px;
padding: 0 10px;
color: var(--vscode-descriptionForeground);
background: var(--vscode-button-secondaryBackground, var(--vscode-sideBar-background));
border: 1px solid var(--vscode-sideBar-border);
border-radius: 4px;
font-size: type.$font-size-xs;
white-space: nowrap;
&:hover { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); }
&[aria-pressed="true"] { color: var(--vscode-list-activeSelectionForeground); background: var(--vscode-list-activeSelectionBackground); border-color: transparent; }
}
}
@@ -1,25 +1,28 @@
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 "../../utils/parseTimeInput";
import controls from "../ui/Controls.module.scss";
import { Icon } from "../ui/Icon";
import { MultiSelect, type MultiSelectOption } from "../ui/MultiSelect";
import { TooltipTrigger } from "../ui/TooltipTrigger";
import { refreshIcon } from "../ui/icons";
import { parseTimeInput } from "../../../shared/utils/parseTimeInput";
import controls from "../../../shared/ui/Controls.module.scss";
import { Icon } from "../../../shared/ui/Icon";
import { ModelSelect, type ModelSelectOption } from "../../../shared/ui/ModelSelect";
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 type QuickPreset = "four-hours" | "twenty-four-hours";
export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: {
export function OverviewTimeRangeFilter({ value, quick, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onQuickSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: {
value: OverviewRangePreset;
quick: QuickPreset | null;
customOpen: boolean;
customStart: string;
customEnd: string;
modelOptions: MultiSelectOption[];
modelOptions: ModelSelectOption[];
selectedModels: string[];
busy: boolean;
onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void;
onQuickSelect: (durationMs: number) => void;
onCustomOpenChange: (open: boolean) => void;
onCustomStartChange: (value: string) => void;
onCustomEndChange: (value: string) => void;
@@ -34,6 +37,10 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
{ value: "week", label: t("近一周") },
{ value: "month", label: t("近一个月") },
];
const quickPresets: Array<{ value: QuickPreset; label: string }> = [
{ value: "four-hours", label: t("近4小时") },
{ value: "twenty-four-hours", label: t("近24小时") },
];
const customButton = useRef<HTMLButtonElement>(null);
const popover = useRef<HTMLDivElement>(null);
const popoverId = useId();
@@ -66,7 +73,6 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
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
@@ -95,6 +101,7 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
role="dialog"
aria-label={t("自定义概览筛选")}
style={position}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
@@ -103,9 +110,17 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
}
}}
>
<div className={styles.quickPresets} aria-label={t("快捷时间范围")}>
{quickPresets.map((preset) => <button
key={preset.value}
type="button"
aria-pressed={quick === preset.value}
onClick={() => onQuickSelect(preset.value === "four-hours" ? 4 * 60 * 60_000 : 24 * 60 * 60_000)}
>{preset.label}</button>)}
</div>
<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.filterRow}><ModelSelect mode="multiple" 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>
@@ -1,6 +1,6 @@
import { createContext, useContext, type ReactNode } from "react";
import { useAppStore } from "../../store/appStore";
import controls from "../ui/Controls.module.scss";
import { useAppStore } from "../../shared/store/appStore";
import controls from "../../shared/ui/Controls.module.scss";
import styles from "./CursorSettings.module.scss";
const CaReady = createContext(false);
@@ -24,8 +24,9 @@ export function CursorCaGate({ busy, waitingForRefresh, onInitialize, onRefresh,
}
export function CursorModelProvider({ children }: { children: ReactNode }) {
const { models } = useAppStore();
return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>;
const { models, plugins } = useAppStore();
const hasConfiguredPlugin = plugins.some((plugin) => plugin.providers.some((provider) => provider.configured));
return <ModelsReady.Provider value={models.length > 0 || hasConfiguredPlugin}>{children}</ModelsReady.Provider>;
}
export function CursorModelGate({ busy, previewingImport, onAdd, onImport, children }: { busy: boolean; previewingImport: boolean; onAdd: () => void; onImport: () => void; children: ReactNode }) {
@@ -0,0 +1,318 @@
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, editIcon, 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}
defaultOpen={false}
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={`${props.grouping}:${group.pluginId}`}
label={group.pluginName}
iconSrc={group.icon}
defaultOpen={props.grouping === "flat"}
>
{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, defaultOpen = true, onSettings, children }: {
label: string;
icon?: IconifyIcon;
iconSrc?: string;
defaultOpen?: boolean;
onSettings?: () => void;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
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}>
<Icon icon={editIcon} size="1em" />
{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 };
}
@@ -1,17 +1,19 @@
import type { ModelInput, ModelType } from "../../api";
import { defaultCustomHeadersText } from "../../utils/modelDefaults";
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../utils/modelPresets";
import { Button } from "../ui/Button";
import { Checkbox } from "../ui/Checkbox";
import { FormField, SecretTextInput, TextInput } from "../ui/FormControls";
import { JsonEditor } from "../ui/JsonEditor";
import { Combobox, Select } from "../ui/Select";
import { Switch } from "../ui/Switch";
import { claudeIcon, openAiIcon } from "../ui/icons";
import { useRef } from "react";
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, type ComboboxHandle } 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;
@@ -19,9 +21,11 @@ export type CursorModelDraft = {
};
export const emptyCursorModelDraft = (): CursorModelDraft => ({
providerId: "builtin/openai",
model: {
sort_order: 0,
display_name: "",
group_name: null,
type: "openai",
base_url: "",
use_full_url: false,
@@ -52,8 +56,9 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
modelOptions: string[];
discovering: boolean;
onChange: (draft: CursorModelDraft) => void;
onDiscover: () => void;
onDiscover: () => Promise<boolean>;
}) {
const modelCombobox = useRef<ComboboxHandle>(null);
const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } });
const setType = (type: ModelType) => {
// 切换协议类型时,若当前地址命中某预设的另一协议端点,自动换到该预设对应协议的端点,
@@ -63,6 +68,7 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
const endpoint = preset ? presetEndpoint(preset, type) : null;
onChange({
...draft,
providerId: `builtin/${type}`,
model: {
...draft.model,
type,
@@ -85,6 +91,9 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
.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 discoverModels = async () => {
if (await onDiscover()) modelCombobox.current?.openAll();
};
const applyPreset = (preset: ModelPreset) => {
const endpoint = presetEndpoint(preset, draft.model.type);
const first = preset.models[0];
@@ -123,13 +132,19 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
? "https://api.anthropic.com"
: "https://api.openai.com";
const providerOptions = [
{ value: "builtin/openai", label: "OpenAI", icon: openAiIcon },
{ value: "builtin/anthropic", label: "Anthropic", icon: claudeIcon },
];
const setProvider = (providerId: string) => {
if (providerId === "builtin/openai") setType("openai");
if (providerId === "builtin/anthropic") setType("anthropic");
};
return <div className={styles.editor}>
<CursorPresetChips type={draft.model.type} baseUrl={draft.model.base_url} onPick={applyPreset} />
<div className={styles.grid}>
<FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.model.type} options={[
{ value: "openai", label: "OpenAI", icon: openAiIcon },
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
]} onChange={(value) => setType(value as ModelType)} /></FormField>
<FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.providerId} options={providerOptions} onChange={setProvider} /></FormField>
{draft.model.type === "openai" && <FormField label={t("请求协议")} hint={t("只决定请求与响应的格式,不会改变请求地址。")}> <Select ariaLabel={t("请求协议")} value={draft.model.openai_endpoint} options={[
{ value: "/v1/responses", label: "Responses API" },
{ value: "/v1/chat/completions", label: "Chat Completions API" },
@@ -141,7 +156,7 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
</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("可以直接输入模型标识,也可以读取接口返回的模型列表。")}><Combobox ref={modelCombobox} value={draft.model.model_id} options={combinedOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={() => void discoverModels()}>{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>
@@ -37,6 +37,19 @@
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 28%, transparent);
}
.compact {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border: 1px solid transparent;
border-radius: 999px;
font-size: type.$font-size-2xs;
white-space: nowrap;
cursor: default;
}
.summary {
min-width: 0;
overflow: hidden;
@@ -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";
}
@@ -1,5 +1,5 @@
import type { ModelType } from "../../api";
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../utils/modelPresets";
import type { ModelType } from "../../shared/api";
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../shared/utils/modelPresets";
import styles from "./CursorPresetChips.module.scss";
/** 常用服务商预设:点击按当前协议类型自动填充对应端点与默认模型 */
@@ -5,6 +5,20 @@
gap: 16px;
}
.takeoverActions {
display: flex;
align-items: center;
gap: 7px;
}
.takeoverStatus {
min-width: 39px;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
text-align: right;
white-space: nowrap;
}
.groupActions {
display: flex;
align-items: center;
@@ -70,19 +84,90 @@
gap: 20px;
padding-top: 10px;
}
.modelGroup {
display: flex;
flex-direction: column;
gap: 10px;
.groupCard {
padding: 4px 12px 8px;
}
.modelGroupHeader {
.groupHeader {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
}
.groupSettings {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 2px;
color: var(--vscode-descriptionForeground);
background: none;
border: none;
font-size: type.$font-size-xs;
white-space: nowrap;
cursor: pointer;
&:hover { color: var(--vscode-foreground); }
}
.groupChevron {
display: flex;
align-items: center;
padding: 4px 0;
color: var(--vscode-foreground);
background: none;
border: none;
cursor: pointer;
}
.groupToggle {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 2px;
color: var(--vscode-foreground);
background: none;
border: none;
font-size: type.$font-size-base;
opacity: 0.6;
padding-left: 6px;
cursor: pointer;
}
.groupLabel {
flex: 1;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.modelList {
display: flex;
flex-direction: column;
}
.modelRow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 12px;
padding: 10px 2px;
border-top: 1px solid var(--vscode-sideBar-border);
}
.modelRowName {
min-width: 0;
flex: 1 1 200px;
display: flex;
flex-direction: column;
gap: 2px;
.modelRowNameText,
.modelRowModelId {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modelRowNameText {
color: var(--vscode-foreground);
font-size: type.$font-size-base;
}
.modelRowModelId {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-2xs;
}
}
.modelCard {
position: relative;
@@ -141,9 +226,15 @@
}
.modelCardActions {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 8px;
// 空间不足时按钮收缩显示省略号,而不是换行。
> button {
min-width: 0;
flex: 0 1 auto;
}
}
.deleteButton:hover {
color: var(--vscode-errorForeground, #f48771);
@@ -1,24 +1,28 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api, type Model, type ModelInput } from "../api";
import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "../components/cursor/CursorGates";
import { CursorModelCards, cursorModelGroups, type CursorModelGrouping } from "../components/cursor/CursorModelCards";
import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "../components/cursor/CursorModelEditor";
import { CursorModelTestResult, type CursorModelTestState } from "../components/cursor/CursorModelTestResult";
import styles from "../components/cursor/CursorSettings.module.scss";
import { PageContent } from "../components/layout/PageContent";
import { LegacyModelImport } from "../components/models/LegacyModelImport";
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
import controls from "../components/ui/Controls.module.scss";
import { Icon } from "../components/ui/Icon";
import { Modal } from "../components/ui/Modal";
import { TooltipTrigger } from "../components/ui/TooltipTrigger";
import { addIcon } from "../components/ui/icons";
import { useMessage } from "../components/ui/message";
import { PageActions } from "../layouts/PageActions";
import { appStore, useAppStore } from "../store/appStore";
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 { Switch } from "../../shared/ui/Switch";
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 } = useAppStore();
const { models, cursorHarness, cursorBusy, plugins } = useAppStore();
const navigate = useNavigate();
const message = useMessage();
const [draft, setDraft] = useState<CursorModelDraft | null>(null);
const [editing, setEditing] = useState<Model | null>(null);
@@ -27,13 +31,26 @@ export function CursorSettingsPage() {
const [caCommand, setCaCommand] = useState<string | null>(null);
const [waitingForCaRefresh, setWaitingForCaRefresh] = useState(false);
const [deleting, setDeleting] = useState<Model | null>(null);
const [confirmDisableTakeover, setConfirmDisableTakeover] = useState(false);
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 cursorTakenOver = cursorHarness?.settings_applied ?? false;
const takeoverLabel = cursorTakenOver ? t("关闭接管Cursor") : t("开启接管Cursor");
const pluginModels = configuredPluginModels(plugins);
const testTargets = [
...models.map((model) => ({ model_hash: model.model_hash, display_name: model.display_name })),
...pluginModels.map((model) => ({ model_hash: model.id, display_name: model.displayName })),
];
const providerGroups = cursorModelGroups(models, "provider");
const typeGroups = cursorModelGroups(models, "type");
const canGroupByProvider = providerGroups.length > 1;
@@ -64,14 +81,15 @@ export function CursorSettingsPage() {
setEditing(model);
setModelOptions([model.model_id]);
setDraft({
providerId: `builtin/${model.type}`,
model: modelInput(model),
openAIExtraParamsText: JSON.stringify(model.openai_extra_params, null, 2),
customHeadersText: JSON.stringify(model.custom_headers, null, 2),
anthropicExtraParamsText: JSON.stringify(model.anthropic_extra_params, null, 2),
});
};
const discover = async () => {
if (!draft) return;
const discover = async (): Promise<boolean> => {
if (!draft) return false;
setDiscovering(true);
try {
const custom_headers = parseHeaders(draft.customHeadersText);
@@ -83,8 +101,10 @@ export function CursorSettingsPage() {
custom_headers,
});
setModelOptions([...new Set(result.models)]);
return true;
} catch (cause) {
message(errorText(cause));
return false;
} finally {
setDiscovering(false);
}
@@ -109,18 +129,17 @@ export function CursorSettingsPage() {
const active = activeModelTests.current.get(modelHash);
if (!active || active.cancelling) return;
active.cancelling = true;
active.controller.abort();
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, notify = true): Promise<"success" | "failure" | "cancelled"> => {
const testModel = async (model: { model_hash: string; display_name: string }, notify = true): Promise<"success" | "failure" | "cancelled"> => {
if (activeModelTests.current.has(model.model_hash)) {
await cancelModelTest(model.model_hash);
return "cancelled";
@@ -167,17 +186,17 @@ export function CursorSettingsPage() {
await appStore.refresh();
};
const testAllModels = async () => {
if (!models.length || batchTesting) return;
if (!testTargets.length || batchTesting) return;
setBatchTesting(true);
try {
const results = await Promise.all(models.map((model) => testModel(model, false)));
const results = await Promise.all(testTargets.map((model) => testModel(model, false)));
const successful = results.filter((result) => result === "success").length;
const failed = results.filter((result) => result === "failure").length;
const cancelled = results.filter((result) => result === "cancelled").length;
message(cancelled > 0
? t("连通性测试已取消:成功 {successful},失败 {failed}", { successful, failed })
: failed === 0
? t("全部 {count} 个模型连通性测试成功", { count: models.length })
? t("全部 {count} 个模型连通性测试成功", { count: testTargets.length })
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
{ duration: failed === 0 && cancelled === 0 ? 2400 : 5000 });
} finally {
@@ -200,6 +219,39 @@ export function CursorSettingsPage() {
}]);
if (created) message(t("模型已复制"));
};
const openGroupSettings = (group: CursorModelGroup) => {
setGroupNameDraft(group.models.find((model) => model.group_name?.trim())?.group_name?.trim() ?? "");
setGroupBaseUrlDraft(sharedValue(group.models.map((model) => model.base_url)) ?? "");
setGroupApiKeyDraft(sharedValue(group.models.map((model) => model.api_key)) ?? "");
setSettingsGroup(group);
};
const saveGroupSettings = async () => {
if (!settingsGroup) return;
const group_name = groupNameDraft.trim() || null;
const base_url = groupBaseUrlDraft.trim();
const api_key = groupApiKeyDraft.trim();
setGroupSettingsBusy(true);
try {
for (const model of settingsGroup.models) {
const input: ModelInput = {
...modelInput(model),
group_name,
...(base_url ? { base_url } : {}),
...(api_key ? { api_key } : {}),
};
if (input.group_name === (model.group_name ?? null)
&& input.base_url === model.base_url
&& input.api_key === model.api_key) continue;
await api.updateModel(model.model_hash, input);
}
await appStore.refresh();
setSettingsGroup(null);
} catch (cause) {
message(errorText(cause));
} finally {
setGroupSettingsBusy(false);
}
};
const reorderModels = useCallback(async (modelHashes: string[]) => {
if (!await appStore.reorderCursorModels(modelHashes)) {
message(appStore.getSnapshot().error || t("排序失败"));
@@ -208,6 +260,7 @@ export function CursorSettingsPage() {
const list = <CursorModelCards
models={models}
pluginModels={pluginModels}
grouping={grouping}
disabled={cursorBusy}
testingModelHashes={testingModelHashes}
@@ -216,7 +269,10 @@ export function CursorSettingsPage() {
onEdit={openEdit}
onDuplicate={(model) => void duplicateModel(model)}
onDelete={setDeleting}
onTestPluginModel={(model) => void testModel({ model_hash: model.id, display_name: model.displayName })}
onPluginSettings={() => navigate("/plugins")}
onReorder={reorderModels}
onGroupSettings={openGroupSettings}
/>;
const refreshCa = async () => {
@@ -239,29 +295,70 @@ export function CursorSettingsPage() {
const editorTestState = editing ? modelTestResults.get(editing.model_hash) : undefined;
const editorTesting = Boolean(editing && testingModelHashes.has(editing.model_hash));
const activeGroups = grouping === "provider" ? providerGroups : typeGroups;
const pluginSectionHeight = pluginModels.length > 0 ? 60 + pluginModels.length * 56 : 0;
const estimatedModelHeight = grouping === "flat"
? Math.max(380, Math.ceil(models.length / 3) * 196)
: Math.max(380, activeGroups.reduce((height, group) => height + Math.ceil(group.models.length / 3) * 196 + 34, 0) + Math.max(0, activeGroups.length - 1) * 20);
? Math.max(380, Math.ceil(models.length / 3) * 196 + pluginSectionHeight)
: Math.max(380, activeGroups.reduce((height, group) => height + 60 + group.models.length * 56, 0) + Math.max(0, activeGroups.length - 1) * 20 + pluginSectionHeight);
return <>
{models.length > 0 && <PageActions position="left">
<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>
<PageActions position="left">
<div className={styles.takeoverActions}>
<span className={styles.takeoverStatus}>{cursorTakenOver ? t("已接管") : t("未接管")}</span>
<TooltipTrigger label={takeoverLabel}>
<Switch
checked={cursorTakenOver}
disabled={cursorBusy || (!cursorTakenOver && !caReady)}
label={takeoverLabel}
onChange={(enabled) => {
if (enabled) void appStore.setCursorEnabled(true);
else setConfirmDisableTakeover(true);
}}
/>
</TooltipTrigger>
{testTargets.length > 0 && <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>}
</div>
</PageActions>}
</PageActions>
<PageActions><TooltipTrigger label={caReady ? t("添加模型") : t("请先初始化 CA")}><button className={controls.iconButton} aria-label={t("添加模型")} disabled={!caReady || cursorBusy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger></PageActions>
<PageContent title={t("Cursor 配置")} sections={[{ key: "cursor-settings", estimatedHeight: estimatedModelHeight, content }]} />
<Modal fullHeight open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => { if (editing && editorTesting) void cancelModelTest(editing.model_hash); setDraft(null); setEditing(null); }} onSubmit={() => void save()} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void (editorTesting && editing ? cancelModelTest(editing.model_hash) : saveAndTest())}>{savingAndTesting ? t("处理中…") : editorTesting ? t("取消测试") : t("保存并测试")}</button>}>
<PageContent title="Cursor" sections={[{ key: "cursor-settings", estimatedHeight: estimatedModelHeight, content }]} />
<ConfirmDialog
open={confirmDisableTakeover}
title={t("关闭接管Cursor?")}
cancelLabel={t("取消")}
confirmLabel={t("关闭接管")}
onCancel={() => setConfirmDisableTakeover(false)}
onConfirm={() => {
setConfirmDisableTakeover(false);
void appStore.setCursorEnabled(false);
}}
>
<p>{t("关闭后将移除 Cursor 本地代理配置。如果你需要登陆官方账号,通常不需要关闭操作,推荐直接登陆你的账号即可(byok模型与官方账号的模型已支持无缝衔接),是否继续关闭并清理代理?")}</p>
</ConfirmDialog>
<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()} />
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={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>
</>;
}
@@ -271,6 +368,13 @@ function modelInput(model: Model): ModelInput {
return input;
}
/** 组内所有模型取值一致时返回该值,否则返回 null(表单留空表示保持不变)。 */
function sharedValue(values: string[]): string | null {
const [first, ...rest] = values;
if (first === undefined) return null;
return rest.every((value) => value === first) ? first : null;
}
function draftInput(draft: CursorModelDraft): ModelInput {
const model = {
...draft.model,
@@ -1,8 +1,8 @@
import { useState, type ReactNode } from "react";
import { api, type LegacyModelImportPreview } from "../../api";
import { appStore } from "../../store/appStore";
import { ConfirmDialog } from "../ui/ConfirmDialog";
import { useMessage } from "../ui/message";
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 = {
@@ -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,328 @@
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, type ActionMenuItem } 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)}
onSubmit={() => setSelected(null)}
submitLabel={t("确定")}
>
{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 = "";
}
};
const moreItems: ActionMenuItem[] = [
...(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,
),
),
},
]
: []),
...(plugin.version
? [{ id: "version", type: "text" as const, label: `v${plugin.version}` }]
: []),
];
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>
{plugin.author && (
<span className={styles.pluginAuthor}>{plugin.author}</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")}
/>
)}
{moreItems.length > 0 && (
<span className={styles.moreAction}>
<ActionMenu label={t("更多")} items={moreItems} />
</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,237 @@
@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;
}
}
.modelToolbar {
display: flex;
gap: 8px;
}
.modelTableWrap {
min-width: min(520px, 70vw);
}
.modelTable {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
th,
td {
padding: 10px 12px;
border-bottom: 1px solid var(--vscode-sideBar-border);
text-align: left;
}
th:last-child,
td:last-child {
width: 72px;
text-align: center;
}
td:first-child {
min-width: 0;
}
.modelName {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
td span {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.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;
}
}
.actionBody {
display: flex;
flex-direction: column;
gap: 12px;
}
.actionDescription {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
.actionCardList {
display: flex;
flex-direction: column;
gap: 8px;
}
.actionCard {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px;
}
.actionCardMain {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
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,495 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
api,
pluginText,
type PluginAddMethod,
type PluginDescriptor,
type PluginOAuthBegin,
type PluginProviderDescriptor,
type PluginResourceAction,
type PluginResourceActionCard,
type PluginResourceActionResult,
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 { ConfirmDialog } from "../../shared/ui/ConfirmDialog";
import { FormField, TextInput } from "../../shared/ui/FormControls";
import { Modal } from "../../shared/ui/Modal";
import { Switch } from "../../shared/ui/Switch";
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");
if (next.userCode) await api.copyCursorText(next.userCode).catch(() => undefined);
await api.openExternalUrl(next.verificationUrlComplete || next.verificationUrl);
} catch (cause) {
setStatus("error");
setError(errorText(cause));
}
};
const userCode = begun?.userCode;
return <Card className={styles.methodCard}>
<strong>{pluginText(method.displayName, locale)}</strong>
{method.description && <span>{pluginText(method.description, locale)}</span>}
{userCode && status === "polling" && <div className={styles.deviceCode}>
<small>{t("设备验证码")}</small>
<button type="button" onClick={() => void copyCode(userCode)}>{userCode}</button>
<button type="button" className={styles.copy} onClick={() => void copyCode(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 [modelProviderId, setModelProviderId] = useState<string | null>(null);
const [resourceAction, setResourceAction] = useState<{
resource: PluginResourceDescriptor;
item: PluginResourceView;
} | null>(null);
const [resourceActionResult, setResourceActionResult] = useState<PluginResourceActionResult | null>(null);
const [resourceActionError, setResourceActionError] = useState<string | null>(null);
const modelProvider = modelProviderId ? plugin.providers.find((provider) => provider.id === modelProviderId) ?? 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);
}
};
const executeResourceAction = async (
target: { resource: PluginResourceDescriptor; item: PluginResourceView },
action: PluginResourceAction,
input: unknown = {},
) => {
const key = `action:${target.item.id}:${action.id}`;
setBusy(key);
setResourceActionError(null);
try {
const result = await api.pluginResourceAction(
plugin.id,
target.resource.type,
target.item.id,
action.id,
input,
);
setResourceActionResult(result);
await appStore.refreshPlugins();
} catch (cause) {
setResourceActionError(errorText(cause));
} finally {
setBusy(null);
}
};
const openResourceAction = (resource: PluginResourceDescriptor, item: PluginResourceView, action: PluginResourceAction) => {
setResourceAction({ resource, item });
setResourceActionResult(null);
setResourceActionError(null);
void executeResourceAction({ resource, item }, action);
};
return <div className={styles.panel}>
{plugin.providers.map((provider) => <ProviderRow
key={provider.id}
provider={provider}
busy={busy !== null}
syncing={busy === `sync:${provider.id}`}
onManageModels={() => setModelProviderId(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}
onAction={(item, action) => openResourceAction(resource, item, action)}
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>}
{modelProvider && <ModelManagementModal
provider={modelProvider}
busy={busy !== null}
onClose={() => setModelProviderId(null)}
onSubmit={(enabledByModel) => void run("models", async () => {
for (const model of modelProvider.models) {
const enabled = enabledByModel[model.id] ?? model.enabled;
if (model.enabled !== enabled) await api.setPluginModelEnabled(plugin.id, modelProvider.id, model.modelId, enabled);
}
})}
/>}
{resourceAction && <ResourceActionModal
action={resourceAction.resource.actions.find((item) => item.target === "resource") ?? null}
cardAction={resourceAction.resource.actions.find((item) => item.target === "card") ?? null}
result={resourceActionResult}
busy={busy !== null}
error={resourceActionError}
onClose={() => setResourceAction(null)}
onCardAction={(action, card) => void executeResourceAction(resourceAction, action, { cardId: card.id })}
/>}
</div>;
}
function ProviderRow({ provider, busy, syncing, onManageModels, onSync }: {
provider: PluginProviderDescriptor;
busy: boolean;
syncing: boolean;
onManageModels: () => void;
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 && <div className={styles.actions}>
<Button size="small" disabled={busy || provider.models.length === 0} onClick={onManageModels}>{t("模型管理")}</Button>
<Button size="small" disabled={busy} onClick={onSync}>
{syncing ? t("正在同步…") : t("同步模型")}
</Button>
</div>}
</Card>;
}
function ModelManagementModal({ provider, busy, onClose, onSubmit }: {
provider: PluginProviderDescriptor;
busy: boolean;
onClose: () => void;
onSubmit: (enabledByModel: Record<string, boolean>) => void;
}) {
const { locale } = useI18n();
const [enabledByModel, setEnabledByModel] = useState<Record<string, boolean>>({});
useEffect(() => {
setEnabledByModel(Object.fromEntries(provider.models.map((model) => [model.id, model.enabled])));
}, [provider.models]);
const setAll = (enabled: boolean) => {
setEnabledByModel(Object.fromEntries(provider.models.map((model) => [model.id, enabled])));
};
return <Modal
fullHeight
open
title={t("{name} 模型管理", { name: pluginText(provider.displayName, locale) })}
busy={busy}
onClose={onClose}
onSubmit={() => onSubmit(enabledByModel)}
submitLabel={t("确定")}
>
<div className={styles.modelToolbar}>
<Button size="small" disabled={busy || provider.models.length === 0} onClick={() => setAll(true)}>{t("全选")}</Button>
<Button size="small" disabled={busy || provider.models.length === 0} onClick={() => setAll(false)}>{t("全不选")}</Button>
</div>
<div className={styles.modelTableWrap}>
<table className={styles.modelTable}>
<thead><tr><th scope="col">{t("模型名称")}</th><th scope="col">{t("启用")}</th></tr></thead>
<tbody>
{provider.models.map((model) => <tr key={model.id}>
<td><div className={styles.modelName}>
<strong>{model.displayName}</strong>
{model.description && <span>{model.description}</span>}
</div></td>
<td><Switch
checked={enabledByModel[model.id] ?? model.enabled}
disabled={busy}
label={t("启用 {model}", { model: model.displayName })}
onChange={(enabled) => setEnabledByModel((current) => ({ ...current, [model.id]: enabled }))}
/></td>
</tr>)}
</tbody>
</table>
{provider.models.length === 0 && <span className={styles.empty}>{t("尚未同步模型")}</span>}
</div>
</Modal>;
}
function ResourceList({ resource, busy, onAction, onRefresh, onDelete }: {
resource: PluginResourceDescriptor;
busy: boolean;
onAction: (item: PluginResourceView, action: PluginResourceAction) => void;
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}
actions={resource.actions.filter((action) => action.target === "resource")}
canRefresh={resource.canRefresh}
disabled={busy}
onAction={(action) => onAction(item, action)}
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, actions, canRefresh, disabled, onAction, onRefresh, onDelete }: {
item: PluginResourceView;
actions: PluginResourceAction[];
canRefresh: boolean;
disabled: boolean;
onAction: (action: PluginResourceAction) => void;
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} />
{actions.map((action) => <Button key={action.id} size="small" disabled={disabled} onClick={() => onAction(action)}>{pluginText(action.displayName, locale)}</Button>)}
{canRefresh && <Button size="small" disabled={disabled} onClick={onRefresh}>{t("刷新")}</Button>}
<Button size="small" disabled={disabled} onClick={onDelete}>{t("删除")}</Button>
</div>
</Card>;
}
function ResourceActionModal({ action, cardAction, result, busy, error, onClose, onCardAction }: {
action: PluginResourceAction | null;
cardAction: PluginResourceAction | null;
result: PluginResourceActionResult | null;
busy: boolean;
error: string | null;
onClose: () => void;
onCardAction: (action: PluginResourceAction, card: PluginResourceActionCard) => void;
}) {
const { locale } = useI18n();
const [pendingCard, setPendingCard] = useState<PluginResourceActionCard | null>(null);
const title = result ? pluginText(result.title, locale) : action ? pluginText(action.displayName, locale) : t("资源详情");
const cardActions = cardAction ? [cardAction] : [];
return <>
<Modal compact open title={title} busy={busy} onClose={onClose} submitLabel={t("关闭")} onSubmit={onClose}>
<div className={styles.actionBody}>
{result?.description && <span className={styles.actionDescription}>{pluginText(result.description, locale)}</span>}
{busy && <span className={styles.empty}>{t("正在加载…")}</span>}
{error && <span className={styles.error} role="alert">{error}</span>}
{!busy && !error && result && result.cards.length === 0 && <span className={styles.empty}>{t("没有可用的重置卡。")}</span>}
{!busy && !error && result && <div className={styles.actionCardList}>
{result.cards.map((card) => <Card key={card.id} className={styles.actionCard}>
<div className={styles.actionCardMain}>
<strong>{pluginText(card.title, locale)}</strong>
{card.status && <span>{formatActionStatus(card.status, locale)}</span>}
{card.grantedAtMs !== null && card.grantedAtMs !== undefined && <span>{t("发放时间:{time}", { time: formatActionDate(card.grantedAtMs, locale) })}</span>}
{card.expiresAtMs !== null && card.expiresAtMs !== undefined && <span>{t("到期时间:{time}", { time: formatActionDate(card.expiresAtMs, locale) })}</span>}
{card.fields.map((field) => <span key={field.id}>{pluginText(field.label, locale)}: {field.value}</span>)}
</div>
{cardActions.length > 0 && <div className={styles.actions}>
{cardActions.map((cardActionItem) => <Button
key={cardActionItem.id}
size="small"
disabled={busy || card.status !== "available"}
onClick={() => cardActionItem.destructive ? setPendingCard(card) : onCardAction(cardActionItem, card)}
>{pluginText(cardActionItem.displayName, locale)}</Button>)}
</div>}
</Card>)}
</div>}
</div>
</Modal>
{pendingCard && cardAction && <ConfirmDialog
open
title={t("使用重置卡")}
busy={busy}
confirmLabel={t("确认使用")}
onCancel={() => setPendingCard(null)}
onConfirm={() => {
const card = pendingCard;
setPendingCard(null);
onCardAction(cardAction, card);
}}
>
<p>{t("使用后会立即消耗这张重置卡,且无法恢复。确定继续吗?")}</p>
<strong>{pluginText(pendingCard.title, locale)}</strong>
</ConfirmDialog>}
</>;
}
function formatActionStatus(status: PluginResourceActionCard["status"], locale: string) {
const value = typeof status === "string" ? status : pluginText(status, locale);
switch (value.toLowerCase()) {
case "available": return t("可用");
case "redeemed":
case "used": return t("已使用");
case "expired": return t("已过期");
default: return value;
}
}
function formatActionDate(value: number, locale: string) {
return new Date(value).toLocaleString(locale);
}
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);
}
@@ -8,12 +8,12 @@ import {
writeAutostart,
writeDockIconVisibility,
writeSilentStart,
} from "../../native/appLifecycle";
import { updateStore, useUpdateStore } from "../../store/updateStore";
import { Button } from "../ui/Button";
import { Switch } from "../ui/Switch";
import { TitledCard } from "../ui/TitledCard";
import { useMessage } from "../ui/message";
} 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() {
@@ -95,7 +95,8 @@ export function AppLifecycleSettingsCard() {
const nextVersion = await updateStore.check();
message(nextVersion ? t("发现新版本 {version}", { version: nextVersion }) : t("当前已是最新版本"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
const error = cause instanceof Error ? cause.message : String(cause);
message(t("检查更新失败:{error}", { error }));
}
};
@@ -103,7 +104,8 @@ export function AppLifecycleSettingsCard() {
try {
await updateStore.install();
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
const error = cause instanceof Error ? cause.message : String(cause);
message(t("安装更新失败:{error}", { error }));
}
};
@@ -0,0 +1,76 @@
@use "../../styles/typography" as type;
.content {
padding: 16px;
}
.row {
min-width: 0;
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.details {
display: grid;
gap: 5px;
}
.select {
width: min(320px, 50%);
}
.value {
min-width: 0;
max-width: 70%;
color: var(--vscode-descriptionForeground);
overflow-wrap: anywhere;
text-align: right;
}
.actionGroup {
display: flex;
align-items: center;
gap: 6px;
}
.textButton {
padding: 4px;
color: var(--vscode-textLink-foreground);
background: transparent;
border: 0;
&:hover { color: var(--vscode-textLink-activeForeground); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
.promptEditor {
width: 100%;
flex: 1;
min-height: 320px;
box-sizing: border-box;
padding: 10px 12px;
color: var(--vscode-input-foreground);
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border, var(--vscode-sideBar-border));
border-radius: 6px;
font-family: var(--vscode-editor-font-family, monospace);
font-size: type.$font-size-xs;
line-height: 1.6;
resize: none;
&:focus {
outline: 1px solid var(--vscode-focusBorder);
}
}
@media (max-width: 680px) {
.select { width: 68%; }
}
@@ -0,0 +1,229 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { api, pluginText, type CommitSettingsView } from "../../shared/api";
import { useI18n } from "../../i18n/store";
import { useAppStore } from "../../shared/store/appStore";
import { Button } from "../../shared/ui/Button";
import { Modal } from "../../shared/ui/Modal";
import { ModelSelect, type ModelSelectOption } from "../../shared/ui/ModelSelect";
import { claudeIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
import { TitledCard } from "../../shared/ui/TitledCard";
import { useMessage } from "../../shared/ui/message";
import controls from "../../shared/ui/Controls.module.scss";
import { modelProviderName } from "../../shared/utils/modelProvider";
import styles from "./CommitSettingsCard.module.scss";
function errorText(cause: unknown) {
return cause instanceof Error ? cause.message : String(cause);
}
export function CommitSettingsCard() {
const { models, plugins } = useAppStore();
const { locale } = useI18n();
const message = useMessage();
const [view, setView] = useState<CommitSettingsView | null>(null);
const [modelDraft, setModelDraft] = useState("");
const [editing, setEditing] = useState(false);
const [savingModel, setSavingModel] = useState(false);
const [promptOpen, setPromptOpen] = useState(false);
const [promptDraft, setPromptDraft] = useState("");
const [savingPrompt, setSavingPrompt] = useState(false);
useEffect(() => {
let active = true;
void (async () => {
try {
let loaded = await api.commitSettings(locale);
if (!loaded.prompt.trim() && loaded.prompt_locale !== locale) {
loaded = await api.setCommitSettings({
model_id: loaded.model_id,
prompt: "",
prompt_locale: locale,
});
}
if (active) {
setView(loaded);
setModelDraft(loaded.model_id);
}
} catch (cause) {
if (active) message(errorText(cause));
}
})();
return () => {
active = false;
};
}, [locale, message]);
const modelOptions = useMemo(() => {
const options: ModelSelectOption[] = [{ value: "", label: t("直连"), group: "Cursor" }];
const seen = new Set<string>();
for (const model of models) {
seen.add(model.model_hash);
options.push({
value: model.model_hash,
label: model.display_name && model.display_name !== model.model_id
? `${model.display_name}(${model.model_id})`
: model.display_name || model.model_id,
group: modelProviderName(model),
icon: model.type === "anthropic" ? claudeIcon : openAiIcon,
});
}
for (const plugin of plugins) {
for (const provider of plugin.providers) {
if (!provider.configured) continue;
const group = pluginText(provider.displayName, locale) || plugin.name;
for (const model of provider.models.filter((model) => model.enabled)) {
seen.add(model.id);
options.push({
value: model.id,
label: model.displayName,
group,
iconSrc: model.icon || undefined,
icon: model.icon ? undefined : flatColorOrganizationIcon,
});
}
}
}
if (view?.model_id && !seen.has(view.model_id)) {
options.push({ value: view.model_id, label: view.model_id, group: "Cursor" });
}
return options;
}, [locale, models, plugins, view]);
const persist = useCallback(
async (modelId: string, prompt: string) => {
if (!view) return null;
const normalizedPrompt =
prompt.trim() === view.default_prompt.trim() ? "" : prompt.trim();
return api.setCommitSettings({
model_id: modelId,
prompt: normalizedPrompt,
prompt_locale: locale,
});
},
[view, locale],
);
const editModel = useCallback(() => {
if (!view) return;
setModelDraft(view.model_id);
setEditing(true);
}, [view]);
const cancelModelEdit = useCallback(() => {
setModelDraft(view?.model_id ?? "");
setEditing(false);
}, [view]);
const saveModel = useCallback(async () => {
if (!view) return;
setSavingModel(true);
try {
const saved = await persist(modelDraft, view.prompt);
if (saved) {
setView(saved);
setModelDraft(saved.model_id);
setEditing(false);
}
} catch (cause) {
message(errorText(cause));
} finally {
setSavingModel(false);
}
}, [modelDraft, view, persist, message]);
const openPrompt = useCallback(() => {
if (!view) return;
setPromptDraft(view.prompt || view.default_prompt);
setPromptOpen(true);
}, [view]);
const savePrompt = useCallback(async () => {
if (!view) return;
setSavingPrompt(true);
try {
const saved = await persist(view.model_id, promptDraft);
if (saved) setView(saved);
setPromptOpen(false);
message(t("提示词设置已保存"));
} catch (cause) {
message(errorText(cause));
} finally {
setSavingPrompt(false);
}
}, [view, promptDraft, persist, message]);
const resetPrompt = useCallback(() => {
if (!view) return;
setPromptDraft(view.default_prompt);
}, [view]);
const selectedModelLabel = modelOptions.find((option) => option.value === view?.model_id)?.label
?? view?.model_id
?? t("加载中…");
const action = editing ? (
<div className={styles.actionGroup}>
<Button size="small" disabled={savingModel} onClick={cancelModelEdit}>{t("取消")}</Button>
<Button variant="primary" size="small" disabled={savingModel} onClick={() => void saveModel()}>
{savingModel ? t("保存中…") : t("保存")}
</Button>
</div>
) : (
<div className={styles.actionGroup}>
<button type="button" className={styles.textButton} disabled={!view} onClick={openPrompt}>
{t("提示词设置")}
</button>
<button type="button" className={styles.textButton} disabled={!view} onClick={editModel}>
{t("编辑")}
</button>
</div>
);
return (
<>
<TitledCard title={t("Commit 提交代码模型设置")} action={action}>
<div className={styles.content}>
<div className={styles.row}>
<div className={styles.details}>
<strong>{t("生成模型")}</strong>
</div>
{editing ? <div className={styles.select}>
<ModelSelect
mode="single"
value={modelDraft}
options={modelOptions}
disabled={savingModel}
label={t("生成模型")}
onChange={setModelDraft}
/>
</div> : <span className={styles.value}>{selectedModelLabel}</span>}
</div>
</div>
</TitledCard>
<Modal
open={promptOpen}
title={t("提示词设置")}
wide
fullHeight
busy={savingPrompt}
onClose={() => setPromptOpen(false)}
onSubmit={() => void savePrompt()}
submitLabel={t("保存")}
closeLabel={t("取消")}
secondaryAction={
<button type="button" className={controls.secondary} onClick={resetPrompt}>
{t("恢复默认")}
</button>
}
>
<textarea
className={styles.promptEditor}
value={promptDraft}
spellCheck={false}
aria-label={t("提交信息提示词")}
onChange={(event) => setPromptDraft(event.target.value)}
/>
</Modal>
</>
);
}
@@ -20,7 +20,7 @@
}
.control {
width: min(420px, 62%);
width: min(320px, 50%);
}
.value {
@@ -1,9 +1,9 @@
import type { ProxySettings, ProxySettingsInput } from "../../api";
import { Button } from "../ui/Button";
import { Checkbox } from "../ui/Checkbox";
import { TextInput } from "../ui/FormControls";
import { Select } from "../ui/Select";
import { TitledCard } from "../ui/TitledCard";
import type { ProxySettings, ProxySettingsInput } from "../../shared/api";
import { Button } from "../../shared/ui/Button";
import { Checkbox } from "../../shared/ui/Checkbox";
import { TextInput } from "../../shared/ui/FormControls";
import { Select } from "../../shared/ui/Select";
import { TitledCard } from "../../shared/ui/TitledCard";
import styles from "./ProxySettingsCard.module.scss";
export function ProxySettingsCard({
@@ -26,7 +26,7 @@ export function ProxySettingsCard({
onSave: () => void;
}) {
const custom = draft.mode === "custom";
const modeLabel = (mode: ProxySettingsInput["mode"]) => mode === "system" ? t("使用系统代理") : t("自定义");
const modeLabel = (mode: ProxySettingsInput["mode"]) => mode === "default" ? t("默认") : t("自定义");
const action = editing ? (
<div className={styles.actionGroup}>
<Button size="small" disabled={saving} onClick={onCancel}>{t("取消")}</Button>
@@ -43,7 +43,7 @@ export function ProxySettingsCard({
{editing ? <>
<div className={styles.row}>
<strong>{t("代理方式")}</strong>
<div className={styles.control}><Select ariaLabel={t("代理方式")} value={draft.mode} options={[{ value: "system", label: t("使用系统代理") }, { value: "custom", label: t("自定义") }]} onChange={(mode) => onDraftChange({ ...draft, mode: mode as ProxySettingsInput["mode"] })} /></div>
<div className={styles.control}><Select ariaLabel={t("代理方式")} value={draft.mode} options={[{ value: "default", label: t("默认") }, { value: "custom", label: t("自定义") }]} onChange={(mode) => onDraftChange({ ...draft, mode: mode as ProxySettingsInput["mode"] })} /></div>
</div>
{custom && <div className={styles.customFields}>
<div className={styles.row}>
@@ -1,4 +1,4 @@
@use "../styles/typography" as type;
@use "../../styles/typography" as type;
.page {
display: grid;
@@ -41,6 +41,15 @@
}
}
.textButton {
padding: 4px;
color: var(--vscode-textLink-foreground);
background: transparent;
border: 0;
&:hover { color: var(--vscode-textLink-activeForeground); }
}
.importRow {
display: flex;
align-items: center;
@@ -59,15 +68,6 @@
}
}
.textButton {
padding: 4px;
color: var(--vscode-textLink-foreground);
background: transparent;
border: 0;
&:hover { color: var(--vscode-textLink-activeForeground); }
}
.confirmContent {
display: grid;
gap: 8px;
@@ -155,6 +155,7 @@
align-items: stretch;
flex-direction: column;
}
}
.themeActions .selected {
@@ -1,20 +1,21 @@
import { useEffect, useState } from "react";
import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorage, type TabSettings } from "../api";
import { PageContent } from "../components/layout/PageContent";
import { LegacyModelImport } from "../components/models/LegacyModelImport";
import { AppLifecycleSettingsCard } from "../components/settings/AppLifecycleSettingsCard";
import { ProxySettingsCard } from "../components/settings/ProxySettingsCard";
import { TabSettingsCard } from "../components/settings/TabSettingsCard";
import { Button } from "../components/ui/Button";
import { Checkbox } from "../components/ui/Checkbox";
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
import { FormField, TextInput } from "../components/ui/FormControls";
import { Select } from "../components/ui/Select";
import { TitledCard } from "../components/ui/TitledCard";
import { setLocalePreference, useI18n, type LocalePreference } from "../i18n/store";
import { useMessage } from "../components/ui/message";
import { appStore, useAppStore } from "../store/appStore";
import { themeOptions } from "../theme/theme";
import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorage, type StatisticsStorageScope, type TabSettings } from "../../shared/api";
import { PageContent } from "../../shell/layout/PageContent";
import { LegacyModelImport } from "../models/LegacyModelImport";
import { AppLifecycleSettingsCard } from "./AppLifecycleSettingsCard";
import { CommitSettingsCard } from "./CommitSettingsCard";
import { ProxySettingsCard } from "./ProxySettingsCard";
import { TabSettingsCard } from "./TabSettingsCard";
import { Button } from "../../shared/ui/Button";
import { Checkbox } from "../../shared/ui/Checkbox";
import { ConfirmDialog } from "../../shared/ui/ConfirmDialog";
import { FormField, TextInput } from "../../shared/ui/FormControls";
import { Select } from "../../shared/ui/Select";
import { TitledCard } from "../../shared/ui/TitledCard";
import { setLocalePreference, useI18n, type LocalePreference } from "../../i18n/store";
import { useMessage } from "../../shared/ui/message";
import { appStore, useAppStore } from "../../shared/store/appStore";
import { themeOptions } from "../../shared/theme/theme";
import styles from "./SettingsPage.module.scss";
export function SettingsPage() {
@@ -27,9 +28,10 @@ export function SettingsPage() {
const [savingPorts, setSavingPorts] = useState(false);
const [storage, setStorage] = useState<StatisticsStorage | null>(null);
const [confirmClear, setConfirmClear] = useState(false);
const [clearScope, setClearScope] = useState<StatisticsStorageScope>("details");
const [clearing, setClearing] = useState(false);
const [outboundProxy, setOutboundProxy] = useState<ProxySettings | null>(null);
const [proxyDraft, setProxyDraft] = useState<ProxySettingsInput>({ mode: "system", address: "", auth_enabled: false, username: "", password: "" });
const [proxyDraft, setProxyDraft] = useState<ProxySettingsInput>({ mode: "default", address: "", auth_enabled: false, username: "", password: "" });
const [editingProxy, setEditingProxy] = useState(false);
const [savingProxy, setSavingProxy] = useState(false);
const [tabSettings, setTabSettings] = useState<TabSettings | null>(null);
@@ -87,10 +89,10 @@ export function SettingsPage() {
const clearStorage = async () => {
try {
setClearing(true);
setStorage(await api.clearStatisticsStorage());
setStorage(await api.clearStatisticsStorage(clearScope));
setConfirmClear(false);
await appStore.refresh();
message(t("统计数据已清理"));
message(clearScope === "all" ? t("全部统计数据已清理") : t("详细记录已清理"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
@@ -154,6 +156,10 @@ export function SettingsPage() {
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
};
const clearTitle = clearScope === "all" ? t("确定要清理全部统计数据吗?") : t("确定要清理详细记录吗?");
const clearDescription = clearScope === "all"
? t("所有调用汇总、详细内容和追踪记录都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。")
: t("仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。");
const content = (
<div className={styles.page}>
<TitledCard title={t("调用观测")}>
@@ -224,6 +230,7 @@ export function SettingsPage() {
</TitledCard>
<ProxySettingsCard settings={outboundProxy} draft={proxyDraft} editing={editingProxy} saving={savingProxy} onDraftChange={setProxyDraft} onEdit={editProxy} onCancel={cancelProxyEdit} onSave={() => void saveProxy()} />
<TabSettingsCard settings={tabSettings} draft={tabDraft} editing={editingTab} saving={savingTab} onDraftChange={setTabDraft} onEdit={editTab} onCancel={cancelTabEdit} onSave={() => void saveTab()} />
<CommitSettingsCard />
<AppLifecycleSettingsCard />
<LegacyModelImport>{({ busy, previewing, open }) => <TitledCard title={t("导入")}>
<div className={styles.importRow}>
@@ -278,7 +285,7 @@ export function SettingsPage() {
<button
type="button"
className={styles.textButton}
onClick={() => setConfirmClear(true)}
onClick={() => { setClearScope("details"); setConfirmClear(true); }}
>
{t("清理存储空间")}
</button>
@@ -286,7 +293,7 @@ export function SettingsPage() {
</TitledCard>
<ConfirmDialog
open={confirmClear}
title={t("确定要清理所有统计数据吗?")}
title={clearTitle}
busy={clearing}
cancelLabel={t("取消")}
confirmLabel={t("确认清理")}
@@ -294,11 +301,16 @@ export function SettingsPage() {
onConfirm={() => void clearStorage()}
>
<div className={styles.confirmContent}>
<small>
{t(
"所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
)}
</small>
<Select
value={clearScope}
ariaLabel={t("清理范围")}
options={[
{ value: "details", label: t("仅清理详细记录") },
{ value: "all", label: t("清理全部统计数据") },
]}
onChange={(value) => setClearScope(value as StatisticsStorageScope)}
/>
<small>{clearDescription}</small>
</div>
</ConfirmDialog>
</div>
@@ -34,7 +34,7 @@
}
.control {
width: min(420px, 62%);
width: min(320px, 50%);
}
.value {
@@ -1,10 +1,10 @@
import cursorIconUrl from "../../assets/icons/cursor.svg";
import type { TabMode, TabSettings } from "../../api";
import { Button } from "../ui/Button";
import { TextInput } from "../ui/FormControls";
import { Icon } from "../ui/Icon";
import { Select } from "../ui/Select";
import { TitledCard } from "../ui/TitledCard";
import cursorIconUrl from "../../shared/assets/icons/cursor.svg";
import type { TabMode, TabSettings } from "../../shared/api";
import { Button } from "../../shared/ui/Button";
import { TextInput } from "../../shared/ui/FormControls";
import { Icon } from "../../shared/ui/Icon";
import { Select } from "../../shared/ui/Select";
import { TitledCard } from "../../shared/ui/TitledCard";
import styles from "./TabSettingsCard.module.scss";
export function TabSettingsCard({
File diff suppressed because it is too large Load Diff
+112 -10
View File
@@ -2,29 +2,35 @@
"0006d696d8e1ec28": "New",
"00929f23850e4ff0": "Successful calls: {count}",
"01f3e69a5a9b2c9b": "The key required to access the model service.",
"023810003eb4563d": "{count} models",
"028a4de61bff743d": "Regular input: {tokens} × ${price}/1M = {cost}",
"028c60a8a8e30a1b": "Page {page} / {total}",
"03ff62ab4b818492": "Cache write: {tokens} × ${price}/1M = {cost}",
"051836569928a9f9": "Edit",
"0525054436acdb6e": "Commit message prompt",
"05468af47054d488": "Connectivity test for {model} succeeded ({duration} ms)",
"0580e0a99a6f1afc": "Artifacts",
"06619f339fa0ab46": "Preparing the plugin runtime",
"076832c1b2de22c3": "Cache write: {tokens}",
"07879e064ae16542": "Estimated output: {tokens}",
"07c657ed4747126e": "Anthropic extra parameters",
"08d742566ffc9fa3": "Clear all statistics?",
"08791ba06e7441de": "{accounts} accounts · {models} models",
"092b520558eff5f2": "Not tested",
"099008ea7a42ebd1": "All custom Header values must be strings",
"09ebc2643631ba25": "Estimated value",
"0b96da34f6fbdd3b": "Cache read: {tokens} × ${price}/1M = {cost}",
"0bbb2c0ce279d6d5": "Models not synced yet",
"0c70665b6eb65f1a": "No",
"0c72229b7db0e1a9": "Model output",
"0d2dab3d62eb73d6": "All statistics cleared",
"0d5e2bdb15579fc4": "Messages",
"0e41f8e3d59ec47b": "Storage management",
"0e67021ebf0a3580": "Import complete: added {imported} models and skipped {skipped} existing models",
"0ec1e85b0c3cfa65": "Call details",
"100fad4a0b3ab781": "Last 24 hours",
"105a9082c346f958": "Testing…",
"124be3f86f197802": "Token usage",
"12ae77e6202d063e": "Custom Headers",
"12b86e89701b0241": "Enter a model name and press Enter to add it",
"133340e53175128a": "Test all",
"13a9ac7a68c5fd96": "The CA is stored only on this device and is used to securely inspect Cursor HTTPS requests.",
"13b61c5f697b6700": "Cache hit rate",
@@ -33,29 +39,40 @@
"168e845a86bc3703": "Add model",
"16d0d7e2b332af72": "Total calls: {count}",
"1813d362a82fd437": "Maximize window",
"18165f8865eacc91": "No plugins installed",
"19658d9fa9aa8de4": "Installing…",
"19bb071f8c362695": "Prompt settings",
"1a3f0617d6de8e52": "Username",
"1a60c9eb3cf1dbb5": "Import finished: {added} added, {updated} updated",
"1aa65c55c6cc6163": "Device code",
"1ae6b0a0f8266382": "Close window",
"1b5932b8946d2d68": "Delete model",
"1b7d5b1a9315fc64": "Calculating…",
"1c6926877b1bbfb2": "Total requests: {tokens}",
"1cb7e646ac883fb2": "Proxy port",
"1cdde778cfe6a130": "Formula: cache read / (cache read + non-cached input)",
"1d19efc260a0a18f": "Use reset card",
"1d27f02ed278ebc9": "Configuration file",
"1f9b46e61efcccb5": "Filter: {label}, {summary}",
"202064bb84804852": "Leave blank to use the default.",
"20e14248fd4fb981": "{label} must be an integer greater than 0",
"217cfe7db1e3d10a": "Use system language",
"21bd738e0d7191a7": "Loading…",
"22c6b4eb4caee6ae": "Proxy settings saved",
"22d7895ea5fca72e": "By provider",
"23ae7a90b1b9816d": "Clear scope",
"23e49479e15e6770": "Version {version} is available",
"2400fbd0aeab9e13": "Downloaded {downloaded}",
"24a0a24864454575": "Existing, skipped",
"2555d6c7fbb7e070": "Enter a model ID directly or load models returned by the API.",
"29585d7193539200": "Current version {version}",
"296da37506328d89": "Disable Cursor takeover",
"29fbbef32a6eb58b": "Do not show this ad again",
"2a2773134a829016": "Aggregated from historical LLM calls; in-progress calls are excluded.",
"2caeaec539e78898": "Thinking budget tokens",
"2cbc58108d78b06c": "Waiting for browser authorization…",
"2cd0f3be8738a86c": "Cancel",
"2cdf7d3275016cc2": "Disable Cursor takeover?",
"2d30c2a98ebb5278": "Current: {rate}",
"2eb2bf7c6597ab9a": "Detailed records",
"2f280a8120c0db12": "Used only for display and does not change the model name sent to the model service.",
@@ -64,15 +81,20 @@
"2f5f1d6fbfb061ed": "Not set",
"2f6416a2c424856b": "Final request URL",
"2f7ba5fd1d12f7f9": "Open the tutorial?",
"2f7dec3be28d7597": "{count} selected",
"2f9daa828907b93f": "Delete",
"2fe0e3339ac4d1bb": "Expired",
"2fe5a8d0eee9f14c": "Invalid",
"303c30f301514250": "Search resources",
"3260348163d03b8e": "Leave blank to keep unchanged",
"32896fdaaaa4c106": "Account saved and the model catalog is synced.",
"346ff60e6c7c5181": "Reading…",
"36f33adaf0942634": "Confirm",
"37125ef2e1d707cb": "Server address or complete request URL, API Key, model name, display name, and note are required",
"378bb0eec39fa8a2": "Last page",
"37cb98ff4d5dcfcc": "Successful {successful} / failed {failed}",
"382f2e3419a02fef": "Only clear detailed records",
"38844b135cf70dfc": "More",
"393e1241552b1870": "Request",
"398f8e6c6f0a0b97": "Continue selecting or typing",
"39f52eee100131d7": "Cached input",
"3a0fb74abe2460b6": "Chunks",
"3a3f595df70ec8ff": "Clear storage",
@@ -86,6 +108,8 @@
"3f6c25aa329163a4": "The original endpoint path is appended to this service address.",
"3fd118e2ffe0b2b6": "Cancel all tests",
"3fd47edce45b3603": "Close",
"402495402ce333b1": "Reinitialize plugins",
"40a08e7cf320ae07": "Clear detailed records?",
"4125fc7ba333524c": "Default light",
"42655ed8e4108ae2": "Input (non-cached)",
"42a1d9e5b037c210": "Bytes",
@@ -94,6 +118,7 @@
"461d6a57900c2ed7": "Connectivity test failed: {error}",
"470049252e54de6a": "Success rate: {rate}",
"47d1c20aa017ff05": "Hide the main window on startup and keep only the tray icon.",
"48a3bf87eb254591": "Start sign-in",
"48b970b568a7f8f9": "Proxy settings",
"48d8db17bae06246": "{count} total",
"492042ed1fdc29ed": "Version {version} is ready to install",
@@ -101,19 +126,24 @@
"497c85690c4cc0fc": "No data",
"499c729eb09aa2a6": "Context window tokens",
"49be72e6045c007d": "Cancel test",
"4a861200ad513a3c": "Initialize plugin runtime",
"4a8d6841b4023edf": "Confirm import",
"4aca6a31090fe2b8": "Initializing…",
"4b458e6e147221d7": "The standard endpoint path is appended automatically for the selected protocol.",
"4d0680f9efaef147": "Unread",
"4d99c976beb8827e": "Ready",
"4e30d7c9ed2b0eee": "Not set",
"4eafa9e925b30bcd": "Custom",
"51d04bc3d286f018": "Last calendar day",
"51de3bcec137ab1b": "Connectivity tests succeeded for all {count} models",
"5228358a6db59fe7": "For example: now, 2026-08-23 18:00",
"5319e7374f78fb38": "Taken over",
"5401344227e49e2f": "TAB settings",
"54644705e9c61009": "Port settings",
"54c53e5fe791d1f3": "Initialize CA",
"54e6745ff43c9c74": "Unable to save model order",
"550eddc3c7fefa99": "Sponsored",
"552a5d4baf45d878": "Commit Message Model Settings",
"56432ba297009bdc": "Initialize the CA first",
"56627c94a9decee6": "Maximum output tokens",
"576d81bb0631b165": "Import",
@@ -121,21 +151,25 @@
"59346e82b3dd2998": "TAB service address",
"5a284a1a2be8da0e": "Read models from the local legacy configuration. New and existing models are shown before confirmation.",
"5a3bd99fa69a40c1": "Use public service",
"5acb7d61688e3c4f": "Confirm use",
"5b17f59d33bde39e": "Error: {error}",
"5ba65a74c4e792c5": "By type",
"5c55a67935af8f45": "All",
"5cae248525cb9140": "All call records and detailed traces will be deleted. Model configuration, CA, and application settings are unaffected. This action cannot be undone.",
"5c62e36c152dfc7c": "Plugin runtime initialized",
"5d59857bf039cac9": "Cursor Assistant v{version}",
"5f8d556a9c47da3c": "Launch at login disabled",
"5f9acfb945229062": "Are you sure you no longer want to see this ad?",
"5fd2ec5a6e9b654c": "Total: {cost}",
"6003d3246f0fca2b": "Model management",
"6078a681a306930d": "Cache write",
"609640f72d422b57": "Quick time ranges",
"61a4c7bac12dc125": "Failed calls: {count}",
"61c7d1f758b647d5": "Call information",
"621f63a5f08384ac": "Cache read: {tokens}",
"6320b4a8722a851f": "Status",
"63c73c4730f4473e": "Apply",
"63d90d977348ab1f": "Duplicate",
"6449a43900b609a4": "{name} model management",
"6478a5f1218c484e": "Use the desktop app to copy to the system clipboard",
"651f274470153a05": "Software updates",
"652ec5d40c29fd6a": "Speed {speed} tokens/s · first token {firstText} ms · total {duration} ms · output {tokens} tokens{estimated} · response: {output}",
@@ -144,6 +178,7 @@
"65a6318e07ec1e07": "Tools",
"65cb9a7b4f620b6b": "Prompt {tokens}",
"680680288a6d2ad2": "Show in Dock",
"68102220092c1f0f": "Detailed records cleared",
"68ad603fafe4e0d6": "Import legacy configuration",
"68ea5dd4d7af20e6": "System settings",
"6a9906c79f26c0ba": "Start time",
@@ -155,53 +190,77 @@
"72644ec4389da2f7": "Default layout",
"736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.",
"7392e20d61abaa07": "Also store complete requests and streamed responses; by default only timing, status, and usage are stored.",
"77c9e582e85583af": "Test failed",
"788db1cfec2a3db5": "Theme",
"7995087e5a3dfe66": "Restore window",
"7a2229f6a6d330a5": "Open a terminal from the desktop app to install the CA",
"7a3cec4ca715de80": "Call statistics",
"7ba2d6728fe2531b": "Confirm clear",
"7c10d97162c96dbd": "Validating the plugin runtime",
"7cea2f3c46565d29": "OpenAI extra parameters",
"7d9f043f8f7ab45c": "Version {version} is available in Settings",
"7e0891860c9e6374": "TAB service address is required",
"7e1f06318e80c3af": "Statistics cleared",
"7e7df68f2a82e09e": "Importing the same configuration again will not create duplicate models. Existing models are skipped automatically.",
"7f3c8312816fe26a": "Refreshing…",
"7f68ebad19ba6bcd": "Check for updates",
"802b0faf0ceb513e": "{label}: {percent}% left",
"80a57e03f0717f91": "Not configured",
"811a3b22a5a7f2d5": "Unable to connect to the local management service",
"8213941f12320ce1": "This operating system or CPU architecture is not currently supported",
"83c4efccd9a6bf69": "Connectivity test cancelled: {successful} succeeded, {failed} failed",
"83e8d0b7aff2b394": "Downloaded {downloaded} / {total}",
"83fcfb4c1f2c1641": "Fetch models",
"842b9f11cdd96bda": "Launch at login",
"843ac7e15a5047a7": "Confirm legacy model configuration import",
"844b8cc8dff7c1d8": "Default",
"864597982c308d72": "Silent start enabled",
"86b7355ec3bd55ef": "Hide API Key",
"86de7c4ee8fa7689": "Sync models",
"8716e1344b0daddb": "Cursor official",
"878a8ab176429a86": "View instructions",
"8911e4f1407d58cb": "Downloading the plugin runtime",
"89a101b809be7cfc": "This address is used exactly as entered without changing or appending the request path.",
"8a8542f6964852dc": "Next page",
"8b6ff498515bcc2f": "Time",
"8cbcf741e727dbf7": "Models",
"8ccaf87ddb9ca3f4": "Legacy configuration",
"8d0c47eb9eac2d34": "Call type",
"8df48894086d6fbd": "Reason (optional)",
"8e2d04638a11a7cb": "Only determines the request and response format; it does not change the request URL.",
"8ea973394446abba": "Cursor Configuration",
"8f6f8d979c981ced": "Copied",
"8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.",
"90800c48a1dd0655": "{label} must be a JSON object",
"919cb0ce0c8db4e7": "Leave blank to keep the current password",
"91aaf184cfc17ffd": "Overview",
"91af6e57e7453fbe": "Add account",
"92156a483d4ba248": "Only request, response, and trace attachments are deleted; call summaries, metrics, and configuration are kept.",
"92e26b27d5ea8f0e": "Failed to check for updates: {error}",
"9305c0e13642cab4": "Not taken over",
"940a168911ade998": "Items per page",
"945fb1c67eca8493": "Installing the plugin runtime",
"946b3ffc02f026c0": "Delete this model?",
"954ec984cd4f49d1": "Syncing…",
"966498853d801a52": "TAB connection",
"96f7642963ca0dbf": "Enable Cursor takeover",
"9845c165151daee3": "Used",
"9850ed41a5bfbb0c": "{count} selected",
"997ec8201c2adeda": "Open terminal to install CA",
"9a84733cc9ab1706": "Resource details",
"9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?",
"9b9bc9cd7c76406f": "Open authorization page",
"9c41b3a9e12ac994": "Reasoning effort",
"9db205c6055bacc4": "Plugin runtime initialization failed",
"9e356080c56877f8": "Silent start disabled",
"9e46da6923836182": "For example: 2026-08-23 09:00, 1 hour ago",
"9ebeab8c4532d671": "{name} accounts",
"9ec4caa5fe43b8e3": "Installed plugins will appear here.",
"9ed11266ead88f5b": "Verifying the plugin runtime download",
"9ef7da883941091c": "The account was saved, but model sync failed: {error}",
"9f6fee1aba17a565": "Language",
"9fb48101d237ff96": "Last week",
"a026f37e613cf48b": "Output Tokens",
"a03a1a0cb35414f8": " must be an integer from 0 to 65535",
"a0c42c24e74f8380": "{name} Copy",
"a12ee6a3e98a29c2": "Hide sensitive content",
"a1a42cd9b16e2162": "Application",
"a1b8c98f29374a2f": "Silent start",
"a3030bf8f16dc63c": "Save",
@@ -212,10 +271,12 @@
"a4d222236dc1003d": "Failed to cancel test: {error}",
"a5fb6189a8ad011d": "Open tutorial",
"a621ab606db2a11f": "Password",
"a66e11477dcc97c1": "Add {name} account",
"a693d69af48bfe48": "Save and test",
"a748cc074f78de00": "View details",
"a7617f42f898b2bf": "Use complete request URL",
"a8036485f9227f2c": "Drag to reorder",
"a80b53f8848e6d27": "Failed to install update: {error}",
"a98585871c5313ff": "Display name",
"ab9084a640fbb864": "Deselect all",
"abecab6701177721": "Launch at login enabled",
@@ -225,12 +286,17 @@
"ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}",
"aecb952b1e6cce36": "Hide the Dock icon when disabled. You can still open the app from the menu bar icon.",
"aee88743413144a2": "Refresh",
"affb73206cfa035d": "Expires: {time}",
"b06325c5660f0c29": "Direct",
"b16c3b2ecedd6fe1": "Cursor integration is active. Add a model configuration to use a BYOK model.",
"b254ff315d861346": "Try initializing again",
"b2617bf9ae663752": "Group settings",
"b4411558b932266f": "Provider type",
"b4c9e08870d41aa2": "Initialize the plugin runtime first",
"b502b1d414664337": "Prompt: {tokens}",
"b5141d3d19e9a048": "Yes",
"b6725f218ebaef26": "Dock icon shown",
"b67e408132f4dbd7": "Clear all statistics?",
"b75a46aad3e7c132": "Non-cached input: {tokens}",
"b79354009c614ae9": "Statistics",
"b86967982067d295": " (estimated)",
@@ -238,15 +304,22 @@
"b8c9b486c83b5778": "Hide ad",
"b9670c85a4ab939e": "Route",
"b9af2de88d903be7": "Proxy address",
"ba2e93e73037c71e": "Restore default",
"ba5865fbc734e672": "For example: Primary model",
"ba6403d22876d626": "Cooling down",
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
"bb2b7736433ae867": "Cursor tracing",
"bb7efdcb6af6e805": "Default dark",
"bda62ce1d5e4ace9": "Tell us why",
"bda74b5674b6a57d": "Initialize plugins",
"be961dc60ab610da": "Applies to every model in this group when changed; leave blank to keep each model's current configuration.",
"bf57afd709694b55": "Overview time range",
"bfc01caf9fe0c841": "Cache hit rate {rate}",
"c0b3fbff51ccc40b": "Done",
"c1e98892a77f7a19": "{count} per page",
"c3760858cdb6d9f4": "Request body",
"c54863655e879b36": "Plugin runtime is not supported on this system",
"c6e7e1a9da356efc": "No resources yet. Add one first.",
"c7ea2c9bc43134bd": "Edit model",
"c8c14507b2d37395": "Reasoning effort",
"c8df3c14a003bfcd": "Unable to load call details",
@@ -254,78 +327,107 @@
"c9b9ae7a61444ab7": "Previous page",
"c9d146d006993cc1": "Cache statistics policy: default ({rate})",
"cb2f1709f983d2f4": "Model name",
"cb99f0138b032687": "Initialization downloads and installs the plugin runtime.",
"cea1aafe9416de7b": "Request headers",
"cfae1a14d2120c57": "Detailed mode",
"cfe085015632e9c8": "The local management service port used by the desktop frontend. Enter 0 to select a random port at startup.",
"d0bfccc77315d887": "Last month",
"d1251cd752d4ec25": "Leave blank to use adaptive thinking.",
"d1a3d72618d1ed27": "All call summaries, detailed content, and trace records will be deleted. Model configuration, CA, and application settings are unaffected. This action cannot be undone.",
"d2d648bd1c94b7f9": "Authentication",
"d2fcdde81f06645c": "Bulk export",
"d34335433395cd3a": "Start Cursor BYOK automatically after signing in.",
"d3716cc5a2f5a810": "Server address",
"d3d21191f32e79a5": "Processing…",
"d507652243a2151e": "Show sensitive content",
"d58c88688e1a949d": "Presets",
"d59e47070f7f358e": "Callable",
"d60669bb26a22f5d": "Leave blank to use the default",
"d6b1f203680f5496": "Leave blank to use adaptive thinking",
"d86fa42c3848c680": "Use system proxy",
"d71b0171c44b668a": "Disabling this will remove Cursor's local proxy configuration. If you need to sign in to an official account, you usually do not need to disable it. You can sign in directly because BYOK models and official-account models now work together seamlessly. Do you want to continue disabling and clearing the proxy configuration?",
"d766536c18e8e990": "Plugin runtime {version} is installed and ready to use.",
"d7e266bdc8064193": "Group name",
"d896c62fb6712bda": "Enable {model}",
"d8c47e9776cf1082": "Main menu",
"d8c589c455675b46": "Prompt settings saved",
"da521d1c1cbd36af": "Authorization is required to install the certificate",
"da7ae985487c38e6": "Last hour",
"daede9881787abe7": "Notes",
"db340a9896306d08": "Test cancelled",
"dbd3596e4a86f3c2": "Configured models",
"ddde16f8839da3ce": "Total requests",
"de8184da1ef88d03": "Configured",
"de878020fe02f0e9": "Using this card consumes it immediately and cannot be undone. Continue?",
"dea7749c4cd77e6d": "Total request Tokens include the prompt and model output.",
"df1baa9f706d970b": "To add",
"df3d58c7d84b85f2": "Settings",
"df8b71c74d9b8478": "Response stream",
"dfb802238b38fbd4": "Enabled",
"e025f1ff71996425": "Set",
"e049096ab5614581": "This plugin does not need any resources.",
"e0fae77446a389a3": "Speed: {speed} tokens/s",
"e1295adecbb77755": "Close ad",
"e14115de7f7c5795": "Token usage over the past year",
"e14f20d572c02611": "Provider call sequence",
"e17a5b9c90cda6ab": "Model duplicated",
"e18516550b9a5105": "No usage",
"e231f1f3428d1c93": "Requesting an authorization code…",
"e24096c81b1a8af4": "Authorization was denied or failed.",
"e24ebe4a866d69bf": "Test failed: {error}",
"e25bf3f419bb68f0": "Call history",
"e2ebc59779f012ed": "Generation model",
"e3fee05f688708b4": "LLM calls",
"e4760c6a1df24f17": "Complete request URL",
"e5043c7a2b408271": "Last 10 minutes",
"e59ae97924d62f01": "First page",
"e5b9961a0d5242e3": "Port settings saved. Restart the app to apply them.",
"e5c84c9aa7826566": "Not ready",
"e77e3d58b0dcffaa": "Duration",
"e825a2a42c22380e": "Model type",
"e828bd3a0151edc2": "The local CA must be trusted by the system",
"e8b1268c1e3610f2": "Existing",
"e9d8d890d33584e8": "No reset cards available.",
"ea26b760e930a7ca": "Call observability",
"eb11e2df1d8ae387": "Provider URL",
"eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.",
"eb4a3db23661fb52": "Applies to every model in this group and is used as the badge label in Cursor's model picker; clear it to fall back to the server domain.",
"eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.",
"eba54690937bc532": "Manage accounts",
"ed31fbb483ee1b0a": "Actions",
"edc70de18c6da1a6": "Install local CA",
"ee239f3943293f87": "Sunday",
"ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.",
"f2bdc88464c51c2e": "Show API Key",
"eec6bf2dad677b9f": "Granted: {time}",
"f4694c46b1e19602": "Final request type",
"f4dcb6a3ceb32247": "Page {page} of {count}",
"f4f0ead1116b5b62": "Enabled",
"f4fa9f31ea2ae58d": "Token usage calendar for the past year",
"f50276449943286c": "End time",
"f69273dbbebfb3a1": "Format",
"f6dc1b1641600dd0": "Importing…",
"f78265089144369a": "Plugins",
"f78413c36d36f090": "Uses the operating system language by default; unsupported languages fall back to English. Current: {language}",
"f784b165bd3fcb0e": "Last 4 hours",
"f85537d1fd2ef6f6": "Custom overview filters",
"f95ea7f4c063eea7": "Disabled",
"f9615d05d8e18595": "Clear {label} filter",
"f9aa11dbb15ce647": "Saturday",
"f9b55ca75425161b": "Response content was not recorded. Enable detailed records and try again.",
"fa5b4b8a751c7d1b": "The local proxy port used by Cursor. Enter 0 to select a random port at startup.",
"fac2a67ad87807c4": "Confirm",
"fad86bf65f72c747": "Download progress",
"fb11aa6f29827095": "Checking…",
"fbe8778fa8b9bab5": "Initialize the local CA first",
"fc22d1ab9ac73c6f": "Checking the plugin runtime",
"fc3947ebe6b2177b": "Default {defaultRate} / include creation {reuseRate}",
"fc50d0b72bc871db": "Disable takeover",
"fcd311fd8ad42462": "Open model list",
"fd415f8e0097c832": "Cache reads and writes are included in prompt-side statistics.",
"fd77192739703811": "Bulk import",
"fdc4cabc370fa3f7": "No options",
"fea405f9b01d1416": "Summary",
"fec45092945f8790": "User guide",
"fec7210590309465": "Clear all statistics",
"ff509c9ba052a21c": "Saving…",
"ff66b1c7e010bbc2": "Paste the authorization command into the terminal and enter your password, then click the button below",
"ff673195f05fb056": "Service port"
+112 -10
View File
@@ -2,29 +2,35 @@
"0006d696d8e1ec28": "新增",
"00929f23850e4ff0": "成功调用:{count}",
"01f3e69a5a9b2c9b": "访问模型服务所需的密钥。",
"023810003eb4563d": "{count} 个模型",
"028a4de61bff743d": "普通输入:{tokens} × ${price}/1M = {cost}",
"028c60a8a8e30a1b": "第 {page} / {total} 页",
"03ff62ab4b818492": "缓存写入:{tokens} × ${price}/1M = {cost}",
"051836569928a9f9": "编辑",
"0525054436acdb6e": "提交信息提示词",
"05468af47054d488": "模型 {model} 连通性测试成功({duration} ms)",
"0580e0a99a6f1afc": "工件数",
"06619f339fa0ab46": "正在准备插件运行时",
"076832c1b2de22c3": "缓存写入:{tokens}",
"07879e064ae16542": "输出推算:{tokens}",
"07c657ed4747126e": "Anthropic 额外参数",
"08d742566ffc9fa3": "确定要清理所有统计数据吗?",
"08791ba06e7441de": "{accounts} 个账号 · {models} 个模型",
"092b520558eff5f2": "未测试",
"099008ea7a42ebd1": "自定义 Headers 的值必须都是字符串",
"09ebc2643631ba25": "价值估算",
"0b96da34f6fbdd3b": "缓存读取:{tokens} × ${price}/1M = {cost}",
"0bbb2c0ce279d6d5": "尚未同步模型",
"0c70665b6eb65f1a": "否",
"0c72229b7db0e1a9": "模型输出",
"0d2dab3d62eb73d6": "全部统计数据已清理",
"0d5e2bdb15579fc4": "消息数",
"0e41f8e3d59ec47b": "存储管理",
"0e67021ebf0a3580": "导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型",
"0ec1e85b0c3cfa65": "调用详情",
"100fad4a0b3ab781": "近24小时",
"105a9082c346f958": "测试中…",
"124be3f86f197802": "Token 消耗",
"12ae77e6202d063e": "自定义 Headers",
"12b86e89701b0241": "输入模型名称后按 Enter 添加",
"133340e53175128a": "一键测试",
"13a9ac7a68c5fd96": "CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。",
"13b61c5f697b6700": "缓存命中率",
@@ -33,29 +39,40 @@
"168e845a86bc3703": "添加模型",
"16d0d7e2b332af72": "总调用:{count}",
"1813d362a82fd437": "最大化窗口",
"18165f8865eacc91": "还没有安装插件",
"19658d9fa9aa8de4": "安装中…",
"19bb071f8c362695": "提示词设置",
"1a3f0617d6de8e52": "用户名",
"1a60c9eb3cf1dbb5": "导入完成:新增 {added},更新 {updated}",
"1aa65c55c6cc6163": "设备验证码",
"1ae6b0a0f8266382": "关闭窗口",
"1b5932b8946d2d68": "删除模型",
"1b7d5b1a9315fc64": "计算中…",
"1c6926877b1bbfb2": "总请求:{tokens}",
"1cb7e646ac883fb2": "代理端口",
"1cdde778cfe6a130": "公式:缓存读取 /(缓存读取 + 非缓存输入)",
"1d19efc260a0a18f": "使用重置卡",
"1d27f02ed278ebc9": "配置文件",
"1f9b46e61efcccb5": "筛选项:{label},{summary}",
"202064bb84804852": "留空时使用默认值。",
"20e14248fd4fb981": "{label} 必须是大于 0 的整数",
"217cfe7db1e3d10a": "跟随系统",
"21bd738e0d7191a7": "正在加载…",
"22c6b4eb4caee6ae": "代理设置已保存",
"22d7895ea5fca72e": "按供应商",
"23ae7a90b1b9816d": "清理范围",
"23e49479e15e6770": "发现新版本 {version}",
"2400fbd0aeab9e13": "已下载 {downloaded}",
"24a0a24864454575": "已存在,跳过",
"2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。",
"29585d7193539200": "当前版本 {version}",
"296da37506328d89": "关闭接管Cursor",
"29fbbef32a6eb58b": "不再显示此广告",
"2a2773134a829016": "按历史 LLM 调用记录汇总,进行中的调用不计入。",
"2caeaec539e78898": "思考预算 Token",
"2cbc58108d78b06c": "等待网页端确认授权中…",
"2cd0f3be8738a86c": "取消",
"2cdf7d3275016cc2": "关闭接管Cursor?",
"2d30c2a98ebb5278": "当前:{rate}",
"2eb2bf7c6597ab9a": "详细记录",
"2f280a8120c0db12": "仅用于界面展示,不会改变发送给模型服务的模型名称。",
@@ -64,15 +81,20 @@
"2f5f1d6fbfb061ed": "未设置",
"2f6416a2c424856b": "最终请求地址",
"2f7ba5fd1d12f7f9": "打开使用教程?",
"2f7dec3be28d7597": "已选择 {count} 个",
"2f9daa828907b93f": "删除",
"2fe0e3339ac4d1bb": "已过期",
"2fe5a8d0eee9f14c": "已失效",
"303c30f301514250": "搜索资源",
"3260348163d03b8e": "留空保持不变",
"32896fdaaaa4c106": "账号已保存,模型目录已同步。",
"346ff60e6c7c5181": "读取中…",
"36f33adaf0942634": "确认",
"37125ef2e1d707cb": "服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空",
"378bb0eec39fa8a2": "最后一页",
"37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}",
"382f2e3419a02fef": "仅清理详细记录",
"38844b135cf70dfc": "更多",
"393e1241552b1870": "请求",
"398f8e6c6f0a0b97": "继续选择或输入",
"39f52eee100131d7": "缓存输入",
"3a0fb74abe2460b6": "分块数",
"3a3f595df70ec8ff": "清理存储空间",
@@ -86,6 +108,8 @@
"3f6c25aa329163a4": "原接口路径会追加到此服务地址。",
"3fd118e2ffe0b2b6": "取消全部测试",
"3fd47edce45b3603": "关闭",
"402495402ce333b1": "重新初始化插件",
"40a08e7cf320ae07": "确定要清理详细记录吗?",
"4125fc7ba333524c": "默认亮色",
"42655ed8e4108ae2": "输入(非缓存)",
"42a1d9e5b037c210": "字节数",
@@ -94,6 +118,7 @@
"461d6a57900c2ed7": "连通性测试失败:{error}",
"470049252e54de6a": "成功占比:{rate}",
"47d1c20aa017ff05": "开机启动时不显示主窗口,仅保留系统托盘图标。",
"48a3bf87eb254591": "开始登录",
"48b970b568a7f8f9": "代理设置",
"48d8db17bae06246": "共 {count} 条",
"492042ed1fdc29ed": "版本 {version} 可以安装",
@@ -101,19 +126,24 @@
"497c85690c4cc0fc": "暂无数据",
"499c729eb09aa2a6": "上下文窗口 Token",
"49be72e6045c007d": "取消测试",
"4a861200ad513a3c": "初始化插件运行时",
"4a8d6841b4023edf": "确认导入",
"4aca6a31090fe2b8": "初始化中…",
"4b458e6e147221d7": "系统会根据请求协议自动追加标准端点路径。",
"4d0680f9efaef147": "未读",
"4d99c976beb8827e": "可用",
"4e30d7c9ed2b0eee": "不设置",
"4eafa9e925b30bcd": "自定义",
"51d04bc3d286f018": "近1自然日",
"51de3bcec137ab1b": "全部 {count} 个模型连通性测试成功",
"5228358a6db59fe7": "如:现在、2026-08-23 18:00",
"5319e7374f78fb38": "已接管",
"5401344227e49e2f": "TAB 设置",
"54644705e9c61009": "端口设置",
"54c53e5fe791d1f3": "初始化 CA",
"54e6745ff43c9c74": "排序失败",
"550eddc3c7fefa99": "推广",
"552a5d4baf45d878": "Commit 提交代码模型设置",
"56432ba297009bdc": "请先初始化 CA",
"56627c94a9decee6": "最大输出 Token",
"576d81bb0631b165": "导入",
@@ -121,21 +151,25 @@
"59346e82b3dd2998": "TAB 服务地址",
"5a284a1a2be8da0e": "从本机旧版配置读取模型;确认前会显示新增和已存在的模型。",
"5a3bd99fa69a40c1": "使用公益服务",
"5acb7d61688e3c4f": "确认使用",
"5b17f59d33bde39e": "错误:{error}",
"5ba65a74c4e792c5": "按类型",
"5c55a67935af8f45": "全部",
"5cae248525cb9140": "所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
"5c62e36c152dfc7c": "插件运行时初始化完成",
"5d59857bf039cac9": "Cursor 助手 v{version}",
"5f8d556a9c47da3c": "已关闭开机启动",
"5f9acfb945229062": "你确认不想再看到此广告吗?",
"5fd2ec5a6e9b654c": "合计:{cost}",
"6003d3246f0fca2b": "模型管理",
"6078a681a306930d": "缓存写入",
"609640f72d422b57": "快捷时间范围",
"61a4c7bac12dc125": "异常调用:{count}",
"61c7d1f758b647d5": "调用信息",
"621f63a5f08384ac": "缓存读取:{tokens}",
"6320b4a8722a851f": "状态",
"63c73c4730f4473e": "应用",
"63d90d977348ab1f": "复制",
"6449a43900b609a4": "{name} 模型管理",
"6478a5f1218c484e": "请在桌面应用中复制到系统剪贴板",
"651f274470153a05": "软件更新",
"652ec5d40c29fd6a": "速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}",
@@ -144,6 +178,7 @@
"65a6318e07ec1e07": "工具数",
"65cb9a7b4f620b6b": "提示词 {tokens}",
"680680288a6d2ad2": "在 Dock 栏显示",
"68102220092c1f0f": "详细记录已清理",
"68ad603fafe4e0d6": "导入旧版配置",
"68ea5dd4d7af20e6": "系统设置",
"6a9906c79f26c0ba": "开始时间",
@@ -155,53 +190,77 @@
"72644ec4389da2f7": "默认平铺",
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
"7392e20d61abaa07": "额外保存完整请求和流响应;默认只保存时间、状态与用量。",
"77c9e582e85583af": "测试失败",
"788db1cfec2a3db5": "主题",
"7995087e5a3dfe66": "还原窗口",
"7a2229f6a6d330a5": "请在桌面应用中打开终端安装 CA",
"7a3cec4ca715de80": "调用统计",
"7ba2d6728fe2531b": "确认清理",
"7c10d97162c96dbd": "正在验证插件运行时",
"7cea2f3c46565d29": "OpenAI 额外参数",
"7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装",
"7e0891860c9e6374": "TAB 服务地址不能为空",
"7e1f06318e80c3af": "统计数据已清理",
"7e7df68f2a82e09e": "重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。",
"7f3c8312816fe26a": "刷新中…",
"7f68ebad19ba6bcd": "检查更新",
"802b0faf0ceb513e": "{label} 剩余 {percent}%",
"80a57e03f0717f91": "未配置",
"811a3b22a5a7f2d5": "无法连接本地管理服务",
"8213941f12320ce1": "当前操作系统或 CPU 架构暂不受支持",
"83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}",
"83e8d0b7aff2b394": "已下载 {downloaded} / {total}",
"83fcfb4c1f2c1641": "获取模型",
"842b9f11cdd96bda": "开机启动",
"843ac7e15a5047a7": "确认导入旧版模型配置",
"844b8cc8dff7c1d8": "默认",
"864597982c308d72": "已开启静默启动",
"86b7355ec3bd55ef": "隐藏 API Key",
"86de7c4ee8fa7689": "同步模型",
"8716e1344b0daddb": "Cursor 官方",
"878a8ab176429a86": "查看说明",
"8911e4f1407d58cb": "正在下载插件运行时",
"89a101b809be7cfc": "系统会原样使用此地址,不追加或修改请求路径。",
"8a8542f6964852dc": "下一页",
"8b6ff498515bcc2f": "时间",
"8cbcf741e727dbf7": "模型配置",
"8ccaf87ddb9ca3f4": "旧版配置",
"8d0c47eb9eac2d34": "调用类型",
"8df48894086d6fbd": "原因(可选)",
"8e2d04638a11a7cb": "只决定请求与响应的格式,不会改变请求地址。",
"8ea973394446abba": "Cursor 配置",
"8f6f8d979c981ced": "已复制",
"8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。",
"90800c48a1dd0655": "{label} 必须是 JSON 对象",
"919cb0ce0c8db4e7": "留空表示保留当前密码",
"91aaf184cfc17ffd": "数据概览",
"91af6e57e7453fbe": "添加账号",
"92156a483d4ba248": "仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。",
"92e26b27d5ea8f0e": "检查更新失败:{error}",
"9305c0e13642cab4": "未接管",
"940a168911ade998": "每页条数",
"945fb1c67eca8493": "正在安装插件运行时",
"946b3ffc02f026c0": "确定删除这个模型吗?",
"954ec984cd4f49d1": "正在同步…",
"966498853d801a52": "TAB 选择",
"96f7642963ca0dbf": "开启接管Cursor",
"9845c165151daee3": "已使用",
"9850ed41a5bfbb0c": "已选 {count} 项",
"997ec8201c2adeda": "打开终端安装 CA",
"9a84733cc9ab1706": "资源详情",
"9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?",
"9b9bc9cd7c76406f": "打开授权网页",
"9c41b3a9e12ac994": "思考强度",
"9db205c6055bacc4": "插件运行时初始化失败",
"9e356080c56877f8": "已关闭静默启动",
"9e46da6923836182": "如:2026-08-23 09:00、1小时前",
"9ebeab8c4532d671": "{name} 账号管理",
"9ec4caa5fe43b8e3": "安装插件后会显示在这里。",
"9ed11266ead88f5b": "正在验证插件运行时下载文件",
"9ef7da883941091c": "账号已保存,但同步模型失败:{error}",
"9f6fee1aba17a565": "语言",
"9fb48101d237ff96": "近一周",
"a026f37e613cf48b": "输出 Token",
"a03a1a0cb35414f8": "必须是 0–65535 之间的整数",
"a0c42c24e74f8380": "{name} 副本",
"a12ee6a3e98a29c2": "隐藏敏感内容",
"a1a42cd9b16e2162": "应用设置",
"a1b8c98f29374a2f": "静默启动",
"a3030bf8f16dc63c": "保存",
@@ -212,10 +271,12 @@
"a4d222236dc1003d": "取消测试失败:{error}",
"a5fb6189a8ad011d": "打开教程",
"a621ab606db2a11f": "密码",
"a66e11477dcc97c1": "添加 {name} 账号",
"a693d69af48bfe48": "保存并测试",
"a748cc074f78de00": "查看详情",
"a7617f42f898b2bf": "使用完整请求地址",
"a8036485f9227f2c": "拖动排序",
"a80b53f8848e6d27": "安装更新失败:{error}",
"a98585871c5313ff": "显示名称",
"ab9084a640fbb864": "全不选",
"abecab6701177721": "已开启开机启动",
@@ -225,12 +286,17 @@
"ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}",
"aecb952b1e6cce36": "关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。",
"aee88743413144a2": "刷新",
"affb73206cfa035d": "到期时间:{time}",
"b06325c5660f0c29": "直连",
"b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。",
"b254ff315d861346": "请重试初始化",
"b2617bf9ae663752": "分组设置",
"b4411558b932266f": "上游类型",
"b4c9e08870d41aa2": "需要先初始化插件运行时",
"b502b1d414664337": "提示词:{tokens}",
"b5141d3d19e9a048": "是",
"b6725f218ebaef26": "已显示 Dock 栏图标",
"b67e408132f4dbd7": "确定要清理全部统计数据吗?",
"b75a46aad3e7c132": "非缓存输入:{tokens}",
"b79354009c614ae9": "统计数据",
"b86967982067d295": "(估算)",
@@ -238,15 +304,22 @@
"b8c9b486c83b5778": "不再显示广告",
"b9670c85a4ab939e": "路由",
"b9af2de88d903be7": "代理地址",
"ba2e93e73037c71e": "恢复默认",
"ba5865fbc734e672": "例如:主力模型",
"ba6403d22876d626": "冷却中",
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
"bb2b7736433ae867": "Cursor 追踪",
"bb7efdcb6af6e805": "默认暗色",
"bda62ce1d5e4ace9": "可以告诉我们原因",
"bda74b5674b6a57d": "初始化插件",
"be961dc60ab610da": "修改后应用于该分组下的全部模型;留空保持各模型现有配置不变。",
"bf57afd709694b55": "概览时间范围",
"bfc01caf9fe0c841": "缓存命中率 {rate}",
"c0b3fbff51ccc40b": "完成",
"c1e98892a77f7a19": "{count} 条/页",
"c3760858cdb6d9f4": "请求体",
"c54863655e879b36": "当前系统不支持插件运行时",
"c6e7e1a9da356efc": "还没有资源,请先添加。",
"c7ea2c9bc43134bd": "编辑模型",
"c8c14507b2d37395": "推理强度",
"c8df3c14a003bfcd": "无法加载调用详情",
@@ -254,78 +327,107 @@
"c9b9ae7a61444ab7": "上一页",
"c9d146d006993cc1": "缓存统计策略:默认口径({rate})",
"cb2f1709f983d2f4": "模型名称",
"cb99f0138b032687": "初始化将下载并安装插件运行时。",
"cea1aafe9416de7b": "请求头",
"cfae1a14d2120c57": "详细模式",
"cfe085015632e9c8": "桌面前端连接的本地管理服务端口;填写 0 时启动时随机选择。",
"d0bfccc77315d887": "近一个月",
"d1251cd752d4ec25": "留空时使用 adaptive thinking。",
"d1a3d72618d1ed27": "所有调用汇总、详细内容和追踪记录都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
"d2d648bd1c94b7f9": "认证",
"d2fcdde81f06645c": "批量导出",
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
"d3716cc5a2f5a810": "服务器地址",
"d3d21191f32e79a5": "处理中…",
"d507652243a2151e": "显示敏感内容",
"d58c88688e1a949d": "常用预设",
"d59e47070f7f358e": "可调用",
"d60669bb26a22f5d": "留空使用默认值",
"d6b1f203680f5496": "留空使用 adaptive thinking",
"d86fa42c3848c680": "使用系统代理",
"d71b0171c44b668a": "关闭后将移除 Cursor 本地代理配置。如果你需要登陆官方账号,通常不需要关闭操作,推荐直接登陆你的账号即可(byok模型与官方账号的模型已支持无缝衔接),是否继续关闭并清理代理?",
"d766536c18e8e990": "插件运行时 {version} 已安装,可以开始使用插件。",
"d7e266bdc8064193": "分组名称",
"d896c62fb6712bda": "启用 {model}",
"d8c47e9776cf1082": "主菜单",
"d8c589c455675b46": "提示词设置已保存",
"da521d1c1cbd36af": "需要授权安装证书",
"da7ae985487c38e6": "近1小时",
"daede9881787abe7": "备注",
"db340a9896306d08": "测试已取消",
"dbd3596e4a86f3c2": "配置模型",
"ddde16f8839da3ce": "总请求",
"de8184da1ef88d03": "已配置",
"de878020fe02f0e9": "使用后会立即消耗这张重置卡,且无法恢复。确定继续吗?",
"dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。",
"df1baa9f706d970b": "将新增",
"df3d58c7d84b85f2": "设置",
"df8b71c74d9b8478": "响应流",
"dfb802238b38fbd4": "已启用",
"e025f1ff71996425": "已设置",
"e049096ab5614581": "该插件不需要添加资源",
"e0fae77446a389a3": "速度:{speed} tokens/s",
"e1295adecbb77755": "关闭广告",
"e14115de7f7c5795": "过去一年的 Token 用量",
"e14f20d572c02611": "上游调用序号",
"e17a5b9c90cda6ab": "模型已复制",
"e18516550b9a5105": "无用量",
"e231f1f3428d1c93": "正在申请授权码…",
"e24096c81b1a8af4": "授权被拒绝或已失败。",
"e24ebe4a866d69bf": "测试失败:{error}",
"e25bf3f419bb68f0": "调用详细",
"e2ebc59779f012ed": "生成模型",
"e3fee05f688708b4": "LLM 调用",
"e4760c6a1df24f17": "完整请求 URL",
"e5043c7a2b408271": "近10分钟",
"e59ae97924d62f01": "第一页",
"e5b9961a0d5242e3": "端口设置已保存,重启软件后生效",
"e5c84c9aa7826566": "未就绪",
"e77e3d58b0dcffaa": "耗时",
"e825a2a42c22380e": "模型类型",
"e828bd3a0151edc2": "需要在系统中信任本地 CA",
"e8b1268c1e3610f2": "已存在",
"e9d8d890d33584e8": "没有可用的重置卡。",
"ea26b760e930a7ca": "调用观测",
"eb11e2df1d8ae387": "上游地址",
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
"eb4a3db23661fb52": "应用于该分组下的全部模型,并作为 Cursor 模型选择器中的徽章标签;清空则恢复显示服务器域名。",
"eb77492c9f76a7e1": "安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。",
"eba54690937bc532": "账号管理",
"ed31fbb483ee1b0a": "操作",
"edc70de18c6da1a6": "安装本地 CA",
"ee239f3943293f87": "周日",
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
"f2bdc88464c51c2e": "显示 API Key",
"eec6bf2dad677b9f": "发放时间:{time}",
"f4694c46b1e19602": "最终请求类型",
"f4dcb6a3ceb32247": "第 {page} / {count} 页",
"f4f0ead1116b5b62": "启用",
"f4fa9f31ea2ae58d": "过去一年的 Token 用量日历",
"f50276449943286c": "结束时间",
"f69273dbbebfb3a1": "格式化",
"f6dc1b1641600dd0": "正在导入…",
"f78265089144369a": "插件配置",
"f78413c36d36f090": "默认跟随操作系统;不支持的系统语言使用英文。当前:{language}",
"f784b165bd3fcb0e": "近4小时",
"f85537d1fd2ef6f6": "自定义概览筛选",
"f95ea7f4c063eea7": "未启用",
"f9615d05d8e18595": "清除{label}筛选",
"f9aa11dbb15ce647": "周六",
"f9b55ca75425161b": "未记录响应内容,请开启详细记录后重试。",
"fa5b4b8a751c7d1b": "Cursor 使用的本地代理端口;填写 0 时启动时随机选择。",
"fac2a67ad87807c4": "确定",
"fad86bf65f72c747": "下载进度",
"fb11aa6f29827095": "检查中…",
"fbe8778fa8b9bab5": "需要先初始化本地 CA",
"fc22d1ab9ac73c6f": "正在检查插件运行时",
"fc3947ebe6b2177b": "默认 {defaultRate} / 计入创建 {reuseRate}",
"fc50d0b72bc871db": "关闭接管",
"fcd311fd8ad42462": "打开模型列表",
"fd415f8e0097c832": "缓存读写已计入提示词侧统计。",
"fd77192739703811": "批量导入",
"fdc4cabc370fa3f7": "暂无选项",
"fea405f9b01d1416": "概览",
"fec45092945f8790": "使用教程",
"fec7210590309465": "清理全部统计数据",
"ff509c9ba052a21c": "保存中…",
"ff66b1c7e010bbc2": "请在终端中粘贴授权命令并输入密码,完成后点击下方按钮",
"ff673195f05fb056": "服务端口"
+2 -2
View File
@@ -2,8 +2,8 @@ import ReactDOM from "react-dom/client";
import "../node_modules/monaco-editor/min/vs/editor/editor.main.css";
import { I18nRoot } from "./i18n/I18nRoot";
import { initializeI18n } from "./i18n/store";
import { appStore } from "./store/appStore";
import { applyTheme } from "./theme/theme";
import { appStore } from "./shared/store/appStore";
import { applyTheme } from "./shared/theme/theme";
import "./styles/globals.scss";
initializeI18n();
-10
View File
@@ -1,10 +0,0 @@
import { CallTable } from "../components/CallTable";
import { PageContent } from "../components/layout/PageContent";
import { appStore, useAppStore } from "../store/appStore";
import styles from "./CallsPage.module.scss";
export function CallsPage() {
const { calls } = useAppStore();
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 }]} />;
}
@@ -1,5 +1,5 @@
import type { AdRuntime } from "./components/ads/types";
import type { Locale } from "./i18n/runtime";
import type { AdRuntime } from "../shell/ads/types";
import type { Locale } from "../i18n/runtime";
export type ModelType = "openai" | "anthropic";
@@ -7,6 +7,7 @@ export interface Model {
model_hash: string;
sort_order: number;
display_name: string;
group_name: string | null;
type: ModelType;
base_url: string;
use_full_url: boolean;
@@ -33,6 +34,7 @@ export interface Model {
export interface ModelInput {
sort_order: number;
display_name: string;
group_name: string | null;
type: ModelType;
base_url: string;
use_full_url: boolean;
@@ -86,7 +88,7 @@ export interface LegacyModelImportResult {
export interface ModelConnectivityResult {
duration_ms: number;
first_text_ms: number | null;
first_valid_response_ms: number | null;
output_tokens: number;
tokens_per_second: number;
tokens_estimated: boolean;
@@ -101,6 +103,7 @@ export interface CursorHarnessStatus {
configured_models: number;
enabled_models: number;
integration: IntegrationState;
settings_applied: boolean;
proxy_url: string | null;
ca_install_command: string | null;
}
@@ -116,7 +119,9 @@ export interface StatisticsStorage {
trace_count: number;
}
export type ProxyMode = "system" | "custom";
export type StatisticsStorageScope = "details" | "all";
export type ProxyMode = "default" | "custom";
export interface ProxySettings {
mode: ProxyMode;
@@ -146,6 +151,191 @@ export interface DesktopSettings {
show_dock_icon: boolean;
}
export interface CommitSettings {
model_id: string;
prompt: string;
prompt_locale: Locale;
}
export interface CommitSettingsView extends CommitSettings {
default_prompt: string;
}
export type PluginRuntimeState = "uninitialized" | "initializing" | "ready" | "failed" | "unsupported";
export type PluginRuntimePhase = "checking" | "downloading" | "verifying" | "installing" | "validating";
export interface PluginRuntimeStatus {
state: PluginRuntimeState;
version: string;
target: string | null;
phase: PluginRuntimePhase | null;
downloaded_bytes: number;
total_bytes: number | null;
error: string | null;
}
/** 插件提供的显示文本:纯字符串或 locale → 文本映射。 */
export type PluginLocalizedText = string | Record<string, string>;
export function pluginText(value: PluginLocalizedText | null | undefined, locale: string): string {
if (!value) return "";
if (typeof value === "string") return value;
if (value[locale]) return value[locale];
const language = locale.split("-")[0].toLowerCase();
for (const [key, text] of Object.entries(value)) {
const normalized = key.toLowerCase();
if (normalized === language || normalized.startsWith(`${language}-`)) return text;
}
return value["en-US"] ?? value["en"] ?? Object.values(value)[0] ?? "";
}
export interface PluginResourceState {
status: "ready" | "cooling" | "invalid";
retryAtMs?: number | null;
message?: string | null;
}
export interface PluginResourceMetric {
id: string;
label: PluginLocalizedText;
unit: "percent" | "count";
value: number;
resetAtMs?: number | null;
}
export interface PluginResourceView {
id: string;
state: PluginResourceState;
displayName: string;
description: PluginLocalizedText | null;
metrics: PluginResourceMetric[];
createdAtMs: number;
}
export interface PluginAddMethod {
type: "oauth2.0" | "oauth2.authorization-code";
id: string;
displayName: PluginLocalizedText;
description: PluginLocalizedText | null;
callback?: { port: number | null; path: string | null };
}
export interface PluginImportDescriptor {
displayName: PluginLocalizedText;
description: PluginLocalizedText | null;
accept: string[];
multiple: boolean;
}
export interface PluginResourceAction {
id: string;
displayName: PluginLocalizedText;
description: PluginLocalizedText | null;
target: "resource" | "card";
destructive: boolean;
}
export interface PluginResourceActionField {
id: string;
label: PluginLocalizedText;
value: string;
}
export interface PluginResourceActionCard {
id: string;
title: PluginLocalizedText;
status: PluginLocalizedText | null;
grantedAtMs: number | null;
expiresAtMs: number | null;
fields: PluginResourceActionField[];
}
export interface PluginResourceActionResult {
title: PluginLocalizedText;
description: PluginLocalizedText | null;
cards: PluginResourceActionCard[];
}
export interface PluginResourceDescriptor {
type: string;
displayName: PluginLocalizedText;
add: PluginAddMethod[];
import: PluginImportDescriptor | null;
canRefresh: boolean;
canRemove: boolean;
actions: PluginResourceAction[];
resources: PluginResourceView[];
}
export interface PluginModelDescriptor {
id: string;
pluginId: string;
pluginName: string;
providerId: string;
modelId: string;
displayName: string;
description: string | null;
icon: string;
providerType: string;
maxOutputTokens: number | null;
images: boolean;
enabled: boolean;
}
export interface PluginProviderDescriptor {
id: string;
pluginId: string;
displayName: PluginLocalizedText;
description: PluginLocalizedText | null;
providerType: string;
resourceType: string | null;
hasModels: boolean;
configured: boolean;
models: PluginModelDescriptor[];
}
export interface PluginDescriptor {
id: string;
name: string;
version: string;
author: string | null;
icon: string;
providers: PluginProviderDescriptor[];
resources: PluginResourceDescriptor[];
}
export interface PluginOAuthBegin {
sessionId: string;
userCode: string | null;
verificationUrl: string;
verificationUrlComplete: string | null;
expiresAtMs: number;
pollIntervalMs: number;
}
export type PluginOAuthPoll =
| { status: "pending"; pollIntervalMs: number }
| { status: "completed"; added: number; updated: number; modelSyncError: string | null }
| { status: "denied"; message: string | null }
| { status: "failed"; message: string };
export interface PluginImportFile {
name: string;
content: string;
}
export interface PluginImportResult {
added: number;
updated: number;
warnings: string[];
modelSyncError: string | null;
}
export function configuredPluginModels(plugins: PluginDescriptor[]): PluginModelDescriptor[] {
return plugins.flatMap((plugin) =>
plugin.providers.flatMap((provider) => provider.configured ? provider.models.filter((model) => model.enabled) : []));
}
export interface OverviewMetrics {
llm_calls: number;
successful_calls: number;
@@ -195,6 +385,7 @@ export interface LlmCall {
created_at_ms: number;
ttfb_ms: number | null;
ttft_ms: number | null;
ttfr_ms: number | null;
duration_ms: number | null;
input_tokens: number | null;
output_tokens: number | null;
@@ -276,14 +467,14 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export const api = {
ads: (disabledAdIds: Iterable<string>, locale: Locale) => {
const value = [...disabledAdIds].join(",");
return request<AdRuntime>("/ads", {
return request<AdRuntime>("/promotions", {
headers: {
"accept-language": locale,
...(value ? { "disable-ad-ids": value } : {}),
},
});
},
dismissAd: (id: string, reason: string) => request<void>(`/ads/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }),
dismissAd: (id: string, reason: string) => request<void>(`/promotions/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }),
models: () => request<Model[]>("/models"),
createModels: (models: ModelInput[]) => request<Model[]>("/models", { method: "POST", body: JSON.stringify({ models }) }),
reorderModels: (modelHashes: string[]) => request<Model[]>("/models/order", { method: "PUT", body: JSON.stringify({ model_hashes: modelHashes }) }),
@@ -292,20 +483,35 @@ export const api = {
importV0049Models: () => request<LegacyModelImportResult>("/models/import-v0049", { method: "POST" }),
updateModel: (hash: string, model: ModelInput) => request<Model>(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }),
deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }),
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => {
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[]; bucketMs?: number }) => {
const params = new URLSearchParams();
if (filter) {
params.set("start_ms", String(filter.startMs));
params.set("end_ms", String(filter.endMs));
if (filter.modelHashes?.length) params.set("model_hashes", JSON.stringify(filter.modelHashes));
if (filter.bucketMs) params.set("bucket_ms", String(filter.bucketMs));
}
const query = params.toString();
return request<Overview>(`/overview${query ? `?${query}` : ""}`);
},
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
plugins: () => request<PluginDescriptor[]>("/plugins"),
pluginOAuthBegin: (pluginId: string, resourceType: string, methodId: string) => request<PluginOAuthBegin>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/add/${encodeURIComponent(methodId)}/begin`, { method: "POST" }),
pluginOAuthPoll: (sessionId: string, signal?: AbortSignal) => request<PluginOAuthPoll>(`/plugins/oauth/${encodeURIComponent(sessionId)}/poll`, { method: "POST", signal }),
importPluginResources: (pluginId: string, resourceType: string, files: PluginImportFile[]) => request<PluginImportResult>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/import`, { method: "POST", body: JSON.stringify(files) }),
refreshPluginResource: (pluginId: string, resourceType: string, resourceId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/refresh`, { method: "POST" }),
pluginResourceAction: (pluginId: string, resourceType: string, resourceId: string, actionId: string, input: unknown = {}) => request<PluginResourceActionResult>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/actions/${encodeURIComponent(actionId)}`, { method: "POST", body: JSON.stringify(input) }),
deletePluginResource: (pluginId: string, resourceType: string, resourceId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}`, { method: "DELETE" }),
syncPluginModels: (pluginId: string, providerId: string) => request<{ models: number }>(`/plugins/${encodeURIComponent(pluginId)}/providers/${encodeURIComponent(providerId)}/models/sync`, { method: "POST" }),
setPluginModelEnabled: (pluginId: string, providerId: string, modelId: string, enabled: boolean) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/providers/${encodeURIComponent(providerId)}/models/enabled`, { method: "PUT", body: JSON.stringify({ modelId, enabled }) }),
pluginResourceExportUrl: (servicePort: number, pluginId: string, resourceType: string) => `http://127.0.0.1:${servicePort}${API_ROOT}/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/export`,
removePluginConfiguration: (pluginId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}`, { method: "DELETE" }),
pluginRuntime: () => request<PluginRuntimeStatus>("/plugins/runtime"),
initializePluginRuntime: () => request<PluginRuntimeStatus>("/plugins/runtime", { method: "POST" }),
cancelPluginRuntimeInitialization: () => request<PluginRuntimeStatus>("/plugins/runtime", { method: "DELETE" }),
openCursorCaInstallTerminal: async (command: string) => {
if (!packagedDesktop) throw new Error(t("请在桌面应用中打开终端安装 CA"));
const { invoke } = await import("@tauri-apps/api/core");
@@ -330,11 +536,13 @@ export const api = {
ports: () => request<PortSettings>("/settings/ports"),
setPorts: (settings: PortSettings) => request<PortSettings>("/settings/ports", { method: "PUT", body: JSON.stringify(settings) }),
statisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics"),
clearStatisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics", { method: "DELETE" }),
clearStatisticsStorage: (scope: StatisticsStorageScope) => request<StatisticsStorage>("/settings/storage/statistics", { method: "DELETE", body: JSON.stringify({ scope }) }),
proxySettings: () => request<ProxySettings>("/settings/proxy"),
setProxySettings: (settings: ProxySettingsInput) => request<ProxySettings>("/settings/proxy", { method: "PUT", body: JSON.stringify(settings) }),
tabSettings: () => request<TabSettings>("/settings/tab"),
setTabSettings: (settings: TabSettings) => request<TabSettings>("/settings/tab", { method: "PUT", body: JSON.stringify(settings) }),
desktopSettings: () => request<DesktopSettings>("/settings/desktop"),
setDesktopSettings: (settings: DesktopSettings) => request<DesktopSettings>("/settings/desktop", { method: "PUT", body: JSON.stringify(settings) }),
commitSettings: (locale: Locale) => request<CommitSettingsView>("/settings/commit", { headers: { "accept-language": locale } }),
setCommitSettings: (settings: CommitSettings) => request<CommitSettingsView>("/settings/commit", { method: "PUT", body: JSON.stringify(settings) }),
};

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Before

Width:  |  Height:  |  Size: 711 B

After

Width:  |  Height:  |  Size: 711 B

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